Merge branch 'afiq' into bp-process

This commit is contained in:
Afiq 2025-07-03 12:28:44 +08:00
commit 0cb5ed10b5
8 changed files with 1395 additions and 359 deletions

View File

@ -339,16 +339,182 @@
</div>
</div>
<!-- Form Section Component -->
<div v-else-if="component.type === 'form-section'" class="py-2">
<div
class="form-section-container"
: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'
}"
>
<!-- Section Header -->
<div
v-if="component.props.showHeader"
class="section-header"
:class="{
'header-small': component.props.headerSize === 'small',
'header-large': component.props.headerSize === 'large',
'collapsible': component.props.collapsible
}"
:style="{ backgroundColor: component.props.headerBackground || '#f9fafb' }"
@click="component.props.collapsible && toggleSectionCollapse(component.id)"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="section-title">
{{ component.props.label || 'Section Title' }}
</h3>
<p v-if="component.props.description" class="section-description">
{{ component.props.description }}
</p>
</div>
<div v-if="component.props.collapsible" class="collapse-icon">
<Icon
:name="component.props.collapsed ? 'material-symbols:expand-more' : 'material-symbols:expand-less'"
class="w-5 h-5 text-gray-500"
/>
</div>
</div>
</div>
<!-- Section Content -->
<div
v-if="!component.props.collapsed"
class="section-content"
:class="{
'no-header': !component.props.showHeader
}"
>
<!-- In preview mode, show the nested components or placeholder -->
<div v-if="isPreview" class="section-fields">
<!-- Render nested components if they exist -->
<div v-if="component.props.children && component.props.children.length > 0" class="space-y-3">
<template v-for="(childComponent, childIndex) in component.props.children" :key="childIndex">
<component-preview :component="childComponent" :is-preview="true" />
</template>
</div>
<!-- Show placeholder if no nested components -->
<div v-else class="section-placeholder">
<div class="placeholder-content">
<Icon name="material-symbols:dashboard-customize-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p class="text-sm text-gray-500 text-center mb-2">Form Section Container</p>
<p class="text-xs text-gray-400 text-center">
This section will contain the form fields grouped here
</p>
</div>
</div>
</div>
<!-- In form builder mode, show drop zone -->
<div v-else class="section-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-gray-600 rounded"
title="Drag to reorder"
>
<span class="drag-handle cursor-move">
<Icon name="material-symbols:drag-indicator" class="w-3 h-3" />
</span>
</button>
<button
class="p-1 text-gray-400 hover:text-red-500 rounded"
title="Remove from section"
@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
class="section-drop-placeholder"
:class="{
'drop-active': sectionDropStates[component.id]?.isDraggingOver,
'empty': !component.props.children || component.props.children.length === 0
}"
@dragover.prevent="handleSectionDragOver($event, component.id)"
@dragleave="handleSectionDragLeave($event, component.id)"
@drop="handleSectionDrop($event, component.id)"
@dragenter.prevent="handleSectionDragEnter($event, component.id)"
>
<div class="placeholder-content">
<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 section
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Unknown Component Type Fallback -->
<div v-else class="p-4 border border-dashed border-gray-300 rounded">
<div class="text-gray-500">Unknown component type: {{ component.type }}</div>
</div>
</div>
<!-- Nested Component Settings Modal -->
<FormBuilderFieldSettingsModal
v-model="showNestedSettingsModal"
:component="selectedNestedComponent"
@close="closeNestedSettingsModal"
@update-component="saveNestedComponentSettings"
/>
</template>
<script setup>
import { useNuxtApp } from '#app';
import { useFormBuilderStore } from '~/stores/formBuilder';
import FormBuilderFieldSettingsModal from '~/components/FormBuilderFieldSettingsModal.vue';
const props = defineProps({
component: {
@ -361,6 +527,8 @@ const props = defineProps({
}
});
const emit = defineEmits(['select-nested-component']);
// Get access to the form builder store
const formStore = useFormBuilderStore();
const previewFormData = computed(() => formStore.previewFormData || {});
@ -370,6 +538,13 @@ const searchQuery = ref({});
const selectedItems = ref({});
const validationErrors = ref({});
// New reactive state for form sections
const sectionDropStates = ref({});
// State for nested component settings modal
const showNestedSettingsModal = ref(false);
const selectedNestedComponent = ref(null);
// Initialize data for components
onMounted(() => {
// Initialize search and selection data for dynamic lists
@ -859,6 +1034,195 @@ const updateTableData = (newData) => {
const updatedFormData = { ...formStore.previewFormData, [tableName]: newData };
formStore.updatePreviewFormData(updatedFormData);
};
// Form Section Component
const toggleSectionCollapse = (sectionId) => {
// Find the section component and toggle its collapsed state
const section = formStore.formComponents.find(comp => comp.id === sectionId);
if (section) {
section.props.collapsed = !section.props.collapsed;
formStore.updateComponent(section);
}
};
// New methods for form section component
const onNestedDragEnd = (event) => {
// Handle reordering within a section
console.log('Nested drag end:', event);
};
const onNestedComponentAdd = (event) => {
// Handle adding a new component to a section
console.log('Nested component add:', event);
};
const removeFromSection = (sectionId, childIndex) => {
// Actually delete the component from the section (don't move it back to main form)
const section = formStore.formComponents.find(comp => comp.id === sectionId);
if (section && section.props.children) {
const removedComponent = section.props.children.splice(childIndex, 1)[0];
if (removedComponent) {
// Update the section (don't add the component back to main form)
formStore.updateComponent(section);
// Record the deletion in history
formStore.recordHistory('delete_component_from_section', {
componentId: removedComponent.id,
componentType: removedComponent.type,
componentName: removedComponent.props.label || removedComponent.props.name,
sectionId: sectionId,
deletedComponent: removedComponent
});
console.log('Component deleted from section:', removedComponent);
}
}
};
const handleSectionDragOver = (event, sectionId) => {
event.preventDefault();
event.stopPropagation();
// Initialize section drop state if it doesn't exist
if (!sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId] = { isDraggingOver: false };
}
sectionDropStates.value[sectionId].isDraggingOver = true;
};
const handleSectionDragLeave = (event, sectionId) => {
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[sectionId]) {
sectionDropStates.value[sectionId].isDraggingOver = false;
}
};
const handleSectionDrop = (event, sectionId) => {
event.preventDefault();
event.stopPropagation();
// Reset drag state
if (sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId].isDraggingOver = false;
}
try {
// Get the dropped component data
let componentData = null;
try {
componentData = JSON.parse(event.dataTransfer.getData('text/plain') || '{}');
} catch (parseError) {
// Fallback for Safari or other browsers
componentData = window.__draggedComponentData || {};
}
if (!componentData.type) {
console.warn('No valid component data found in drop event');
return;
}
// Create a new component instance with proper label setup
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', // Default to half width in sections
width: '50%',
// Ensure the component has a proper label
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 section
const section = formStore.formComponents.find(comp => comp.id === sectionId);
if (section) {
// Initialize children array if it doesn't exist
if (!section.props.children) {
section.props.children = [];
}
// Add the component to the section
section.props.children.push(newComponent);
// Update the section in the form store
formStore.updateComponent(section);
// Record the action in history
formStore.recordHistory('add_component_to_section', {
componentType: newComponent.type,
componentName: newComponent.name,
sectionId: sectionId
});
console.log('Component added to section:', newComponent);
}
} catch (error) {
console.error('Error dropping component into section:', error);
}
};
const handleSectionDragEnter = (event, sectionId) => {
event.preventDefault();
event.stopPropagation();
// Initialize section drop state if it doesn't exist
if (!sectionDropStates.value[sectionId]) {
sectionDropStates.value[sectionId] = { isDraggingOver: false };
}
};
const openNestedComponentSettings = (component) => {
// Set the selected nested component and open the modal
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 section and update the nested component
const parentSection = formStore.formComponents.find(comp =>
comp.type === 'form-section' &&
comp.props.children &&
comp.props.children.some(child => child.id === updatedComponent.id)
);
if (parentSection) {
const childIndex = parentSection.props.children.findIndex(child => child.id === updatedComponent.id);
if (childIndex !== -1) {
// Update the nested component
parentSection.props.children[childIndex] = { ...updatedComponent };
// Update the section to trigger reactivity
formStore.updateComponent(parentSection);
console.log('Updated nested component:', updatedComponent);
}
}
// Close the modal
closeNestedSettingsModal();
};
</script>
<style scoped>
@ -932,4 +1296,175 @@ const updateTableData = (newData) => {
.repeating-table-wrapper{
margin-bottom: 0.5rem !important;
}
/* Form Section Component */
.form-section-container {
padding: 1rem;
border-width: 1px;
border-style: solid;
border-color: transparent;
border-radius: 0.375rem;
background-color: #ffffff;
}
.section-header {
background-color: #f9fafb;
padding: 0.75rem 1rem;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #e5e7eb;
cursor: pointer;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.section-description {
font-size: 0.875rem;
color: #6b7280;
}
.collapse-icon {
margin-left: auto;
}
.section-content {
padding: 1rem;
}
.section-placeholder {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
border-width: 1px;
border-style: dashed;
border-color: #e5e7eb;
border-radius: 0.375rem;
}
.placeholder-content {
text-align: center;
}
.section-fields {
padding: 1rem;
}
.section-drop-zone {
padding: 1rem;
min-height: 120px;
border-radius: 0.5rem;
background-color: #ffffff;
transition: all 0.2s ease-in-out;
}
.nested-components {
width: 100%;
min-height: 80px;
}
.form-component {
transition: all 0.2s ease-in-out;
margin-bottom: 0.5rem;
}
.form-component:hover {
border-color: #93c5fd;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.component-actions {
position: absolute;
right: 0;
top: 0;
z-index: 10;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 0 0.375rem 0 0.375rem;
padding: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.form-component:hover .component-actions {
opacity: 1;
}
.component-actions:hover {
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.component-actions button {
transition: all 0.15s ease-in-out;
}
.component-actions button:hover {
transform: scale(1.1);
}
.drag-handle {
cursor: move;
opacity: 0.6;
}
.drag-handle:hover {
opacity: 1;
}
.section-drop-placeholder {
padding: 2rem;
border-width: 1px;
border-style: dashed;
border-color: #d1d5db;
border-radius: 0.375rem;
background-color: #ffffff;
transition: all 0.2s ease-in-out;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.5rem;
}
.section-drop-placeholder.empty {
border-color: #e5e7eb;
min-height: 120px;
padding: 2rem;
}
.section-drop-placeholder:not(.empty) {
border-color: #e5e7eb;
min-height: 80px;
padding: 1.5rem;
border-style: dashed;
background-color: #fafafa;
}
.section-drop-placeholder.drop-active {
border-color: #3b82f6;
background-color: #eff6ff;
border-width: 2px;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.section-drop-placeholder.empty:hover {
border-color: #9ca3af;
background-color: #f8fafc;
}
.section-drop-placeholder:not(.empty):hover {
border-color: #9ca3af;
background-color: #f1f5f9;
}
/* Ghost styles for dragging */
.ghost {
opacity: 0.5;
background-color: #dbeafe !important;
border-color: #3b82f6 !important;
}
</style>

View File

@ -82,6 +82,7 @@
<component-preview
:component="element"
:is-preview="false"
@select-nested-component="handleNestedComponentSelect"
/>
</div>
@ -113,7 +114,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout']);
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout', 'select-nested-component']);
const selectedComponentId = ref(null);
const resizeMode = ref(false);
@ -162,6 +163,12 @@ const deleteComponent = (id) => {
emit('delete-component', id);
};
// Handle nested component selection from sections
const handleNestedComponentSelect = (component) => {
// Pass the nested component selection up to the main form builder
emit('select-nested-component', component);
};
// Toggle resize mode
const toggleResizeMode = (component) => {
resizeMode.value = !resizeMode.value;

View File

@ -603,6 +603,36 @@ const availableComponents = [
},
// Layout
{
type: 'form-section',
name: 'Form Section',
category: 'Layout',
icon: 'material-symbols:view-module-outline',
description: 'Container to group related form fields into sections',
defaultProps: {
label: 'Section Title',
description: 'Optional section description',
collapsible: false,
collapsed: false,
showBorder: true,
borderStyle: 'solid', // solid, dashed, dotted
backgroundColor: '#ffffff',
headerBackground: '#f9fafb',
showHeader: true,
headerSize: 'medium', // small, medium, large
spacing: 'normal', // compact, normal, relaxed
width: '100%',
gridColumn: 'span 12',
children: [], // Array to hold nested components
// Conditional Logic Properties
conditionalLogic: {
enabled: false,
conditions: [],
action: 'show',
operator: 'and'
}
}
},
{
type: 'info-display',
name: 'Info Display',

View File

@ -608,263 +608,201 @@
</div>
</template>
<!-- Repeating Table Configuration -->
<template v-if="component.type === 'repeating-table'">
<!-- Form Section Configuration -->
<template v-if="component.type === 'form-section'">
<div class="space-y-6">
<!-- Basic Table Settings -->
<div class="grid grid-cols-2 gap-4">
<!-- Section Content -->
<div class="grid grid-cols-1 gap-4">
<FormKit
type="text"
label="Add Button Text"
name="buttonText"
v-model="configModel.buttonText"
help="Text for the add record button"
type="textarea"
label="Section Description"
name="description"
v-model="configModel.description"
help="Optional description text below the section title"
:classes="{ outer: 'field-wrapper' }"
placeholder="Add Record"
/>
<FormKit
type="text"
label="Edit Button Text"
name="editText"
v-model="configModel.editText"
help="Text for the edit record button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Edit"
placeholder="Describe this section..."
rows="2"
/>
</div>
<!-- Record Limits -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="number"
label="Minimum Records"
name="minRecords"
v-model="configModel.minRecords"
help="Minimum number of records required"
:classes="{ outer: 'field-wrapper' }"
:min="0"
placeholder="0"
/>
<FormKit
type="number"
label="Maximum Records"
name="maxRecords"
v-model="configModel.maxRecords"
help="Maximum number of records allowed"
:classes="{ outer: 'field-wrapper' }"
:min="1"
placeholder="50"
/>
</div>
<!-- Display Settings -->
<!-- Section Header Settings -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Display Settings</h5>
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Header Settings</h5>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="switch"
label="Show Row Numbers"
name="showRowNumbers"
v-model="configModel.showRowNumbers"
help="Display row numbers in the table"
label="Show Header"
name="showHeader"
v-model="configModel.showHeader"
help="Display the section header"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Enable Search"
name="enableSearch"
v-model="configModel.enableSearch"
help="Add search functionality to the table"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Allow Edit"
name="allowEdit"
v-model="configModel.allowEdit"
help="Allow users to edit existing records"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Allow Delete"
name="allowDelete"
v-model="configModel.allowDelete"
help="Allow users to delete records"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Confirm Delete"
name="confirmDelete"
v-model="configModel.confirmDelete"
help="Require confirmation before deleting"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
<!-- Table Columns Configuration -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Table Columns</h5>
<div v-if="configModel.columns && configModel.columns.length > 0" class="space-y-4">
<div
v-for="(column, index) in configModel.columns"
:key="index"
class="border rounded-lg p-4 bg-gray-50"
>
<div class="flex justify-between items-center mb-3">
<h6 class="text-sm font-medium text-gray-800">Column {{ index + 1 }}</h6>
<button
@click="removeTableColumn(index)"
class="text-red-500 hover:text-red-700 text-sm"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<FormKit
type="text"
label="Column Name"
v-model="column.name"
placeholder="field_name"
help="Internal field name (no spaces)"
:classes="{ outer: 'field-wrapper mb-0' }"
/>
<FormKit
type="text"
label="Display Label"
v-model="column.label"
placeholder="Display Name"
help="What users will see"
:classes="{ outer: 'field-wrapper mb-0' }"
/>
<FormKit
type="select"
label="Field Type"
v-model="column.type"
label="Header Size"
name="headerSize"
v-model="configModel.headerSize"
:options="[
{ label: 'Text', value: 'text' },
{ label: 'Number', value: 'number' },
{ label: 'Email', value: 'email' },
{ label: 'Phone', value: 'tel' },
{ label: 'Date', value: 'date' },
{ label: 'Dropdown', value: 'select' },
{ label: 'Text Area', value: 'textarea' }
{ label: 'Small', value: 'small' },
{ label: 'Medium', value: 'medium' },
{ label: 'Large', value: 'large' }
]"
:classes="{ outer: 'field-wrapper mb-0' }"
help="Size of the section header"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Placeholder"
v-model="column.placeholder"
placeholder="Enter value..."
help="Hint text for the input"
:classes="{ outer: 'field-wrapper mb-0' }"
/>
</div>
<div class="mt-3 grid grid-cols-2 gap-3">
<FormKit
type="switch"
label="Required Field"
v-model="column.required"
help="Make this field mandatory"
:classes="{ outer: 'field-wrapper mb-0' }"
/>
<FormKit
type="text"
label="Validation Rules"
v-model="column.validation"
placeholder="required|email"
help="Validation rules (e.g., required, email)"
:classes="{ outer: 'field-wrapper mb-0' }"
/>
</div>
<div v-if="column.type === 'select'" class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Options</label>
<div v-if="column.options && column.options.length > 0" class="space-y-2">
<div
v-for="(option, optionIndex) in column.options"
:key="optionIndex"
class="flex items-center space-x-2"
>
<input
type="text"
v-model="option.label"
placeholder="Option label"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
/>
<input
type="text"
v-model="option.value"
placeholder="Option value"
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
/>
<button
@click="removeColumnOption(index, optionIndex)"
class="text-red-500 hover:text-red-700"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
</div>
<button
@click="addColumnOption(index)"
class="text-sm text-blue-600 hover:text-blue-800 flex items-center mt-2"
type="button"
>
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
Add Option
</button>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500 border-2 border-dashed border-gray-300 rounded-lg">
<Icon name="heroicons:table-cells" class="w-8 h-8 mx-auto mb-2 text-gray-400" />
<p class="text-sm mb-3">No columns defined yet</p>
</div>
<button
@click="addTableColumn"
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
type="button"
>
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
Add Column
</button>
</div>
</div>
</template>
<!-- Content for Text Components -->
<template v-if="showField('value')">
<FormKit
type="textarea"
label="Text Content"
name="value"
v-model="configModel.value"
help="The text that will be displayed"
label="Collapsible"
name="collapsible"
v-model="configModel.collapsible"
help="Allow users to collapse this section"
:classes="{ outer: 'field-wrapper' }"
rows="3"
/>
<FormKit
type="switch"
label="Start Collapsed"
name="collapsed"
v-model="configModel.collapsed"
help="Start with this section collapsed"
:classes="{ outer: 'field-wrapper' }"
:disabled="!configModel.collapsible"
/>
</div>
</div>
<!-- Visual Settings -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Visual Settings</h5>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="switch"
label="Show Border"
name="showBorder"
v-model="configModel.showBorder"
help="Display border around the section"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="select"
label="Border Style"
name="borderStyle"
v-model="configModel.borderStyle"
:options="[
{ label: 'Solid', value: 'solid' },
{ label: 'Dashed', value: 'dashed' },
{ label: 'Dotted', value: 'dotted' }
]"
help="Style of the section border"
:classes="{ outer: 'field-wrapper' }"
:disabled="!configModel.showBorder"
/>
<FormKit
type="select"
label="Spacing"
name="spacing"
v-model="configModel.spacing"
:options="[
{ label: 'Compact', value: 'compact' },
{ label: 'Normal', value: 'normal' },
{ label: 'Relaxed', value: 'relaxed' }
]"
help="Internal padding of the section"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
<!-- Color Settings -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Color Settings</h5>
<div class="grid grid-cols-2 gap-4">
<FormKit
type="color"
label="Background Color"
name="backgroundColor"
v-model="configModel.backgroundColor"
help="Background color of the section"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="color"
label="Header Background"
name="headerBackground"
v-model="configModel.headerBackground"
help="Background color of the header"
:classes="{ outer: 'field-wrapper' }"
:disabled="!configModel.showHeader"
/>
</div>
</div>
<!-- Nested Components Management -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Nested Components</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 this section: {{ (configModel.children || []).length }}
</p>
<p class="text-xs text-gray-500">
Drag form components into the section to group them here
</p>
</div>
<div v-if="(configModel.children || []).length > 0">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ (configModel.children || []).length }} {{ (configModel.children || []).length === 1 ? 'component' : 'components' }}
</span>
</div>
</div>
<!-- List of nested components -->
<div v-if="(configModel.children || []).length > 0" class="space-y-2">
<div
v-for="(child, index) in configModel.children"
:key="child.id"
class="flex items-center justify-between bg-white border border-gray-200 rounded px-3 py-2"
>
<div class="flex items-center space-x-2">
<Icon :name="getComponentIcon(child.type)" class="w-4 h-4 text-gray-500" />
<span class="text-sm text-gray-700">
{{ child.props.label || getComponentTypeName(child.type) }}
</span>
<span class="text-xs text-gray-400">
({{ child.type }})
</span>
</div>
<button
@click="removeNestedComponent(index)"
class="text-red-500 hover:text-red-700 text-xs"
title="Remove from section"
>
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
</div>
</div>
<!-- Empty state -->
<div v-else class="text-center py-4">
<Icon name="material-symbols:inbox-outline" class="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p class="text-sm text-gray-500 mb-1">No components in this section</p>
<p class="text-xs text-gray-400">
Drag components from the sidebar into the section area
</p>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
@ -1433,6 +1371,7 @@ const getComponentIcon = (type) => {
heading: 'heroicons:h1',
paragraph: 'heroicons:document-text',
divider: 'heroicons:minus',
'form-section': 'material-symbols:view-module-outline',
'info-display': 'heroicons:information-circle',
'dynamic-list': 'heroicons:list-bullet',
'repeating-table': 'heroicons:table-cells'
@ -1467,6 +1406,7 @@ const getComponentTypeName = (type) => {
heading: 'Heading Text',
paragraph: 'Paragraph Text',
divider: 'Divider Line',
'form-section': 'Form Section',
'info-display': 'Information Display',
'dynamic-list': 'Dynamic List',
'repeating-table': 'Data Table'
@ -1501,6 +1441,7 @@ const getComponentDescription = (type) => {
heading: 'Section heading to organize form content',
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',
'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'
@ -1528,14 +1469,14 @@ const showField = (fieldName) => {
if (!props.component) return false
const fieldConfig = {
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'dynamic-list', 'repeating-table'],
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'dynamic-list', 'repeating-table'],
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'dynamic-list'],
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'dynamic-list', 'repeating-table'],
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
value: ['heading', 'paragraph', 'hidden'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'info-display', 'dynamic-list', 'repeating-table'],
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table'],
options: ['select', 'checkbox', 'radio'],
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'dynamic-list', 'repeating-table']
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table']
}
return fieldConfig[fieldName]?.includes(props.component.type) || false
@ -1544,7 +1485,7 @@ const showField = (fieldName) => {
const hasOptions = computed(() => showField('options'))
const hasSpecificSettings = computed(() => {
if (!props.component) return false
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'heading', 'paragraph', 'button', 'dynamic-list', 'repeating-table']
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'heading', 'paragraph', 'button', 'form-section', 'dynamic-list', 'repeating-table']
return specificTypes.includes(props.component.type)
})
@ -1704,7 +1645,16 @@ watch(() => props.component, (newComponent) => {
}
}, { immediate: true })
// Methods
// Methods - Define resetValidationState first before it's used above
const resetValidationState = () => {
hasLengthValidation.value = false
hasNumberValidation.value = false
minLength.value = null
maxLength.value = null
minNumber.value = null
maxNumber.value = null
}
const updateValidation = (rule, enabled) => {
let current = configModel.value.validation || ''
const rules = current.split('|').filter(r => r && r !== rule)
@ -1716,15 +1666,6 @@ const updateValidation = (rule, enabled) => {
configModel.value.validation = rules.join('|')
}
const resetValidationState = () => {
hasLengthValidation.value = false
hasNumberValidation.value = false
minLength.value = null
maxLength.value = null
minNumber.value = null
maxNumber.value = null
}
const setComponentWidth = (percentage, gridColumns) => {
configModel.value.width = `${percentage}%`
configModel.value.gridColumn = `span ${gridColumns}`
@ -1921,6 +1862,12 @@ const removeColumnOption = (columnIndex, optionIndex) => {
configModel.value.columns[columnIndex].options.splice(optionIndex, 1)
}
}
const removeNestedComponent = (index) => {
if (configModel.value.children) {
configModel.value.children.splice(index, 1)
}
}
</script>
<style scoped>

View File

@ -458,7 +458,6 @@
{ label: 'Dynamic (from Variable)', value: 'variable' }
]"
placeholder="Select assignment type"
@input="handleAssignmentTypeChange"
:classes="{ outer: 'mb-0' }"
/>
<p class="mt-1 text-xs text-gray-500">How should this task be assigned</p>
@ -472,15 +471,32 @@
</div>
<div class="space-y-3">
<!-- User Dropdown -->
<div class="relative">
<FormKit
type="select"
v-model="localNodeData.assignedUsers"
:options="availableUsers"
placeholder="Select users..."
multiple
v-model="selectedUserId"
:options="filteredAvailableUsers"
placeholder="Select a user to add..."
:classes="{ outer: 'mb-0' }"
@input="handleUserSelection"
/>
<p class="text-xs text-blue-700">Selected users will be able to complete this form task</p>
<p class="mt-1 text-xs text-blue-700">Select users who will be able to complete this form task</p>
</div>
<!-- Selected Users Pills -->
<div v-if="localNodeData.assignedUsers && localNodeData.assignedUsers.length > 0" class="mt-3">
<label class="block text-sm font-medium text-blue-700 mb-2">Selected Users</label>
<div class="flex flex-wrap gap-2 p-2 bg-white border border-blue-100 rounded-md min-h-[40px]">
<div v-for="(user, index) in localNodeData.assignedUsers" :key="'user-' + user.value"
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800 border border-blue-200">
<span class="mr-1">{{ user.label }}</span>
<button @click="removeAssignedUser(index)" class="text-blue-600 hover:text-blue-800">
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
@ -492,15 +508,32 @@
</div>
<div class="space-y-3">
<!-- Role Dropdown -->
<div class="relative">
<FormKit
type="select"
v-model="localNodeData.assignedRoles"
:options="availableRoles"
placeholder="Select roles..."
multiple
v-model="selectedRoleId"
:options="filteredAvailableRoles"
placeholder="Select a role to add..."
:classes="{ outer: 'mb-0' }"
@input="handleRoleSelection"
/>
<p class="text-xs text-purple-700">Any user with the selected roles will be able to complete this form task</p>
<p class="mt-1 text-xs text-purple-700">Select roles that will be able to complete this form task</p>
</div>
<!-- Selected Roles Pills -->
<div v-if="localNodeData.assignedRoles && localNodeData.assignedRoles.length > 0" class="mt-3">
<label class="block text-sm font-medium text-purple-700 mb-2">Selected Roles</label>
<div class="flex flex-wrap gap-2 p-2 bg-white border border-purple-100 rounded-md min-h-[40px]">
<div v-for="(role, index) in localNodeData.assignedRoles" :key="'role-' + role.value"
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-purple-100 text-purple-800 border border-purple-200">
<span class="mr-1">{{ role.label }}</span>
<button @click="removeAssignedRole(index)" class="text-purple-600 hover:text-purple-800">
<Icon name="material-symbols:close" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
@ -623,6 +656,10 @@ const localNodeData = ref({
assignmentVariableType: 'user_id'
});
// New reactive state for selected user and role
const selectedUserId = ref('');
const selectedRoleId = ref('');
// Watch for changes from parent props
watch(() => props.nodeData, async (newNodeData) => {
if (newNodeData) {
@ -859,21 +896,31 @@ const processVariableOptions = computed(() => {
const users = ref([]);
const roles = ref([]);
// Computed property for available users (for FormKit select)
const availableUsers = computed(() => {
return users.value.map(user => ({
// Computed property for available users with filtering out already selected users
const filteredAvailableUsers = computed(() => {
// Convert all IDs to strings for consistent comparison
const selectedUserIds = (localNodeData.value.assignedUsers || []).map(user => String(user.value));
return users.value
.filter(user => !selectedUserIds.includes(String(user.userID)))
.map(user => ({
label: user.userFullName ? `${user.userFullName} (${user.userUsername})` : user.userUsername,
value: user.userID,
value: String(user.userID), // Ensure value is a string
username: user.userUsername,
email: user.userEmail
}));
});
// Computed property for available roles (for FormKit select)
const availableRoles = computed(() => {
return roles.value.map(role => ({
// Computed property for available roles with filtering out already selected roles
const filteredAvailableRoles = computed(() => {
// Convert all IDs to strings for consistent comparison
const selectedRoleIds = (localNodeData.value.assignedRoles || []).map(role => String(role.value));
return roles.value
.filter(role => !selectedRoleIds.includes(String(role.roleID)))
.map(role => ({
label: role.roleName,
value: role.roleID,
value: String(role.roleID), // Ensure value is a string
description: role.roleDescription
}));
});
@ -974,11 +1021,161 @@ function getStringValue(value) {
}
}
// Handle assignment type change
function handleAssignmentTypeChange() {
// Implementation of handleAssignmentTypeChange function
// Handle user selection from dropdown
function handleUserSelection(userId) {
if (userId) {
console.log('User selected:', userId, typeof userId);
// Convert userId to string to ensure consistent comparison
const userIdStr = String(userId);
// Find the selected user from available users
const selectedUser = filteredAvailableUsers.value.find(user => String(user.value) === userIdStr);
if (selectedUser) {
console.log('Found user:', selectedUser);
// Initialize the array if needed
if (!localNodeData.value.assignedUsers) {
localNodeData.value.assignedUsers = [];
}
// Add the user to the assigned users
localNodeData.value.assignedUsers.push({...selectedUser});
// Reset the selection
selectedUserId.value = '';
// Save changes
saveChanges();
} else {
console.warn('Selected user not found in filtered available users', userIdStr);
// Fallback: If we can't find the user in filtered list, try to find it in the original users list
const userFromOriginal = users.value.find(user => String(user.userID) === userIdStr);
if (userFromOriginal) {
console.log('Found user in original users list:', userFromOriginal);
// Add the user to the assigned users
const userToAdd = {
label: userFromOriginal.userFullName ? `${userFromOriginal.userFullName} (${userFromOriginal.userUsername})` : userFromOriginal.userUsername,
value: String(userFromOriginal.userID),
username: userFromOriginal.userUsername,
email: userFromOriginal.userEmail
};
if (!localNodeData.value.assignedUsers) {
localNodeData.value.assignedUsers = [];
}
localNodeData.value.assignedUsers.push(userToAdd);
selectedUserId.value = '';
saveChanges();
} else {
console.error('User not found in any list. Available users:', users.value);
}
}
}
}
// Handle role selection from dropdown
function handleRoleSelection(roleId) {
if (roleId) {
console.log('Role selected:', roleId, typeof roleId);
// Convert roleId to string to ensure consistent comparison
const roleIdStr = String(roleId);
// Find the selected role from available roles
const selectedRole = filteredAvailableRoles.value.find(role => String(role.value) === roleIdStr);
if (selectedRole) {
console.log('Found role:', selectedRole);
// Initialize the array if needed
if (!localNodeData.value.assignedRoles) {
localNodeData.value.assignedRoles = [];
}
// Add the role to the assigned roles
localNodeData.value.assignedRoles.push({...selectedRole});
// Reset the selection
selectedRoleId.value = '';
// Save changes
saveChanges();
} else {
console.warn('Selected role not found in filtered available roles', roleIdStr);
// Fallback: If we can't find the role in filtered list, try to find it in the original roles list
const roleFromOriginal = roles.value.find(role => String(role.roleID) === roleIdStr);
if (roleFromOriginal) {
console.log('Found role in original roles list:', roleFromOriginal);
// Add the role to the assigned roles
const roleToAdd = {
label: roleFromOriginal.roleName,
value: String(roleFromOriginal.roleID),
description: roleFromOriginal.roleDescription
};
if (!localNodeData.value.assignedRoles) {
localNodeData.value.assignedRoles = [];
}
localNodeData.value.assignedRoles.push(roleToAdd);
selectedRoleId.value = '';
saveChanges();
} else {
console.error('Role not found in any list. Available roles:', roles.value);
}
}
}
}
// Function to remove an assigned user
function removeAssignedUser(index) {
if (localNodeData.value.assignedUsers) {
localNodeData.value.assignedUsers.splice(index, 1);
saveChanges();
}
}
// Function to remove an assigned role
function removeAssignedRole(index) {
if (localNodeData.value.assignedRoles) {
localNodeData.value.assignedRoles.splice(index, 1);
saveChanges();
}
}
// Watch for changes to assignment type
watch(() => localNodeData.value.assignmentType, (newType, oldType) => {
if (newType !== oldType) {
// Reset selections when assignment type changes
selectedUserId.value = '';
selectedRoleId.value = '';
// If changing from users to another type, clear assigned users
if (oldType === 'users' && newType !== 'users') {
localNodeData.value.assignedUsers = [];
}
// If changing from roles to another type, clear assigned roles
if (oldType === 'roles' && newType !== 'roles') {
localNodeData.value.assignedRoles = [];
}
// If changing from variable to another type, clear assignment variable
if (oldType === 'variable' && newType !== 'variable') {
localNodeData.value.assignmentVariable = '';
localNodeData.value.assignmentVariableType = 'user_id';
}
saveChanges();
}
});
</script>
<style scoped>

View File

@ -183,6 +183,24 @@
>
<h2 class="text-sm font-medium text-gray-700">{{ isPreview ? 'Form Preview' : 'Form Canvas' }}</h2>
<div class="flex items-center gap-2">
<!-- Device Preview Controls (only show in preview mode) -->
<div v-if="isPreview" class="flex items-center bg-white border border-gray-200 rounded-lg p-1">
<button
v-for="device in deviceSizes"
:key="device.name"
@click="selectedDevice = device.name"
class="px-3 py-1 text-xs font-medium rounded transition-colors flex items-center space-x-1"
:class="{
'bg-blue-500 text-white': selectedDevice === device.name,
'text-gray-600 hover:text-gray-800 hover:bg-gray-100': selectedDevice !== device.name
}"
:title="`Preview on ${device.name} (${device.width}px)`"
>
<Icon :name="device.icon" class="w-3 h-3" />
<span>{{ device.name }}</span>
</button>
</div>
<span class="text-xs text-gray-500">
{{ isPreview ? 'Preview mode - Form is interactive' : 'Drag components to build your form' }}
</span>
@ -204,6 +222,33 @@
>
<!-- Preview Mode -->
<div v-if="isPreview" class="form-container">
<!-- Device Frame Container -->
<div
class="device-preview-container mx-auto"
:class="[
currentDeviceConfig.class,
{
'device-frame': selectedDevice !== 'Desktop'
}
]"
:style="{
maxWidth: selectedDevice === 'Desktop' ? '100%' : `${currentDeviceConfig.width}px`,
height: selectedDevice === 'Desktop' ? 'auto' : `${currentDeviceConfig.height}px`
}"
>
<!-- Device Screen -->
<div
class="device-screen"
:class="{
'overflow-y-auto': selectedDevice !== 'Desktop',
'bg-white rounded-lg shadow-xl border': selectedDevice !== 'Desktop',
'scrollable-device': selectedDevice !== 'Desktop'
}"
:style="{
height: selectedDevice === 'Desktop' ? 'auto' : '100%',
overflowY: selectedDevice === 'Desktop' ? 'visible' : 'auto'
}"
>
<FormScriptEngine
ref="formScriptEngine"
:form-data="previewFormData"
@ -229,7 +274,13 @@
:actions="false"
v-model="previewFormData"
>
<div class="grid-preview-container">
<div
class="grid-preview-container"
:class="{
'p-4': selectedDevice !== 'Desktop',
'p-6': selectedDevice === 'Desktop'
}"
>
<template
v-for="(component, index) in formStore.formComponents"
:key="index"
@ -244,15 +295,22 @@
type="submit"
label="Submit"
class="form-submit mt-6"
:class="{
'mx-4 mb-4': selectedDevice !== 'Desktop',
'mx-0 mb-0': selectedDevice === 'Desktop'
}"
/>
</FormKit>
</div>
</div>
</div>
<!-- Edit Mode -->
<div v-else class="relative">
<FormBuilderCanvas
:form-components="formStore.formComponents"
@select-component="handleSelectComponent"
@select-nested-component="handleSelectNestedComponent"
@move-component="handleMoveComponent"
@delete-component="handleDeleteComponent"
@update-component="handleUpdateComponent"
@ -916,20 +974,45 @@ try {
};
}
const showPreview = ref(false);
const showUnsavedChangesModal = ref(false);
const pendingNavigation = ref(null);
const navigationTarget = ref(null);
const navigationConfirmed = ref(false);
const leftSidebarTab = ref('components');
const showFormSettings = ref(false);
const activeSettingsTab = ref('info');
// Reactive state
const isPreview = ref(false);
const showDropdown = ref(false);
const showFormSettings = ref(false);
const showTemplatesModal = ref(false);
const showFieldSettings = ref(false);
const showFieldSettingsPanel = ref(false);
const showFormHistoryModal = ref(false);
const showDropdown = ref(false);
const leftSidebarTab = ref('components');
const showFieldSettingsPanel = ref(true);
// Responsive device preview state
const selectedDevice = ref('Desktop');
const deviceSizes = ref([
{
name: 'Mobile',
width: 375,
height: 667,
icon: 'material-symbols:smartphone',
class: 'device-mobile'
},
{
name: 'Tablet',
width: 768,
height: 1024,
icon: 'material-symbols:tablet',
class: 'device-tablet'
},
{
name: 'Desktop',
width: 1200,
height: 800,
icon: 'material-symbols:desktop-windows',
class: 'device-desktop'
}
]);
const currentDeviceConfig = computed(() => {
return deviceSizes.value.find(device => device.name === selectedDevice.value) || deviceSizes.value[2];
});
const previewForm = ref(null);
const formScriptEngine = ref(null);
const conditionalLogicEngine = ref(null);
@ -1326,43 +1409,43 @@ const handleJsonImport = (event) => {
// Ensure all required info display properties
processedProps = {
title: processedProps.title || 'Information',
name: processedProps.name || `info_display_${index + 1}`,
help: processedProps.help || '',
layout: processedProps.layout || 'vertical',
showBorder: processedProps.showBorder !== undefined ? processedProps.showBorder : true,
backgroundColor: processedProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedProps.fields) ? processedProps.fields : [
name: processedDefaultProps.name || `info_display_${index + 1}`,
help: processedDefaultProps.help || '',
layout: processedDefaultProps.layout || 'vertical',
showBorder: processedDefaultProps.showBorder !== undefined ? processedDefaultProps.showBorder : true,
backgroundColor: processedDefaultProps.backgroundColor || '#f8fafc',
fields: Array.isArray(processedDefaultProps.fields) ? processedDefaultProps.fields : [
{ label: 'Info Item', value: 'Value', key: 'item_1' }
],
...processedProps
...processedDefaultProps
};
break;
case 'file':
// Ensure all required file upload properties
processedProps = {
label: processedProps.label || 'File Upload',
name: processedProps.name || `file_upload_${index + 1}`,
help: processedProps.help || 'Upload a file',
accept: processedProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedProps
label: processedDefaultProps.label || 'File Upload',
name: processedDefaultProps.name || `file_upload_${index + 1}`,
help: processedDefaultProps.help || 'Upload a file',
accept: processedDefaultProps.accept || '.pdf,.doc,.docx,.jpg,.jpeg,.png',
...processedDefaultProps
};
break;
case 'heading':
// Ensure all required heading properties
processedProps = {
value: processedProps.value || 'Heading',
level: processedProps.level || 2,
...processedProps
value: processedDefaultProps.value || 'Heading',
level: processedDefaultProps.level || 2,
...processedDefaultProps
};
break;
case 'paragraph':
// Ensure all required paragraph properties
processedProps = {
value: processedProps.value || 'Paragraph text',
...processedProps
value: processedDefaultProps.value || 'Paragraph text',
...processedDefaultProps
};
break;
@ -1370,43 +1453,42 @@ const handleJsonImport = (event) => {
case 'radio':
case 'checkbox':
// Ensure options array exists
if (!Array.isArray(processedProps.options) || processedProps.options.length === 0) {
processedProps.options = [
if (!Array.isArray(processedDefaultProps.options) || processedDefaultProps.options.length === 0) {
processedDefaultProps.options = [
{ label: 'Option 1', value: 'option_1' },
{ label: 'Option 2', value: 'option_2' }
];
}
break;
// Fall through for other basic properties
default:
// Basic properties for all other component types
if (!processedProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedProps.label = component.type.charAt(0).toUpperCase() + component.type.slice(1) + ' ' + (index + 1);
if (!processedDefaultProps.label && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedDefaultProps.label = component.name || component.type.charAt(0).toUpperCase() + component.type.slice(1);
}
if (!processedProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedProps.name = `${component.type}_${index + 1}`;
if (!processedDefaultProps.name && !['heading', 'paragraph', 'divider'].includes(component.type)) {
processedDefaultProps.name = `${component.type}_${index + 1}`;
}
break;
}
}
// Create component in expected format
// Create a component in the expected format for addComponent
const formattedComponent = {
type: component.type,
name: processedProps.label || component.type,
name: component.name || component.type,
category: getDefaultCategory(component.type),
icon: getDefaultIcon(component.type),
// Use the processed default props
defaultProps: processedProps
defaultProps: processedDefaultProps
};
console.log('Formatted component:', formattedComponent);
// Add component to form
// Add the component to the form
formStore.addComponent(formattedComponent);
} else {
console.warn('Skipping invalid component:', component);
}
});
}
@ -1567,9 +1649,48 @@ const handleSelectComponent = (component) => {
formStore.selectComponent(component.id);
};
const handleSelectNestedComponent = (nestedComponent) => {
if (!nestedComponent || !nestedComponent.id) return;
// For nested components, we need to handle the selection differently
// since they're not in the main formComponents array but inside section children
formStore.selectedComponent = nestedComponent;
formStore.selectedComponentId = nestedComponent.id;
// Ensure the settings panel is open
if (!showFieldSettingsPanel.value) {
showFieldSettingsPanel.value = true;
}
console.log('Selected nested component:', nestedComponent);
};
const handleUpdateComponent = (updatedComponent) => {
if (!updatedComponent || !updatedComponent.id) return;
// Check if this is a nested component inside a section
let foundInSection = false;
// Look for the component in section children
formStore.formComponents.forEach(component => {
if (component.type === 'form-section' && component.props.children) {
const nestedIndex = component.props.children.findIndex(child => child.id === updatedComponent.id);
if (nestedIndex !== -1) {
// Update the nested component
component.props.children[nestedIndex] = updatedComponent;
// Update the entire section to trigger reactivity
formStore.updateComponent(component);
foundInSection = true;
console.log('Updated nested component in section:', updatedComponent);
return;
}
}
});
// If not found in sections, update as a main component
if (!foundInSection) {
formStore.updateComponent(updatedComponent);
}
};
const handleMoveComponent = ({ oldIndex, newIndex }) => {
@ -1578,7 +1699,37 @@ const handleMoveComponent = ({ oldIndex, newIndex }) => {
const handleDeleteComponent = (id) => {
if (!id) return;
// Check if this is a nested component inside a section
let foundInSection = false;
// Look for the component in section children
formStore.formComponents.forEach(component => {
if (component.type === 'form-section' && component.props.children) {
const nestedIndex = component.props.children.findIndex(child => child.id === id);
if (nestedIndex !== -1) {
// Remove the nested component
const deletedComponent = component.props.children.splice(nestedIndex, 1)[0];
// Update the entire section to trigger reactivity
formStore.updateComponent(component);
foundInSection = true;
// Clear selection if the deleted component was selected
if (formStore.selectedComponentId === id) {
formStore.selectedComponent = null;
formStore.selectedComponentId = null;
}
console.log('Deleted nested component from section:', deletedComponent);
return;
}
}
});
// If not found in sections, delete as a main component
if (!foundInSection) {
formStore.deleteComponent(id);
}
};
const handleDragOver = (event) => {
@ -2796,4 +2947,145 @@ const handleFormRestored = (restoredForm) => {
text-orientation: mixed;
transform: rotate(180deg);
}
/* Device Preview Styles */
.device-preview-container {
transition: all 0.3s ease-in-out;
position: relative;
}
.device-frame {
position: relative;
border-radius: 20px;
padding: 20px;
background: #1a1a1a;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
}
.device-frame.device-mobile {
border-radius: 25px;
padding: 15px;
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
max-height: 80vh; /* Ensure it fits in viewport */
}
.device-frame.device-tablet {
border-radius: 20px;
padding: 20px;
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
max-height: 85vh; /* Ensure it fits in viewport */
}
.device-screen {
width: 100%;
height: 100%;
border-radius: 15px;
overflow: hidden;
position: relative;
}
.device-screen.scrollable-device {
overflow-y: auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch; /* iOS smooth scrolling */
}
/* Custom scrollbar for device screens */
.device-screen.scrollable-device::-webkit-scrollbar {
width: 6px;
}
.device-screen.scrollable-device::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
margin: 10px 0;
}
.device-screen.scrollable-device::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 3px;
}
.device-screen.scrollable-device::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
.device-mobile .device-screen {
border-radius: 20px;
min-height: 100%;
}
.device-tablet .device-screen {
border-radius: 15px;
min-height: 100%;
}
/* Device-specific responsive adjustments */
.device-mobile .grid-preview-container {
font-size: 14px;
padding: 1rem !important;
}
.device-mobile .grid-preview-container .formkit-wrapper {
margin-bottom: 1rem;
}
.device-tablet .grid-preview-container {
font-size: 15px;
padding: 1.5rem !important;
}
/* Form content adjustments for devices */
.device-mobile .form-submit,
.device-tablet .form-submit {
position: sticky;
bottom: 0;
background: white;
border-top: 1px solid #e5e7eb;
margin-top: 2rem;
padding-top: 1rem;
z-index: 10;
}
/* Scroll fade indicators */
.device-screen.scrollable-device::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 20px;
background: linear-gradient(to bottom, rgba(255,255,255,0.9), rgba(255,255,255,0));
pointer-events: none;
z-index: 5;
}
.device-screen.scrollable-device::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 20px;
background: linear-gradient(to top, rgba(255,255,255,0.9), rgba(255,255,255,0));
pointer-events: none;
z-index: 5;
}
/* Responsive grid adjustments */
@media (max-width: 768px) {
.device-preview-container {
transform: scale(0.8);
transform-origin: top center;
}
}
@media (max-width: 480px) {
.device-preview-container {
transform: scale(0.6);
transform-origin: top center;
}
}
</style>

View File

@ -63,6 +63,18 @@
],
"format": "date-time"
},
"caseInstance": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
},
"caseTimeline": {
"type": "array",
"items": {
"$ref": "#/definitions/caseTimeline"
}
},
"forms": {
"type": "array",
"items": {
@ -87,6 +99,12 @@
"$ref": "#/definitions/processHistory"
}
},
"task": {
"type": "array",
"items": {
"$ref": "#/definitions/task"
}
},
"userrole": {
"type": "array",
"items": {
@ -473,6 +491,12 @@
],
"format": "date-time"
},
"caseInstance": {
"type": "array",
"items": {
"$ref": "#/definitions/caseInstance"
}
},
"creator": {
"anyOf": [
{

View File

@ -23,10 +23,13 @@ model user {
userStatus String? @db.VarChar(255)
userCreatedDate DateTime? @db.DateTime(0)
userModifiedDate DateTime? @db.DateTime(0)
caseInstance caseInstance[]
caseTimeline caseTimeline[]
forms form[] @relation("FormCreator")
formHistoryEntries formHistory[]
processes process[] @relation("ProcessCreator")
processHistoryEntries processHistory[]
task task[]
userrole userrole[]
startedCases caseInstance[] @relation("CaseStartedBy")
assignedTasks task[] @relation("TaskAssignedTo")
@ -121,6 +124,7 @@ model process {
processVariables Json?
templateCategory String? @db.VarChar(100)
processDeletedDate DateTime? @db.DateTime(0)
caseInstance caseInstance[]
creator user? @relation("ProcessCreator", fields: [processCreatedBy], references: [userID])
history processHistory[] @relation("ProcessHistoryEntries")
cases caseInstance[]