diff --git a/frontend/assets/images/icons/widgets/index.jsx b/frontend/assets/images/icons/widgets/index.jsx index 058bf838e1..7ecb678d1b 100644 --- a/frontend/assets/images/icons/widgets/index.jsx +++ b/frontend/assets/images/icons/widgets/index.jsx @@ -137,6 +137,7 @@ const WidgetIcon = (props) => { case 'map': return ; case 'modal': + case 'modallegacy': return ; case 'multiselect': case 'multiselectv2': diff --git a/frontend/src/AppBuilder/AppCanvas/ConfigHandle/ConfigHandle.jsx b/frontend/src/AppBuilder/AppCanvas/ConfigHandle/ConfigHandle.jsx index 436c04a430..bb4d4fc69b 100644 --- a/frontend/src/AppBuilder/AppCanvas/ConfigHandle/ConfigHandle.jsx +++ b/frontend/src/AppBuilder/AppCanvas/ConfigHandle/ConfigHandle.jsx @@ -29,7 +29,7 @@ export const ConfigHandle = ({ ); const setComponentToInspect = useStore((state) => state.setComponentToInspect); - + const isModal = componentType === 'Modal' || componentType === 'ModalV2'; const _showHandle = useStore((state) => { const isWidgetHovered = state.getHoveredComponentForGrid() === id || state.hoveredComponentBoundaryId === id; const anyComponentHovered = state.getHoveredComponentForGrid() !== '' || state.hoveredComponentBoundaryId !== ''; @@ -37,7 +37,7 @@ export const ConfigHandle = ({ return ( isWidgetHovered || (showHandle && - (!isMultipleComponentsSelected || (componentType === 'Modal' && isModalOpen)) && + (!isMultipleComponentsSelected || (isModal && isModalOpen)) && !anyComponentHovered) ); }, shallow); @@ -63,7 +63,7 @@ export const ConfigHandle = ({ > diff --git a/frontend/src/AppBuilder/AppCanvas/Grid/Grid.jsx b/frontend/src/AppBuilder/AppCanvas/Grid/Grid.jsx index d27a66b0ec..6821f43ce2 100644 --- a/frontend/src/AppBuilder/AppCanvas/Grid/Grid.jsx +++ b/frontend/src/AppBuilder/AppCanvas/Grid/Grid.jsx @@ -22,6 +22,7 @@ import { } from './gridUtils'; import { useAppVersionStore } from '@/_stores/appVersionStore'; import { resolveWidgetFieldValue } from '@/_helpers/utils'; +import { dragContextBuilder, getAdjustedDropPosition } from './helpers/dragEnd'; import useStore from '@/AppBuilder/_stores/store'; import './Grid.css'; import { NO_OF_GRIDS, SUBCONTAINER_WIDGETS } from '../appCanvasConstants'; @@ -54,6 +55,7 @@ export default function Grid({ gridWidth, currentLayout }) { const canvasWidth = NO_OF_GRIDS * gridWidth; const getHoveredComponentForGrid = useStore((state) => state.getHoveredComponentForGrid, shallow); const getResolvedComponent = useStore((state) => state.getResolvedComponent, shallow); + const [canvasBounds, setCanvasBounds] = useState(CANVAS_BOUNDS); const draggingComponentId = useGridStore((state) => state.draggingComponentId, shallow); const resizingComponentId = useGridStore((state) => state.resizingComponentId, shallow); const [dragParentId, setDragParentId] = useState(null); @@ -794,7 +796,7 @@ export default function Grid({ gridWidth, currentLayout }) { // to handle their own interactions like column resizing or card dragging let isDragOnInnerElement = false; - /* If the drag or click is on a calender popup draggable interactions are not executed so that popups and other components inside calender popup works. + /* If the drag or click is on a calender popup draggable interactions are not executed so that popups and other components inside calender popup works. Also user dont need to drag an calender from using popup */ if (hasParentWithClass(e.inputEvent.target, 'react-datepicker-popper')) { return false; @@ -835,109 +837,47 @@ export default function Grid({ gridWidth, currentLayout }) { newDragParentId.current = null; setDragParentId(null); - if (!e.lastEvent) { - return; + if (!e.lastEvent) return; + + // Build the drag context from the event + const dragContext = dragContextBuilder({ event: e, widgets: boxList }); + const { target, source, dragged } = dragContext; + + const targetSlotId = target?.slotId; + const targetGridWidth = useGridStore.getState().subContainerWidths[targetSlotId] || gridWidth; + + // const restrictedWidgets = RESTRICTED_WIDGETS_CONFIG?.[source.widgetType] || []; + // const draggedWidgetType = dragged.widgetType; + const isParentChangeAllowed = dragContext.isDroppable; + + // Compute new position + let { left, top } = getAdjustedDropPosition(e, target, isParentChangeAllowed, targetGridWidth, dragged); + + const isModalToCanvas = source.isModal && target.slotId === 'real-canvas'; + + if (isParentChangeAllowed && !isModalToCanvas) { + const parent = target.slotId === 'real-canvas' ? null : target.slotId; + // Special case for Modal; If source widget is modal, prevent drops to canvas + handleDragEnd([{ id: e.target.id, x: left, y: top, parent }]); + } else { + const sourcegridWidth = useGridStore.getState().subContainerWidths[source.slotId] || gridWidth; + + left = dragged.left * sourcegridWidth; + top = dragged.top; + + !isModalToCanvas ?? + toast.error(`${dragged.widgetType} is not compatible as a child component of ${target.widgetType}`); } - let draggedOverElemId = boxList.find((box) => box.id === e.target.id)?.parent; - let draggedOverElemIdType; - const parentComponent = boxList.find((box) => box.id === boxList.find((b) => b.id === e.target.id)?.parent); - let draggedOverElem; - if (document.elementFromPoint(e.clientX, e.clientY) && parentComponent?.component?.component !== 'Modal') { - const targetElems = document.elementsFromPoint(e.clientX, e.clientY); - draggedOverElem = targetElems.find((ele) => { - const isOwnChild = e.target.contains(ele); // if the hovered element is a child of actual draged element its not considered - if (isOwnChild) return false; + // Apply transform for smooth transition + e.target.style.transform = `translate(${left}px, ${top}px)`; - let isDroppable = ele.id !== e.target.id && ele.classList.contains('drag-container-parent'); - if (isDroppable) { - let widgetId = ele?.getAttribute('component-id') || ele.id; - let widgetType = boxList.find(({ id }) => id === widgetId)?.component?.component; - if (!widgetType) { - widgetId = widgetId.split('-').slice(0, -1).join('-'); - widgetType = boxList.find(({ id }) => id === widgetId)?.component?.component; - } - if ( - !['Calendar', 'Kanban', 'Form', 'Tabs', 'Modal', 'Listview', 'Container', 'Table'].includes( - widgetType - ) - ) { - isDroppable = false; - } - } - return isDroppable; - }); - draggedOverElemId = draggedOverElem?.getAttribute('component-id') || draggedOverElem?.id; - draggedOverElemIdType = draggedOverElem?.getAttribute('data-parent-type'); - } - - const _gridWidth = useGridStore.getState().subContainerWidths[draggedOverElemId] || gridWidth; - const currentParentId = boxList.find(({ id: widgetId }) => e.target.id === widgetId)?.component?.parent; - let left = e.lastEvent?.translate[0]; - let top = e.lastEvent?.translate[1]; - if ( - ['Listview', 'Kanban', 'Container'].includes( - boxList.find((box) => box.id === draggedOverElemId)?.component?.component - ) - ) { - const elemContainer = e.target.closest('.real-canvas'); - const containerHeight = elemContainer.clientHeight; - const maxY = containerHeight - e.target.clientHeight; - top = top > maxY ? maxY : top; - } - - const currentWidget = boxList.find(({ id }) => id === e.target.id)?.component?.component; - const parentId = draggedOverElemId?.length > 36 ? draggedOverElemId.slice(0, 36) : draggedOverElemId; - draggedOverElemIdType = getComponentTypeFromId(parentId); - const parentWidget = draggedOverElemIdType === 'Kanban' ? 'Kanban_card' : draggedOverElemIdType; - const restrictedWidgets = RESTRICTED_WIDGETS_CONFIG?.[parentWidget] || []; - const isParentChangeAllowed = !restrictedWidgets.includes(currentWidget); - if (draggedOverElemId !== currentParentId) { - if (isParentChangeAllowed) { - const draggedOverWidget = boxList.find((box) => box.id === draggedOverElemId); - - let parentWidgetType = boxList.find((box) => box.id === draggedOverElemId)?.component?.component; - // @TODO - When dropping back to container from canvas, the boxList doesn't have canvas header, - // boxList will return null. But we need to tell getMouseDistanceFromParentDiv parentWidgetType is container - // As container id is like 'canvas-2375e23765e-123234' - if (parentId && !parentWidgetType && draggedOverElemId.includes('-header')) { - parentWidgetType = 'Container'; - } - - let { left: _left, top: _top } = getMouseDistanceFromParentDiv( - e, - draggedOverWidget?.component?.component === 'Kanban' ? draggedOverElem : draggedOverElemId, - parentWidgetType - ); - left = _left; - top = _top; - } else { - const currBox = boxList.find((l) => l.id === e.target.id); - left = currBox.left * gridWidth; - top = currBox.top; - toast.error(`${currentWidget} is not compatible as a child component of ${parentWidget}`); - } - } - - e.target.style.transform = `translate(${Math.round(left / _gridWidth) * _gridWidth}px, ${ - Math.round(top / GRID_HEIGHT) * GRID_HEIGHT - }px)`; - if (draggedOverElemId === currentParentId || isParentChangeAllowed) { - handleDragEnd([ - { - id: e.target.id, - x: left, - y: Math.round(top / GRID_HEIGHT) * GRID_HEIGHT, - parent: isParentChangeAllowed ? draggedOverElemId : undefined, - }, - ]); - } - const box = boxList.find((box) => box.id === e.target.id); - // - setTimeout(() => setSelectedComponents([box.id])); + // Select the dragged component after drop + setTimeout(() => setSelectedComponents([dragged.id])); } catch (error) { - console.log('draggedOverElemId->error', error); + console.error('Error in onDragEnd:', error); } + setCanvasBounds({ ...CANVAS_BOUNDS }); hideGridLines(); toggleCanvasUpdater(); }} @@ -965,16 +905,31 @@ export default function Grid({ gridWidth, currentLayout }) { } // Special case for Modal - const parentComponent = boxList.find((box) => box.id === boxList.find((b) => b.id === e.target.id)?.parent); - if (parentComponent?.component?.component === 'Modal') { - const elemContainer = e.target.closest('.real-canvas'); - const containerHeight = elemContainer.clientHeight; - const containerWidth = elemContainer.clientWidth; - const maxY = containerHeight - e.target.clientHeight; - const maxLeft = containerWidth - e.target.clientWidth; + const oldParentId = boxList.find((b) => b.id === e.target.id)?.parent; + const parentId = oldParentId?.length > 36 ? oldParentId.slice(0, 36) : oldParentId; + const parentComponent = boxList.find((box) => box.id === parentId); + const parentWidgetType = parentComponent?.component?.component; + const isOnHeaderOrFooter = oldParentId + ? oldParentId.includes('-header') || oldParentId.includes('-footer') + : false; + const isParentModalSlot = parentWidgetType === 'ModalV2' && isOnHeaderOrFooter; + const isParentNewModal = parentComponent?.component?.component === 'ModalV2'; + const isParentLegacyModal = parentComponent?.component?.component === 'Modal'; + const isParentModal = isParentNewModal || isParentLegacyModal || isParentModalSlot; - top = top < 0 ? 0 : top > maxY ? maxY : top; - left = left < 0 ? 0 : left > maxLeft ? maxLeft : left; + if (isParentModal) { + const modalContainer = e.target.closest('.tj-modal-widget-content'); + const mainCanvas = document.getElementById('real-canvas'); + + const mainRect = mainCanvas.getBoundingClientRect(); + const modalRect = modalContainer.getBoundingClientRect(); + const relativePosition = { + top: modalRect.top - mainRect.top, + right: mainRect.right - modalRect.right + modalContainer.offsetWidth, + bottom: modalRect.height + (modalRect.top - mainRect.top), + left: modalRect.left - mainRect.left, + }; + setCanvasBounds({ ...relativePosition }); } e.target.style.transform = `translate(${left}px, ${top}px)`; @@ -1063,6 +1018,7 @@ export default function Grid({ gridWidth, currentLayout }) { snapGap={false} isDisplaySnapDigit={false} snapThreshold={GRID_HEIGHT} + bounds={canvasBounds} // Guidelines configuration elementGuidelines={elementGuidelines} snapDirections={{ diff --git a/frontend/src/AppBuilder/AppCanvas/Grid/gridUtils.js b/frontend/src/AppBuilder/AppCanvas/Grid/gridUtils.js index 4bfd030a3c..2889fc06db 100644 --- a/frontend/src/AppBuilder/AppCanvas/Grid/gridUtils.js +++ b/frontend/src/AppBuilder/AppCanvas/Grid/gridUtils.js @@ -291,6 +291,7 @@ export function getMouseDistanceFromParentDiv(event, id, parentWidgetType) { ? document.getElementById(id) : id : document.getElementsByClassName('real-canvas')[0]; + parentDiv = id === 'real-canvas' ? document.getElementById('real-canvas') : document.getElementById('canvas-' + id); if (parentWidgetType === 'Container' || parentWidgetType === 'Modal') { parentDiv = document.getElementById('canvas-' + id); } diff --git a/frontend/src/AppBuilder/AppCanvas/Grid/helpers/dragEnd.js b/frontend/src/AppBuilder/AppCanvas/Grid/helpers/dragEnd.js new file mode 100644 index 0000000000..a9405d043e --- /dev/null +++ b/frontend/src/AppBuilder/AppCanvas/Grid/helpers/dragEnd.js @@ -0,0 +1,266 @@ +/** + * Drag Context Breakdown: + * + * This object encapsulates all relevant details about a drag event, + * grouping the **source (where the widget came from)** and **target (where it's being dropped)**. + * + * Core Concepts: + * - `draggedWidget` → The widget being dragged (`e.target`). + * - `sourceSlot` → The original parent container of `draggedWidget`. + * - This could be a **header, footer, or a sub-container (like a container body)**. + * - `targetSlot` → The new parent container where `draggedWidget` is dropped. + * - `sourceWidget` → The **widget that owns** `sourceSlot` (its direct parent). + * - `targetWidget` → The **widget that owns** `targetSlot` (its direct parent). + * + * These entities are structured into a **contextual grouping**, allowing for easy access: + * + * { + * source: { + * widget: sourceWidget, // The original widget that holds the source slot. + * slot: sourceSlot, // The slot where the widget was initially located. + * id: sourceWidget.id, // Unique identifier of the source widget. + * slotId: sourceSlot.id, // Unique identifier of the source slot. + * + * isModal: computed function, // Checks if sourceWidget is a Modal. + * slotType: computed function, // Determines if the slot is a header, footer, or body. + * widgetType: computed function, // Returns the type of the widget (e.g., Table, Form, etc.). + * }, + * + * target: { + * widget: targetWidget, // The new widget where the dragged widget is being placed. + * slot: targetSlot, // The slot inside `targetWidget` where the drop is happening. + * id: targetWidget.id, // Unique identifier of the target widget. + * slotId: targetSlot.id, // Unique identifier of the target slot. + * + * isModal: computed function, // Checks if targetWidget is a Modal. + * slotType: computed function, // Determines if the slot is a header, footer, or body. + * widgetType: computed function, // Returns the type of the target widget. + * } + * } + * + * Additional Checks: + * - `isSourceModal` → **Is the source inside a modal?** + * - `isTargetModal` → **Is the target inside a modal?** + * - `isDraggingToModalSlots` → **Is the widget being dragged into a modal slot (header/footer)?** + * - `targetSlotType` → **Determines whether the drop is happening in a header, footer, or body.** + * + * Why This Matters? + * - This structure helps **validate and restrict movements**, ensuring widgets follow UI constraints. + * - Prevents invalid drops (e.g., putting a button inside a Table component). + * - Enables **modular and flexible** widget movement across different UI sections. + */ +import { getMouseDistanceFromParentDiv } from '../gridUtils'; +import { + RESTRICTED_WIDGETS_CONFIG, + RESTRICTED_WIDGET_SLOTS_CONFIG, +} from '@/AppBuilder/WidgetManager/configs/restrictedWidgetsConfig'; + +const CANVAS_ID = 'canvas'; +const REAL_CANVAS_ID = 'real-canvas'; + +/** + * Represents the widget being dragged. + * + * This class encapsulates all necessary information about the dragged widget, + * including its type, position, and whether it is allowed to move into certain areas. + */ +export class DragEntity { + constructor(widget) { + this.widget = widget; // The widget object being dragged + this.id = widget?.id || null; // Unique ID of the dragged widget + this.left = widget.left; // Initial X position (relative to grid) + this.top = widget.top; // Initial Y position (relative to grid) + } + + get widgetType() { + return this.widget?.component?.component || null; + } +} + +/** + * Defines a **droppable area** in the canvas. + * + * A droppable area is a container that can accept dragged widgets. + * This class helps determine if a slot is valid and handles various properties like modals. + */ +export class DropAreaEntity { + static dropAreaWidgets = ['Calendar', 'Kanban', 'Form', 'Tabs', 'Modal', 'ModalV2', 'Listview', 'Container', 'Table']; + + constructor(widget, slotId) { + this.widget = widget; // The widget that owns this slot + this.id = widget?.id || CANVAS_ID; // ID of the widget + this.slotId = slotId || REAL_CANVAS_ID; // ID of the slot where the widget is located + } + + // Checks if the widget is a modal + get isModal() { + return ['Modal', 'ModalV2'].includes(this.widget?.component?.component); + } + + // Checks if the widget is the new version of modal + get isNewModal() { + return this.widget?.component?.component === 'ModalV2'; + } + + // Checks if the widget is the legacy modal + get isLegacyModal() { + return this.widget?.component?.component === 'Modal'; + } + + // Determines if the slot belongs to a modal's header/footer + get isInModalSlot() { + return this.isNewModal && this.isOnCustomSlot; + } + + // Identifies if the slot is a custom slot (e.g., modal header/footer) + get isOnCustomSlot() { + return this.slotId.includes('-header') || this.slotId.includes('-footer'); + } + + // Determines if the slot is a valid drop target + get isDroppable() { + return DropAreaEntity.dropAreaWidgets.includes(this.widgetType); + } + + // Returns the type of slot (header, footer, body, etc.) + get slotType() { + return this.slotId ? this.slotId.split('-').pop() : CANVAS_ID; + } + + // Returns the type of the widget inside the slot + get widgetType() { + return this.widget?.component?.component || CANVAS_ID; + } +} + +/** + * Represents the **dragging context**, encapsulating information + * about the source, target, and the dragged widget. + * + * This helps determine: + * - Whether the move is valid + * - Where the widget should be placed + * - Any restrictions based on parent-child relationships + */ +export class DragContext { + constructor({ sourceSlotId, targetSlotId, draggedWidgetId, widgets }) { + const sourceWidgetId = sourceSlotId?.slice(0, 36); + const sourceWidget = getWidgetById(widgets, sourceWidgetId); + + const targetWidgetId = targetSlotId?.slice(0, 36); + const targetWidget = getWidgetById(widgets, targetWidgetId); + + const draggedWidget = getWidgetById(widgets, draggedWidgetId); + + this.source = new DropAreaEntity(sourceWidget, sourceSlotId); + this.target = new DropAreaEntity(targetWidget, targetSlotId); + this.dragged = new DragEntity(draggedWidget); + this.widgets = widgets; + } + + /** + * Updates the **target slot** dynamically as the drag event progresses. + */ + updateTarget(targetSlotId) { + const targetWidgetId = targetSlotId?.slice(0, 36); + const targetWidget = getWidgetById(this.widgets, targetWidgetId); + this.target = new DropAreaEntity(targetWidget, targetSlotId); + } + + get isDroppable() { + const { dragged, target } = this; + + const restrictedWidgetsOnTarget = RESTRICTED_WIDGETS_CONFIG?.[target.widgetType] || []; + const restrictedWidgetsOnTargetSlot = RESTRICTED_WIDGET_SLOTS_CONFIG?.[target.slotType] || []; + + const restrictedWidgets = [...restrictedWidgetsOnTarget, ...restrictedWidgetsOnTargetSlot]; + return !restrictedWidgets.includes(dragged.widgetType); + ß; + } +} + +/** + * Constructs the **dragging context** by gathering all relevant details from the event. + */ +export function dragContextBuilder({ event, widgets }) { + const draggedWidgetId = event.target.id; + const draggedWidget = getWidgetById(widgets, draggedWidgetId); + const sourceSlotId = draggedWidget.parent; + + // Initialize drag context + const context = new DragContext({ widgets, draggedWidgetId, sourceSlotId, targetSlotId: sourceSlotId }); + + // Determine the potential drop target + const targetSlotId = getDroppableSlotIdOnScreen(event, widgets); + context.updateTarget(targetSlotId); + + return context; +} + +/** + * Given an event, finds the **nearest valid droppable slot**. + */ +export const getDroppableSlotIdOnScreen = (event, widgets) => { + const [slotId] = document + .elementsFromPoint(event.clientX, event.clientY) + .filter( + (ele) => + !event.target.contains(ele) && ele.id !== event.target.id && ele.classList.contains('drag-container-parent') + ) + .map((ele) => extractSlotId(ele)) + .filter((slotId) => { + const widgetType = getWidgetById(widgets, slotId.slice(0, 36))?.component?.component || CANVAS_ID; + return DropAreaEntity.dropAreaWidgets.includes(widgetType); + }); + + return slotId; +}; + +/** + * Finds a widget by its ID. + */ +export function getWidgetById(boxList, targetId) { + return boxList.find((box) => box.id === targetId) ?? null; +} + +/** + * Extracts the **slot ID** from a given DOM element. + */ +const extractSlotId = (element) => { + return element?.getAttribute('component-id') || element.id.replace(/^canvas-/, ''); +}; + +/** + * Computes the final (left, top) position for a dragged widget based on grid snapping and drop conditions. + * + * @param {Object} event - Drag event object containing movement data. + * @param {DropAreaEntity} target - The target drop area entity (where widget is dropped). + * @param {boolean} isParentChangeAllowed - Whether the widget can move to the target. + * @param {number} gridWidth - The width of the grid for alignment. + * @param {DragEntity} dragged - The entity being dragged. + * @returns {Object} { left, top } - The computed position. + */ +export const getAdjustedDropPosition = (event, target, isParentChangeAllowed, gridWidth, dragged) => { + let left = event.lastEvent?.translate[0]; + let top = event.lastEvent?.translate[1]; + + if (isParentChangeAllowed) { + // Compute the relative position inside the new container + const { left: adjustedLeft, top: adjustedTop } = getMouseDistanceFromParentDiv( + event, + target.slotId, + target.widgetType + ); + + return { + left: Math.round(adjustedLeft / gridWidth) * gridWidth, // Snap to the nearest grid column + top: Math.round(adjustedTop / 10) * 10, // Snap to the nearest 10px + }; + } + + // If movement is restricted, revert to original position + return { + left: dragged.left * gridWidth, + top: dragged.top, + }; +}; diff --git a/frontend/src/AppBuilder/AppCanvas/appCanvasConstants.js b/frontend/src/AppBuilder/AppCanvas/appCanvasConstants.js index 6ca8f97e2f..75b3402331 100644 --- a/frontend/src/AppBuilder/AppCanvas/appCanvasConstants.js +++ b/frontend/src/AppBuilder/AppCanvas/appCanvasConstants.js @@ -6,7 +6,7 @@ export const CANVAS_WIDTHS = Object.freeze({ rightSideBarWidth: 300, }); -export const WIDGETS_WITH_DEFAULT_CHILDREN = ['Listview', 'Tabs', 'Form', 'Kanban', 'Container']; +export const WIDGETS_WITH_DEFAULT_CHILDREN = ['Listview', 'Tabs', 'Form', 'Kanban', 'Container', 'ModalV2']; export const DEFAULT_CANVAS_WIDTH = 1292; diff --git a/frontend/src/AppBuilder/AppCanvas/appCanvasUtils.js b/frontend/src/AppBuilder/AppCanvas/appCanvasUtils.js index 2c4259c22f..4e7b56ea70 100644 --- a/frontend/src/AppBuilder/AppCanvas/appCanvasUtils.js +++ b/frontend/src/AppBuilder/AppCanvas/appCanvasUtils.js @@ -89,7 +89,8 @@ export function addChildrenWidgetsToParent(componentType, parentId, currentLayou const defaultChildren = deepClone(parentMeta)['defaultChildren']; defaultChildren.forEach((child) => { - const { componentName, layout, incrementWidth, properties, accessorKey, tab, defaultValue, styles } = child; + const { componentName, layout, incrementWidth, properties, accessorKey, tab, defaultValue, styles, slotName } = + child; const componentMeta = deepClone(componentTypes.find((component) => component.component === componentName)); const componentData = JSON.parse(JSON.stringify(componentMeta)); @@ -139,7 +140,12 @@ export function addChildrenWidgetsToParent(componentType, parentId, currentLayou } const nonActiveLayout = currentLayout === 'desktop' ? 'mobile' : 'desktop'; - const _parent = getParentComponentIdByType(child, parentMeta.component, parentId); + const _parent = getParentComponentIdByType({ + child, + parentComponent: parentMeta.component, + parentId, + slotName, + }); const newChildComponent = { id: uuidv4(), @@ -199,7 +205,8 @@ export const getAllChildComponents = (allComponents, parentId) => { allComponents[parentId]?.component?.component === 'Tabs' || allComponents[parentId]?.component?.component === 'Calendar' || allComponents[parentId]?.component?.component === 'Kanban' || - allComponents[parentId]?.component?.component === 'Container'; + allComponents[parentId]?.component?.component === 'Container' || + allComponents[parentId]?.component?.component === 'ModalV2'; if (componentParentId && isParentTabORCalendar) { let childComponent = deepClone(allComponents[componentId]); @@ -319,7 +326,8 @@ const isChildOfTabsOrCalendar = (component, allComponents = [], componentParentI return ( parentComponent.component.component === 'Tabs' || parentComponent.component.component === 'Calendar' || - parentComponent.component.component === 'Container' + parentComponent.component.component === 'Container' || + parentComponent.component.component === 'ModalV2' ); } @@ -657,10 +665,23 @@ export const computeViewerBackgroundColor = (isAppDarkMode, canvasBgColor) => { return canvasBgColor; }; -export const getParentComponentIdByType = (child, parentComponent, parentId) => { +export const getParentComponentIdByType = ({ child, parentComponent, parentId, slotName = 'header' }) => { const { tab } = child; if (parentComponent === 'Tabs') return `${parentId}-${tab}`; - else if (parentComponent === 'Container') return `${parentId}-header`; + else if (parentComponent === 'Container' || parentComponent === 'ModalV2') { + return `${parentId}-${slotName}`; + } return parentId; }; + +export const getParentWidgetFromId = (parentType, parentId) => { + const isAddingToSlot = parentId?.includes('-header') || parentId?.includes('-footer'); + + if (parentType === 'ModalV2' && isAddingToSlot) { + return 'ModalSlot'; + } else if (parentType === 'Kanban') { + return 'Kanban_card'; + } + return parentType; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/ComponentsManagerTab.jsx b/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/ComponentsManagerTab.jsx index ef70343140..2ad7977496 100644 --- a/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/ComponentsManagerTab.jsx +++ b/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/ComponentsManagerTab.jsx @@ -133,7 +133,7 @@ export const ComponentsManagerTab = ({ darkMode }) => { 'StarRating', ]; const integrationItems = ['Map']; - const layoutItems = ['Container', 'Listview', 'Tabs', 'Modal']; + const layoutItems = ['Container', 'Listview', 'Tabs', 'ModalV2']; filteredComponents.forEach((f) => { if (commonItems.includes(f)) commonSection.items.push(f); diff --git a/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/constants.js b/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/constants.js index cc66756eff..fda26656ca 100644 --- a/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/constants.js +++ b/frontend/src/AppBuilder/RightSideBar/ComponentsManagerTab/constants.js @@ -1 +1 @@ -export const LEGACY_ITEMS = ['ToggleSwitch', 'DropDown', 'Multiselect', 'RadioButton', 'Datepicker']; +export const LEGACY_ITEMS = ['ToggleSwitch', 'DropDown', 'Multiselect', 'RadioButton', 'Datepicker', 'Modal']; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/DefaultComponent.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/DefaultComponent.jsx index acd1908bba..d680f21d1e 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/DefaultComponent.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/DefaultComponent.jsx @@ -23,6 +23,7 @@ const SHOW_ADDITIONAL_ACTIONS = [ 'Button', 'RichTextEditor', 'Image', + 'ModalV2', ]; const PROPERTIES_VS_ACCORDION_TITLE = { Text: 'Data', @@ -34,6 +35,7 @@ const PROPERTIES_VS_ACCORDION_TITLE = { Button: 'Data', Image: 'Data', Container: 'Data', + ModalV2: 'Data', }; export const DefaultComponent = ({ componentMeta, darkMode, ...restProps }) => { @@ -151,7 +153,8 @@ export const baseComponentProperties = ( 'properties', currentState, allComponents, - darkMode + darkMode, + '' ) ), }); diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/ModalV2.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/ModalV2.jsx new file mode 100644 index 0000000000..4131217386 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/ModalV2.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import Accordion from '@/_ui/Accordion'; +import { renderElement } from '../Utils'; +import { baseComponentProperties } from './DefaultComponent'; +import { resolveReferences } from '@/_helpers/utils'; + +const INDEX_OF_TRIGGER = 2; + +export const ModalV2 = ({ componentMeta, darkMode, ...restProps }) => { + const { + layoutPropertyChanged, + component, + paramUpdated, + dataQueries, + currentState, + eventsChanged, + apps, + allComponents, + } = restProps; + + let properties = []; + let additionalActions = []; + let dataProperties = []; + + const events = Object.keys(componentMeta.events); + const validations = Object.keys(componentMeta.validation || {}); + + for (const [key] of Object.entries(componentMeta?.properties)) { + if (componentMeta?.properties[key]?.section === 'additionalActions') { + additionalActions.push(key); + } else if (componentMeta?.properties[key]?.accordian === 'Data') { + dataProperties.push(key); + } else { + properties.push(key); + } + } + + const renderCustomElement = (param, paramType = 'properties') => { + return renderElement(component, componentMeta, paramUpdated, dataQueries, param, paramType, currentState); + }; + const conditionalAccordionItems = (component) => { + const useDefaultButton = resolveReferences( + component.component.definition.properties.useDefaultButton?.value ?? false + ); + const accordionItems = []; + let renderOptions = []; + const options = ['visibility', 'disabledTrigger', 'useDefaultButton']; + + options.map((option) => renderOptions.push(renderCustomElement(option))); + + const conditionalOptions = [{ name: 'triggerButtonLabel', condition: useDefaultButton }]; + + conditionalOptions.map(({ name, condition }) => { + if (condition) renderOptions.push(renderCustomElement(name)); + }); + + accordionItems.push({ + title: 'Trigger', + children: renderOptions, + }); + + return accordionItems; + }; + + if (component.component.definition.properties.size.value === 'fullscreen') { + component.component.properties.modalHeight = { + ...component.component.properties.modalHeight, + isHidden: true, + }; + } + + if (component.component.definition.properties.showHeader.value === '{{false}}') { + component.component.properties.headerHeight = { + ...component.component.properties.headerHeight, + isHidden: true, + }; + } + + if (component.component.definition.properties.showFooter.value === '{{false}}') { + component.component.properties.footerHeight = { + ...component.component.properties.footerHeight, + isHidden: true, + }; + } + + const accordionItems = baseComponentProperties( + dataProperties, + events, + component, + componentMeta, + layoutPropertyChanged, + paramUpdated, + dataQueries, + currentState, + eventsChanged, + apps, + allComponents, + validations, + darkMode, + [], + additionalActions + ); + + const [optionsItems] = conditionalAccordionItems(component); + + // Insert the Trigger option as the third item + accordionItems.splice(INDEX_OF_TRIGGER, 0, optionsItems); + + return ; +}; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx index 7ee512ee20..c74e023c0b 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Elements/Code.jsx @@ -19,6 +19,7 @@ export const Code = ({ accordian, placeholder, validationFn, + isHidden = false, }) => { const currentState = useCurrentState(); @@ -43,6 +44,7 @@ export const Code = ({ onChange({ name: 'iconVisibility' }, 'value', value, 'styles'); } + if (isHidden) return null; return (
{ @@ -703,6 +705,9 @@ const GetAccordion = React.memo( case 'FilePicker': return ; + case 'ModalV2': + return ; + case 'Modal': return ; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js index 0ddda9f572..62ee032172 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Utils.js @@ -50,7 +50,8 @@ export function renderCustomStyles( componentConfig.component == 'MultiselectV2' || componentConfig.component == 'RadioButtonV2' || componentConfig.component == 'Button' || - componentConfig.component == 'Image' + componentConfig.component == 'Image' || + componentConfig.component == 'ModalV2' ) { const paramTypeConfig = componentMeta[paramType] || {}; const paramConfig = paramTypeConfig[param] || {}; @@ -131,6 +132,7 @@ export function renderElement( const paramTypeDefinition = componentDefinition[paramType] || {}; const definition = paramTypeDefinition[param] || {}; const meta = componentMeta[paramType][param]; + const isHidden = component.component.properties[param]?.isHidden ?? false; if ( componentConfig.component == 'DropDown' || @@ -170,6 +172,7 @@ export function renderElement( component={component} placeholder={placeholder} validationFn={validationFn} + isHidden={isHidden} /> ); } diff --git a/frontend/src/AppBuilder/RightSideBar/WidgetBox/WidgetBox.jsx b/frontend/src/AppBuilder/RightSideBar/WidgetBox/WidgetBox.jsx index 1226bfba3e..69ded14971 100644 --- a/frontend/src/AppBuilder/RightSideBar/WidgetBox/WidgetBox.jsx +++ b/frontend/src/AppBuilder/RightSideBar/WidgetBox/WidgetBox.jsx @@ -2,7 +2,7 @@ import React from 'react'; import WidgetIcon from '@/../assets/images/icons/widgets'; import { useTranslation } from 'react-i18next'; -const LEGACY_WIDGETS = ['ToggleSwitch', 'DropDown', 'Multiselect', 'RadioButton', 'Datepicker']; +const LEGACY_WIDGETS = ['ToggleSwitch', 'DropDown', 'Multiselect', 'RadioButton', 'Datepicker', 'Modal']; const NEW_WIDGETS = [ 'ToggleSwitchV2', 'DropdownV2', @@ -12,6 +12,7 @@ const NEW_WIDGETS = [ 'DaterangePicker', 'DatePickerV2', 'TimePicker', + 'ModalV2', ]; export const WidgetBox = ({ component, darkMode }) => { diff --git a/frontend/src/AppBuilder/WidgetManager/configs/restrictedWidgetsConfig.js b/frontend/src/AppBuilder/WidgetManager/configs/restrictedWidgetsConfig.js index 4ad52da372..6933f4a5a5 100644 --- a/frontend/src/AppBuilder/WidgetManager/configs/restrictedWidgetsConfig.js +++ b/frontend/src/AppBuilder/WidgetManager/configs/restrictedWidgetsConfig.js @@ -4,7 +4,14 @@ export const RESTRICTED_WIDGETS_CONFIG = { Calendar: ['Calendar', 'Kanban'], Container: ['Calendar', 'Kanban'], Modal: ['Calendar', 'Kanban'], + ModalV2: ['Calendar', 'Kanban'], + ModalSlot: ['Calendar', 'Kanban', 'Table', 'Listview', 'Container'], Tabs: ['Calendar', 'Kanban'], Kanban_popout: ['Calendar', 'Kanban'], Listview: ['Calendar', 'Kanban'], }; + +export const RESTRICTED_WIDGET_SLOTS_CONFIG = { + header: ['Calendar', 'Kanban', 'Table', 'Listview', 'Container'], + footer: ['Calendar', 'Kanban', 'Table', 'Listview', 'Container'], +}; diff --git a/frontend/src/AppBuilder/WidgetManager/configs/widgetConfig.js b/frontend/src/AppBuilder/WidgetManager/configs/widgetConfig.js index 13832857fb..be2855c476 100644 --- a/frontend/src/AppBuilder/WidgetManager/configs/widgetConfig.js +++ b/frontend/src/AppBuilder/WidgetManager/configs/widgetConfig.js @@ -3,6 +3,7 @@ import { tableConfig, chartConfig, modalConfig, + modalV2Config, formConfig, textinputConfig, numberinputConfig, @@ -64,6 +65,7 @@ export const widgets = [ buttonConfig, chartConfig, modalConfig, + modalV2Config, formConfig, textinputConfig, numberinputConfig, diff --git a/frontend/src/AppBuilder/WidgetManager/widgets/index.js b/frontend/src/AppBuilder/WidgetManager/widgets/index.js index 2540cdeeef..ce0e73fdf5 100644 --- a/frontend/src/AppBuilder/WidgetManager/widgets/index.js +++ b/frontend/src/AppBuilder/WidgetManager/widgets/index.js @@ -2,6 +2,7 @@ import { buttonConfig } from './button'; import { tableConfig } from './table'; import { chartConfig } from './chart'; import { modalConfig } from './modal'; +import { modalV2Config } from './modalV2'; import { formConfig } from './form'; import { textinputConfig } from './textinput'; import { numberinputConfig } from './numberinput'; @@ -62,7 +63,8 @@ export { buttonConfig, tableConfig, chartConfig, - modalConfig, + modalConfig, //Deprecated + modalV2Config, formConfig, textinputConfig, numberinputConfig, diff --git a/frontend/src/AppBuilder/WidgetManager/widgets/modal.js b/frontend/src/AppBuilder/WidgetManager/widgets/modal.js index 42740ad9c1..8f0c34b566 100644 --- a/frontend/src/AppBuilder/WidgetManager/widgets/modal.js +++ b/frontend/src/AppBuilder/WidgetManager/widgets/modal.js @@ -1,6 +1,6 @@ export const modalConfig = { - name: 'Modal', - displayName: 'Modal', + name: 'ModalLegacy', + displayName: 'Modal (Legacy)', description: 'Show pop-up windows', component: 'Modal', defaultSize: { diff --git a/frontend/src/AppBuilder/WidgetManager/widgets/modalV2.js b/frontend/src/AppBuilder/WidgetManager/widgets/modalV2.js new file mode 100644 index 0000000000..e7e96c4398 --- /dev/null +++ b/frontend/src/AppBuilder/WidgetManager/widgets/modalV2.js @@ -0,0 +1,277 @@ +export const modalV2Config = { + name: 'Modal', + displayName: 'Modal', + description: 'Show pop-up windows', + component: 'ModalV2', + defaultSize: { + width: 10, + height: 34, + }, + others: { + showOnDesktop: { type: 'toggle', displayName: 'Show on desktop' }, + showOnMobile: { type: 'toggle', displayName: 'Show on mobile' }, + }, + properties: { + loadingState: { + type: 'toggle', + displayName: 'Loading state', + validation: { + schema: { type: 'boolean' }, + defaultValue: false, + }, + section: 'additionalActions', + }, + visibility: { + type: 'toggle', + displayName: 'Modal trigger visibility', + validation: { + schema: { type: 'boolean' }, + defaultValue: true, + }, + }, + disabledTrigger: { + type: 'toggle', + displayName: 'Disable modal trigger', + validation: { + schema: { type: 'boolean' }, + defaultValue: false, + }, + }, + disabledModal: { + type: 'toggle', + displayName: 'Disable modal window', + validation: { + schema: { type: 'boolean' }, + defaultValue: false, + }, + section: 'additionalActions', + }, + useDefaultButton: { + type: 'toggle', + displayName: 'Use default trigger button', + validation: { + schema: { + type: 'boolean', + }, + defaultValue: true, + }, + }, + triggerButtonLabel: { + type: 'code', + displayName: 'Trigger button label', + validation: { + schema: { + type: 'string', + }, + defaultValue: 'Launch Modal', + }, + }, + + // Data Accordion + showHeader: { type: 'toggle', displayName: 'Header', accordian: 'Data' }, + showFooter: { type: 'toggle', displayName: 'Footer', accordian: 'Data' }, + + size: { + type: 'select', + displayName: 'Width', + accordian: 'Data', + options: [ + { name: 'small', value: 'sm' }, + { name: 'medium', value: 'lg' }, + { name: 'large', value: 'xl' }, + { name: 'fullscreen', value: 'fullscreen' }, + ], + validation: { + schema: { type: 'string' }, + defaultValue: 'lg', + }, + }, + modalHeight: { + type: 'numberInput', + displayName: 'Height', + accordian: 'Data', + validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 400 }, + }, + headerHeight: { + type: 'numberInput', + displayName: 'Header height', + accordian: 'Data', + validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 }, + }, + footerHeight: { + type: 'numberInput', + displayName: 'Footer height', + accordian: 'Data', + validation: { schema: { type: 'union', schemas: [{ type: 'string' }, { type: 'number' }] }, defaultValue: 80 }, + }, + hideOnEsc: { type: 'toggle', displayName: 'Close on escape key', section: 'additionalActions' }, + closeOnClickingOutside: { type: 'toggle', displayName: 'Close on clicking outside', section: 'additionalActions' }, + hideCloseButton: { type: 'toggle', displayName: 'Hide close button', section: 'additionalActions' }, + }, + events: { + onOpen: { displayName: 'On open' }, + onClose: { displayName: 'On close' }, + }, + defaultChildren: [ + { + componentName: 'Text', + slotName: 'header', + layout: { + top: 21, + left: 1, + height: 40, + }, + displayName: 'ModalHeaderTitle', + properties: ['text'], + accessorKey: 'text', + styles: ['fontWeight', 'textSize', 'textColor'], + defaultValue: { + text: 'Modal title', + textSize: 20, + textColor: '#000', + }, + }, + { + componentName: 'Button', + slotName: 'footer', + layout: { + top: 24, + left: 22, + height: 36, + }, + displayName: 'ModalFooterCancel', + properties: ['text'], + styles: ['type', 'borderColor', 'padding'], + defaultValue: { + text: 'Button1', + type: 'outline', + borderColor: '#CCD1D5', + }, + }, + { + componentName: 'Button', + slotName: 'footer', + layout: { + top: 24, + left: 32, + height: 36, + }, + displayName: 'ModalFooterConfirm', + properties: ['text'], + defaultValue: { + text: 'Button2', + padding: 'none', + }, + }, + ], + styles: { + headerBackgroundColor: { + type: 'color', + displayName: 'Header background color', + validation: { + schema: { type: 'string' }, + defaultValue: '#ffffffff', + }, + }, + footerBackgroundColor: { + type: 'color', + displayName: 'Footer background color', + validation: { + schema: { type: 'string' }, + defaultValue: '#ffffffff', + }, + }, + bodyBackgroundColor: { + type: 'color', + displayName: 'Body background color', + validation: { + schema: { type: 'string' }, + defaultValue: '#ffffffff', + }, + }, + triggerButtonBackgroundColor: { + type: 'color', + displayName: 'Trigger button background color', + validation: { + schema: { type: 'string' }, + defaultValue: false, + }, + }, + triggerButtonTextColor: { + type: 'color', + displayName: 'Trigger button text color', + validation: { + schema: { type: 'string' }, + defaultValue: false, + }, + }, + }, + exposedVariables: { + show: false, + isDisabledModal: false, + isDisabledTrigger: false, + isVisible: true, + isLoading: false, + }, + actions: [ + { + handle: 'open', + displayName: 'Open', + }, + { + handle: 'close', + displayName: 'Close', + }, + { + handle: 'setVisibility', + displayName: 'Set visibility', + params: [{ handle: 'setVisibility', displayName: 'Value', defaultValue: '{{true}}', type: 'toggle' }], + }, + { + handle: 'setDisableTrigger', + displayName: 'Set disable trigger', + params: [{ handle: 'setDisableTrigger', displayName: 'Value', defaultValue: '{{false}}', type: 'toggle' }], + }, + { + handle: 'setDisableModal', + displayName: 'Set disable modal', + params: [{ handle: 'setDisableModal', displayName: 'Value', defaultValue: '{{false}}', type: 'toggle' }], + }, + { + handle: 'setLoading', + displayName: 'Set loading', + params: [{ handle: 'setLoading', displayName: 'Value', defaultValue: '{{false}}', type: 'toggle' }], + }, + ], + definition: { + others: { + showOnDesktop: { value: '{{true}}' }, + showOnMobile: { value: '{{false}}' }, + }, + properties: { + loadingState: { value: `{{false}}` }, + visibility: { value: '{{true}}' }, + disabledTrigger: { value: '{{false}}' }, + disabledModal: { value: '{{false}}' }, + useDefaultButton: { value: `{{true}}` }, + triggerButtonLabel: { value: `Launch Modal` }, + size: { value: 'lg' }, + showHeader: { value: '{{true}}' }, + showFooter: { value: '{{true}}' }, + hideCloseButton: { value: '{{false}}' }, + hideOnEsc: { value: '{{true}}' }, + closeOnClickingOutside: { value: '{{false}}' }, + modalHeight: { value: 400 }, + headerHeight: { value: 80 }, + footerHeight: { value: 80 }, + }, + events: [], + styles: { + headerBackgroundColor: { value: '#ffffffff' }, + footerBackgroundColor: { value: '#ffffffff' }, + bodyBackgroundColor: { value: '#ffffffff' }, + triggerButtonBackgroundColor: { value: '#4D72FA' }, + triggerButtonTextColor: { value: '#ffffffff' }, + }, + }, +}; diff --git a/frontend/src/AppBuilder/Widgets/Container.jsx b/frontend/src/AppBuilder/Widgets/Container.jsx index 16623280a5..1334098423 100644 --- a/frontend/src/AppBuilder/Widgets/Container.jsx +++ b/frontend/src/AppBuilder/Widgets/Container.jsx @@ -45,6 +45,7 @@ export const Container = ({ border: `1px solid ${borderColor}`, height, display: isVisible ? 'flex' : 'none', + flexDirection: 'column', position: 'relative', boxShadow, }; diff --git a/frontend/src/AppBuilder/Widgets/ModalV2/Components/Footer.jsx b/frontend/src/AppBuilder/Widgets/ModalV2/Components/Footer.jsx new file mode 100644 index 0000000000..e25027ce33 --- /dev/null +++ b/frontend/src/AppBuilder/Widgets/ModalV2/Components/Footer.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { default as BootstrapModal } from 'react-bootstrap/Modal'; +import { Container as SubContainer } from '@/AppBuilder/AppCanvas/Container'; +import { getCanvasHeight } from '@/AppBuilder/Widgets/ModalV2/helpers/utils'; + +export const ModalFooter = React.memo(({ id, isDisabled, customStyles, darkMode, width, footerHeight, onClick }) => { + const canvasFooterHeight = getCanvasHeight(footerHeight); + return ( + + + {isDisabled && ( +