Enhance Form Builder with Repeating Group Functionality and Data Management

- Added support for initializing and managing repeating groups in ComponentPreview.vue, allowing for dynamic creation of group items based on minimum item settings.
- Implemented event emission for form data updates to notify parent components, ensuring synchronization with FormKit integration.
- Enhanced FormBuilderFieldSettingsModal.vue with new configuration options for repeating tables, including minimum and maximum records, button text settings, and table behavior controls.
- Updated safeGetField.js to ensure backward compatibility by returning empty strings for undefined or null values.
- Improved workflow page to handle form data updates from ComponentPreview, maintaining data consistency across the application.
- Enhanced form store logic to initialize preview form data for repeating groups upon component addition and form loading.
This commit is contained in:
Afiq 2025-08-06 15:12:52 +08:00
parent a2b707eb6e
commit 3abaf7afe5
5 changed files with 428 additions and 3 deletions

View File

@ -547,7 +547,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['select-nested-component']);
const emit = defineEmits(['select-nested-component', 'form-data-updated']);
// Get access to the form builder store
const formStore = useFormBuilderStore();
@ -606,6 +606,37 @@ onMounted(() => {
}
}
}
// Initialize repeating groups
if (props.component.type === 'repeating-group') {
const groupName = props.component.props.name;
if (groupName) {
// Get current groups or initialize empty array
const currentGroups = safeGetField(groupName, formStore.previewFormData);
// If no groups exist and minItems is specified, create initial groups
if ((!currentGroups || currentGroups.length === 0) && props.component.props.minItems > 0) {
const initialGroups = [];
const minItems = props.component.props.minItems || 1;
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (props.component.props.fields) {
props.component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
const updatedData = { ...formStore.previewFormData, [groupName]: initialGroups };
formStore.updatePreviewFormData(updatedData);
}
}
}
});
// Watch for changes to component props, especially defaultItems
@ -622,6 +653,39 @@ watch(() => props.component.props.defaultItems, (newDefaultItems, oldDefaultItem
}
}, { deep: true, immediate: true });
// Watch for changes to repeating group props
watch(() => props.component.props.minItems, (newMinItems, oldMinItems) => {
if (props.component.type === 'repeating-group') {
const groupName = props.component.props.name;
if (!groupName) return;
const currentGroups = safeGetField(groupName, formStore.previewFormData);
const minItems = newMinItems || 1;
// If current groups are fewer than minItems, add missing groups
if ((!currentGroups || currentGroups.length < minItems) && minItems > 0) {
const groupsToAdd = minItems - (currentGroups?.length || 0);
const updatedGroups = [...(currentGroups || [])];
for (let i = 0; i < groupsToAdd; i++) {
const newGroup = {};
// Add fields from configuration
if (props.component.props.fields) {
props.component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
updatedGroups.push(newGroup);
}
const updatedData = { ...formStore.previewFormData, [groupName]: updatedGroups };
formStore.updatePreviewFormData(updatedData);
}
}
}, { deep: true, immediate: true });
// Repeating group and dynamic list functionality
const addGroupItem = () => {
if (props.isPreview) return;
@ -648,6 +712,9 @@ const addGroupItem = () => {
// Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
emit('form-data-updated', updatedData);
};
const removeGroupItem = (index) => {
@ -659,12 +726,22 @@ const removeGroupItem = (index) => {
// Get current groups
const currentGroups = [...(safeGetField(groupName, formStore.previewFormData) || [])];
// Check if we can remove this item (respect minimum items)
const minItems = props.component.props.minItems || 1;
if (currentGroups.length <= minItems) {
return;
}
// Remove the group at the specified index
currentGroups.splice(index, 1);
// Update the form data
const updatedData = { ...formStore.previewFormData, [groupName]: currentGroups };
formStore.updatePreviewFormData(updatedData);
// Also emit an event to notify parent components about the data change
// This is important for FormKit integration
emit('form-data-updated', updatedData);
};
const addListItem = () => {

View File

@ -939,6 +939,255 @@ if (name && email) {
</div>
</template>
<!-- Data Table Configuration -->
<template v-if="component.type === 'repeating-table'">
<div class="space-y-6">
<!-- Table 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>
<!-- Button Text Settings -->
<div class="grid grid-cols-2 gap-4">
<FormKit
type="text"
label="Add Button Text"
name="buttonText"
v-model="configModel.buttonText"
help="Text for the add record button"
: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 button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Edit"
/>
<FormKit
type="text"
label="Delete Button Text"
name="deleteText"
v-model="configModel.deleteText"
help="Text for the delete button"
:classes="{ outer: 'field-wrapper' }"
placeholder="Delete"
/>
</div>
<!-- Table Behavior Settings -->
<div class="space-y-4">
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Table Behavior</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"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Allow Edit"
name="allowEdit"
v-model="configModel.allowEdit"
help="Allow editing existing records"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="switch"
label="Allow Delete"
name="allowDelete"
v-model="configModel.allowDelete"
help="Allow deleting 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' }"
/>
<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="Enable Export"
name="enableExport"
v-model="configModel.enableExport"
help="Allow exporting table data"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
<!-- Table Columns Management -->
<div class="space-y-4">
<div class="flex justify-between items-center">
<h5 class="text-sm font-medium text-gray-700">Table Columns</h5>
<RsButton @click="addTableColumn" variant="primary" size="sm">
<Icon name="heroicons:plus" class="w-4 h-4 mr-1" />
Add Column
</RsButton>
</div>
<div class="border rounded-md p-3 bg-gray-50 space-y-3">
<div v-for="(column, index) in (configModel.columns || [])" :key="index" class="border p-3 rounded bg-white">
<div class="flex justify-between items-center mb-3">
<h4 class="font-medium text-sm text-gray-800">Column {{ index + 1 }}</h4>
<button
@click="removeTableColumn(index)"
class="text-red-500 hover:text-red-700"
type="button"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormKit
type="text"
label="Column Name"
v-model="column.name"
placeholder="e.g., name, email, phone"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Column Label"
v-model="column.label"
placeholder="e.g., Name, Email, Phone"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="select"
label="Column Type"
v-model="column.type"
:options="[
{ label: 'Text', value: 'text' },
{ label: 'Number', value: 'number' },
{ label: 'Email', value: 'email' },
{ label: 'Phone', value: 'tel' },
{ label: 'Date', value: 'date' },
{ label: 'Time', value: 'time' },
{ label: 'URL', value: 'url' },
{ label: 'Select', value: 'select' },
{ label: 'Checkbox', value: 'checkbox' },
{ label: 'Text Area', value: 'textarea' }
]"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Placeholder"
v-model="column.placeholder"
placeholder="Enter placeholder text"
:classes="{ outer: 'field-wrapper' }"
/>
<FormKit
type="text"
label="Validation Rules"
v-model="column.validation"
placeholder="e.g., required|email"
:classes="{ outer: 'field-wrapper' }"
/>
<div class="flex items-center">
<label class="flex items-center">
<input type="checkbox" v-model="column.required" class="mr-2" />
<span class="text-sm text-gray-700">Required Field</span>
</label>
</div>
</div>
<!-- Options for select type -->
<div v-if="column.type === 'select'" class="mt-4">
<div class="flex justify-between items-center mb-2">
<h6 class="text-sm font-medium text-gray-700">Select Options</h6>
<button
@click="addColumnOption(index)"
class="text-blue-600 hover:text-blue-800 text-sm"
type="button"
>
<Icon name="heroicons:plus" class="w-3 h-3 mr-1" />
Add Option
</button>
</div>
<div 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-3 h-3" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Repeating Group Configuration -->
<template v-if="component.type === 'repeating-group'">
<div class="space-y-6">

View File

@ -1,7 +1,12 @@
// Utility to safely get a field value from a form data object
export function safeGetField(field, formData) {
if (formData && Object.prototype.hasOwnProperty.call(formData, field)) {
return formData[field] !== undefined && formData[field] !== null ? formData[field] : '';
const value = formData[field];
// If the value is undefined or null, return empty string for backward compatibility
if (value === undefined || value === null) {
return '';
}
return value;
}
if (process.env.NODE_ENV !== 'production') {
// Only warn in development

View File

@ -1017,6 +1017,14 @@ const handleScriptFieldChange = ({ fieldName, value }) => {
formStore.updatePreviewFormData(formData.value);
};
// Handle form data updates from ComponentPreview
const handleFormDataUpdate = (updatedData) => {
// Update the form data with the new data
formData.value = { ...formData.value, ...updatedData };
// Also update form store to keep them in sync
formStore.updatePreviewFormData(formData.value);
};
// Handle conditional logic script generation
const handleConditionalLogicGenerated = (generatedScript) => {
console.log('[WorkflowExecution] Conditional logic script generated');
@ -1205,7 +1213,38 @@ watch(currentStep, async (newStep) => {
// Update form store with form components and data for ComponentPreview
if (currentForm.value?.formComponents) {
formStore.formComponents = currentForm.value.formComponents;
formStore.updatePreviewFormData(formData.value);
// Initialize repeating groups in form data
const updatedFormData = { ...formData.value };
currentForm.value.formComponents.forEach(component => {
if (component.type === 'repeating-group' && component.props?.name) {
const groupName = component.props.name;
const minItems = component.props.minItems || 1;
// If the field doesn't exist or is not an array, initialize it
if (!updatedFormData[groupName] || !Array.isArray(updatedFormData[groupName])) {
const initialGroups = [];
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (component.props.fields) {
component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
updatedFormData[groupName] = initialGroups;
}
}
});
formData.value = updatedFormData;
formStore.updatePreviewFormData(updatedFormData);
}
}
} else if (["decision", "gateway"].includes(currentNode.value.type)) {
@ -1693,6 +1732,7 @@ const getWorkflowSubmitButtonStyle = () => {
:component="component"
:is-preview="false"
:field-states="fieldStates"
@form-data-updated="handleFormDataUpdate"
/>
</template>
<!-- Submit button - respects form builder configuration -->

View File

@ -377,6 +377,30 @@ export const useFormBuilderStore = defineStore('formBuilder', {
};
this.formComponents.push(newComponent);
// Initialize preview form data for repeating groups
if (newComponent.type === 'repeating-group' && newComponent.props?.name) {
const groupName = newComponent.props.name;
const minItems = newComponent.props.minItems || 1;
const initialGroups = [];
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (newComponent.props.fields) {
newComponent.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
const updatedFormData = { ...this.previewFormData, [groupName]: initialGroups };
this.updatePreviewFormData(updatedFormData);
}
// Explicitly select the new component
this.selectedComponentId = newComponentId;
this.hasUnsavedChanges = true;
@ -801,6 +825,33 @@ export const useFormBuilderStore = defineStore('formBuilder', {
}));
}
// Initialize preview form data with repeating groups
const initialFormData = {};
this.formComponents.forEach(component => {
if (component.type === 'repeating-group' && component.props?.name) {
const groupName = component.props.name;
const minItems = component.props.minItems || 1;
const initialGroups = [];
for (let i = 0; i < minItems; i++) {
const newGroup = {};
// Add fields from configuration
if (component.props.fields) {
component.props.fields.forEach(field => {
newGroup[field.name] = '';
});
}
initialGroups.push(newGroup);
}
initialFormData[groupName] = initialGroups;
}
});
this.updatePreviewFormData(initialFormData);
// Clear and initialize history when loading a form
this.actionHistory = [];
this.currentHistoryIndex = -1;
@ -876,6 +927,9 @@ export const useFormBuilderStore = defineStore('formBuilder', {
this.actionHistory = [];
this.currentHistoryIndex = -1;
// Clear preview form data
this.updatePreviewFormData({});
// Record the initial empty state
this.recordHistory('new_form', {
message: 'Created a new empty form'