From cf3a2c1a58f70794799328e0e16b62a2e7e0830f Mon Sep 17 00:00:00 2001 From: Afiq Date: Sat, 31 May 2025 10:45:56 +0800 Subject: [PATCH] Add Repeating Table Component and Enhance Form Builder Functionality - Introduced a new RepeatingTable component for structured data collection, allowing users to add, edit, and delete records dynamically. - Enhanced FormBuilderComponents to include the RepeatingTable in the available components list with default properties. - Updated FormBuilderFieldSettingsModal to support configuration options for the RepeatingTable, including button texts, record limits, and display settings. - Implemented data management functions for updating table data and handling dynamic list items in the FormBuilder. - Improved ComponentPreview to render the RepeatingTable component correctly in preview mode. - Enhanced user experience with intuitive UI elements and responsive design for the new table component. - Updated documentation to reflect the addition of the RepeatingTable and its configuration options. --- components/ComponentPreview.vue | 33 +- components/FormBuilderComponents.vue | 49 + components/FormBuilderFieldSettingsModal.vue | 378 +++++- components/RepeatingTable.vue | 1015 +++++++++++++++++ .../process-flow/FormNodeConfiguration.vue | 237 +++- components/process-flow/VariableManager.vue | 3 +- server/api/roles/index.get.js | 2 +- server/api/users/index.get.js | 2 +- stores/formBuilder.js | 20 + 9 files changed, 1696 insertions(+), 43 deletions(-) create mode 100644 components/RepeatingTable.vue diff --git a/components/ComponentPreview.vue b/components/ComponentPreview.vue index eb1205d..eb2a021 100644 --- a/components/ComponentPreview.vue +++ b/components/ComponentPreview.vue @@ -35,8 +35,6 @@ options: 'space-y-0.5' } : {}" :class="{ 'canvas-component': isPreview }" /> - -
+ +
+
- -
Unknown component type: {{ component.type }}
@@ -838,6 +844,21 @@ const componentStyle = computed(() => { boxSizing: 'border-box' }; }); + +// Update dynamic list items +const updateListItems = (listName, newItems) => { + const updatedData = { ...formStore.previewFormData, [listName]: newItems }; + formStore.updatePreviewFormData(updatedData); +}; + +// Update table data for repeating-table component +const updateTableData = (newData) => { + const tableName = props.component.props.name; + if (!tableName) return; + + const updatedFormData = { ...formStore.previewFormData, [tableName]: newData }; + formStore.updatePreviewFormData(updatedFormData); +}; \ No newline at end of file diff --git a/components/FormBuilderComponents.vue b/components/FormBuilderComponents.vue index 209e245..cd53f0f 100644 --- a/components/FormBuilderComponents.vue +++ b/components/FormBuilderComponents.vue @@ -504,6 +504,55 @@ const availableComponents = [ importEnabled: false } }, + { + type: 'repeating-table', + name: 'Data Table', + category: 'Advanced', + icon: 'material-symbols:table', + description: 'Structured table for collecting multiple records', + defaultProps: { + label: 'Data Records', + name: 'data_table', + help: 'Add records using the form and view them in a table', + buttonText: 'Add Record', + editText: 'Edit', + deleteText: 'Delete', + minRecords: 0, + maxRecords: 50, + showRowNumbers: true, + allowEdit: true, + allowDelete: true, + confirmDelete: true, + enableSearch: false, + columns: [ + { + name: 'name', + label: 'Name', + type: 'text', + required: true, + placeholder: 'Enter name', + validation: 'required' + }, + { + name: 'email', + label: 'Email', + type: 'email', + required: true, + placeholder: 'Enter email', + validation: 'required|email' + }, + { + name: 'phone', + label: 'Phone', + type: 'tel', + required: false, + placeholder: 'Enter phone', + validation: '' + } + ], + defaultData: [] + } + }, { type: 'otp', name: 'OTP Input', diff --git a/components/FormBuilderFieldSettingsModal.vue b/components/FormBuilderFieldSettingsModal.vue index f029753..864fb1e 100644 --- a/components/FormBuilderFieldSettingsModal.vue +++ b/components/FormBuilderFieldSettingsModal.vue @@ -233,18 +233,18 @@

Common patterns:

