From 3abc215b4250b2cd23dbf0dd8fbb3bea95c6efec Mon Sep 17 00:00:00 2001 From: Afiq Date: Thu, 7 Aug 2025 18:59:17 +0800 Subject: [PATCH] Add Layout Grid Component and Enhance Form Builder Functionality - Introduced a new 'layout-grid' component in FormBuilderComponents.vue, allowing users to create custom grid layouts with drag-and-drop capabilities. - Enhanced ComponentPreview.vue to support the layout grid, including cell spanning and dynamic component placement within grid cells. - Updated FormBuilderFieldSettingsModal.vue to include configuration options for the layout grid, such as grid dimensions, cell spanning, and layout presets. - Improved form data handling in workflow pages to accommodate nested components within layout grids, ensuring accurate data extraction and management. - Refactored styles across components to ensure a cohesive visual experience for the new layout grid functionality. - Updated package.json to modify the deploy script for improved Prisma integration. --- components/ComponentPreview.vue | 711 ++++++++++++++- components/FormBuilderComponents.vue | 25 + components/FormBuilderFieldSettingsModal.vue | 441 +++++++++- components/formkit/LayoutGrid.vue | 599 +++++++++++++ docs/form-builder-node-creation-guide.md | 866 +++++++++++++++++++ package.json | 2 +- pages/form-builder/index.vue | 2 +- pages/workflow/[id].vue | 51 +- prisma/json/json-schema.json | 2 +- prisma/schema.prisma | 2 +- 10 files changed, 2682 insertions(+), 19 deletions(-) create mode 100644 components/formkit/LayoutGrid.vue create mode 100644 docs/form-builder-node-creation-guide.md diff --git a/components/ComponentPreview.vue b/components/ComponentPreview.vue index 6176210..f1841c6 100644 --- a/components/ComponentPreview.vue +++ b/components/ComponentPreview.vue @@ -277,7 +277,7 @@
- +
@@ -496,6 +496,78 @@ + +
+
+ +
+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + Drop component here +
+ + +
+ +
+
+
+
+
+
@@ -695,7 +767,7 @@
- +
@@ -779,6 +851,7 @@ import { useNuxtApp } from '#app'; import { useFormBuilderStore } from '~/stores/formBuilder'; import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue'; + import { safeGetField } from '~/composables/safeGetField'; import { onMounted, onUnmounted, watch, computed, nextTick, triggerRef, defineComponent, h } from 'vue'; import draggable from 'vuedraggable'; @@ -823,6 +896,11 @@ const repeatingGroupContext = ref(null); // New reactive state for form sections const sectionDropStates = ref({}); +// Grid state for layout grid +const highlightedGridCell = ref(null) +const dragOverGridCell = ref(null) +const selectedGridCell = ref(null) + // State for nested component settings modal const showNestedSettingsModal = ref(false); const selectedNestedComponent = ref(null); @@ -889,13 +967,45 @@ onMounted(() => { for (let i = 0; i < minItems; i++) { const newGroup = {}; - // Add fields from configuration + // Add fields from configuration (legacy) if (props.component.props.fields) { props.component.props.fields.forEach(field => { newGroup[field.name] = ''; }); } + // Add fields from children components (form builder format) + if (props.component.props.children) { + const extractFieldsFromComponent = (comp) => { + if (!comp) return; + + // If this component has a name, add it to the group + if (comp.props && comp.props.name) { + newGroup[comp.props.name] = getDefaultValueForType(comp.type); + } + + // Handle layout grid components + if (comp.type === 'layout-grid' && comp.props.cells) { + comp.props.cells.forEach(cell => { + if (cell.component) { + extractFieldsFromComponent(cell.component); + } + }); + } + + // Handle nested container components + if (comp.props.children && Array.isArray(comp.props.children)) { + comp.props.children.forEach(nestedChild => { + extractFieldsFromComponent(nestedChild); + }); + } + }; + + props.component.props.children.forEach(child => { + extractFieldsFromComponent(child); + }); + } + initialGroups.push(newGroup); } @@ -967,13 +1077,45 @@ watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => { for (let i = 0; i < groupsToAdd; i++) { const newGroup = {}; - // Add fields from configuration + // Add fields from configuration (legacy) if (props.component.props.fields) { props.component.props.fields.forEach(field => { newGroup[field.name] = ''; }); } + // Add fields from children components (form builder format) + if (props.component.props.children) { + const extractFieldsFromComponent = (comp) => { + if (!comp) return; + + // If this component has a name, add it to the group + if (comp.props && comp.props.name) { + newGroup[comp.props.name] = getDefaultValueForType(comp.type); + } + + // Handle layout grid components + if (comp.type === 'layout-grid' && comp.props.cells) { + comp.props.cells.forEach(cell => { + if (cell.component) { + extractFieldsFromComponent(cell.component); + } + }); + } + + // Handle nested container components + if (comp.props.children && Array.isArray(comp.props.children)) { + comp.props.children.forEach(nestedChild => { + extractFieldsFromComponent(nestedChild); + }); + } + }; + + props.component.props.children.forEach(child => { + extractFieldsFromComponent(child); + }); + } + updatedGroups.push(newGroup); } @@ -1496,15 +1638,38 @@ const addRepeatingGroupItem = (groupName, children) => { // Initialize fields from children if (children) { - children.forEach(child => { - if (child.props && child.props.name) { + const extractFieldsFromComponent = (comp) => { + if (!comp) return; + + // If this component has a name, add it to the item + if (comp.props && comp.props.name) { // Use the same default value logic as getDefaultValueForType - if (child.type === 'select' && child.props.options && child.props.options.length > 0) { - newItem[child.props.name] = child.props.options[0].value || ''; + if (comp.type === 'select' && comp.props.options && comp.props.options.length > 0) { + newItem[comp.props.name] = comp.props.options[0].value || ''; } else { - newItem[child.props.name] = getDefaultValueForType(child.type); + newItem[comp.props.name] = getDefaultValueForType(comp.type); } } + + // Handle layout grid components + if (comp.type === 'layout-grid' && comp.props.cells) { + comp.props.cells.forEach(cell => { + if (cell.component) { + extractFieldsFromComponent(cell.component); + } + }); + } + + // Handle nested container components + if (comp.props.children && Array.isArray(comp.props.children)) { + comp.props.children.forEach(nestedChild => { + extractFieldsFromComponent(nestedChild); + }); + } + }; + + children.forEach(child => { + extractFieldsFromComponent(child); }); } @@ -1588,6 +1753,32 @@ const updateTableData = (newData) => { }); }; +// Handle form data update from nested components (especially in layout grid cells) +const handleFormDataUpdate = (updatedData) => { + console.log('[ComponentPreview] handleFormDataUpdate received:', updatedData); + + // Update the form store with the new data + formStore.updatePreviewFormData(updatedData); + + // Re-emit the event to propagate it up to parent components (like workflow execution) + emit('form-data-updated', updatedData); +}; + +// Update grid config for layout-grid component +const updateGridConfig = (newConfig) => { + console.log('[ComponentPreview] updateGridConfig:', newConfig); + + // Update the component props + if (props.component && props.component.props) { + Object.assign(props.component.props, newConfig); + + // Update the component in the form store + formStore.updateComponent(props.component); + + console.log('[ComponentPreview] Grid config updated:', newConfig); + } +}; + // Form Section Component const toggleSectionCollapse = (sectionId) => { // Find the section component at any nesting level @@ -1633,6 +1824,12 @@ const removeFromSection = (sectionId, childIndex) => { }; const handleSectionDragOver = (event, containerId) => { + console.log('🟡 [RepeatingGroup] handleSectionDragOver called', { + containerId, + target: event.target, + targetClass: event.target.className + }); + event.preventDefault(); event.stopPropagation(); @@ -1734,11 +1931,305 @@ const findContainerRecursively = (containerId, components = formStore.formCompon return found; } } + + // Special handling for Layout Grid components - search in their cells + if (component.type === 'layout-grid' && component.props.cells) { + for (const cell of component.props.cells) { + if (cell.component) { + // Check if this cell's component is the target container + if (containerId && cell.component.id === containerId) { + return cell.component; + } + + // If searching for parent by child ID, check if this cell's component contains the child + if (childId && cell.component.props.children && Array.isArray(cell.component.props.children)) { + const hasChild = cell.component.props.children.some(child => child.id === childId); + if (hasChild) { + return cell.component; + } + } + + // Recursively search in the cell's component children + if (cell.component.props.children && Array.isArray(cell.component.props.children)) { + const found = findContainerRecursively(containerId, cell.component.props.children, childId); + if (found) { + return found; + } + } + } + } + } } return null; }; +// Layout Grid helper functions +const getGridStyles = (props) => ({ + display: 'grid', + gridTemplateRows: `repeat(${props.rows || 2}, auto)`, + gridTemplateColumns: `repeat(${props.columns || 2}, minmax(0, 1fr))`, + gap: `${props.gap || 8}px`, + minHeight: 'auto', + height: 'auto' +}) + +const getCellStyles = (cell) => ({ + gridRow: `${cell.row + 1} / span ${cell.rowSpan}`, + gridColumn: `${cell.col + 1} / span ${cell.colSpan}`, + position: 'relative' +}) + +const isCellCovered = (cell, cells) => { + if (!cell || cell.rowSpan > 1 || cell.colSpan > 1) return false + + // Only check if this cell is covered by spanning cells + for (let i = 0; i < cells.length; i++) { + const otherCell = cells[i] + if (!otherCell || otherCell.rowSpan === 1 && otherCell.colSpan === 1) continue + + // Check if this cell is within the span of another cell + const isInRowSpan = cell.row >= otherCell.row && + cell.row < otherCell.row + otherCell.rowSpan + const isInColSpan = cell.col >= otherCell.col && + cell.col < otherCell.col + otherCell.colSpan + + if (isInRowSpan && isInColSpan) { + return true + } + } + + return false +} + +const isContainerComponent = (cell) => { + // Add null checks to prevent errors + if (!cell || !cell.component) { + console.log('🔍 [isContainerComponent] - No cell or component', { cell }) + return false + } + + const isContainer = ['repeating-group', 'form-section', 'dynamic-list'].includes(cell.component.type) + console.log('🔍 [isContainerComponent]', { + cell, + isContainer, + componentType: cell.component.type + }) + return isContainer +} + +const getGridCellEvents = (cell, index) => { + // If it's a container component, return empty events object to let the container handle its own events + if (isContainerComponent(cell)) { + return {} + } + + // For non-container components, return the Layout Grid's event handlers + return { + dragover: (event) => { + event.preventDefault() + handleGridCellDragOver(index, event) + }, + drop: (event) => { + handleGridCellDrop(index, event) + }, + dragenter: (event) => { + event.preventDefault() + handleGridCellDragEnter(index) + }, + dragleave: () => { + handleGridCellDragLeave(index) + } + } +} + +const selectGridCell = (index) => { + selectedGridCell.value = index +} + +const removeGridComponent = (componentId, cellIndex) => { + if (props.component && props.component.id === componentId && props.component.props.cells) { + props.component.props.cells[cellIndex].component = null + formStore.updateComponent(props.component) + } +} + +// Layout Grid drag and drop handlers +const handleGridDragOver = (event) => { + console.log('🔵 [LayoutGrid] handleGridDragOver called', { + target: event.target, + targetClass: event.target.className, + componentPreview: event.target.closest('.component-preview'), + gridCell: event.target.closest('.grid-cell') + }) + + // Don't prevent default if we're over a container component + const target = event.target.closest('.component-preview') + if (target) { + const cell = target.closest('.grid-cell') + if (cell && cell.parentElement) { + const cellIndex = Array.from(cell.parentElement.children).indexOf(cell) + console.log('🔵 [LayoutGrid] Found cell index:', cellIndex) + + if (cellIndex >= 0 && props.component.props.cells[cellIndex]) { + const currentCell = props.component.props.cells[cellIndex] + console.log('🔵 [LayoutGrid] Current cell:', currentCell) + + if (isContainerComponent(currentCell)) { + console.log('🔵 [LayoutGrid] Container component detected, letting it handle dragover') + return // Let the container handle the dragover + } + } + } + } + + console.log('🔵 [LayoutGrid] Handling dragover normally') + event.preventDefault() +} + +const handleGridDrop = (event) => { + console.log('🟢 [LayoutGrid] handleGridDrop called', { + target: event.target, + targetClass: event.target.className, + componentPreview: event.target.closest('.component-preview'), + gridCell: event.target.closest('.grid-cell') + }) + + // Don't handle drop if we're over a container component + const target = event.target.closest('.component-preview') + if (target) { + const cell = target.closest('.grid-cell') + if (cell && cell.parentElement) { + const cellIndex = Array.from(cell.parentElement.children).indexOf(cell) + console.log('🟢 [LayoutGrid] Found cell index:', cellIndex) + + if (cellIndex >= 0 && props.component.props.cells[cellIndex]) { + const currentCell = props.component.props.cells[cellIndex] + console.log('🟢 [LayoutGrid] Current cell:', currentCell) + + if (isContainerComponent(currentCell)) { + console.log('🟢 [LayoutGrid] Container component detected, letting it handle drop') + return // Let the container handle the drop + } + } + } + } + + console.log('🟢 [LayoutGrid] Handling drop normally') + event.preventDefault() + event.stopPropagation() + + try { + const componentData = JSON.parse(event.dataTransfer.getData('text/plain')) + console.log('🟢 [LayoutGrid] Component data:', componentData) + if (componentData.type && selectedGridCell.value !== null) { + const success = addComponentToGridCell(selectedGridCell.value, componentData) + if (success) { + event.stopImmediatePropagation() + } + } + } catch (error) { + console.warn('Invalid component data dropped:', error) + } +} + +const handleGridDragEnter = (event) => { + // Don't prevent default if we're over a container component + const target = event.target.closest('.component-preview') + if (target) { + const cell = target.closest('.grid-cell') + if (cell && cell.parentElement) { + const cellIndex = Array.from(cell.parentElement.children).indexOf(cell) + if (cellIndex >= 0 && props.component.props.cells[cellIndex]) { + const currentCell = props.component.props.cells[cellIndex] + if (isContainerComponent(currentCell)) { + return // Let the container handle the dragenter + } + } + } + } + event.preventDefault() +} + +const handleGridDragLeave = (event) => { + const rect = event.currentTarget.getBoundingClientRect() + const isOutside = ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ) + + if (isOutside) { + dragOverGridCell.value = null + } +} + +const handleGridCellDragOver = (cellIndex, event) => { + if (props.isPreview) return + event.preventDefault() + dragOverGridCell.value = cellIndex +} + +const handleGridCellDrop = (cellIndex, event) => { + if (props.isPreview) return + event.preventDefault() + event.stopPropagation() + dragOverGridCell.value = null + + try { + const componentData = JSON.parse(event.dataTransfer.getData('text/plain')) + if (componentData.type) { + const success = addComponentToGridCell(cellIndex, componentData) + if (success) { + event.stopImmediatePropagation() + } + } + } catch (error) { + console.warn('Invalid component data dropped:', error) + } +} + +const handleGridCellDragEnter = (cellIndex) => { + if (props.isPreview) return + dragOverGridCell.value = cellIndex +} + +const handleGridCellDragLeave = (cellIndex) => { + if (dragOverGridCell.value === cellIndex) { + dragOverGridCell.value = null + } +} + +const addComponentToGridCell = (cellIndex, componentData) => { + const newComponent = { + id: `${componentData.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + type: componentData.type, + name: componentData.name, + props: { + ...componentData.defaultProps, + label: componentData.defaultProps.label || componentData.name || componentData.type, + name: componentData.defaultProps.name || `${componentData.type}_${Date.now()}` + } + } + + // Update the current layout grid component directly + if (props.component && props.component.type === 'layout-grid' && props.component.props.cells) { + props.component.props.cells[cellIndex].component = newComponent + formStore.updateComponent(props.component) + console.log(`[LayoutGrid] Added component ${componentData.type} to cell ${cellIndex}`) + return true + } + + return false +} + const handleSectionDrop = (event, containerId) => { + console.log('🟡 [RepeatingGroup] handleSectionDrop called', { + containerId, + target: event.target, + targetClass: event.target.className + }); + event.preventDefault(); event.stopPropagation(); @@ -1758,6 +2249,8 @@ const handleSectionDrop = (event, containerId) => { componentData = window.__draggedComponentData || {}; } + console.log('🟡 [RepeatingGroup] Component data:', componentData); + if (!componentData.type) { console.warn('No valid component data found in drop event'); return; @@ -1780,6 +2273,62 @@ const handleSectionDrop = (event, containerId) => { // Find the target container at any nesting level const container = findContainerRecursively(containerId); + console.log('🟡 [RepeatingGroup] Found container:', container); + + // If container not found in main form, try to find it in the current Layout Grid + console.log('🟡 [RepeatingGroup] Checking Layout Grid search conditions:', { + container: container, + propsComponent: props.component, + componentType: props.component?.type + }); + + if (!container && props.component && props.component.type === 'layout-grid') { + console.log('🟡 [RepeatingGroup] Searching in Layout Grid cells...'); + console.log('🟡 [RepeatingGroup] Layout Grid cells:', props.component.props.cells); + console.log('🟡 [RepeatingGroup] Looking for containerId:', containerId); + + for (let i = 0; i < props.component.props.cells.length; i++) { + const cell = props.component.props.cells[i]; + console.log('🟡 [RepeatingGroup] Checking cell', i, ':', cell); + + if (cell.component) { + console.log('🟡 [RepeatingGroup] Cell', i, 'has component:', cell.component.id, 'vs looking for:', containerId); + + if (cell.component.id === containerId) { + console.log('🟡 [RepeatingGroup] Found container in Layout Grid cell:', cell.component); + + if (cell.component.type === 'repeating-group' || cell.component.type === 'form-section') { + // Initialize children array if it doesn't exist + if (!cell.component.props.children) { + cell.component.props.children = []; + } + + // Add the component to the container + cell.component.props.children.push(newComponent); + + // Update the Layout Grid component in the form store + formStore.updateComponent(props.component); + + // Record the action in history + formStore.recordHistory('add_component_to_container', { + componentType: newComponent.type, + componentName: newComponent.name, + containerId: containerId, + containerType: cell.component.type + }); + + console.log('🟡 [RepeatingGroup] Component added to container in Layout Grid:', newComponent); + return; + } + } + } else { + console.log('🟡 [RepeatingGroup] Cell', i, 'has no component'); + } + } + + console.log('🟡 [RepeatingGroup] Container not found in any Layout Grid cell'); + } + if (container && (container.type === 'form-section' || container.type === 'repeating-group')) { // Initialize children array if it doesn't exist if (!container.props.children) { @@ -1800,12 +2349,12 @@ const handleSectionDrop = (event, containerId) => { containerType: container.type }); - console.log('Component added to container:', newComponent); + console.log('🟡 [RepeatingGroup] Component added to container:', newComponent); } else { - console.warn('Container not found or invalid container type:', containerId); + console.warn('🟡 [RepeatingGroup] Container not found or invalid container type:', containerId); } } catch (error) { - console.error('Error dropping component into container:', error); + console.error('🟡 [RepeatingGroup] Error dropping component into container:', error); } }; @@ -2499,6 +3048,137 @@ onMounted(() => { transform: none; } +/* Layout Grid Styles */ +.layout-grid-container { + width: 100%; +} + +.grid-container { + display: grid; + min-height: auto; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px; + background-color: #ffffff; + transition: all 0.2s ease-in-out; + width: 100%; +} + +.grid-container.is-preview { + padding: 0; + border: none; + background: transparent; +} + +.grid-cell { + border: 1px dashed #d1d5db; + border-radius: 6px; + padding: 8px; + background-color: #fafafa; + transition: all 0.2s ease-in-out; + position: relative; + min-height: auto; + height: auto; + display: flex; + flex-direction: column; +} + +.grid-cell.is-preview { + border: none; + padding: 0; + background: transparent; +} + +.grid-cell.cell-empty { + display: flex; + align-items: center; + justify-content: center; + background-color: #f8fafc; + border-color: #e5e7eb; +} + +.grid-cell.cell-occupied { + background-color: #ffffff; + border-color: #d1d5db; +} + +.grid-cell.cell-highlighted { + border-color: #3b82f6; + background-color: #eff6ff; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); +} + +.grid-cell.cell-drag-over { + border-color: #10b981; + background-color: #ecfdf5; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.1); +} + +.cell-empty-state { + @apply flex flex-col items-center justify-center text-center; + min-height: 30px; + padding: 4px; +} + +.cell-empty-preview { + display: none; +} + +.component-preview { + position: relative; + width: 100%; + height: 100%; + flex: 1; + display: flex; + flex-direction: column; +} + +/* Ensure components inside grid cells are compact */ +.grid-cell .component-preview > div { + margin: 0 !important; + padding: 0 !important; +} + +.grid-cell .component-preview .py-2 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.grid-cell .component-preview .mb-2 { + margin-bottom: 0.25rem !important; +} + +.grid-cell .component-preview .mt-2 { + margin-top: 0.25rem !important; +} + +.component-actions { + position: absolute; + top: 4px; + right: 4px; + z-index: 10; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.component-preview:hover .component-actions { + opacity: 1; +} + +.component-actions button { + padding: 4px; + border-radius: 4px; + transition: all 0.15s ease-in-out; + background-color: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(4px); +} + +.component-actions button:hover { + transform: scale(1.1); +} + /* Custom button hover effects */ .custom-button[data-hover-effect="darken"]:hover { filter: brightness(0.9); @@ -2598,4 +3278,9 @@ onMounted(() => { overflow-wrap: break-word; word-wrap: break-word; } + +/* Layout Grid Component Styles */ +.layout-grid-wrapper { + @apply w-full; +} \ No newline at end of file diff --git a/components/FormBuilderComponents.vue b/components/FormBuilderComponents.vue index f895ca1..37f9b0f 100644 --- a/components/FormBuilderComponents.vue +++ b/components/FormBuilderComponents.vue @@ -758,6 +758,31 @@ const availableComponents = [ description: 'Horizontal divider line', defaultProps: {} }, + { + type: 'layout-grid', + name: 'Layout Grid', + category: 'Layout', + icon: 'material-symbols:grid-on', + description: 'Custom grid layout with drag-and-drop components and spanning capabilities', + defaultProps: { + label: '', + help: '', + rows: 2, + columns: 2, + gap: 8, + cells: [], // Array of cell objects with components and span settings + width: '100%', + gridColumn: 'span 12', + showPlaceholder: true, + // Conditional Logic Properties + conditionalLogic: { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } + } + }, { type: 'customHtml', name: 'Custom HTML', diff --git a/components/FormBuilderFieldSettingsModal.vue b/components/FormBuilderFieldSettingsModal.vue index 1222ebf..2533b5b 100644 --- a/components/FormBuilderFieldSettingsModal.vue +++ b/components/FormBuilderFieldSettingsModal.vue @@ -1343,6 +1343,204 @@ if (name && email) { + + +