diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/PopoverMenu.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/PopoverMenu.jsx index 0aab243db3..0f1fc5f77a 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/PopoverMenu.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/PopoverMenu.jsx @@ -1,30 +1,12 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { renderElement } from '../../Utils'; import Accordion from '@/_ui/Accordion'; import { EventManager } from '../../EventManager'; -import List from '@/ToolJetUI/List/List'; -import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; -import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; -import Popover from 'react-bootstrap/Popover'; -import CodeHinter from '@/AppBuilder/CodeEditor'; -import ListGroup from 'react-bootstrap/ListGroup'; -import SortableList from '@/_components/SortableList'; -import { ButtonSolid } from '@/_ui/AppButton/AppButton'; -import Trash from '@/_ui/Icon/solidIcons/Trash'; -import AddNewButton from '@/ToolJetUI/Buttons/AddNewButton/AddNewButton'; -import useStore from '@/AppBuilder/_stores/store'; -import { shallow } from 'zustand/shallow'; -import { getSafeRenderableValue } from '@/Editor/Components/utils'; -import { Button as ButtonComponent } from '@/components/ui/Button/Button.jsx'; +import { OptionsList } from './components'; +import { useOptionsManager } from './hooks/useOptionsManager'; import './styles.scss'; export const PopoverMenu = ({ componentMeta, darkMode, ...restProps }) => { - // ===== STATE AND VARIABLES ===== - const [options, setOptions] = useState([]); - const [hoveredOptionIndex, setHoveredOptionIndex] = useState(null); - - const getResolvedValue = useStore((state) => state.getResolvedValue, shallow); - const { layoutPropertyChanged, component, @@ -37,313 +19,19 @@ export const PopoverMenu = ({ componentMeta, darkMode, ...restProps }) => { pages, } = restProps; - const isDynamicOptionsEnabled = getResolvedValue(component?.component?.definition?.properties?.advanced?.value); - - // ===== HELPER FUNCTIONS ===== - const updateOptions = (options) => { - setOptions(options); - paramUpdated({ name: 'options' }, 'value', options, 'properties', false); - }; - - const constructOptions = () => { - let optionsValue = component?.component?.definition?.properties?.options?.value; - if (!Array.isArray(optionsValue)) { - optionsValue = Object.values(optionsValue); - } - let options = []; - - if (isDynamicOptionsEnabled || typeof optionsValue === 'string') { - options = getResolvedValue(optionsValue); - } else { - options = optionsValue?.map((option) => option); - } - return options.map((option) => { - const newOption = { ...option }; - - Object.keys(option).forEach((key) => { - if (typeof option[key]?.value === 'boolean') { - newOption[key]['value'] = `{{${option[key]?.value}}}`; - } - }); - - return newOption; - }); - }; - - const generateNewOptions = () => { - let found = false; - let label = ''; - let currentNumber = options.length + 1; - let value = currentNumber; - while (!found) { - label = `option${currentNumber}`; - value = currentNumber.toString(); - if (options.find((option) => option.label === label) === undefined) { - found = true; - } - currentNumber += 1; - } - - return { - format: 'plain', - label, - description: ``, - value, - icon: { - value: [ - 'IconBriefcase', - 'IconStar', - 'IconSettings', - 'IconUser', - 'IconHome', - 'IconSearch', - 'IconBell', - 'IconMail', - 'IconCamera', - 'IconMusic', - ][Math.floor(Math.random() * 10)], - }, - iconVisibility: false, - visible: { value: '{{true}}' }, - disable: { value: '{{false}}' }, - }; - }; - - const getItemStyle = (isDragging, draggableStyle) => ({ - userSelect: 'none', - ...draggableStyle, - }); - - // ===== EVENT HANDLERS ===== - const handleOptionChange = (propertyPath, value, index) => { - const newOptions = options.map((option, i) => { - if (i === index) { - if (propertyPath.includes('.')) { - const [parentKey, childKey] = propertyPath.split('.'); - return { - ...option, - [parentKey]: { - ...option[parentKey], - [childKey]: value, - }, - }; - } - return { - ...option, - [propertyPath]: value, - }; - } - return option; - }); - updateOptions(newOptions); - }; - - const handleDeleteOption = (index) => { - const newOptions = options.filter((option, i) => i !== index); - updateOptions(newOptions); - }; - - const handleAddOption = () => { - let _option = generateNewOptions(); - const newOptions = [...options, _option]; - updateOptions(newOptions); - }; - - const reorderOptions = async (startIndex, endIndex) => { - const result = [...options]; - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - updateOptions(result); - }; - - const onDragEnd = ({ source, destination }) => { - if (!destination || source?.index === destination?.index) { - return; - } - reorderOptions(source.index, destination.index); - }; - - // ===== SIDE EFFECTS ===== - useEffect(() => { - const newOptions = constructOptions(); - setOptions(newOptions); - }, [isDynamicOptionsEnabled]); - - // ===== RENDER FUNCTIONS ===== - const _renderOverlay = (item, index) => { - const iconVisibility = item?.iconVisibility; - return ( - - -
-
- - Option details - -
- handleDeleteOption(index)} - trailingIcon="trash" - size="medium" - /> -
-
-
-
- { - handleOptionChange('format', value, index); - }} - fieldMeta={{ - type: 'select', - displayName: 'Data format', - options: [ - { name: 'Plain', value: 'plain' }, - { name: 'HTML', value: 'html' }, - { name: 'Markdown', value: 'markdown' }, - ], - isFxNotRequired: true, - }} - paramType={'select'} - /> -
- -
- - { - handleOptionChange('label', value, index); - }} - /> -
-
- - { - handleOptionChange('description', value, index); - }} - /> -
-
- - { - handleOptionChange('value', value, index); - }} - /> -
-
- { - handleOptionChange('icon.value', value, index); - }} - onVisibilityChange={(value) => { - const transformedValue = getResolvedValue(value); - handleOptionChange('iconVisibility', transformedValue, index); - }} - onFxPress={(active) => handleOptionChange('icon.fxActive', active, index)} - fxActive={item?.icon?.fxActive} - fieldMeta={{ type: 'icon', displayName: 'Icon' }} - paramType={'icon'} - iconVisibility={iconVisibility} - /> -
-
- { - handleOptionChange('visible.value', value, index); - }} - onFxPress={(active) => handleOptionChange('visible.fxActive', active, index)} - fxActive={item?.visible?.fxActive} - fieldMeta={{ type: 'toggle', displayName: 'Option visibility' }} - paramType={'toggle'} - /> -
-
- { - handleOptionChange('disable.value', value, index); - }} - onFxPress={(active) => handleOptionChange('disable.fxActive', active, index)} - fxActive={item?.disable?.fxActive} - fieldMeta={{ type: 'toggle', displayName: 'Disable option' }} - paramType={'toggle'} - /> -
-
-
-
-
- ); - }; + // Use the custom hook for options management + const { + options, + hoveredOptionIndex, + setHoveredOptionIndex, + handleOptionChange, + handleDeleteOption, + handleAddOption, + onDragEnd, + getItemStyle, + getResolvedValue, + isDynamicOptionsEnabled, + } = useOptionsManager(component, paramUpdated); // ===== PROPERTY ORGANIZATION ===== let properties = []; @@ -363,100 +51,20 @@ export const PopoverMenu = ({ componentMeta, darkMode, ...restProps }) => { // ===== RENDER FUNCTIONS ===== const _renderOptions = () => { return ( - - { - onDragEnd(result); - }} - > - - {({ innerRef, droppableProps, placeholder }) => ( -
- {options?.map((item, index) => { - return ( - - {(provided, snapshot) => ( -
- { - if (!isOpen) { - document.activeElement?.blur(); // Manually trigger blur when popover closes - } - }} - > -
- setHoveredOptionIndex(index)} - onMouseLeave={() => setHoveredOptionIndex(null)} - {...restProps} - > -
-
- -
-
- {getSafeRenderableValue(getResolvedValue(item?.label))} -
-
- {index === hoveredOptionIndex && ( - { - e.stopPropagation(); - handleDeleteOption(index); - }} - > - - - - - )} -
-
-
-
-
-
- )} -
- ); - })} - {placeholder} -
- )} -
-
- - Add new option - -
+ setHoveredOptionIndex(null)} + onDeleteOption={handleDeleteOption} + onOptionChange={handleOptionChange} + onAddOption={handleAddOption} + onDragEnd={onDragEnd} + getResolvedValue={getResolvedValue} + getItemStyle={getItemStyle} + {...restProps} + /> ); }; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionDetailsPopover.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionDetailsPopover.jsx new file mode 100644 index 0000000000..502f391925 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionDetailsPopover.jsx @@ -0,0 +1,185 @@ +import React, { forwardRef } from 'react'; +import Popover from 'react-bootstrap/Popover'; +import CodeHinter from '@/AppBuilder/CodeEditor'; +import { Button as ButtonComponent } from '@/components/ui/Button/Button.jsx'; + +const OptionDetailsPopover = forwardRef( + ({ item, index, darkMode, onOptionChange, onDeleteOption, getResolvedValue, ...restProps }, ref) => { + const iconVisibility = item?.iconVisibility; + + // Common CodeHinter props + const commonCodeHinterProps = { + theme: darkMode ? 'monokai' : 'default', + mode: 'javascript', + lineNumbers: false, + }; + + const basicCodeHinterProps = { + ...commonCodeHinterProps, + type: 'basic', + }; + + const fxEditorCodeHinterProps = { + ...commonCodeHinterProps, + type: 'fxEditor', + }; + + return ( + + +
+
+ + Option details + +
+ onDeleteOption(index)} + trailingIcon="trash" + size="medium" + /> +
+
+
+
+ { + onOptionChange('format', value, index); + }} + fieldMeta={{ + type: 'select', + displayName: 'Data format', + options: [ + { name: 'Plain', value: 'plain' }, + { name: 'HTML', value: 'html' }, + { name: 'Markdown', value: 'markdown' }, + ], + isFxNotRequired: true, + }} + paramType={'select'} + /> +
+ +
+ + { + onOptionChange('label', value, index); + }} + /> +
+
+ + { + onOptionChange('description', value, index); + }} + /> +
+
+ + { + onOptionChange('value', value, index); + }} + /> +
+
+ { + onOptionChange('icon.value', value, index); + }} + onVisibilityChange={(value) => { + const transformedValue = getResolvedValue(value); + onOptionChange('iconVisibility', transformedValue, index); + }} + onFxPress={(active) => onOptionChange('icon.fxActive', active, index)} + fxActive={item?.icon?.fxActive} + fieldMeta={{ type: 'icon', displayName: 'Icon' }} + paramType={'icon'} + iconVisibility={iconVisibility} + /> +
+
+ { + onOptionChange('visible.value', value, index); + }} + onFxPress={(active) => onOptionChange('visible.fxActive', active, index)} + fxActive={item?.visible?.fxActive} + fieldMeta={{ type: 'toggle', displayName: 'Option visibility' }} + paramType={'toggle'} + /> +
+
+ { + onOptionChange('disable.value', value, index); + }} + onFxPress={(active) => onOptionChange('disable.fxActive', active, index)} + fxActive={item?.disable?.fxActive} + fieldMeta={{ type: 'toggle', displayName: 'Disable option' }} + paramType={'toggle'} + /> +
+
+
+
+
+ ); + } +); + +export default OptionDetailsPopover; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionItem.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionItem.jsx new file mode 100644 index 0000000000..b3dff71936 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionItem.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { Draggable } from 'react-beautiful-dnd'; +import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import ListGroup from 'react-bootstrap/ListGroup'; +import SortableList from '@/_components/SortableList'; +import { ButtonSolid } from '@/_ui/AppButton/AppButton'; +import Trash from '@/_ui/Icon/solidIcons/Trash'; +import { getSafeRenderableValue } from '@/Editor/Components/utils'; +import OptionDetailsPopover from './OptionDetailsPopover'; + +const OptionItem = ({ + item, + index, + darkMode, + hoveredOptionIndex, + onMouseEnter, + onMouseLeave, + onDeleteOption, + onOptionChange, + getResolvedValue, + getItemStyle, + ...restProps +}) => { + return ( + + {(provided, snapshot) => { + return ( +
+ + } + onToggle={(isOpen) => { + if (!isOpen) { + document.activeElement?.blur(); // Manually trigger blur when popover closes + } + }} + > +
+ onMouseEnter(index)} + onMouseLeave={() => onMouseLeave()} + {...restProps} + > +
+
+ +
+
+ {getSafeRenderableValue(getResolvedValue(item?.label))} +
+
+ {index === hoveredOptionIndex && ( + { + e.stopPropagation(); + onDeleteOption(index); + }} + > + + + + + )} +
+
+
+
+
+
+ ); + }} +
+ ); +}; + +export default OptionItem; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionsList.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionsList.jsx new file mode 100644 index 0000000000..e71c384a00 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/OptionsList.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import List from '@/ToolJetUI/List/List'; +import AddNewButton from '@/ToolJetUI/Buttons/AddNewButton/AddNewButton'; +import OptionItem from './OptionItem'; + +const OptionsList = ({ + options, + darkMode, + hoveredOptionIndex, + onMouseEnter, + onMouseLeave, + onDeleteOption, + onOptionChange, + onAddOption, + onDragEnd, + getResolvedValue, + getItemStyle, + ...restProps +}) => { + return ( + + { + onDragEnd(result); + }} + > + + {({ innerRef, droppableProps, placeholder }) => { + return ( +
+ {options?.map((item, index) => ( + + ))} + {placeholder} +
+ ); + }} +
+
+ + Add new option + +
+ ); +}; + +export default OptionsList; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/index.js new file mode 100644 index 0000000000..00db4e7a21 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/components/index.js @@ -0,0 +1,3 @@ +export { default as OptionDetailsPopover } from './OptionDetailsPopover'; +export { default as OptionItem } from './OptionItem'; +export { default as OptionsList } from './OptionsList'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/hooks/useOptionsManager.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/hooks/useOptionsManager.js new file mode 100644 index 0000000000..75e645d553 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/hooks/useOptionsManager.js @@ -0,0 +1,158 @@ +import { useState, useEffect } from 'react'; +import { shallow } from 'zustand/shallow'; +import useStore from '@/AppBuilder/_stores/store'; + +export const useOptionsManager = (component, paramUpdated) => { + const [options, setOptions] = useState([]); + const [hoveredOptionIndex, setHoveredOptionIndex] = useState(null); + + const getResolvedValue = useStore((state) => state.getResolvedValue, shallow); + + const isDynamicOptionsEnabled = getResolvedValue(component?.component?.definition?.properties?.advanced?.value); + + // Helper function to update options + const updateOptions = (newOptions) => { + setOptions(newOptions); + paramUpdated({ name: 'options' }, 'value', newOptions, 'properties', false); + }; + + // Helper function to construct options from component definition + const constructOptions = () => { + let optionsValue = component?.component?.definition?.properties?.options?.value; + if (!Array.isArray(optionsValue)) { + optionsValue = Object.values(optionsValue); + } + let options = []; + + if (isDynamicOptionsEnabled || typeof optionsValue === 'string') { + options = getResolvedValue(optionsValue); + } else { + options = optionsValue?.map((option) => option); + } + return options.map((option) => { + const newOption = { ...option }; + + Object.keys(option).forEach((key) => { + if (typeof option[key]?.value === 'boolean') { + newOption[key]['value'] = `{{${option[key]?.value}}}`; + } + }); + + return newOption; + }); + }; + + // Helper function to generate new option + const generateNewOptions = () => { + let found = false; + let label = ''; + let currentNumber = options.length + 1; + let value = currentNumber; + while (!found) { + label = `option${currentNumber}`; + value = currentNumber.toString(); + if (options.find((option) => option.label === label) === undefined) { + found = true; + } + currentNumber += 1; + } + + return { + format: 'plain', + label, + description: ``, + value, + icon: { + value: [ + 'IconBriefcase', + 'IconStar', + 'IconSettings', + 'IconUser', + 'IconHome', + 'IconSearch', + 'IconBell', + 'IconMail', + 'IconCamera', + 'IconMusic', + ][Math.floor(Math.random() * 10)], + }, + iconVisibility: false, + visible: { value: '{{true}}' }, + disable: { value: '{{false}}' }, + }; + }; + + // Helper function for drag and drop styling + const getItemStyle = (isDragging, draggableStyle) => ({ + userSelect: 'none', + ...draggableStyle, + }); + + // Event handlers + const handleOptionChange = (propertyPath, value, index) => { + const newOptions = options.map((option, i) => { + if (i === index) { + if (propertyPath.includes('.')) { + const [parentKey, childKey] = propertyPath.split('.'); + return { + ...option, + [parentKey]: { + ...option[parentKey], + [childKey]: value, + }, + }; + } + return { + ...option, + [propertyPath]: value, + }; + } + return option; + }); + updateOptions(newOptions); + }; + + const handleDeleteOption = (index) => { + const newOptions = options.filter((option, i) => i !== index); + updateOptions(newOptions); + }; + + const handleAddOption = () => { + let _option = generateNewOptions(); + const newOptions = [...options, _option]; + updateOptions(newOptions); + }; + + const reorderOptions = async (startIndex, endIndex) => { + const result = [...options]; + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + updateOptions(result); + }; + + const onDragEnd = ({ source, destination }) => { + if (!destination || source?.index === destination?.index) { + return; + } + reorderOptions(source.index, destination.index); + }; + + // Side effects + useEffect(() => { + const newOptions = constructOptions(); + setOptions(newOptions); + }, [isDynamicOptionsEnabled]); + + return { + options, + hoveredOptionIndex, + setHoveredOptionIndex, + handleOptionChange, + handleDeleteOption, + handleAddOption, + onDragEnd, + getItemStyle, + getResolvedValue, + isDynamicOptionsEnabled, + }; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/index.js new file mode 100644 index 0000000000..417d39fffa --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/PopoverMenu/index.js @@ -0,0 +1,3 @@ +export { PopoverMenu } from './PopoverMenu'; +export * from './components'; +export * from './hooks/useOptionsManager';