- - - - +
@@ -608,6 +608,252 @@
+ + + @@ -1145,7 +1391,14 @@ const isOpen = computed({ }) const activeTab = ref('basic') -const configModel = ref({}) +const configModel = ref({ + conditionalLogic: { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } +}) // Component info helpers const modalTitle = computed(() => { @@ -1181,7 +1434,8 @@ const getComponentIcon = (type) => { paragraph: 'heroicons:document-text', divider: 'heroicons:minus', 'info-display': 'heroicons:information-circle', - 'dynamic-list': 'heroicons:list-bullet' + 'dynamic-list': 'heroicons:list-bullet', + 'repeating-table': 'heroicons:table-cells' } return icons[type] || 'heroicons:square-3-stack-3d' } @@ -1214,7 +1468,8 @@ const getComponentTypeName = (type) => { paragraph: 'Paragraph Text', divider: 'Divider Line', 'info-display': 'Information Display', - 'dynamic-list': 'Dynamic List' + 'dynamic-list': 'Dynamic List', + 'repeating-table': 'Data Table' } return names[type] || 'Form Field' } @@ -1247,7 +1502,8 @@ const getComponentDescription = (type) => { paragraph: 'Text content for instructions and descriptions', divider: 'Visual separator to organize form sections', 'info-display': 'Read-only information display in organized format', - 'dynamic-list': 'Dynamic list for displaying and managing items' + 'dynamic-list': 'Dynamic list for displaying and managing items', + 'repeating-table': 'Structured table for collecting multiple records with forms' } return descriptions[type] || 'Configure this form field' } @@ -1272,14 +1528,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'], - 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'], + 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'], 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'], + 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'], 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'], + 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'], 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'] + conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'dynamic-list', 'repeating-table'] } return fieldConfig[fieldName]?.includes(props.component.type) || false @@ -1288,7 +1544,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'] + const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'heading', 'paragraph', 'button', 'dynamic-list', 'repeating-table'] return specificTypes.includes(props.component.type) }) @@ -1408,7 +1664,8 @@ const getRecommendedWidth = (fieldType) => { 'switch': 'full', 'button': 'full', 'info-display': 'full', - 'dynamic-list': 'full' + 'dynamic-list': 'full', + 'repeating-table': 'full' } return recommendations[fieldType] || 'full' @@ -1432,7 +1689,16 @@ const getCurrentGridColumns = () => { // Watch for component changes watch(() => props.component, (newComponent) => { if (newComponent) { - configModel.value = { ...newComponent.props } + configModel.value = { + ...newComponent.props, + // Ensure conditionalLogic is properly initialized + conditionalLogic: newComponent.props.conditionalLogic || { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } + } activeTab.value = 'basic' resetValidationState() } @@ -1480,7 +1746,9 @@ const addOption = () => { } const removeOption = (index) => { - configModel.value.options.splice(index, 1) + if (configModel.value.options && configModel.value.options.length > index) { + configModel.value.options.splice(index, 1) + } } // Default items management for dynamic-list @@ -1597,7 +1865,16 @@ const handleReset = () => { ...props.component, props: props.component.defaultProps || {} } - configModel.value = { ...defaultComponent.props } + configModel.value = { + ...defaultComponent.props, + // Ensure conditionalLogic is properly initialized + conditionalLogic: defaultComponent.props.conditionalLogic || { + enabled: false, + conditions: [], + action: 'show', + operator: 'and' + } + } } } @@ -1605,6 +1882,45 @@ const handleClose = () => { isOpen.value = false emit('close') } + +// Table column management for repeating-table +const addTableColumn = () => { + if (!configModel.value.columns) { + configModel.value.columns = [] + } + configModel.value.columns.push({ + name: `column_${configModel.value.columns.length + 1}`, + label: `Column ${configModel.value.columns.length + 1}`, + type: 'text', + required: false, + placeholder: 'Enter value...', + validation: '', + width: '200px', + options: [] + }) +} + +const removeTableColumn = (index) => { + if (configModel.value.columns) { + configModel.value.columns.splice(index, 1) + } +} + +const addColumnOption = (columnIndex) => { + if (!configModel.value.columns[columnIndex].options) { + configModel.value.columns[columnIndex].options = [] + } + configModel.value.columns[columnIndex].options.push({ + label: `Option ${configModel.value.columns[columnIndex].options.length + 1}`, + value: `option_${configModel.value.columns[columnIndex].options.length + 1}` + }) +} + +const removeColumnOption = (columnIndex, optionIndex) => { + if (configModel.value.columns[columnIndex].options) { + configModel.value.columns[columnIndex].options.splice(optionIndex, 1) + } +} \ No newline at end of file diff --git a/components/process-flow/FormNodeConfiguration.vue b/components/process-flow/FormNodeConfiguration.vue index b8c04aa..a8b4dc1 100644 --- a/components/process-flow/FormNodeConfiguration.vue +++ b/components/process-flow/FormNodeConfiguration.vue @@ -430,6 +430,145 @@ + + +
+
+
+ 4 +
+

Task Assignment

+
+ +

+ Configure who can complete this form task. Choose from specific users, roles, or dynamic assignment using variables. +

+ +
+ +
+ + +

How should this task be assigned

+
+ + +
+
+ +
Assign to Specific Users
+
+ +
+ +

Selected users will be able to complete this form task

+
+
+ + +
+
+ +
Assign to Roles
+
+ +
+ +

Any user with the selected roles will be able to complete this form task

+
+
+ + +
+
+ +
Dynamic Assignment from Variable
+
+ +
+
+ + +

Variable should contain user ID, username, role name, or email address

+
+ +
+ + +

How to interpret the variable value for assignment

+
+
+
+ + +
+
+ +
Public Task
+
+

Anyone with access to the process will be able to complete this form task

