From 649a9560638cb6e7b8060394bb17398ce4570dec Mon Sep 17 00:00:00 2001 From: Afiq Date: Fri, 8 Aug 2025 09:42:29 +0800 Subject: [PATCH] Enhance Button Mapping Functionality in Form Node Configuration and Component Preview - Added support for button click event emission in ComponentPreview.vue, allowing for workflow processing of button interactions. - Introduced a new section in FormNodeConfiguration.vue for mapping button clicks to process variables, enabling users to assign button click events to specific variables. - Implemented functionality to add and remove button mappings, enhancing the flexibility of button interactions within the form workflow. - Updated state management to include button mappings, ensuring proper handling of button-related data during form processing. - Enhanced computed properties to provide options for form buttons, improving user experience in selecting and configuring button mappings. --- components/ComponentPreview.vue | 12 +- .../process-flow/FormNodeConfiguration.vue | 208 +++++++++++++++- pages/workflow/[id].vue | 222 ++++++++++++++++-- server/api/forms/[id]/buttons.get.js | 79 +++++++ 4 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 server/api/forms/[id]/buttons.get.js diff --git a/components/ComponentPreview.vue b/components/ComponentPreview.vue index f1841c6..8c01205 100644 --- a/components/ComponentPreview.vue +++ b/components/ComponentPreview.vue @@ -873,7 +873,7 @@ const props = defineProps({ } }); -const emit = defineEmits(['select-nested-component', 'form-data-updated']); +const emit = defineEmits(['select-nested-component', 'form-data-updated', 'button-clicked']); // Get access to the form builder store const formStore = useFormBuilderStore(); @@ -1445,7 +1445,17 @@ const handleButtonClick = () => { } } + // Emit button click event for workflow processing + emit('button-clicked', { + buttonName: props.component.props.name, + buttonLabel: props.component.props.label, + buttonText: props.component.props.buttonText || props.component.props.label, + buttonType: props.component.props.buttonType || 'button', + timestamp: new Date().toISOString() + }); + // Default behavior - log the button click + console.log('[ComponentPreview] Button clicked:', props.component.props.name); }; // Check if the component is a standard FormKit input type (excluding specialized components) diff --git a/components/process-flow/FormNodeConfiguration.vue b/components/process-flow/FormNodeConfiguration.vue index ebb8219..f798fae 100644 --- a/components/process-flow/FormNodeConfiguration.vue +++ b/components/process-flow/FormNodeConfiguration.vue @@ -242,6 +242,127 @@ + + +
+
+
+
+ + + + Button Variables (Button Clicks → Process) +
+

Assign button click events to process variables

+
+ + Add Mapping + +
+ + +
+

+ No button mappings defined. Button clicks will not be tracked in process variables. +

+ + Add First Mapping + +
+ +
+
+
+
+ + Button Click to Process Variable #{{ index + 1 }} +
+ +
+ +
+
+ + +

+ {{ formButtons.length === 0 ? 'No form buttons available' : 'The source button in the form' }} +

+
+ +
+ + +

+ The target variable to store button click information +

+
+
+ + +
+
+ + +

What value to assign to the process variable when button is clicked

+
+ + +
+ + +

The custom value to assign when button is clicked

