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.
This commit is contained in:
parent
cc5093655a
commit
3abc215b42
@ -277,7 +277,7 @@
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<component-preview :component="childElement" :is-preview="false" />
|
||||
<component-preview :component="childElement" :is-preview="false" @form-data-updated="handleFormDataUpdate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -496,6 +496,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Grid Component -->
|
||||
<div v-else-if="component.type === 'layout-grid'" class="layout-grid-wrapper">
|
||||
<div class="layout-grid-container">
|
||||
<!-- Grid Container -->
|
||||
<div
|
||||
class="grid-container"
|
||||
:class="{ 'is-preview': isPreview }"
|
||||
:style="getGridStyles(component.props)"
|
||||
@dragover.prevent="handleGridDragOver"
|
||||
@drop="handleGridDrop"
|
||||
@dragenter.prevent="handleGridDragEnter"
|
||||
@dragleave="handleGridDragLeave"
|
||||
>
|
||||
<!-- Grid Cells -->
|
||||
<div
|
||||
v-for="(cell, index) in component.props.cells"
|
||||
:key="`cell-${index}`"
|
||||
v-show="!isCellCovered(cell, component.props.cells)"
|
||||
class="grid-cell"
|
||||
:class="{
|
||||
'cell-empty': !cell.component,
|
||||
'cell-occupied': cell.component,
|
||||
'cell-highlighted': highlightedGridCell === index,
|
||||
'cell-drag-over': dragOverGridCell === index,
|
||||
'is-preview': isPreview
|
||||
}"
|
||||
:style="getCellStyles(cell)"
|
||||
@click="!isPreview ? selectGridCell(index) : null"
|
||||
v-on="getGridCellEvents(cell, index)"
|
||||
>
|
||||
<!-- Component Preview -->
|
||||
<div v-if="cell.component" class="component-preview">
|
||||
<!-- Component Actions (when not in preview) -->
|
||||
<div v-if="!isPreview" class="component-actions absolute right-1 top-1 flex space-x-1 z-10">
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||
title="Component settings"
|
||||
@click.stop="openNestedComponentSettings(cell.component)"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-red-500 rounded"
|
||||
title="Remove component"
|
||||
@click.stop="removeGridComponent(component.id, index)"
|
||||
>
|
||||
<Icon name="material-symbols:close" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<component-preview
|
||||
:component="cell.component"
|
||||
:is-preview="isPreview"
|
||||
@form-data-updated="handleFormDataUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!isPreview" class="cell-empty-state">
|
||||
<Icon name="heroicons:plus" class="w-6 h-6 text-gray-400" />
|
||||
<span class="text-sm text-gray-500">Drop component here</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty cells in preview mode should be completely invisible -->
|
||||
<div v-else-if="isPreview && !cell.component" class="cell-empty-preview">
|
||||
<!-- Completely empty - no content -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button Component -->
|
||||
<div v-else-if="component.type === 'button'" class="py-2">
|
||||
<label v-if="component.props.label && component.props.showLabel !== false" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
@ -633,7 +705,7 @@
|
||||
gridColumn: childComponent.props.gridColumn || 'span 6'
|
||||
}"
|
||||
>
|
||||
<component-preview :component="childComponent" :is-preview="true" />
|
||||
<component-preview :component="childComponent" :is-preview="true" @form-data-updated="handleFormDataUpdate" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@ -695,7 +767,7 @@
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<component-preview :component="childElement" :is-preview="false" />
|
||||
<component-preview :component="childElement" :is-preview="false" @form-data-updated="handleFormDataUpdate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
@ -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',
|
||||
|
@ -1343,6 +1343,204 @@ if (name && email) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Layout Grid Configuration -->
|
||||
<template v-if="component.type === 'layout-grid'">
|
||||
<div class="space-y-6">
|
||||
<!-- Grid Layout Settings -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormKit
|
||||
type="number"
|
||||
label="Grid Areas (Vertical)"
|
||||
name="rows"
|
||||
v-model="configModel.rows"
|
||||
help="How many vertical sections you want"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
min="1"
|
||||
max="6"
|
||||
/>
|
||||
<FormKit
|
||||
type="number"
|
||||
label="Grid Areas (Horizontal)"
|
||||
name="columns"
|
||||
v-model="configModel.columns"
|
||||
help="How many horizontal sections you want"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
min="1"
|
||||
max="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormKit
|
||||
type="range"
|
||||
label="Spacing Between Areas"
|
||||
name="gap"
|
||||
v-model="configModel.gap"
|
||||
help="Space between each grid area"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
min="0"
|
||||
max="32"
|
||||
step="4"
|
||||
/>
|
||||
|
||||
<!-- Cell Spanning Controls -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Cell Spanning</h5>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p class="text-sm text-blue-800 mb-3">
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4 inline mr-1" />
|
||||
Configure how cells span across rows and columns
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(cell, index) in configModel.cells" :key="index" class="cell-span-control">
|
||||
<div class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-sm font-medium text-gray-700">Cell {{ index + 1 }}</span>
|
||||
<span class="text-xs text-gray-500">({{ cell.row + 1 }},{{ cell.col + 1 }})</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-1">
|
||||
<label class="text-xs text-gray-600">Rows:</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="cell.rowSpan"
|
||||
min="1"
|
||||
max="6"
|
||||
class="w-12 px-2 py-1 text-xs border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<label class="text-xs text-gray-600">Cols:</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="cell.colSpan"
|
||||
min="1"
|
||||
max="6"
|
||||
class="w-12 px-2 py-1 text-xs border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="removeCell(index)"
|
||||
class="p-1 text-red-400 hover:text-red-600 rounded"
|
||||
title="Remove cell"
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="addCell"
|
||||
class="w-full py-2 px-3 text-sm text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Icon name="heroicons:plus" class="w-4 h-4 inline mr-1" />
|
||||
Add Cell
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layout Presets -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Quick Layout Presets</h5>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@click="applyPreset('sidebar-right')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('sidebar-right') }"
|
||||
>
|
||||
<div class="preset-preview sidebar-right-preview"></div>
|
||||
<span class="preset-label">Sidebar Right</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="applyPreset('sidebar-left')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('sidebar-left') }"
|
||||
>
|
||||
<div class="preset-preview sidebar-left-preview"></div>
|
||||
<span class="preset-label">Sidebar Left</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="applyPreset('header-content')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('header-content') }"
|
||||
>
|
||||
<div class="preset-preview header-content-preview"></div>
|
||||
<span class="preset-label">Header + Content</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="applyPreset('three-column')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('three-column') }"
|
||||
>
|
||||
<div class="preset-preview three-column-preview"></div>
|
||||
<span class="preset-label">Three Column</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="applyPreset('two-by-two')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('two-by-two') }"
|
||||
>
|
||||
<div class="preset-preview two-by-two-preview"></div>
|
||||
<span class="preset-label">2x2 Grid</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="applyPreset('form-layout')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('form-layout') }"
|
||||
>
|
||||
<div class="preset-preview form-layout-preview"></div>
|
||||
<span class="preset-label">Form Layout</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="applyPreset('header-sidebar')"
|
||||
class="preset-button"
|
||||
:class="{ 'preset-active': isCurrentPreset('header-sidebar') }"
|
||||
>
|
||||
<div class="preset-preview header-sidebar-preview"></div>
|
||||
<span class="preset-label">Header + Sidebar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid Layout Management -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Layout Management</h5>
|
||||
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">
|
||||
Components in grid: {{ getTotalComponents() }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Drag form components into the grid areas to position them
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-600 mt-2">
|
||||
<p>• In the form builder, drag components directly into the grid areas</p>
|
||||
<p>• Components will be positioned exactly where you drop them</p>
|
||||
<p>• Adjust grid dimensions above to create more areas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Form Section Configuration -->
|
||||
<template v-if="component.type === 'form-section'">
|
||||
<div class="space-y-6">
|
||||
@ -2808,6 +3006,7 @@ const getComponentIcon = (type) => {
|
||||
paragraph: 'heroicons:document-text',
|
||||
divider: 'heroicons:minus',
|
||||
'form-section': 'material-symbols:view-module-outline',
|
||||
'layout-grid': 'material-symbols:grid-on',
|
||||
'info-display': 'heroicons:information-circle',
|
||||
'dynamic-list': 'heroicons:list-bullet',
|
||||
'repeating-table': 'heroicons:table-cells',
|
||||
@ -2846,6 +3045,7 @@ const getComponentTypeName = (type) => {
|
||||
paragraph: 'Paragraph Text',
|
||||
divider: 'Divider Line',
|
||||
'form-section': 'Form Section',
|
||||
'layout-grid': 'Layout Grid',
|
||||
'info-display': 'Information Display',
|
||||
'dynamic-list': 'Dynamic List',
|
||||
'repeating-table': 'Data Table',
|
||||
@ -2884,6 +3084,7 @@ const getComponentDescription = (type) => {
|
||||
paragraph: 'Text content for instructions and descriptions',
|
||||
divider: 'Visual separator to organize form sections',
|
||||
'form-section': 'Visual container to group related form fields into sections',
|
||||
'layout-grid': 'Create custom grid layouts for precise component positioning',
|
||||
'info-display': 'Read-only information display in organized format',
|
||||
'dynamic-list': 'Dynamic list for displaying and managing items',
|
||||
'repeating-table': 'Structured table for collecting multiple records with forms',
|
||||
@ -2939,7 +3140,7 @@ const showField = (fieldName) => {
|
||||
const hasOptions = computed(() => showField('options'))
|
||||
const hasSpecificSettings = computed(() => {
|
||||
if (!props.component) return false
|
||||
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml', 'info-display']
|
||||
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml', 'info-display', 'layout-grid']
|
||||
return specificTypes.includes(props.component.type)
|
||||
})
|
||||
|
||||
@ -3791,6 +3992,161 @@ const cancelTypeChange = () => {
|
||||
}
|
||||
|
||||
// Get default properties for a specific component type
|
||||
// Layout Grid Presets
|
||||
const layoutPresets = {
|
||||
'sidebar-right': {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-1', row: 0, col: 1, rowSpan: 2, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 1, col: 0, rowSpan: 1, colSpan: 1, component: null }
|
||||
]
|
||||
},
|
||||
'sidebar-left': {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 2, colSpan: 1, component: null },
|
||||
{ id: 'cell-1', row: 0, col: 1, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 1, col: 1, rowSpan: 1, colSpan: 1, component: null }
|
||||
]
|
||||
},
|
||||
'header-content': {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 1, colSpan: 2, component: null },
|
||||
{ id: 'cell-1', row: 1, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 1, col: 1, rowSpan: 1, colSpan: 1, component: null }
|
||||
]
|
||||
},
|
||||
'three-column': {
|
||||
rows: 1,
|
||||
columns: 3,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-1', row: 0, col: 1, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 0, col: 2, rowSpan: 1, colSpan: 1, component: null }
|
||||
]
|
||||
},
|
||||
'two-by-two': {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-1', row: 0, col: 1, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 1, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-3', row: 1, col: 1, rowSpan: 1, colSpan: 1, component: null }
|
||||
]
|
||||
},
|
||||
'form-layout': {
|
||||
rows: 3,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-1', row: 0, col: 1, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 1, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-3', row: 1, col: 1, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-4', row: 2, col: 0, rowSpan: 1, colSpan: 2, component: null }
|
||||
]
|
||||
},
|
||||
'header-sidebar': {
|
||||
rows: 3,
|
||||
columns: 2,
|
||||
cells: [
|
||||
{ id: 'cell-0', row: 0, col: 0, rowSpan: 1, colSpan: 2, component: null },
|
||||
{ id: 'cell-1', row: 1, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-2', row: 2, col: 0, rowSpan: 1, colSpan: 1, component: null },
|
||||
{ id: 'cell-3', row: 1, col: 1, rowSpan: 2, colSpan: 1, component: null }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentPreset = ref('')
|
||||
|
||||
// Apply a layout preset
|
||||
const applyPreset = (presetName) => {
|
||||
if (!props.component || props.component.type !== 'layout-grid') return
|
||||
|
||||
const preset = layoutPresets[presetName]
|
||||
if (!preset) return
|
||||
|
||||
// Update the component configuration
|
||||
configModel.value.rows = preset.rows
|
||||
configModel.value.columns = preset.columns
|
||||
configModel.value.cells = [...preset.cells] // Create a copy to avoid reference issues
|
||||
|
||||
currentPreset.value = presetName
|
||||
}
|
||||
|
||||
// Check if current layout matches a preset
|
||||
const isCurrentPreset = (presetName) => {
|
||||
if (!props.component || props.component.type !== 'layout-grid') return false
|
||||
|
||||
const preset = layoutPresets[presetName]
|
||||
if (!preset) return false
|
||||
|
||||
return (
|
||||
configModel.value.rows === preset.rows &&
|
||||
configModel.value.columns === preset.columns &&
|
||||
JSON.stringify(configModel.value.cells) === JSON.stringify(preset.cells)
|
||||
)
|
||||
}
|
||||
|
||||
// Layout Grid Cell Management
|
||||
const addCell = () => {
|
||||
if (!configModel.value.cells) {
|
||||
configModel.value.cells = []
|
||||
}
|
||||
|
||||
// Find the next available position
|
||||
const rows = configModel.value.rows || 2
|
||||
const cols = configModel.value.columns || 2
|
||||
let newRow = 0
|
||||
let newCol = 0
|
||||
|
||||
// Find first empty position
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const isOccupied = configModel.value.cells.some(cell =>
|
||||
cell.row === row && cell.col === col
|
||||
)
|
||||
if (!isOccupied) {
|
||||
newRow = row
|
||||
newCol = col
|
||||
break
|
||||
}
|
||||
}
|
||||
if (newRow !== 0 || newCol !== 0) break
|
||||
}
|
||||
|
||||
const newCell = {
|
||||
id: `cell-${Date.now()}`,
|
||||
row: newRow,
|
||||
col: newCol,
|
||||
rowSpan: 1,
|
||||
colSpan: 1,
|
||||
component: null
|
||||
}
|
||||
|
||||
configModel.value.cells.push(newCell)
|
||||
}
|
||||
|
||||
const removeCell = (index) => {
|
||||
if (configModel.value.cells && index >= 0 && index < configModel.value.cells.length) {
|
||||
configModel.value.cells.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Get total components in layout grid
|
||||
const getTotalComponents = () => {
|
||||
if (props.component && props.component.type === 'layout-grid' && props.component.props.cells) {
|
||||
return props.component.props.cells.filter(cell => cell.component).length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const getDefaultPropsForType = (type) => {
|
||||
const defaults = {
|
||||
text: {
|
||||
@ -4264,6 +4620,89 @@ const getDefaultPropsForType = (type) => {
|
||||
@apply mt-4 pt-4 border-t border-gray-200;
|
||||
}
|
||||
|
||||
/* Layout Grid Presets */
|
||||
.preset-button {
|
||||
@apply flex flex-col items-center p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
|
||||
}
|
||||
|
||||
.preset-button.preset-active {
|
||||
@apply border-blue-500 bg-blue-100;
|
||||
}
|
||||
|
||||
.preset-preview {
|
||||
@apply w-full h-16 mb-2 bg-gray-100 rounded border border-gray-200 relative;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
@apply text-xs font-medium text-gray-700;
|
||||
}
|
||||
|
||||
/* Preset Preview Layouts */
|
||||
.sidebar-right-preview {
|
||||
background: linear-gradient(90deg, #e5e7eb 0%, #e5e7eb 50%, #d1d5db 50%, #d1d5db 100%);
|
||||
background-size: 100% 50%;
|
||||
background-position: 0 0, 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.sidebar-left-preview {
|
||||
background: linear-gradient(90deg, #d1d5db 0%, #d1d5db 50%, #e5e7eb 50%, #e5e7eb 100%);
|
||||
background-size: 100% 50%;
|
||||
background-position: 0 0, 0 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.header-content-preview {
|
||||
background: linear-gradient(180deg, #d1d5db 0%, #d1d5db 50%, #e5e7eb 50%, #e5e7eb 100%);
|
||||
}
|
||||
|
||||
.three-column-preview {
|
||||
background: linear-gradient(90deg, #e5e7eb 0%, #e5e7eb 33.33%, #d1d5db 33.33%, #d1d5db 66.66%, #f3f4f6 66.66%, #f3f4f6 100%);
|
||||
}
|
||||
|
||||
.two-by-two-preview {
|
||||
background:
|
||||
linear-gradient(90deg, #e5e7eb 0%, #e5e7eb 50%, #d1d5db 50%, #d1d5db 100%) 0 0,
|
||||
linear-gradient(90deg, #d1d5db 0%, #d1d5db 50%, #e5e7eb 50%, #e5e7eb 100%) 0 100%;
|
||||
background-size: 100% 50%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.form-layout-preview {
|
||||
background:
|
||||
linear-gradient(90deg, #d1d5db 0%, #d1d5db 100%) 0 0,
|
||||
linear-gradient(90deg, #e5e7eb 0%, #e5e7eb 50%, #d1d5db 50%, #d1d5db 100%) 0 33.33%,
|
||||
linear-gradient(90deg, #f3f4f6 0%, #f3f4f6 100%) 0 66.66%;
|
||||
background-size: 100% 33.33%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.header-sidebar-preview {
|
||||
background:
|
||||
linear-gradient(90deg, #d1d5db 0%, #d1d5db 100%) 0 0,
|
||||
linear-gradient(90deg, #e5e7eb 0%, #e5e7eb 50%, #f3f4f6 50%, #f3f4f6 100%) 0 33.33%,
|
||||
linear-gradient(90deg, #d1d5db 0%, #d1d5db 50%, #f3f4f6 50%, #f3f4f6 100%) 0 66.66%;
|
||||
background-size: 100% 33.33%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* Layout Grid Cell Spanning Controls */
|
||||
.cell-span-control {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.cell-span-control:hover {
|
||||
@apply transform scale-[1.02];
|
||||
}
|
||||
|
||||
.cell-span-control input[type="number"] {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.cell-span-control input[type="number"]:focus {
|
||||
@apply border-blue-500 ring-1 ring-blue-500;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
@apply flex items-center;
|
||||
|
599
components/formkit/LayoutGrid.vue
Normal file
599
components/formkit/LayoutGrid.vue
Normal file
@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="layout-grid-container">
|
||||
|
||||
|
||||
<!-- Grid Container -->
|
||||
<div
|
||||
class="grid-container"
|
||||
:class="{ 'is-preview': isPreview }"
|
||||
:style="gridStyles"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragleave="handleDragLeave"
|
||||
>
|
||||
<!-- Grid Cells -->
|
||||
<div
|
||||
v-for="(cell, index) in gridCells"
|
||||
:key="`cell-${index}`"
|
||||
v-show="!isCellCovered(index)"
|
||||
class="grid-cell"
|
||||
:class="{
|
||||
'cell-empty': !cell.component,
|
||||
'cell-occupied': cell.component,
|
||||
'cell-highlighted': highlightedCell === index,
|
||||
'cell-drag-over': dragOverCell === index,
|
||||
'is-preview': isPreview
|
||||
}"
|
||||
:style="getCellStyles(cell)"
|
||||
@click="!isPreview ? selectCell(index) : null"
|
||||
@dragover.prevent="!isContainerComponent(cell) ? handleCellDragOver(index, $event) : null"
|
||||
@drop="!isContainerComponent(cell) ? handleCellDrop(index, $event) : null"
|
||||
@dragenter.prevent="!isContainerComponent(cell) ? handleCellDragEnter(index) : null"
|
||||
@dragleave="!isContainerComponent(cell) ? handleCellDragLeave(index) : null"
|
||||
|
||||
>
|
||||
|
||||
|
||||
<!-- Component Preview -->
|
||||
<div v-if="cell.component" class="component-preview">
|
||||
<!-- Component Actions (when not in preview) -->
|
||||
<div v-if="!isPreview" class="component-actions absolute right-1 top-1 flex space-x-1 z-10">
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||
title="Component settings"
|
||||
@click.stop="openComponentSettings(cell.component)"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-red-500 rounded"
|
||||
title="Remove component"
|
||||
@click.stop="removeComponent(index)"
|
||||
>
|
||||
<Icon name="material-symbols:close" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<component-preview
|
||||
:component="cell.component"
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!isPreview" class="cell-empty-state">
|
||||
<Icon name="heroicons:plus" class="w-6 h-6 text-gray-400" />
|
||||
<span class="text-sm text-gray-500">Drop component here</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty cells in preview mode should be completely invisible -->
|
||||
<div v-else-if="isPreview && !cell.component" class="cell-empty-preview">
|
||||
<!-- Completely empty - no content -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Nested Component Settings Modal -->
|
||||
<FormBuilderFieldSettingsModal
|
||||
v-model="showNestedSettingsModal"
|
||||
:component="selectedNestedComponent"
|
||||
@close="closeNestedSettingsModal"
|
||||
@update-component="saveNestedComponentSettings"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useFormBuilderStore } from '~/stores/formBuilder'
|
||||
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isPreview: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:config'])
|
||||
|
||||
// Form builder store
|
||||
const formStore = useFormBuilderStore()
|
||||
|
||||
// Grid state
|
||||
const selectedCell = ref(null)
|
||||
const highlightedCell = ref(null)
|
||||
const dragOverCell = ref(null)
|
||||
|
||||
// Nested settings modal state
|
||||
const showNestedSettingsModal = ref(false)
|
||||
const selectedNestedComponent = ref(null)
|
||||
|
||||
// Grid configuration - better defaults for beginners
|
||||
const gridConfig = ref({
|
||||
rows: props.config.rows || 2,
|
||||
columns: props.config.columns || 2,
|
||||
gap: props.config.gap || 16
|
||||
})
|
||||
|
||||
// Grid cells data
|
||||
const gridCells = ref(props.config.cells || [])
|
||||
|
||||
// Watch for config changes and update grid accordingly
|
||||
watch(() => props.config, (newConfig) => {
|
||||
if (newConfig) {
|
||||
const oldRows = gridConfig.value.rows
|
||||
const oldColumns = gridConfig.value.columns
|
||||
|
||||
gridConfig.value.rows = newConfig.rows || 2
|
||||
gridConfig.value.columns = newConfig.columns || 2
|
||||
gridConfig.value.gap = newConfig.gap || 16
|
||||
|
||||
// Only update cells if dimensions changed or cells are provided
|
||||
if (newConfig.cells && newConfig.cells.length > 0) {
|
||||
gridCells.value = [...newConfig.cells]
|
||||
} else if (oldRows !== gridConfig.value.rows || oldColumns !== gridConfig.value.columns) {
|
||||
initializeGridCells()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Debounced emit to prevent excessive updates
|
||||
let emitTimeout = null
|
||||
watch(gridCells, (newCells) => {
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
emitTimeout = setTimeout(() => {
|
||||
emit('update:config', {
|
||||
...props.config,
|
||||
cells: newCells
|
||||
})
|
||||
}, 100)
|
||||
}, { deep: false })
|
||||
|
||||
// Computed grid styles
|
||||
const gridStyles = computed(() => ({
|
||||
display: 'grid',
|
||||
gridTemplateRows: `repeat(${gridConfig.value.rows}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${gridConfig.value.columns}, 1fr)`,
|
||||
gap: `${gridConfig.value.gap}px`,
|
||||
minHeight: '200px'
|
||||
}))
|
||||
|
||||
// Initialize grid cells
|
||||
function initializeGridCells() {
|
||||
const totalCells = gridConfig.value.rows * gridConfig.value.columns
|
||||
gridCells.value = Array.from({ length: totalCells }, (_, index) => ({
|
||||
id: `cell-${index}`,
|
||||
row: Math.floor(index / gridConfig.value.columns),
|
||||
col: index % gridConfig.value.columns,
|
||||
rowSpan: 1,
|
||||
colSpan: 1,
|
||||
component: null
|
||||
}))
|
||||
}
|
||||
|
||||
// Get cell styles
|
||||
function getCellStyles(cell) {
|
||||
return {
|
||||
gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
|
||||
gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
|
||||
position: 'relative'
|
||||
}
|
||||
}
|
||||
|
||||
// Get cell position string
|
||||
function getCellPosition(index) {
|
||||
const cell = gridCells.value[index]
|
||||
return `${cell.row + 1},${cell.col + 1}`
|
||||
}
|
||||
|
||||
// Check if a cell is covered by a spanning cell (optimized)
|
||||
function isCellCovered(cellIndex) {
|
||||
const cell = gridCells.value[cellIndex]
|
||||
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 < gridCells.value.length; i++) {
|
||||
if (i === cellIndex) continue
|
||||
|
||||
const otherCell = gridCells.value[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
|
||||
}
|
||||
|
||||
// Check if a cell contains a container component
|
||||
function isContainerComponent(cell) {
|
||||
return cell.component && ['repeating-group', 'form-section', 'dynamic-list'].includes(cell.component.type)
|
||||
}
|
||||
|
||||
// Cell selection
|
||||
function selectCell(index) {
|
||||
selectedCell.value = index
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault()
|
||||
dragOverCell.value = null
|
||||
|
||||
// Check if we have a selected cell and if it's a container component
|
||||
if (selectedCell.value !== null) {
|
||||
const cell = gridCells.value[selectedCell.value]
|
||||
const isContainerComponent = cell.component && ['repeating-group', 'form-section', 'dynamic-list'].includes(cell.component.type)
|
||||
|
||||
if (isContainerComponent) {
|
||||
// Allow the drop event to pass through to the container component
|
||||
console.log('Allowing drop to pass through to container component:', cell.component.type)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For non-container components or empty cells, handle the drop here
|
||||
event.stopPropagation()
|
||||
|
||||
try {
|
||||
const componentData = JSON.parse(event.dataTransfer.getData('text/plain'))
|
||||
if (componentData.type) {
|
||||
const success = addComponentToCell(selectedCell.value || 0, componentData)
|
||||
if (success) {
|
||||
// Prevent the component from being added to the main form
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid component data dropped:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnter(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handleDragLeave(event) {
|
||||
// Only clear if actually leaving the container
|
||||
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) {
|
||||
dragOverCell.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleCellDragOver(cellIndex, event) {
|
||||
if (props.isPreview) return
|
||||
|
||||
// Only handle drag over for non-container components or empty cells
|
||||
event.preventDefault()
|
||||
dragOverCell.value = cellIndex
|
||||
}
|
||||
|
||||
function handleCellDrop(cellIndex, event) {
|
||||
if (props.isPreview) return
|
||||
|
||||
// Only handle drops for non-container components or empty cells
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragOverCell.value = null
|
||||
|
||||
try {
|
||||
const componentData = JSON.parse(event.dataTransfer.getData('text/plain'))
|
||||
if (componentData.type) {
|
||||
const success = addComponentToCell(cellIndex, componentData)
|
||||
if (success) {
|
||||
// Prevent the component from being added to the main form
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid component data dropped:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCellDragEnter(cellIndex) {
|
||||
if (props.isPreview) return
|
||||
|
||||
// Only handle drag enter for non-container components or empty cells
|
||||
dragOverCell.value = cellIndex
|
||||
}
|
||||
|
||||
function handleCellDragLeave(cellIndex) {
|
||||
if (dragOverCell.value === cellIndex) {
|
||||
dragOverCell.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Add component to cell
|
||||
function addComponentToCell(cellIndex, componentData) {
|
||||
if (cellIndex >= 0 && cellIndex < gridCells.value.length) {
|
||||
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()}`
|
||||
}
|
||||
}
|
||||
|
||||
gridCells.value[cellIndex].component = newComponent
|
||||
console.log(`[LayoutGrid] Added component ${componentData.type} to cell ${cellIndex}`)
|
||||
|
||||
// Prevent the component from being added to the main form
|
||||
// by stopping the event propagation
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove component from cell
|
||||
function removeComponent(cellIndex) {
|
||||
if (cellIndex >= 0 && cellIndex < gridCells.value.length) {
|
||||
gridCells.value[cellIndex].component = null
|
||||
console.log(`[LayoutGrid] Removed component from cell ${cellIndex}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to find container at any nesting level
|
||||
const findContainerRecursively = (containerId, components, childId = null) => {
|
||||
for (const component of components) {
|
||||
// Check if this is the target container
|
||||
if (containerId && component.id === containerId) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// If searching for parent by child ID, check if this component contains the child
|
||||
if (childId && component.props.children && Array.isArray(component.props.children)) {
|
||||
const hasChild = component.props.children.some(child => child.id === childId);
|
||||
if (hasChild) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
// If this component has children, search recursively
|
||||
if (component.props.children && Array.isArray(component.props.children)) {
|
||||
const found = findContainerRecursively(containerId, component.props.children, childId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nested component settings functions
|
||||
const openComponentSettings = (component) => {
|
||||
selectedNestedComponent.value = { ...component }
|
||||
showNestedSettingsModal.value = true
|
||||
console.log('Opening settings for component:', component)
|
||||
}
|
||||
|
||||
const closeNestedSettingsModal = () => {
|
||||
showNestedSettingsModal.value = false
|
||||
selectedNestedComponent.value = null
|
||||
}
|
||||
|
||||
const saveNestedComponentSettings = (updatedComponent) => {
|
||||
if (!updatedComponent || !selectedNestedComponent.value) return
|
||||
|
||||
// Find the parent container at any nesting level
|
||||
const parentContainer = findContainerRecursively(null, formStore.formComponents, updatedComponent.id)
|
||||
|
||||
if (parentContainer) {
|
||||
const childIndex = parentContainer.props.children.findIndex(child => child.id === updatedComponent.id)
|
||||
if (childIndex !== -1) {
|
||||
// Update the nested component
|
||||
parentContainer.props.children[childIndex] = { ...updatedComponent }
|
||||
// Update the container to trigger reactivity
|
||||
formStore.updateComponent(parentContainer)
|
||||
console.log('Updated nested component:', updatedComponent)
|
||||
}
|
||||
} else {
|
||||
// If not found in form components, it might be in the grid cells
|
||||
const cellIndex = gridCells.value.findIndex(cell =>
|
||||
cell.component && cell.component.id === updatedComponent.id
|
||||
)
|
||||
|
||||
if (cellIndex !== -1) {
|
||||
// Update the component directly in the grid cell
|
||||
gridCells.value[cellIndex].component = { ...updatedComponent }
|
||||
console.log('Updated component in grid cell:', updatedComponent)
|
||||
} else {
|
||||
console.warn('Could not find parent container for component:', updatedComponent.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
closeNestedSettingsModal()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-grid-container {
|
||||
@apply border-0 border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.grid-container {
|
||||
@apply relative;
|
||||
min-height: 200px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-container.is-preview {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
@apply transition-all duration-200;
|
||||
min-height: 80px;
|
||||
position: relative;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.grid-cell.is-preview {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grid-cell.cell-empty {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.grid-cell.cell-occupied {
|
||||
@apply border-gray-400 bg-white;
|
||||
}
|
||||
|
||||
.grid-cell.cell-highlighted {
|
||||
@apply border-blue-400 bg-blue-50;
|
||||
}
|
||||
|
||||
.grid-cell.cell-drag-over {
|
||||
@apply border-green-400 bg-green-50;
|
||||
}
|
||||
|
||||
.cell-header {
|
||||
@apply flex justify-between items-center mb-2 p-1 bg-gray-100 rounded text-xs;
|
||||
}
|
||||
|
||||
.cell-info {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.cell-position {
|
||||
@apply font-mono text-gray-600;
|
||||
}
|
||||
|
||||
.cell-component {
|
||||
@apply text-blue-600 font-medium;
|
||||
}
|
||||
|
||||
.cell-span {
|
||||
@apply text-xs text-gray-500 bg-gray-100 px-1 rounded;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.cell-action-btn {
|
||||
@apply p-1 text-gray-400 hover:text-gray-600 rounded transition-colors;
|
||||
}
|
||||
|
||||
.cell-action-btn:hover {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
|
||||
.component-preview {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.cell-empty-state {
|
||||
@apply flex flex-col items-center justify-center h-full text-center;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.cell-empty-state span {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
.cell-empty-preview {
|
||||
/* Completely invisible in preview mode */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.component-actions {
|
||||
@apply opacity-0 transition-opacity duration-200;
|
||||
}
|
||||
|
||||
.component-preview:hover .component-actions {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.nested-settings-modal {
|
||||
@apply max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
|
||||
.cell-help-text {
|
||||
@apply text-xs text-gray-500 italic;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.grid-header {
|
||||
@apply p-3;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
@apply flex-col gap-2;
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
@apply text-base font-semibold leading-tight;
|
||||
}
|
||||
|
||||
.grid-help {
|
||||
@apply text-sm leading-relaxed;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@apply mt-2;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
@apply p-3;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
@apply p-2;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
@apply mx-2;
|
||||
}
|
||||
}
|
||||
</style>
|
866
docs/form-builder-node-creation-guide.md
Normal file
866
docs/form-builder-node-creation-guide.md
Normal file
@ -0,0 +1,866 @@
|
||||
# Form Builder Node Creation Guide
|
||||
|
||||
This guide explains how to create new nodes/components for the form builder system. The form builder supports various types of components from simple text inputs to complex container components like repeating groups and layout grids.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Component Types](#component-types)
|
||||
3. [Basic Component Creation](#basic-component-creation)
|
||||
4. [Container Component Creation](#container-component-creation)
|
||||
5. [Complex Component Creation](#complex-component-creation)
|
||||
6. [Component Registration](#component-registration)
|
||||
7. [Settings Modal Integration](#settings-modal-integration)
|
||||
8. [Component Preview Integration](#component-preview-integration)
|
||||
9. [Drag & Drop Integration](#drag--drop-integration)
|
||||
10. [Best Practices](#best-practices)
|
||||
11. [Examples](#examples)
|
||||
|
||||
## Overview
|
||||
|
||||
The form builder system consists of several key files:
|
||||
|
||||
- **`FormBuilderComponents.vue`**: Defines available components and their metadata
|
||||
- **`ComponentPreview.vue`**: Renders components in the form builder
|
||||
- **`FormBuilderFieldSettingsModal.vue`**: Provides settings interface for components
|
||||
- **`stores/formBuilder.js`**: Manages form state and component operations
|
||||
|
||||
## Component Types
|
||||
|
||||
### 1. Basic Components
|
||||
Simple input components that collect data:
|
||||
- Text inputs, textareas, numbers
|
||||
- Select dropdowns, checkboxes, radio buttons
|
||||
- Date/time pickers, file uploads
|
||||
|
||||
### 2. Container Components
|
||||
Components that can hold other components:
|
||||
- **Form Section**: Groups related fields
|
||||
- **Repeating Group**: Allows multiple instances of the same fields
|
||||
- **Layout Grid**: Custom grid layout with spanning capabilities
|
||||
|
||||
### 3. Advanced Components
|
||||
Complex components with special functionality:
|
||||
- Custom HTML with CSS/JS
|
||||
- Conditional logic components
|
||||
- API integration components
|
||||
|
||||
## Basic Component Creation
|
||||
|
||||
### Step 1: Define Component Metadata
|
||||
|
||||
Add your component to `FormBuilderComponents.vue`:
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'my-component',
|
||||
name: 'My Component',
|
||||
category: 'Basic Inputs', // or 'Selection Inputs', 'Date and Time', 'Advanced', 'Layout'
|
||||
icon: 'heroicons:document-text', // Use appropriate icon
|
||||
description: 'Description of what this component does',
|
||||
defaultProps: {
|
||||
label: 'My Component',
|
||||
name: 'my_component',
|
||||
help: 'Help text for users',
|
||||
required: false,
|
||||
placeholder: 'Enter value...',
|
||||
width: '100%',
|
||||
gridColumn: 'span 6',
|
||||
// Component-specific properties
|
||||
myCustomProp: 'default value',
|
||||
// Conditional Logic Properties
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add Component Preview
|
||||
|
||||
Add rendering logic to `ComponentPreview.vue`:
|
||||
|
||||
```vue
|
||||
<!-- My Component -->
|
||||
<div v-else-if="component.type === 'my-component'" class="py-2">
|
||||
<label v-if="component.props.label && component.props.showLabel !== false"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ component.props.label }}
|
||||
<span v-if="component.props.required" class="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Component-specific rendering -->
|
||||
<div class="my-component-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="component.props.placeholder"
|
||||
:required="component.props.required"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="component.props.help" class="mt-1 text-xs text-gray-500">
|
||||
{{ component.props.help }}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Step 3: Add Settings Modal Support
|
||||
|
||||
Add settings to `FormBuilderFieldSettingsModal.vue`:
|
||||
|
||||
```javascript
|
||||
// In the script section, add to getComponentTypeName function
|
||||
getComponentTypeName(type) {
|
||||
const typeNames = {
|
||||
// ... existing types
|
||||
'my-component': 'My Component'
|
||||
}
|
||||
return typeNames[type] || 'Unknown Component'
|
||||
}
|
||||
|
||||
// Add to getComponentIcon function
|
||||
getComponentIcon(type) {
|
||||
const icons = {
|
||||
// ... existing icons
|
||||
'my-component': 'heroicons:document-text'
|
||||
}
|
||||
return icons[type] || 'heroicons:question-mark-circle'
|
||||
}
|
||||
|
||||
// Add to getComponentDescription function
|
||||
getComponentDescription(type) {
|
||||
const descriptions = {
|
||||
// ... existing descriptions
|
||||
'my-component': 'A custom component for collecting specific data'
|
||||
}
|
||||
return descriptions[type] || 'Component description'
|
||||
}
|
||||
```
|
||||
|
||||
## Container Component Creation
|
||||
|
||||
Container components are more complex as they can hold other components.
|
||||
|
||||
### Example: Creating a Custom Container
|
||||
|
||||
```javascript
|
||||
// In FormBuilderComponents.vue
|
||||
{
|
||||
type: 'custom-container',
|
||||
name: 'Custom Container',
|
||||
category: 'Layout',
|
||||
icon: 'material-symbols:view-in-ar',
|
||||
description: 'A custom container that can hold other components',
|
||||
defaultProps: {
|
||||
label: 'Custom Container',
|
||||
name: 'custom_container',
|
||||
help: 'Drag components here to add them',
|
||||
showHeader: true,
|
||||
headerBackground: '#f9fafb',
|
||||
backgroundColor: '#ffffff',
|
||||
showBorder: true,
|
||||
borderStyle: 'solid', // 'solid', 'dashed', 'dotted'
|
||||
spacing: 'normal', // 'compact', 'normal', 'relaxed'
|
||||
children: [], // Array to hold nested components
|
||||
// Conditional Logic Properties
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Container Component Preview
|
||||
|
||||
```vue
|
||||
<!-- Custom Container Component -->
|
||||
<div v-else-if="component.type === 'custom-container'" class="py-2">
|
||||
<div
|
||||
class="custom-container-wrapper"
|
||||
:class="{
|
||||
'has-border': component.props.showBorder,
|
||||
'border-dashed': component.props.borderStyle === 'dashed',
|
||||
'border-dotted': component.props.borderStyle === 'dotted',
|
||||
'spacing-compact': component.props.spacing === 'compact',
|
||||
'spacing-relaxed': component.props.spacing === 'relaxed'
|
||||
}"
|
||||
:style="{
|
||||
backgroundColor: component.props.backgroundColor || '#ffffff',
|
||||
borderColor: component.props.showBorder ? '#e5e7eb' : 'transparent'
|
||||
}"
|
||||
>
|
||||
<!-- Container Header -->
|
||||
<div
|
||||
v-if="component.props.showHeader"
|
||||
class="container-header"
|
||||
:style="{ backgroundColor: component.props.headerBackground || '#f9fafb' }"
|
||||
>
|
||||
<h3 class="container-title">
|
||||
{{ component.props.label || 'Custom Container' }}
|
||||
</h3>
|
||||
<p v-if="component.props.description" class="container-description">
|
||||
{{ component.props.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Container Content -->
|
||||
<div class="container-content">
|
||||
<!-- In preview mode, show only the nested components -->
|
||||
<div v-if="isPreview" class="container-fields">
|
||||
<div v-if="component.props.children && component.props.children.length > 0"
|
||||
class="grid grid-cols-12 gap-2">
|
||||
<template v-for="(childComponent, childIndex) in component.props.children"
|
||||
:key="childIndex">
|
||||
<div
|
||||
class="form-component"
|
||||
:style="{
|
||||
gridColumn: childComponent.props.gridColumn || 'span 6'
|
||||
}"
|
||||
>
|
||||
<component-preview :component="childComponent" :is-preview="true" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In form builder mode, show drop zone -->
|
||||
<div v-else class="container-drop-zone">
|
||||
<!-- Nested components rendered in grid -->
|
||||
<div v-if="component.props.children && component.props.children.length > 0"
|
||||
class="nested-components">
|
||||
<draggable
|
||||
v-model="component.props.children"
|
||||
group="form-components"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
ghost-class="ghost"
|
||||
animation="300"
|
||||
class="grid grid-cols-12 gap-2"
|
||||
@end="onNestedDragEnd"
|
||||
@add="onNestedComponentAdd"
|
||||
>
|
||||
<template #item="{ element: childElement, index: childIndex }">
|
||||
<div
|
||||
class="form-component relative border rounded-md overflow-hidden transition-all duration-200 bg-white"
|
||||
:class="'border-gray-200 hover:border-blue-300 hover:shadow-md'"
|
||||
:style="{
|
||||
gridColumn: childElement.props.gridColumn || 'span 6'
|
||||
}"
|
||||
>
|
||||
<!-- Component actions -->
|
||||
<div class="component-actions absolute right-1 top-1 flex space-x-1 z-10">
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||
title="Component settings"
|
||||
@click.stop="openNestedComponentSettings(childElement)"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-red-500 rounded"
|
||||
title="Remove from container"
|
||||
@click.stop="removeFromSection(component.id, childIndex)"
|
||||
>
|
||||
<Icon name="material-symbols:close" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<component-preview :component="childElement" :is-preview="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone placeholder -->
|
||||
<div v-else class="drop-placeholder">
|
||||
<Icon name="material-symbols:add-box-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p class="text-sm text-gray-500 text-center mb-1">Drop Components Here</p>
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
Drag form fields from the sidebar to add them to this container
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Complex Component Creation
|
||||
|
||||
### Example: Repeating Group Component
|
||||
|
||||
Repeating groups are complex container components that allow multiple instances of the same fields.
|
||||
|
||||
#### 1. Component Definition
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'repeating-group',
|
||||
name: 'Repeating Group',
|
||||
category: 'Layout',
|
||||
icon: 'material-symbols:view-in-ar',
|
||||
description: 'Group of fields that can be repeated multiple times',
|
||||
defaultProps: {
|
||||
label: 'Repeating Group',
|
||||
name: 'repeating_group',
|
||||
help: 'Add multiple instances of the same fields',
|
||||
buttonText: 'Add Item',
|
||||
showPlaceholder: true,
|
||||
children: [], // Array to hold nested components
|
||||
// Conditional Logic Properties
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Drag & Drop Integration
|
||||
|
||||
Container components need special drag & drop handling:
|
||||
|
||||
```javascript
|
||||
// In ComponentPreview.vue, add these functions:
|
||||
|
||||
const handleSectionDrop = (event, containerId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Reset drag state
|
||||
if (sectionDropStates.value[containerId]) {
|
||||
sectionDropStates.value[containerId].isDraggingOver = false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the dropped component data
|
||||
let componentData = null;
|
||||
|
||||
try {
|
||||
componentData = JSON.parse(event.dataTransfer.getData('text/plain') || '{}');
|
||||
} catch (parseError) {
|
||||
componentData = window.__draggedComponentData || {};
|
||||
}
|
||||
|
||||
if (!componentData.type) {
|
||||
console.warn('No valid component data found in drop event');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new component instance
|
||||
const newComponent = {
|
||||
id: `${componentData.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: componentData.type,
|
||||
name: componentData.name,
|
||||
props: {
|
||||
...componentData.defaultProps,
|
||||
gridColumn: 'span 6',
|
||||
width: '50%',
|
||||
label: componentData.defaultProps.label || componentData.name || `${componentData.type.charAt(0).toUpperCase() + componentData.type.slice(1)} Field`,
|
||||
name: componentData.defaultProps.name || `${componentData.type}_${Date.now()}`
|
||||
}
|
||||
};
|
||||
|
||||
// Find the target container
|
||||
const container = findContainerRecursively(containerId);
|
||||
if (container && (container.type === 'form-section' || container.type === 'repeating-group')) {
|
||||
// Initialize children array if it doesn't exist
|
||||
if (!container.props.children) {
|
||||
container.props.children = [];
|
||||
}
|
||||
|
||||
// Add the component to the container
|
||||
container.props.children.push(newComponent);
|
||||
|
||||
// Update the container in the form store
|
||||
formStore.updateComponent(container);
|
||||
|
||||
console.log('Component added to container:', newComponent);
|
||||
} else {
|
||||
console.warn('Container not found or invalid container type:', containerId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error dropping component into container:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. Container Search Function
|
||||
|
||||
```javascript
|
||||
// Helper function to find container at any nesting level
|
||||
const findContainerRecursively = (containerId, components = formStore.formComponents, childId = null) => {
|
||||
for (const component of components) {
|
||||
// Check if this is the target container
|
||||
if (containerId && component.id === containerId) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// If searching for parent by child ID, check if this component contains the child
|
||||
if (childId && component.props.children && Array.isArray(component.props.children)) {
|
||||
const hasChild = component.props.children.some(child => child.id === childId);
|
||||
if (hasChild) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
// If this component has children, search recursively
|
||||
if (component.props.children && Array.isArray(component.props.children)) {
|
||||
const found = findContainerRecursively(containerId, component.props.children, childId);
|
||||
if (found) {
|
||||
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;
|
||||
};
|
||||
```
|
||||
|
||||
## Component Registration
|
||||
|
||||
### 1. Add to Available Components
|
||||
|
||||
In `FormBuilderComponents.vue`, add your component to the `availableComponents` array:
|
||||
|
||||
```javascript
|
||||
const availableComponents = [
|
||||
// ... existing components
|
||||
{
|
||||
type: 'my-component',
|
||||
name: 'My Component',
|
||||
category: 'Basic Inputs',
|
||||
icon: 'heroicons:document-text',
|
||||
description: 'A custom component for collecting data',
|
||||
defaultProps: {
|
||||
// ... your default props
|
||||
}
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Add Category Support
|
||||
|
||||
If you're creating a new category, add it to the template:
|
||||
|
||||
```vue
|
||||
<!-- My Category -->
|
||||
<div class="component-category mb-4">
|
||||
<h3 class="text-gray-700 text-xs font-semibold px-3 mb-2 uppercase tracking-wider">My Category</h3>
|
||||
<div class="grid grid-cols-2 gap-1 px-2">
|
||||
<div
|
||||
v-for="component in getComponentsByCategory('My Category')"
|
||||
:key="component.type"
|
||||
class="component-item rounded p-2 flex flex-col items-center justify-center cursor-grab hover:bg-gray-100 transition-colors border border-gray-200"
|
||||
:class="{ 'hidden': !matchesSearch(component) }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, component)"
|
||||
@dragend="onDragEnd($event)"
|
||||
@click="addComponent(component)"
|
||||
>
|
||||
<Icon :name="component.icon" class="mb-1 w-5 h-5 text-gray-600" />
|
||||
<span class="text-xs text-gray-600 text-center">{{ component.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Settings Modal Integration
|
||||
|
||||
### 1. Add Component Type Support
|
||||
|
||||
In `FormBuilderFieldSettingsModal.vue`:
|
||||
|
||||
```javascript
|
||||
// Add to getComponentTypeName function
|
||||
getComponentTypeName(type) {
|
||||
const typeNames = {
|
||||
// ... existing types
|
||||
'my-component': 'My Component'
|
||||
}
|
||||
return typeNames[type] || 'Unknown Component'
|
||||
}
|
||||
|
||||
// Add to getComponentIcon function
|
||||
getComponentIcon(type) {
|
||||
const icons = {
|
||||
// ... existing icons
|
||||
'my-component': 'heroicons:document-text'
|
||||
}
|
||||
return icons[type] || 'heroicons:question-mark-circle'
|
||||
}
|
||||
|
||||
// Add to getComponentDescription function
|
||||
getComponentDescription(type) {
|
||||
const descriptions = {
|
||||
// ... existing descriptions
|
||||
'my-component': 'A custom component for collecting specific data'
|
||||
}
|
||||
return descriptions[type] || 'Component description'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Specific Settings
|
||||
|
||||
For components with specific settings, add them to the settings modal:
|
||||
|
||||
```vue
|
||||
<!-- My Component Specific Settings -->
|
||||
<div v-if="component.type === 'my-component'" class="settings-section">
|
||||
<div class="section-header">
|
||||
<h4 class="section-title">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5 mr-2" />
|
||||
My Component Settings
|
||||
</h4>
|
||||
<p class="section-description">Configure specific settings for this component</p>
|
||||
</div>
|
||||
|
||||
<div class="section-content">
|
||||
<FormKit
|
||||
type="text"
|
||||
label="Custom Property"
|
||||
name="myCustomProp"
|
||||
v-model="component.props.myCustomProp"
|
||||
help="Set a custom property for this component"
|
||||
:classes="{ outer: 'field-wrapper' }"
|
||||
/>
|
||||
|
||||
<!-- Add more component-specific settings -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Component Preview Integration
|
||||
|
||||
### 1. Add Preview Rendering
|
||||
|
||||
In `ComponentPreview.vue`, add your component's preview rendering:
|
||||
|
||||
```vue
|
||||
<!-- My Component Preview -->
|
||||
<div v-else-if="component.type === 'my-component'" class="py-2">
|
||||
<label v-if="component.props.label && component.props.showLabel !== false"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ component.props.label }}
|
||||
<span v-if="component.props.required" class="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Component-specific rendering -->
|
||||
<div class="my-component-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="component.props.placeholder"
|
||||
:required="component.props.required"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="component.props.help" class="mt-1 text-xs text-gray-500">
|
||||
{{ component.props.help }}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. Handle Preview Mode
|
||||
|
||||
Make sure your component handles the `isPreview` prop correctly:
|
||||
|
||||
```vue
|
||||
<!-- My Component with Preview Mode -->
|
||||
<div v-else-if="component.type === 'my-component'" class="py-2">
|
||||
<label v-if="component.props.label && component.props.showLabel !== false"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ component.props.label }}
|
||||
<span v-if="component.props.required" class="text-red-500">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Component-specific rendering -->
|
||||
<div class="my-component-wrapper">
|
||||
<input
|
||||
v-if="!isPreview"
|
||||
type="text"
|
||||
:placeholder="component.props.placeholder"
|
||||
:required="component.props.required"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<div v-else class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500">
|
||||
{{ component.props.placeholder || 'Preview mode' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="component.props.help" class="mt-1 text-xs text-gray-500">
|
||||
{{ component.props.help }}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Drag & Drop Integration
|
||||
|
||||
### 1. Basic Drag & Drop
|
||||
|
||||
For basic components, drag & drop is handled automatically by the form builder system.
|
||||
|
||||
### 2. Container Drag & Drop
|
||||
|
||||
For container components, you need to implement custom drag & drop handlers:
|
||||
|
||||
```javascript
|
||||
// In ComponentPreview.vue
|
||||
|
||||
// Drag over handler
|
||||
const handleSectionDragOver = (event, containerId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Initialize container drop state if it doesn't exist
|
||||
if (!sectionDropStates.value[containerId]) {
|
||||
sectionDropStates.value[containerId] = { isDraggingOver: false };
|
||||
}
|
||||
sectionDropStates.value[containerId].isDraggingOver = true;
|
||||
};
|
||||
|
||||
// Drag leave handler
|
||||
const handleSectionDragLeave = (event, containerId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Only hide the drag over state if we're actually leaving the drop zone
|
||||
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 && sectionDropStates.value[containerId]) {
|
||||
sectionDropStates.value[containerId].isDraggingOver = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Drag enter handler
|
||||
const handleSectionDragEnter = (event, containerId) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Initialize container drop state if it doesn't exist
|
||||
if (!sectionDropStates.value[containerId]) {
|
||||
sectionDropStates.value[containerId] = { isDraggingOver: false };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Nested Component Management
|
||||
|
||||
For container components, you need to handle nested component operations:
|
||||
|
||||
```javascript
|
||||
// Remove component from container
|
||||
const removeFromSection = (sectionId, childIndex) => {
|
||||
const section = findContainerRecursively(sectionId);
|
||||
if (section && section.props.children) {
|
||||
const removedComponent = section.props.children.splice(childIndex, 1)[0];
|
||||
if (removedComponent) {
|
||||
formStore.updateComponent(section);
|
||||
console.log('Component deleted from section:', removedComponent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Open nested component settings
|
||||
const openNestedComponentSettings = (component) => {
|
||||
selectedNestedComponent.value = { ...component };
|
||||
showNestedSettingsModal.value = true;
|
||||
console.log('Opening settings for component:', component);
|
||||
};
|
||||
|
||||
// Save nested component settings
|
||||
const saveNestedComponentSettings = (updatedComponent) => {
|
||||
if (!updatedComponent || !selectedNestedComponent.value) return;
|
||||
|
||||
// Find the parent container at any nesting level
|
||||
const parentContainer = findContainerRecursively(null, formStore.formComponents, updatedComponent.id);
|
||||
|
||||
if (parentContainer) {
|
||||
// Find and update the component in the parent's children
|
||||
const childIndex = parentContainer.props.children.findIndex(child => child.id === updatedComponent.id);
|
||||
if (childIndex !== -1) {
|
||||
parentContainer.props.children[childIndex] = updatedComponent;
|
||||
formStore.updateComponent(parentContainer);
|
||||
console.log('Nested component updated in parent:', parentContainer);
|
||||
}
|
||||
} else {
|
||||
// If not found in main form, it might be in a Layout Grid cell
|
||||
console.log('Parent container not found, component might be in Layout Grid');
|
||||
}
|
||||
|
||||
showNestedSettingsModal.value = false;
|
||||
selectedNestedComponent.value = null;
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Component Design
|
||||
|
||||
- **Consistent Props**: Use consistent prop names across components
|
||||
- **Default Values**: Always provide sensible default values
|
||||
- **Validation**: Include validation rules where appropriate
|
||||
- **Accessibility**: Ensure components are accessible (ARIA labels, keyboard navigation)
|
||||
|
||||
### 2. Container Components
|
||||
|
||||
- **Children Array**: Always initialize `children: []` for container components
|
||||
- **Drag & Drop**: Implement proper drag & drop handlers
|
||||
- **Nested Settings**: Support settings for nested components
|
||||
- **Recursive Search**: Use `findContainerRecursively` for finding parent containers
|
||||
|
||||
### 3. Performance
|
||||
|
||||
- **Lazy Loading**: Consider lazy loading for complex components
|
||||
- **Memoization**: Use computed properties for expensive calculations
|
||||
- **Event Handling**: Properly clean up event listeners
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
- **Validation**: Validate component props
|
||||
- **Fallbacks**: Provide fallback rendering for missing props
|
||||
- **Logging**: Add appropriate console logging for debugging
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Simple Text Input
|
||||
|
||||
```javascript
|
||||
// Component definition
|
||||
{
|
||||
type: 'custom-text',
|
||||
name: 'Custom Text Input',
|
||||
category: 'Basic Inputs',
|
||||
icon: 'heroicons:document-text',
|
||||
description: 'A custom text input with special styling',
|
||||
defaultProps: {
|
||||
label: 'Custom Text',
|
||||
name: 'custom_text',
|
||||
help: 'Enter your custom text',
|
||||
placeholder: 'Type here...',
|
||||
required: false,
|
||||
width: '100%',
|
||||
gridColumn: 'span 6',
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Advanced Container
|
||||
|
||||
```javascript
|
||||
// Component definition
|
||||
{
|
||||
type: 'advanced-container',
|
||||
name: 'Advanced Container',
|
||||
category: 'Layout',
|
||||
icon: 'material-symbols:view-in-ar',
|
||||
description: 'An advanced container with custom styling and behavior',
|
||||
defaultProps: {
|
||||
label: 'Advanced Container',
|
||||
name: 'advanced_container',
|
||||
help: 'A container with advanced features',
|
||||
showHeader: true,
|
||||
headerBackground: '#f9fafb',
|
||||
backgroundColor: '#ffffff',
|
||||
showBorder: true,
|
||||
borderStyle: 'solid',
|
||||
spacing: 'normal',
|
||||
children: [],
|
||||
customFeature: 'default',
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Complex Component with API Integration
|
||||
|
||||
```javascript
|
||||
// Component definition
|
||||
{
|
||||
type: 'api-select',
|
||||
name: 'API Select',
|
||||
category: 'Advanced',
|
||||
icon: 'heroicons:globe-alt',
|
||||
description: 'Dropdown populated from API endpoint',
|
||||
defaultProps: {
|
||||
label: 'API Select',
|
||||
name: 'api_select',
|
||||
help: 'Select from API data',
|
||||
required: false,
|
||||
apiEndpoint: '',
|
||||
apiMethod: 'GET',
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
placeholder: 'Select an option...',
|
||||
width: '100%',
|
||||
gridColumn: 'span 6',
|
||||
conditionalLogic: {
|
||||
enabled: false,
|
||||
conditions: [],
|
||||
action: 'show',
|
||||
operator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Creating new form builder nodes requires understanding the system architecture and following established patterns. Start with simple components and gradually work up to complex container components. Always test thoroughly and ensure proper integration with the existing form builder system.
|
||||
|
||||
For complex components, consider breaking them down into smaller, reusable parts and following the established patterns for drag & drop, settings, and preview rendering.
|
@ -8,7 +8,7 @@
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"prisma": "npx prisma db pull && npx prisma generate",
|
||||
"deploy": "yarn prisma && yarn build && pm2 restart all"
|
||||
"deploy": "npx prisma generate && yarn build && pm2 restart all"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.8.0",
|
||||
|
@ -291,7 +291,7 @@
|
||||
class="grid-preview-container"
|
||||
:class="{
|
||||
'px-4 pt-0': selectedDevice !== 'Desktop',
|
||||
'p-0': selectedDevice === 'Desktop'
|
||||
'p-0 pb-4': selectedDevice === 'Desktop'
|
||||
}"
|
||||
>
|
||||
<template
|
||||
|
@ -1228,13 +1228,62 @@ watch(currentStep, async (newStep) => {
|
||||
for (let i = 0; i < minItems; i++) {
|
||||
const newGroup = {};
|
||||
|
||||
// Add fields from configuration
|
||||
// Add fields from configuration (legacy)
|
||||
if (component.props.fields) {
|
||||
component.props.fields.forEach(field => {
|
||||
newGroup[field.name] = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Add fields from children components (form builder format)
|
||||
if (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) {
|
||||
// Initialize with default value based on component type
|
||||
switch (comp.type) {
|
||||
case 'number':
|
||||
newGroup[comp.props.name] = 0;
|
||||
break;
|
||||
case 'checkbox':
|
||||
newGroup[comp.props.name] = [];
|
||||
break;
|
||||
case 'select':
|
||||
if (comp.props.options && comp.props.options.length > 0) {
|
||||
newGroup[comp.props.name] = comp.props.options[0].value || '';
|
||||
} else {
|
||||
newGroup[comp.props.name] = '';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
newGroup[comp.props.name] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
component.props.children.forEach(child => {
|
||||
extractFieldsFromComponent(child);
|
||||
});
|
||||
}
|
||||
|
||||
initialGroups.push(newGroup);
|
||||
}
|
||||
|
||||
|
@ -339,7 +339,7 @@
|
||||
"form": {
|
||||
"$ref": "#/definitions/form"
|
||||
},
|
||||
"user": {
|
||||
"savedByUser": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/user"
|
||||
|
@ -96,7 +96,7 @@ model formHistory {
|
||||
savedBy Int?
|
||||
savedDate DateTime @default(now()) @db.DateTime(0)
|
||||
form form @relation(fields: [formID], references: [formID], onDelete: Cascade)
|
||||
user user? @relation(fields: [savedBy], references: [userID])
|
||||
savedByUser user? @relation(fields: [savedBy], references: [userID])
|
||||
|
||||
@@index([formID], map: "FK_formHistory_form")
|
||||
@@index([savedBy], map: "FK_formHistory_savedBy")
|
||||
|
Loading…
x
Reference in New Issue
Block a user