+
+ + +
+
Assignment Summary
+
+
+ Task assigned to {{ localNodeData.assignedUsers.length }} user(s) +
+
+ Task assigned to {{ localNodeData.assignedRoles.length }} role(s) +
+
+ Task dynamically assigned using variable: {{ localNodeData.assignmentVariable }} +
+
+
+
+
@@ -447,6 +586,14 @@ const props = defineProps({ availableVariables: { type: Array, default: () => [] + }, + availableUsers: { + type: Array, + default: () => [] + }, + availableRoles: { + type: Array, + default: () => [] } }); @@ -468,7 +615,12 @@ const localNodeData = ref({ formUuid: null, inputMappings: [], outputMappings: [], - fieldConditions: [] + fieldConditions: [], + assignmentType: 'public', + assignedUsers: [], + assignedRoles: [], + assignmentVariable: '', + assignmentVariableType: 'user_id' }); // Watch for changes from parent props @@ -489,7 +641,12 @@ watch(() => props.nodeData, async (newNodeData) => { : [], fieldConditions: Array.isArray(newNodeData.fieldConditions) ? newNodeData.fieldConditions.map(condition => ({ ...condition })) - : [] + : [], + assignmentType: newNodeData.assignmentType || 'public', + assignedUsers: Array.isArray(newNodeData.assignedUsers) ? newNodeData.assignedUsers.map(user => ({ ...user })) : [], + assignedRoles: Array.isArray(newNodeData.assignedRoles) ? newNodeData.assignedRoles.map(role => ({ ...role })) : [], + assignmentVariable: newNodeData.assignmentVariable || '', + assignmentVariableType: newNodeData.assignmentVariableType || 'user_id' }; // Load form fields if form is already selected @@ -510,7 +667,12 @@ async function handleFormSelection(form) { formUuid: form.formUUID, label: form.formName || 'Form Task', description: `Form: ${form.formName}`, - fieldConditions: [] + fieldConditions: [], + assignmentType: 'public', + assignedUsers: [], + assignedRoles: [], + assignmentVariable: '', + assignmentVariableType: 'user_id' }; // Load form fields for this form @@ -530,7 +692,12 @@ function clearFormSelection() { description: 'Form submission task', inputMappings: [], outputMappings: [], - fieldConditions: [] + fieldConditions: [], + assignmentType: 'public', + assignedUsers: [], + assignedRoles: [], + assignmentVariable: '', + assignmentVariableType: 'user_id' }; // Clear form fields @@ -688,6 +855,55 @@ const processVariableOptions = computed(() => { })); }); +// Reactive state for users and roles +const users = ref([]); +const roles = ref([]); + +// Computed property for available users (for FormKit select) +const availableUsers = computed(() => { + return users.value.map(user => ({ + label: user.userFullName ? `${user.userFullName} (${user.userUsername})` : user.userUsername, + value: user.userID, + username: user.userUsername, + email: user.userEmail + })); +}); + +// Computed property for available roles (for FormKit select) +const availableRoles = computed(() => { + return roles.value.map(role => ({ + label: role.roleName, + value: role.roleID, + description: role.roleDescription + })); +}); + +// Fetch users and roles data +async function fetchUsersAndRoles() { + try { + // Fetch users + const usersResponse = await fetch('/api/users'); + const usersResult = await usersResponse.json(); + if (usersResult.success && usersResult.users) { + users.value = usersResult.users; + } + + // Fetch roles + const rolesResponse = await fetch('/api/roles'); + const rolesResult = await rolesResponse.json(); + if (rolesResult.success && rolesResult.roles) { + roles.value = rolesResult.roles; + } + } catch (error) { + console.error('Error fetching users and roles:', error); + } +} + +// Fetch data when component mounts +nextTick(() => { + fetchUsersAndRoles(); +}); + // Save changes by emitting them to parent function saveChanges() { // Create a clean copy of the data to avoid reactivity issues @@ -698,7 +914,12 @@ function saveChanges() { outputMappings: localNodeData.value.outputMappings ? localNodeData.value.outputMappings.map(mapping => ({ ...mapping })) : [], fieldConditions: localNodeData.value.fieldConditions ? - localNodeData.value.fieldConditions.map(condition => ({ ...condition })) : [] + localNodeData.value.fieldConditions.map(condition => ({ ...condition })) : [], + assignmentType: localNodeData.value.assignmentType || 'public', + assignedUsers: localNodeData.value.assignedUsers ? localNodeData.value.assignedUsers.map(user => ({ ...user })) : [], + assignedRoles: localNodeData.value.assignedRoles ? localNodeData.value.assignedRoles.map(role => ({ ...role })) : [], + assignmentVariable: localNodeData.value.assignmentVariable || '', + assignmentVariableType: localNodeData.value.assignmentVariableType || 'user_id' }; // Emit the updated data to parent @@ -752,6 +973,12 @@ function getStringValue(value) { return ''; } } + +// Handle assignment type change +function handleAssignmentTypeChange() { + // Implementation of handleAssignmentTypeChange function + saveChanges(); +}