+
+
+
+
+
@@ -621,9 +742,11 @@ const emit = defineEmits(['update']); // Get the variable store for creating variables const processStore = useProcessBuilderStore(); -// State for form fields +// State for form fields and buttons const formFields = ref([]); +const formButtons = ref([]); const isLoadingFields = ref(false); +const isLoadingButtons = ref(false); // Local state for node data - create a deep copy to avoid mutation issues const localNodeData = ref({ @@ -634,6 +757,7 @@ const localNodeData = ref({ formUuid: null, inputMappings: [], outputMappings: [], + buttonMappings: [], fieldConditions: [], assignmentType: 'public', assignedUsers: [], @@ -662,6 +786,9 @@ watch(() => props.nodeData, async (newNodeData) => { outputMappings: Array.isArray(newNodeData.outputMappings) ? newNodeData.outputMappings.map(mapping => ({ ...mapping })) : [], + buttonMappings: Array.isArray(newNodeData.buttonMappings) + ? newNodeData.buttonMappings.map(mapping => ({ ...mapping })) + : [], fieldConditions: Array.isArray(newNodeData.fieldConditions) ? newNodeData.fieldConditions.map(condition => ({ ...condition })) : [], @@ -672,9 +799,12 @@ watch(() => props.nodeData, async (newNodeData) => { assignmentVariableType: newNodeData.assignmentVariableType || 'user_id' }; - // Load form fields if form is already selected + // Load form fields and buttons if form is already selected if (newNodeData.formId || newNodeData.formUuid) { - await loadFormFields(newNodeData.formId || newNodeData.formUuid); + await Promise.all([ + loadFormFields(newNodeData.formId || newNodeData.formUuid), + loadFormButtons(newNodeData.formId || newNodeData.formUuid) + ]); } } }, { immediate: true, deep: true }); @@ -690,6 +820,7 @@ async function handleFormSelection(form) { formUuid: form.formUUID, label: form.formName || 'Form Task', description: `Form: ${form.formName}`, + buttonMappings: [], fieldConditions: [], assignmentType: 'public', assignedUsers: [], @@ -698,8 +829,11 @@ async function handleFormSelection(form) { assignmentVariableType: 'user_id' }; - // Load form fields for this form - await loadFormFields(form.formID || form.formUUID); + // Load form fields and buttons for this form + await Promise.all([ + loadFormFields(form.formID || form.formUUID), + loadFormButtons(form.formID || form.formUUID) + ]); saveChanges(); } @@ -715,6 +849,7 @@ function clearFormSelection() { description: 'Form submission task', inputMappings: [], outputMappings: [], + buttonMappings: [], fieldConditions: [], assignmentType: 'public', assignedUsers: [], @@ -723,8 +858,9 @@ function clearFormSelection() { assignmentVariableType: 'user_id' }; - // Clear form fields + // Clear form fields and buttons formFields.value = []; + formButtons.value = []; saveChanges(); } @@ -769,6 +905,28 @@ function removeOutputMapping(index) { saveChanges(); } +// Add a button mapping +function addButtonMapping() { + if (!localNodeData.value.buttonMappings) { + localNodeData.value.buttonMappings = []; + } + + localNodeData.value.buttonMappings.push({ + formButton: '', + processVariable: '', + action: 'clicked', + customValue: '' + }); + + saveChanges(); +} + +// Remove a button mapping +function removeButtonMapping(index) { + localNodeData.value.buttonMappings.splice(index, 1); + saveChanges(); +} + // Generate a variable name from a form field function getVariableNameFromFormField(formField) { // Handle cases where formField is not a string @@ -860,6 +1018,33 @@ async function loadFormFields(formId) { } } +// Load form buttons for the selected form +async function loadFormButtons(formId) { + if (!formId) { + formButtons.value = []; + return; + } + + isLoadingButtons.value = true; + + try { + const response = await fetch(`/api/forms/${formId}/buttons`); + const result = await response.json(); + + if (result.success && result.buttons) { + formButtons.value = result.buttons; + } else { + console.error('Failed to load form buttons:', result.error); + formButtons.value = []; + } + } catch (error) { + console.error('Error loading form buttons:', error); + formButtons.value = []; + } finally { + isLoadingButtons.value = false; + } +} + // Computed property for form field options (for FormKit select) const formFieldOptions = computed(() => { return formFields.value.map(field => ({ @@ -869,6 +1054,15 @@ const formFieldOptions = computed(() => { })); }); +// Computed property for form button options (for FormKit select) +const formButtonOptions = computed(() => { + return formButtons.value.map(button => ({ + label: `${button.label} (${button.name})`, + value: button.name, + description: button.description || `${button.buttonType} button` + })); +}); + // Computed property for process variable options (for FormKit select) const processVariableOptions = computed(() => { return props.availableVariables.map(variable => ({ @@ -946,6 +1140,8 @@ function saveChanges() { localNodeData.value.inputMappings.map(mapping => ({ ...mapping })) : [], outputMappings: localNodeData.value.outputMappings ? localNodeData.value.outputMappings.map(mapping => ({ ...mapping })) : [], + buttonMappings: localNodeData.value.buttonMappings ? + localNodeData.value.buttonMappings.map(mapping => ({ ...mapping })) : [], fieldConditions: localNodeData.value.fieldConditions ? localNodeData.value.fieldConditions.map(condition => ({ ...condition })) : [], assignmentType: localNodeData.value.assignmentType || 'public', diff --git a/pages/workflow/[id].vue b/pages/workflow/[id].vue index d58cd33..aebe191 100644 --- a/pages/workflow/[id].vue +++ b/pages/workflow/[id].vue @@ -45,6 +45,7 @@ const tasks = ref([]); const conditionalLogicScript = ref(''); const combinedScript = ref(''); const fieldStates = ref({}); +const visitedNodesInSession = ref(new Set()); // Get process ID from route const processId = computed(() => route.params.id); @@ -195,6 +196,9 @@ const loadProcess = async () => { // Capture URL parameters for variables configured to do so captureUrlParameters(); + // Reset visited nodes tracking for new process execution + visitedNodesInSession.value.clear(); + // Start the process execution (case instance) await startProcessExecution(); @@ -260,24 +264,59 @@ function getNextNodeIdForDecision(currentNodeId) { const currentNodeObj = workflowData.value.nodes.find(n => n.id === currentNodeId); const outgoingEdges = getOutgoingEdges(currentNodeId); - if (!currentNodeObj || !outgoingEdges.length) return null; + console.log('[Gateway Debug] getNextNodeIdForDecision - Node:', currentNodeObj?.data?.label); + console.log('[Gateway Debug] getNextNodeIdForDecision - Outgoing edges:', outgoingEdges); - const { conditions = [] } = currentNodeObj.data || {}; + if (!currentNodeObj || !outgoingEdges.length) { + console.log('[Gateway Debug] No node or edges found'); + return null; + } + + const { conditions = [], defaultPath } = currentNodeObj.data || {}; + console.log('[Gateway Debug] Conditions:', conditions); + console.log('[Gateway Debug] Default path:', defaultPath); // Evaluate condition groups (each group represents a path) for (const conditionGroup of conditions) { - if (evaluateConditionGroup(conditionGroup, processVariables.value)) { + const conditionResult = evaluateConditionGroup(conditionGroup, processVariables.value); + console.log('[Gateway Debug] Condition group result:', conditionGroup.output, '=', conditionResult); + + if (conditionResult) { // Find the edge that matches this condition group's output label - const edge = outgoingEdges.find(e => e.label === conditionGroup.output || e.data?.condition === conditionGroup.output); + const edge = outgoingEdges.find(e => + e.label === conditionGroup.output || + e.data?.condition === conditionGroup.output + ); + console.log('[Gateway Debug] Found matching edge for condition:', edge); if (edge) return edge.target; } } // If no conditions match, look for default path - const defaultEdge = outgoingEdges.find(e => e.data?.isDefault || e.label?.toLowerCase().includes('default')); - if (defaultEdge) return defaultEdge.target; + let defaultEdge = null; + + // First try to find edge with explicit default path name + if (defaultPath) { + defaultEdge = outgoingEdges.find(e => e.label === defaultPath); + console.log('[Gateway Debug] Looking for default path by name:', defaultPath, 'found:', defaultEdge); + } + + // Then look for edge marked as default or containing 'default' in label + if (!defaultEdge) { + defaultEdge = outgoingEdges.find(e => + e.data?.isDefault || + e.label?.toLowerCase().includes('default') + ); + console.log('[Gateway Debug] Looking for default edge by pattern, found:', defaultEdge); + } + + if (defaultEdge) { + console.log('[Gateway Debug] Using default edge:', defaultEdge); + return defaultEdge.target; + } // Fallback to first edge + console.log('[Gateway Debug] Fallback to first edge:', outgoingEdges[0]); return outgoingEdges[0]?.target || null; } @@ -464,13 +503,23 @@ function evaluateCondition(condition, variables) { // Move to a specific node by its ID const moveToSpecificNode = (targetNodeId) => { + // Safety check: prevent revisiting the same node too many times + const visitCount = Array.from(visitedNodesInSession.value).filter(id => id === targetNodeId).length; + if (visitCount > 3) { + console.warn('[Workflow] Safety check: Node', targetNodeId, 'visited too many times, stopping to prevent infinite loop'); + error.value = 'Workflow loop detected. Please check your process configuration.'; + return false; + } + const nextIndex = workflowData.value.nodes.findIndex(n => n.id === targetNodeId); if (nextIndex !== -1) { + visitedNodesInSession.value.add(targetNodeId); currentStep.value = nextIndex; const node = workflowData.value.nodes[currentStep.value]; console.log(`[Workflow] Moved to node: ${node.type} - ${node.data?.label || node.label}`); return true; } + console.warn('[Workflow] Target node not found:', targetNodeId); return false; }; @@ -605,6 +654,39 @@ function applyOutputMappings(nodeData, formData, processVars) { }); } +// Apply button mappings to assign button click data to process variables +function applyButtonMappings(nodeData, buttonEvent, processVars) { + const { buttonMappings = [] } = nodeData; + console.log('[Workflow] Applying button mappings:', buttonMappings, 'for button:', buttonEvent.buttonName); + + buttonMappings.forEach(mapping => { + const { formButton, processVariable, action, customValue } = mapping; + if (formButton === buttonEvent.buttonName && processVariable) { + let valueToAssign; + + switch (action) { + case 'clicked': + valueToAssign = true; + break; + case 'button_name': + valueToAssign = buttonEvent.buttonName; + break; + case 'button_label': + valueToAssign = buttonEvent.buttonLabel; + break; + case 'custom': + valueToAssign = customValue || ''; + break; + default: + valueToAssign = true; + } + + processVars[processVariable] = valueToAssign; + console.log(`[Workflow] Button mapping applied: ${formButton} -> ${processVariable} = ${valueToAssign}`); + } + }); +} + // Apply field conditions for dynamic form behavior function applyFieldConditions(nodeData, processVars) { const { fieldConditions = [] } = nodeData; @@ -1025,6 +1107,33 @@ const handleFormDataUpdate = (updatedData) => { formStore.updatePreviewFormData(formData.value); }; +// Handle button click events and apply button mappings +const handleButtonClick = (buttonEvent) => { + console.log('[Workflow] Button clicked:', buttonEvent); + + // Apply button mappings if configured + if (currentNode.value?.data?.buttonMappings?.length > 0) { + applyButtonMappings(currentNode.value.data, buttonEvent, processVariables.value); + + // Check if any button mapping has navigation action + const buttonMapping = currentNode.value.data.buttonMappings.find( + mapping => mapping.formButton === buttonEvent.buttonName && mapping.navigationAction === 'continue' + ); + + if (buttonMapping) { + console.log('[Workflow] Button mapping has navigation action, moving to next step'); + // Wait a moment for variables to be updated then continue + nextTick(() => { + moveToNextStep(); + // If next node is a gateway, execute it automatically + if (currentNode.value && ['decision', 'gateway'].includes(currentNode.value.type)) { + executeDecisionNode(); + } + }); + } + } +}; + // Handle conditional logic script generation const handleConditionalLogicGenerated = (generatedScript) => { console.log('[WorkflowExecution] Conditional logic script generated'); @@ -1183,6 +1292,8 @@ watch(currentStep, async (newStep) => { if (currentNode.value) { stepLoading.value = true; // Start loading for any node try { + console.log('[Workflow] Step changed to:', newStep, 'Node type:', currentNode.value.type, 'Node label:', currentNode.value.data?.label); + if (["api", "script", "notification"].includes(currentNode.value.type)) { await executeCurrentStep(); } else if (currentNode.value.type === "form") { @@ -1304,9 +1415,13 @@ watch(currentStep, async (newStep) => { } } } else if (["decision", "gateway"].includes(currentNode.value.type)) { + console.log('[Workflow] Processing gateway/decision node'); await executeDecisionNode(); } // html nodes are handled in template - no auto-execution needed + } catch (err) { + console.error('[Workflow] Error in step change watch:', err); + error.value = 'Error processing workflow step: ' + (err.message || err); } finally { stepLoading.value = false; // End loading after all async work } @@ -1349,21 +1464,36 @@ const executeDecisionNode = async () => { // Debug: Log process variables before evaluating gateway console.log('[Gateway Debug] Current processVariables:', JSON.stringify(processVariables.value, null, 2)); + console.log('[Gateway Debug] Current node:', currentNodeObj); + console.log('[Gateway Debug] Execution type:', executionType); if (executionType === 'automatic') { // Automatic decision based on conditions const nextNodeId = getNextNodeIdForDecision(currentNodeObj.id); + console.log('[Gateway Debug] Next node ID from decision:', nextNodeId); + if (nextNodeId) { if (moveToSpecificNode(nextNodeId)) { const node = workflowData.value.nodes[currentStep.value]; console.log(`[Workflow] Decision made automatically, moving to: ${node.data?.label || node.label}`); + + // Continue execution if the next node is also auto-executable + if (currentNode.value && ['api', 'script', 'notification'].includes(currentNode.value.type)) { + await executeCurrentStep(); + } else if (currentNode.value && ['decision', 'gateway'].includes(currentNode.value.type)) { + // Recursively handle nested gateways + await executeDecisionNode(); + } } + } else { + console.warn('[Gateway Debug] No valid next node found for automatic decision'); + error.value = 'Unable to determine next step in workflow'; } } // Manual decisions are handled in the template with buttons } catch (err) { console.error('[Workflow] Error executing decision:', err); - error.value = 'Failed to execute decision'; + error.value = 'Failed to execute decision: ' + (err.message || err); } finally { stepLoading.value = false; } @@ -1397,18 +1527,44 @@ function computeWorkflowPath() { const path = []; const nodes = workflowData.value?.nodes || []; const edges = workflowData.value?.edges || []; + if (!nodes.length) return path; + + const visitedNodes = new Set(); let node = nodes.find(n => n.type === 'start'); - while (node) { + + while (node && !visitedNodes.has(node.id)) { + visitedNodes.add(node.id); path.push(node.id); + + // Stop if we've reached the end node + if (node.type === 'end') break; + const outgoingEdges = edges.filter(e => e.source === node.id); - if (outgoingEdges.length > 1) { - console.warn('[Workflow] Multiple outgoing edges found for node', node.id, outgoingEdges); - // For progress, just follow the first edge for now + + // Handle different cases for outgoing edges + if (outgoingEdges.length === 0) { + // No more edges, end of path + break; + } else if (outgoingEdges.length === 1) { + // Single path, continue normally + const edge = outgoingEdges[0]; + node = nodes.find(n => n.id === edge.target); + } else { + // Multiple paths (gateway/decision node) + // For linear path computation, we can't determine which path to take + // without executing the conditions, so we stop here + console.log('[Workflow] Branching detected at node', node.data?.label || node.id, '- stopping linear path computation'); + break; + } + + // Safety check to prevent infinite loops + if (path.length > 50) { + console.warn('[Workflow] Path computation safety limit reached, stopping'); + break; } - const edge = outgoingEdges[0]; - node = edge ? nodes.find(n => n.id === edge.target) : null; } + return path; } @@ -1423,9 +1579,44 @@ watch( ); const currentStepIndex = computed(() => { - return workflowPath.value.indexOf(currentNode.value?.id) + 1; + if (!currentNode.value?.id) return 0; + + // For workflows with branching, use node position in the overall flow + const nodeIndex = workflowPath.value.indexOf(currentNode.value.id); + if (nodeIndex !== -1) { + return nodeIndex + 1; + } + + // Fallback: count how many steps we've been through + const allNodes = workflowData.value?.nodes || []; + const startIndex = allNodes.findIndex(n => n.type === 'start'); + const currentIndex = allNodes.findIndex(n => n.id === currentNode.value.id); + + if (startIndex !== -1 && currentIndex !== -1) { + return Math.min(currentIndex - startIndex + 1, allNodes.length); + } + + return 1; +}); + +const totalSteps = computed(() => { + // For workflows with branching, estimate total based on all nodes + const allNodes = workflowData.value?.nodes || []; + const linearPath = workflowPath.value.length; + + // If we have a complete linear path, use it + if (linearPath > 0 && allNodes.some(n => n.type === 'end')) { + const hasEndInPath = workflowPath.value.some(nodeId => + allNodes.find(n => n.id === nodeId)?.type === 'end' + ); + if (hasEndInPath) { + return linearPath; + } + } + + // Otherwise, estimate based on total nodes (excluding start/end for better UX) + return Math.max(allNodes.filter(n => !['start', 'end'].includes(n.type)).length, 1); }); -const totalSteps = computed(() => workflowPath.value.length); // Computed: Interpolated HTML content for HTML nodes const interpolatedHtmlContent = computed(() => { @@ -1789,6 +1980,7 @@ const getWorkflowSubmitButtonStyle = () => { :is-preview="true" :field-states="fieldStates" @form-data-updated="handleFormDataUpdate" + @button-clicked="handleButtonClick" /> diff --git a/server/api/forms/[id]/buttons.get.js b/server/api/forms/[id]/buttons.get.js new file mode 100644 index 0000000..b81722c --- /dev/null +++ b/server/api/forms/[id]/buttons.get.js @@ -0,0 +1,79 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default defineEventHandler(async (event) => { + try { + const formId = event.context.params.id; + + if (!formId) { + throw createError({ + statusCode: 400, + statusMessage: 'Form ID is required' + }); + } + + // Fetch the form from database + const form = await prisma.form.findFirst({ + where: { + OR: [ + { formID: parseInt(formId) || -1 }, + { formUUID: formId } + ] + }, + select: { + formID: true, + formUUID: true, + formName: true, + formComponents: true + } + }); + + if (!form) { + throw createError({ + statusCode: 404, + statusMessage: 'Form not found' + }); + } + + // Extract form buttons from the form components + const formButtons = []; + + if (form.formComponents && Array.isArray(form.formComponents)) { + form.formComponents.forEach(component => { + // Only include button components that have names + if (component.type === 'button' && component.props && component.props.name) { + formButtons.push({ + name: component.props.name, + label: component.props.label || component.props.name, + buttonText: component.props.buttonText || component.props.label || 'Button', + buttonType: component.props.buttonType || 'button', + variant: component.props.variant || 'primary', + disabled: component.props.disabled || false, + description: component.props.help || `${component.props.buttonType || 'button'} button` + }); + } + }); + } + + return { + success: true, + formId: form.formID, + formUuid: form.formUUID, + formName: form.formName, + buttons: formButtons + }; + + } catch (error) { + console.error('Error fetching form buttons:', error); + + if (error.statusCode) { + throw error; + } + + throw createError({ + statusCode: 500, + statusMessage: 'Internal server error while fetching form buttons' + }); + } +});