From dfea8e7f47c66a02511840c89f0ad8847ce3d7b2 Mon Sep 17 00:00:00 2001 From: Md Afiq Iskandar Date: Thu, 31 Jul 2025 16:34:52 +0800 Subject: [PATCH 01/14] Enhance Process Templates with New Data-Driven Workflows and URL Parameter Capture - Added a new category for 'Data-Driven Workflows' in the ProcessTemplatesModal component. - Introduced multiple new templates for data-driven workflows, including 'URL Data Capture & Form Pre-fill', 'Dynamic Survey with Conditional Logic', 'Order Processing with Status Updates', and 'Customer Feedback Analysis'. - Enhanced VariableManager to support capturing variable values from URL parameters, including new form fields for URL parameter configuration. - Updated workflow page to capture URL parameters for variables configured to do so, improving the integration of external data into workflows. - Adjusted processBuilder store to include new properties for URL parameter handling in variable definitions. --- components/ProcessTemplatesModal.vue | 832 +++++++++++++++++++- components/process-flow/VariableManager.vue | 34 +- pages/workflow/[id].vue | 95 ++- stores/processBuilder.js | 4 +- 4 files changed, 956 insertions(+), 9 deletions(-) diff --git a/components/ProcessTemplatesModal.vue b/components/ProcessTemplatesModal.vue index f36c6cc..c58579f 100644 --- a/components/ProcessTemplatesModal.vue +++ b/components/ProcessTemplatesModal.vue @@ -256,7 +256,8 @@ const categories = [ { id: 'onboarding', name: 'Onboarding' }, { id: 'support', name: 'Support & Service' }, { id: 'business', name: 'Business Operations' }, - { id: 'automation', name: 'Automation' } + { id: 'automation', name: 'Automation' }, + { id: 'data-driven', name: 'Data-Driven Workflows' } ]; // Active category @@ -1207,6 +1208,832 @@ const templates = [ { id: 'e40', source: 'form-10', target: 'notification-11', sourceHandle: 'form-10-bottom', targetHandle: 'notification-11-top', type: 'smoothstep' }, { id: 'e41', source: 'notification-11', target: 'end-6', sourceHandle: 'notification-11-bottom', targetHandle: 'end-6-top', type: 'smoothstep' } ] + }, + { + id: 'url-data-capture', + name: 'URL Data Capture & Form Pre-fill', + description: 'Capture user ID from URL parameters, fetch user data via API, and pre-fill a form for editing.', + category: 'data-driven', + complexity: 'Simple', + nodeCount: 4, + edgeCount: 3, + variables: [ + { + name: 'userId', + type: 'string', + scope: 'global', + description: 'User ID captured from URL parameter', + defaultValue: '', + captureFromUrl: true, + urlParameterName: 'id' + }, + { + name: 'userData', + type: 'object', + scope: 'global', + description: 'User data retrieved from API', + defaultValue: {} + }, + { + name: 'formSubmissionResult', + type: 'object', + scope: 'global', + description: 'Result of form submission', + defaultValue: {} + } + ], + nodes: [ + { + id: 'start-url-1', + type: 'start', + position: { x: 100, y: 100 }, + data: { + label: 'Start', + description: 'Begin URL data capture process', + backgroundColor: '#dcfce7', + borderColor: '#10b981', + textColor: '#065f46', + shape: 'circle' + } + }, + { + id: 'api-fetch-user', + type: 'api', + position: { x: 300, y: 100 }, + data: { + label: 'Fetch User Data', + description: 'Retrieve user information from API', + apiMethod: 'GET', + apiUrl: '/api/users/{userId}', + headers: [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer YOUR_API_TOKEN' } + ], + outputVariable: 'userData', + errorVariable: 'apiError', + continueOnError: false, + backgroundColor: '#eff6ff', + borderColor: '#3b82f6', + textColor: '#1e40af', + shape: 'rectangle' + } + }, + { + id: 'form-edit-user', + type: 'form', + position: { x: 500, y: 100 }, + data: { + label: 'Edit User Information', + description: 'Form pre-filled with user data for editing', + formId: null, + formName: 'User Information Form', + inputMappings: [ + { processVariable: 'userData.name', formField: 'fullName' }, + { processVariable: 'userData.email', formField: 'email' }, + { processVariable: 'userData.phone', formField: 'phoneNumber' } + ], + outputMappings: [ + { formField: 'fullName', processVariable: 'updatedName' }, + { formField: 'email', processVariable: 'updatedEmail' }, + { formField: 'phoneNumber', processVariable: 'updatedPhone' } + ], + backgroundColor: '#faf5ff', + borderColor: '#9333ea', + textColor: '#6b21a8', + shape: 'rectangle' + } + }, + { + id: 'end-url-1', + type: 'end', + position: { x: 700, y: 100 }, + data: { + label: 'End', + description: 'Process completed', + backgroundColor: '#fee2e2', + borderColor: '#dc2626', + textColor: '#991b1b', + shape: 'circle' + } + } + ], + edges: [ + { id: 'e-url-1', source: 'start-url-1', target: 'api-fetch-user', sourceHandle: 'start-url-1-bottom', targetHandle: 'api-fetch-user-top', type: 'smoothstep' }, + { id: 'e-url-2', source: 'api-fetch-user', target: 'form-edit-user', sourceHandle: 'api-fetch-user-bottom', targetHandle: 'form-edit-user-top', type: 'smoothstep' }, + { id: 'e-url-3', source: 'form-edit-user', target: 'end-url-1', sourceHandle: 'form-edit-user-bottom', targetHandle: 'end-url-1-top', type: 'smoothstep' } + ] + }, + { + id: 'dynamic-survey', + name: 'Dynamic Survey with Conditional Logic', + description: 'Multi-step survey with conditional questions based on URL parameters and previous answers.', + category: 'data-driven', + complexity: 'Medium', + nodeCount: 6, + edgeCount: 7, + variables: [ + { + name: 'surveyType', + type: 'string', + scope: 'global', + description: 'Type of survey from URL parameter', + defaultValue: 'general', + captureFromUrl: true, + urlParameterName: 'type' + }, + { + name: 'userRole', + type: 'string', + scope: 'global', + description: 'User role from URL parameter', + defaultValue: 'user', + captureFromUrl: true, + urlParameterName: 'role' + }, + { + name: 'basicInfo', + type: 'object', + scope: 'global', + description: 'Basic information from first form', + defaultValue: {} + }, + { + name: 'isManager', + type: 'boolean', + scope: 'global', + description: 'Whether user is in management role', + defaultValue: false + }, + { + name: 'surveyResults', + type: 'object', + scope: 'global', + description: 'Complete survey results', + defaultValue: {} + } + ], + nodes: [ + { + id: 'start-survey', + type: 'start', + position: { x: 100, y: 200 }, + data: { + label: 'Start Survey', + description: 'Begin dynamic survey process', + backgroundColor: '#dcfce7', + borderColor: '#10b981', + textColor: '#065f46', + shape: 'circle' + } + }, + { + id: 'form-basic-info', + type: 'form', + position: { x: 300, y: 200 }, + data: { + label: 'Basic Information', + description: 'Collect basic user information', + formId: null, + formName: 'Basic Information Form', + outputMappings: [ + { formField: 'name', processVariable: 'basicInfo.name' }, + { formField: 'department', processVariable: 'basicInfo.department' }, + { formField: 'experience', processVariable: 'basicInfo.experience' } + ], + backgroundColor: '#faf5ff', + borderColor: '#9333ea', + textColor: '#6b21a8', + shape: 'rectangle' + } + }, + { + id: 'business-rule-role-check', + type: 'business-rule', + position: { x: 500, y: 200 }, + data: { + label: 'Check User Role', + description: 'Determine if user is manager based on role parameter', + ruleGroups: [ + { + name: 'Manager Check', + conditions: [ + { + variable: 'userRole', + operator: 'eq', + value: 'manager', + minValue: null, + maxValue: null + } + ], + actions: [ + { + type: 'set_variable', + variable: 'isManager', + value: 'true' + } + ] + } + ], + backgroundColor: '#fdf4ff', + borderColor: '#a855f7', + textColor: '#7c3aed', + shape: 'rectangle' + } + }, + { + id: 'gateway-manager-check', + type: 'gateway', + position: { x: 700, y: 200 }, + data: { + label: 'Is Manager?', + description: 'Route based on manager status', + conditions: [ + { + variable: 'isManager', + operator: 'eq', + value: 'true', + output: 'Manager Path' + } + ], + defaultPath: 'Employee Path', + backgroundColor: '#fff7ed', + borderColor: '#f97316', + textColor: '#c2410c', + shape: 'diamond' + } + }, + { + id: 'form-manager-questions', + type: 'form', + position: { x: 900, y: 150 }, + data: { + label: 'Manager Questions', + description: 'Additional questions for managers', + formId: null, + formName: 'Manager Survey Form', + outputMappings: [ + { formField: 'teamSize', processVariable: 'surveyResults.teamSize' }, + { formField: 'managementStyle', processVariable: 'surveyResults.managementStyle' }, + { formField: 'budgetResponsibility', processVariable: 'surveyResults.budgetResponsibility' } + ], + backgroundColor: '#faf5ff', + borderColor: '#9333ea', + textColor: '#6b21a8', + shape: 'rectangle' + } + }, + { + id: 'form-employee-questions', + type: 'form', + position: { x: 900, y: 250 }, + data: { + label: 'Employee Questions', + description: 'Questions for regular employees', + formId: null, + formName: 'Employee Survey Form', + outputMappings: [ + { formField: 'jobSatisfaction', processVariable: 'surveyResults.satisfaction' }, + { formField: 'workloadRating', processVariable: 'surveyResults.workload' }, + { formField: 'careerGoals', processVariable: 'surveyResults.goals' } + ], + backgroundColor: '#faf5ff', + borderColor: '#9333ea', + textColor: '#6b21a8', + shape: 'rectangle' + } + }, + { + id: 'end-survey', + type: 'end', + position: { x: 1100, y: 200 }, + data: { + label: 'Survey Complete', + description: 'Survey completed successfully', + backgroundColor: '#fee2e2', + borderColor: '#dc2626', + textColor: '#991b1b', + shape: 'circle' + } + } + ], + edges: [ + { id: 'e-survey-1', source: 'start-survey', target: 'form-basic-info', sourceHandle: 'start-survey-bottom', targetHandle: 'form-basic-info-top', type: 'smoothstep' }, + { id: 'e-survey-2', source: 'form-basic-info', target: 'business-rule-role-check', sourceHandle: 'form-basic-info-bottom', targetHandle: 'business-rule-role-check-top', type: 'smoothstep' }, + { id: 'e-survey-3', source: 'business-rule-role-check', target: 'gateway-manager-check', sourceHandle: 'business-rule-role-check-bottom', targetHandle: 'gateway-manager-check-top', type: 'smoothstep' }, + { id: 'e-survey-4', source: 'gateway-manager-check', target: 'form-manager-questions', sourceHandle: 'gateway-manager-check-right', targetHandle: 'form-manager-questions-left', type: 'smoothstep', label: 'Manager Path' }, + { id: 'e-survey-5', source: 'gateway-manager-check', target: 'form-employee-questions', sourceHandle: 'gateway-manager-check-bottom', targetHandle: 'form-employee-questions-left', type: 'smoothstep', label: 'Employee Path' }, + { id: 'e-survey-6', source: 'form-manager-questions', target: 'end-survey', sourceHandle: 'form-manager-questions-bottom', targetHandle: 'end-survey-top', type: 'smoothstep' }, + { id: 'e-survey-7', source: 'form-employee-questions', target: 'end-survey', sourceHandle: 'form-employee-questions-bottom', targetHandle: 'end-survey-left', type: 'smoothstep' } + ] + }, + { + id: 'order-processing', + name: 'Order Processing with Status Updates', + description: 'Process orders with automatic status updates and notifications based on order ID from URL.', + category: 'business', + complexity: 'Medium', + nodeCount: 7, + edgeCount: 8, + variables: [ + { + name: 'orderId', + type: 'string', + scope: 'global', + description: 'Order ID from URL parameter', + defaultValue: '', + captureFromUrl: true, + urlParameterName: 'orderId' + }, + { + name: 'priority', + type: 'string', + scope: 'global', + description: 'Order priority from URL parameter', + defaultValue: 'normal', + captureFromUrl: true, + urlParameterName: 'priority' + }, + { + name: 'orderData', + type: 'object', + scope: 'global', + description: 'Order details from API', + defaultValue: {} + }, + { + name: 'isHighPriority', + type: 'boolean', + scope: 'global', + description: 'Whether order is high priority', + defaultValue: false + }, + { + name: 'processingResult', + type: 'object', + scope: 'global', + description: 'Result of order processing', + defaultValue: {} + } + ], + nodes: [ + { + id: 'start-order', + type: 'start', + position: { x: 100, y: 300 }, + data: { + label: 'Start Processing', + description: 'Begin order processing', + backgroundColor: '#dcfce7', + borderColor: '#10b981', + textColor: '#065f46', + shape: 'circle' + } + }, + { + id: 'api-get-order', + type: 'api', + position: { x: 300, y: 300 }, + data: { + label: 'Fetch Order', + description: 'Get order details from system', + apiMethod: 'GET', + apiUrl: '/api/orders/{orderId}', + headers: [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer YOUR_API_TOKEN' } + ], + outputVariable: 'orderData', + errorVariable: 'orderError', + continueOnError: false, + backgroundColor: '#eff6ff', + borderColor: '#3b82f6', + textColor: '#1e40af', + shape: 'rectangle' + } + }, + { + id: 'business-rule-priority', + type: 'business-rule', + position: { x: 500, y: 300 }, + data: { + label: 'Check Priority', + description: 'Determine processing priority', + ruleGroups: [ + { + name: 'High Priority Check', + conditions: [ + { + variable: 'priority', + operator: 'eq', + value: 'high', + minValue: null, + maxValue: null + } + ], + actions: [ + { + type: 'set_variable', + variable: 'isHighPriority', + value: 'true' + } + ] + } + ], + backgroundColor: '#fdf4ff', + borderColor: '#a855f7', + textColor: '#7c3aed', + shape: 'rectangle' + } + }, + { + id: 'gateway-priority-check', + type: 'gateway', + position: { x: 700, y: 300 }, + data: { + label: 'High Priority?', + description: 'Route based on priority', + conditions: [ + { + variable: 'isHighPriority', + operator: 'eq', + value: 'true', + output: 'Express Processing' + } + ], + defaultPath: 'Standard Processing', + backgroundColor: '#fff7ed', + borderColor: '#f97316', + textColor: '#c2410c', + shape: 'diamond' + } + }, + { + id: 'api-express-process', + type: 'api', + position: { x: 900, y: 250 }, + data: { + label: 'Express Processing', + description: 'Process high priority order', + apiMethod: 'POST', + apiUrl: '/api/orders/{orderId}/process-express', + requestBody: '{"orderId": "{orderId}", "priority": "high"}', + headers: [ + { key: 'Content-Type', value: 'application/json' } + ], + outputVariable: 'processingResult', + backgroundColor: '#eff6ff', + borderColor: '#3b82f6', + textColor: '#1e40af', + shape: 'rectangle' + } + }, + { + id: 'api-standard-process', + type: 'api', + position: { x: 900, y: 350 }, + data: { + label: 'Standard Processing', + description: 'Process regular order', + apiMethod: 'POST', + apiUrl: '/api/orders/{orderId}/process-standard', + requestBody: '{"orderId": "{orderId}", "priority": "normal"}', + headers: [ + { key: 'Content-Type', value: 'application/json' } + ], + outputVariable: 'processingResult', + backgroundColor: '#eff6ff', + borderColor: '#3b82f6', + textColor: '#1e40af', + shape: 'rectangle' + } + }, + { + id: 'notification-complete', + type: 'notification', + position: { x: 1100, y: 300 }, + data: { + label: 'Notify Completion', + description: 'Send processing complete notification', + notificationType: 'success', + recipientType: 'email', + recipientEmail: '{orderData.customerEmail}', + subject: 'Order Processing Complete', + message: 'Your order {orderId} has been processed successfully.', + priority: 'medium', + backgroundColor: '#f0f9ff', + borderColor: '#0ea5e9', + textColor: '#0284c7', + shape: 'rectangle' + } + }, + { + id: 'end-order', + type: 'end', + position: { x: 1300, y: 300 }, + data: { + label: 'Complete', + description: 'Order processing complete', + backgroundColor: '#fee2e2', + borderColor: '#dc2626', + textColor: '#991b1b', + shape: 'circle' + } + } + ], + edges: [ + { id: 'e-order-1', source: 'start-order', target: 'api-get-order', sourceHandle: 'start-order-bottom', targetHandle: 'api-get-order-top', type: 'smoothstep' }, + { id: 'e-order-2', source: 'api-get-order', target: 'business-rule-priority', sourceHandle: 'api-get-order-bottom', targetHandle: 'business-rule-priority-top', type: 'smoothstep' }, + { id: 'e-order-3', source: 'business-rule-priority', target: 'gateway-priority-check', sourceHandle: 'business-rule-priority-bottom', targetHandle: 'gateway-priority-check-top', type: 'smoothstep' }, + { id: 'e-order-4', source: 'gateway-priority-check', target: 'api-express-process', sourceHandle: 'gateway-priority-check-right', targetHandle: 'api-express-process-left', type: 'smoothstep', label: 'Express Processing' }, + { id: 'e-order-5', source: 'gateway-priority-check', target: 'api-standard-process', sourceHandle: 'gateway-priority-check-bottom', targetHandle: 'api-standard-process-left', type: 'smoothstep', label: 'Standard Processing' }, + { id: 'e-order-6', source: 'api-express-process', target: 'notification-complete', sourceHandle: 'api-express-process-bottom', targetHandle: 'notification-complete-top', type: 'smoothstep' }, + { id: 'e-order-7', source: 'api-standard-process', target: 'notification-complete', sourceHandle: 'api-standard-process-bottom', targetHandle: 'notification-complete-left', type: 'smoothstep' }, + { id: 'e-order-8', source: 'notification-complete', target: 'end-order', sourceHandle: 'notification-complete-bottom', targetHandle: 'end-order-top', type: 'smoothstep' } + ] + }, + { + id: 'customer-feedback-analysis', + name: 'Customer Feedback Analysis', + description: 'Automated feedback collection and analysis with sentiment detection and response routing.', + category: 'automation', + complexity: 'Complex', + nodeCount: 8, + edgeCount: 10, + variables: [ + { + name: 'customerId', + type: 'string', + scope: 'global', + description: 'Customer ID from URL parameter', + defaultValue: '', + captureFromUrl: true, + urlParameterName: 'customer' + }, + { + name: 'feedbackType', + type: 'string', + scope: 'global', + description: 'Type of feedback from URL parameter', + defaultValue: 'general', + captureFromUrl: true, + urlParameterName: 'type' + }, + { + name: 'customerData', + type: 'object', + scope: 'global', + description: 'Customer information from API', + defaultValue: {} + }, + { + name: 'feedbackData', + type: 'object', + scope: 'global', + description: 'Feedback form submission data', + defaultValue: {} + }, + { + name: 'sentimentAnalysis', + type: 'object', + scope: 'global', + description: 'AI sentiment analysis results', + defaultValue: {} + }, + { + name: 'isNegativeFeedback', + type: 'boolean', + scope: 'global', + description: 'Whether feedback is negative', + defaultValue: false + }, + { + name: 'escalationRequired', + type: 'boolean', + scope: 'global', + description: 'Whether escalation is needed', + defaultValue: false + } + ], + nodes: [ + { + id: 'start-feedback', + type: 'start', + position: { x: 100, y: 400 }, + data: { + label: 'Start Feedback', + description: 'Begin feedback collection process', + backgroundColor: '#dcfce7', + borderColor: '#10b981', + textColor: '#065f46', + shape: 'circle' + } + }, + { + id: 'api-get-customer', + type: 'api', + position: { x: 300, y: 400 }, + data: { + label: 'Get Customer Info', + description: 'Fetch customer details', + apiMethod: 'GET', + apiUrl: '/api/customers/{customerId}', + headers: [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'Authorization', value: 'Bearer YOUR_API_TOKEN' } + ], + outputVariable: 'customerData', + errorVariable: 'customerError', + continueOnError: false, + backgroundColor: '#eff6ff', + borderColor: '#3b82f6', + textColor: '#1e40af', + shape: 'rectangle' + } + }, + { + id: 'form-feedback', + type: 'form', + position: { x: 500, y: 400 }, + data: { + label: 'Feedback Form', + description: 'Collect customer feedback', + formId: null, + formName: 'Customer Feedback Form', + inputMappings: [ + { processVariable: 'customerData.name', formField: 'customerName' }, + { processVariable: 'customerData.email', formField: 'customerEmail' }, + { processVariable: 'feedbackType', formField: 'feedbackCategory' } + ], + outputMappings: [ + { formField: 'rating', processVariable: 'feedbackData.rating' }, + { formField: 'comments', processVariable: 'feedbackData.comments' }, + { formField: 'improvementSuggestions', processVariable: 'feedbackData.suggestions' } + ], + backgroundColor: '#faf5ff', + borderColor: '#9333ea', + textColor: '#6b21a8', + shape: 'rectangle' + } + }, + { + id: 'api-sentiment-analysis', + type: 'api', + position: { x: 700, y: 400 }, + data: { + label: 'Analyze Sentiment', + description: 'AI-powered sentiment analysis', + apiMethod: 'POST', + apiUrl: '/api/ai/sentiment-analysis', + requestBody: '{"text": "{feedbackData.comments}", "context": "customer_feedback"}', + headers: [ + { key: 'Content-Type', value: 'application/json' }, + { key: 'X-API-Key', value: 'YOUR_AI_API_KEY' } + ], + outputVariable: 'sentimentAnalysis', + backgroundColor: '#eff6ff', + borderColor: '#3b82f6', + textColor: '#1e40af', + shape: 'rectangle' + } + }, + { + id: 'business-rule-sentiment', + type: 'business-rule', + position: { x: 900, y: 400 }, + data: { + label: 'Evaluate Sentiment', + description: 'Determine if feedback requires escalation', + ruleGroups: [ + { + name: 'Negative Sentiment', + conditions: [ + { + variable: 'sentimentAnalysis.sentiment', + operator: 'eq', + value: 'negative', + minValue: null, + maxValue: null + } + ], + actions: [ + { + type: 'set_variable', + variable: 'isNegativeFeedback', + value: 'true' + } + ] + }, + { + name: 'Low Rating', + conditions: [ + { + variable: 'feedbackData.rating', + operator: 'lte', + value: '2', + minValue: null, + maxValue: null + } + ], + actions: [ + { + type: 'set_variable', + variable: 'escalationRequired', + value: 'true' + } + ] + } + ], + backgroundColor: '#fdf4ff', + borderColor: '#a855f7', + textColor: '#7c3aed', + shape: 'rectangle' + } + }, + { + id: 'gateway-escalation', + type: 'gateway', + position: { x: 1100, y: 400 }, + data: { + label: 'Needs Escalation?', + description: 'Check if escalation is required', + conditions: [ + { + variable: 'escalationRequired', + operator: 'eq', + value: 'true', + output: 'Escalate' + } + ], + defaultPath: 'Standard Response', + backgroundColor: '#fff7ed', + borderColor: '#f97316', + textColor: '#c2410c', + shape: 'diamond' + } + }, + { + id: 'notification-escalate', + type: 'notification', + position: { x: 1300, y: 350 }, + data: { + label: 'Escalate to Manager', + description: 'Notify manager of negative feedback', + notificationType: 'warning', + recipientType: 'role', + recipientRole: 'customer_success_manager', + subject: 'Urgent: Negative Customer Feedback', + message: 'Customer {customerData.name} has provided negative feedback requiring immediate attention.', + priority: 'high', + backgroundColor: '#f0f9ff', + borderColor: '#0ea5e9', + textColor: '#0284c7', + shape: 'rectangle' + } + }, + { + id: 'notification-standard', + type: 'notification', + position: { x: 1300, y: 450 }, + data: { + label: 'Thank Customer', + description: 'Send standard thank you message', + notificationType: 'success', + recipientType: 'variable', + recipientVariable: 'customerData.email', + subject: 'Thank you for your feedback', + message: 'Thank you for taking the time to provide feedback. We appreciate your input.', + priority: 'medium', + backgroundColor: '#f0f9ff', + borderColor: '#0ea5e9', + textColor: '#0284c7', + shape: 'rectangle' + } + }, + { + id: 'end-feedback', + type: 'end', + position: { x: 1500, y: 400 }, + data: { + label: 'Complete', + description: 'Feedback processing complete', + backgroundColor: '#fee2e2', + borderColor: '#dc2626', + textColor: '#991b1b', + shape: 'circle' + } + } + ], + edges: [ + { id: 'e-feedback-1', source: 'start-feedback', target: 'api-get-customer', sourceHandle: 'start-feedback-bottom', targetHandle: 'api-get-customer-top', type: 'smoothstep' }, + { id: 'e-feedback-2', source: 'api-get-customer', target: 'form-feedback', sourceHandle: 'api-get-customer-bottom', targetHandle: 'form-feedback-top', type: 'smoothstep' }, + { id: 'e-feedback-3', source: 'form-feedback', target: 'api-sentiment-analysis', sourceHandle: 'form-feedback-bottom', targetHandle: 'api-sentiment-analysis-top', type: 'smoothstep' }, + { id: 'e-feedback-4', source: 'api-sentiment-analysis', target: 'business-rule-sentiment', sourceHandle: 'api-sentiment-analysis-bottom', targetHandle: 'business-rule-sentiment-top', type: 'smoothstep' }, + { id: 'e-feedback-5', source: 'business-rule-sentiment', target: 'gateway-escalation', sourceHandle: 'business-rule-sentiment-bottom', targetHandle: 'gateway-escalation-top', type: 'smoothstep' }, + { id: 'e-feedback-6', source: 'gateway-escalation', target: 'notification-escalate', sourceHandle: 'gateway-escalation-right', targetHandle: 'notification-escalate-left', type: 'smoothstep', label: 'Escalate' }, + { id: 'e-feedback-7', source: 'gateway-escalation', target: 'notification-standard', sourceHandle: 'gateway-escalation-bottom', targetHandle: 'notification-standard-left', type: 'smoothstep', label: 'Standard Response' }, + { id: 'e-feedback-8', source: 'notification-escalate', target: 'end-feedback', sourceHandle: 'notification-escalate-bottom', targetHandle: 'end-feedback-top', type: 'smoothstep' }, + { id: 'e-feedback-9', source: 'notification-standard', target: 'end-feedback', sourceHandle: 'notification-standard-bottom', targetHandle: 'end-feedback-left', type: 'smoothstep' } + ] } ]; @@ -1246,7 +2073,8 @@ const getTemplateIcon = (template) => { 'onboarding': 'material-symbols:person-add-outline', 'support': 'material-symbols:support-agent', 'business': 'material-symbols:business-center-outline', - 'automation': 'material-symbols:precision-manufacturing-outline' + 'automation': 'material-symbols:precision-manufacturing-outline', + 'data-driven': 'material-symbols:data-usage' }; return icons[template.category] || 'material-symbols:account-tree'; diff --git a/components/process-flow/VariableManager.vue b/components/process-flow/VariableManager.vue index 5b8a1c4..1e85907 100644 --- a/components/process-flow/VariableManager.vue +++ b/components/process-flow/VariableManager.vue @@ -479,6 +479,28 @@ help="A clear description helps others understand the purpose of this variable" /> + + + +
Cancel @@ -512,7 +534,9 @@ const variableForm = ref({ type: "string", scope: "global", description: "", - defaultValue: "" + defaultValue: "", + captureFromUrl: false, + urlParameterName: "" }); // Variable type options with descriptions @@ -1498,7 +1522,9 @@ const resetForm = () => { type: "string", scope: "global", description: "", - defaultValue: "" + defaultValue: "", + captureFromUrl: false, + urlParameterName: "" }; editingVariable.value = null; }; @@ -1532,7 +1558,9 @@ const saveVariable = async (formData) => { type: formData.type, scope: "global", description: formData.description, - value: processedDefaultValue + value: processedDefaultValue, + captureFromUrl: formData.captureFromUrl || false, + urlParameterName: formData.urlParameterName || "" }; if (editingVariable.value) { diff --git a/pages/workflow/[id].vue b/pages/workflow/[id].vue index 4fd067c..4ed96ab 100644 --- a/pages/workflow/[id].vue +++ b/pages/workflow/[id].vue @@ -83,6 +83,84 @@ watch(isProcessComplete, (newValue) => { } }); +// Capture URL parameters for variables that are configured to do so +const captureUrlParameters = () => { + try { + console.log('[Workflow] Capturing URL parameters...'); + + // Get current URL parameters + const urlParams = new URLSearchParams(window.location.search); + console.log('[Workflow] Available URL parameters:', Object.fromEntries(urlParams.entries())); + + // Check if we have process variables defined + const variableSource = process.value?.variables || process.value?.processVariables; + if (!variableSource) { + console.log('[Workflow] No process variables defined, skipping URL parameter capture'); + return; + } + + // Iterate through process variables to find ones configured for URL capture + Object.entries(variableSource).forEach(([variableName, variableConfig]) => { + if (variableConfig && variableConfig.captureFromUrl && variableConfig.urlParameterName) { + const paramName = variableConfig.urlParameterName; + const paramValue = urlParams.get(paramName); + + if (paramValue !== null) { + // Convert the parameter value based on the variable type + let convertedValue = paramValue; + + switch (variableConfig.type) { + case 'number': + case 'int': + case 'decimal': + convertedValue = Number(paramValue); + if (isNaN(convertedValue)) { + console.warn(`[Workflow] Could not convert URL parameter "${paramName}" value "${paramValue}" to number for variable "${variableName}"`); + convertedValue = variableConfig.value || 0; // Use default value + } + break; + case 'boolean': + convertedValue = paramValue.toLowerCase() === 'true' || paramValue === '1'; + break; + case 'object': + try { + convertedValue = JSON.parse(paramValue); + } catch (e) { + console.warn(`[Workflow] Could not parse URL parameter "${paramName}" value "${paramValue}" as JSON for variable "${variableName}"`); + convertedValue = variableConfig.value || {}; // Use default value + } + break; + case 'date': + case 'datetime': + const dateValue = new Date(paramValue); + if (isNaN(dateValue.getTime())) { + console.warn(`[Workflow] Could not parse URL parameter "${paramName}" value "${paramValue}" as date for variable "${variableName}"`); + convertedValue = variableConfig.value || null; // Use default value + } else { + convertedValue = variableConfig.type === 'date' ? dateValue.toISOString().split('T')[0] : dateValue.toISOString(); + } + break; + case 'string': + default: + convertedValue = String(paramValue); + break; + } + + // Set the variable value + processVariables.value[variableName] = convertedValue; + console.log(`[Workflow] Captured URL parameter "${paramName}" = "${paramValue}" -> variable "${variableName}" = `, convertedValue); + } else { + console.log(`[Workflow] URL parameter "${paramName}" not found for variable "${variableName}"`); + } + } + }); + + console.log('[Workflow] Final process variables after URL capture:', processVariables.value); + } catch (err) { + console.error('[Workflow] Error capturing URL parameters:', err); + } +}; + // Load process data const loadProcess = async () => { try { @@ -94,7 +172,6 @@ const loadProcess = async () => { if (response.success) { process.value = response.process; // includes processDefinition - console.log('[Workflow] Process loaded:', process.value.processName, process.value.processDefinition); // Check if process is published const status = process.value.processStatus || process.value.status || 'draft'; @@ -103,8 +180,20 @@ const loadProcess = async () => { return; } - // Initialize process variables from DB (process.processVariables) - processVariables.value = process.value.processVariables ? { ...process.value.processVariables } : {}; + // Initialize process variables from DB - extract values from variable definitions + processVariables.value = {}; + + // Try both possible property names for variables + const variableSource = process.value.variables || process.value.processVariables; + if (variableSource) { + Object.entries(variableSource).forEach(([name, variableDefinition]) => { + // Set the actual value, defaulting to the variable's default value or empty string + processVariables.value[name] = variableDefinition.value || variableDefinition.defaultValue || ''; + }); + } + + // Capture URL parameters for variables configured to do so + captureUrlParameters(); // Start the process execution (case instance) await startProcessExecution(); diff --git a/stores/processBuilder.js b/stores/processBuilder.js index 8f0545e..63543bf 100644 --- a/stores/processBuilder.js +++ b/stores/processBuilder.js @@ -1159,7 +1159,9 @@ export const useProcessBuilderStore = defineStore('processBuilder', { type: variable.type || 'string', scope: variable.scope || 'global', value: variable.value, - description: variable.description || '' + description: variable.description || '', + captureFromUrl: variable.captureFromUrl || false, + urlParameterName: variable.urlParameterName || '' }; this.unsavedChanges = true; From bae98c2b173404b0887e93c0ad2730ad0d18f614 Mon Sep 17 00:00:00 2001 From: Afiq Date: Mon, 4 Aug 2025 12:50:54 +0800 Subject: [PATCH 02/14] Enhance Workflow API Call Handling and Authorization Logic - Introduced a new proxy endpoint for API calls during workflow execution to handle CORS issues and streamline API interactions. - Updated the authorization logic to support Basic Auth with both token and username/password options, improving flexibility in API authentication. - Enhanced the API request building process to accommodate new node data structures, including dynamic handling of headers, parameters, and body content. - Improved error handling and response management in the workflow execution process, ensuring better feedback and control over API call outcomes. - Refactored the workflow page to utilize the new API call structure, enhancing overall workflow execution reliability and user experience. --- pages/workflow/[id].vue | 61 ++- prisma/json/json-schema.json | 428 +++++++++---------- prisma/schema.prisma | 206 +++++---- server/api/process/workflow-api-call.post.js | 198 +++++++++ 4 files changed, 545 insertions(+), 348 deletions(-) create mode 100644 server/api/process/workflow-api-call.post.js diff --git a/pages/workflow/[id].vue b/pages/workflow/[id].vue index 4ed96ab..1280b81 100644 --- a/pages/workflow/[id].vue +++ b/pages/workflow/[id].vue @@ -655,11 +655,18 @@ function buildApiRequest(nodeData, variables) { // 4. Authorization if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') { const auth = nodeData.authorization; + if (auth.type === 'bearer' && auth.token) { headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`; - } else if (auth.type === 'basic' && auth.username && auth.password) { - const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`); - headers['Authorization'] = `Basic ${token}`; + } else if (auth.type === 'basic') { + if (auth.token) { + // Basic Auth with token (JWT or other token) + headers['Authorization'] = `Basic ${substituteVariables(auth.token, variables)}`; + } else if (auth.username && auth.password) { + // Basic Auth with username/password + const token = btoa(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`); + headers['Authorization'] = `Basic ${token}`; + } } else if (auth.type === 'apiKey' && auth.key && auth.value) { if (auth.in === 'header') { headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables); @@ -712,26 +719,45 @@ const executeCurrentStep = async () => { if (currentNode.value?.type === 'api') { console.log(`[Workflow] Executing API node: ${currentNode.value.data?.label || currentNode.value.label}`); const nodeData = currentNode.value.data || {}; - // Use new structure if present - if (nodeData.body || nodeData.headers || nodeData.params || nodeData.authorization) { - const { url, headers, body } = buildApiRequest(nodeData, processVariables.value); - const apiMethod = nodeData.apiMethod || 'GET'; + + // Use new structure if present (check for any new structure properties) + if (nodeData.body !== undefined || nodeData.headers !== undefined || nodeData.params !== undefined || nodeData.authorization !== undefined) { const outputVariable = nodeData.outputVariable || 'apiResponse'; const errorVariable = nodeData.errorVariable || 'apiError'; const continueOnError = nodeData.continueOnError || false; + try { - const response = await $fetch(url, { - method: apiMethod, - headers, - body: ['GET', 'HEAD'].includes(apiMethod) ? undefined : body, + // Use proxy endpoint to avoid CORS issues + const response = await $fetch('/api/process/workflow-api-call', { + method: 'POST', + body: { + nodeData, + processVariables: processVariables.value + } }); - processVariables.value[outputVariable] = response; - processVariables.value[errorVariable] = null; - console.log('[Workflow] API call success. Output variable set:', outputVariable, response); - if (canAutoProgress(currentNode.value)) { - moveToNextStep(); + + if (response.success) { + processVariables.value[outputVariable] = response.data; + processVariables.value[errorVariable] = null; + console.log('[Workflow] API call success. Output variable set:', outputVariable, response.data); + if (canAutoProgress(currentNode.value)) { + moveToNextStep(); + } else { + console.log('[Workflow] API completed, multiple paths available - waiting for user choice'); + } } else { - console.log('[Workflow] API completed, multiple paths available - waiting for user choice'); + processVariables.value[errorVariable] = response.error; + console.error('[Workflow] API call failed:', response.error); + if (continueOnError) { + if (canAutoProgress(currentNode.value)) { + moveToNextStep(); + } else { + console.log('[Workflow] API failed but continuing, multiple paths available - waiting for user choice'); + } + } else { + error.value = 'API call failed: ' + (response.error.message || response.error); + notifyParentOfError(error.value); + } } } catch (err) { processVariables.value[errorVariable] = err; @@ -749,6 +775,7 @@ const executeCurrentStep = async () => { } } else { // Fallback: old structure + const { apiUrl, apiMethod = 'GET', diff --git a/prisma/json/json-schema.json b/prisma/json/json-schema.json index 5125336..de2955f 100644 --- a/prisma/json/json-schema.json +++ b/prisma/json/json-schema.json @@ -1,73 +1,82 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "user": { + "caseInstance": { "type": "object", "properties": { - "userID": { + "caseID": { "type": "integer" }, - "userSecretKey": { + "caseUUID": { + "type": "string" + }, + "caseName": { + "type": "string" + }, + "caseStatus": { + "type": "string", + "default": "active" + }, + "caseVariables": { "type": [ + "number", "string", + "boolean", + "object", + "array", "null" ] }, - "userUsername": { + "caseSettings": { "type": [ + "number", "string", + "boolean", + "object", + "array", "null" ] }, - "userPassword": { + "caseDefinition": { "type": [ + "number", "string", + "boolean", + "object", + "array", "null" ] }, - "userFullName": { - "type": [ - "string", - "null" - ] + "caseCreatedDate": { + "type": "string", + "format": "date-time" }, - "userEmail": { - "type": [ - "string", - "null" - ] - }, - "userPhone": { - "type": [ - "string", - "null" - ] - }, - "userStatus": { - "type": [ - "string", - "null" - ] - }, - "userCreatedDate": { + "caseModifiedDate": { "type": [ "string", "null" ], "format": "date-time" }, - "userModifiedDate": { + "caseCompletedDate": { "type": [ "string", "null" ], "format": "date-time" }, - "caseInstance": { - "type": "array", - "items": { - "$ref": "#/definitions/caseInstance" - } + "startedBy": { + "anyOf": [ + { + "$ref": "#/definitions/user" + }, + { + "type": "null" + } + ] + }, + "process": { + "$ref": "#/definitions/process" }, "caseTimeline": { "type": "array", @@ -75,123 +84,45 @@ "$ref": "#/definitions/caseTimeline" } }, - "forms": { - "type": "array", - "items": { - "$ref": "#/definitions/form" - } - }, - "formHistoryEntries": { - "type": "array", - "items": { - "$ref": "#/definitions/formHistory" - } - }, - "processes": { - "type": "array", - "items": { - "$ref": "#/definitions/process" - } - }, - "processHistoryEntries": { - "type": "array", - "items": { - "$ref": "#/definitions/processHistory" - } - }, "task": { "type": "array", "items": { "$ref": "#/definitions/task" } - }, - "userrole": { - "type": "array", - "items": { - "$ref": "#/definitions/userrole" - } - }, - "startedCases": { - "type": "array", - "items": { - "$ref": "#/definitions/caseInstance" - } - }, - "assignedTasks": { - "type": "array", - "items": { - "$ref": "#/definitions/task" - } - }, - "caseTimelineEntries": { - "type": "array", - "items": { - "$ref": "#/definitions/caseTimeline" - } } } }, - "role": { + "caseTimeline": { "type": "object", "properties": { - "roleID": { + "timelineID": { "type": "integer" }, - "roleName": { + "timelineType": { + "type": "string" + }, + "timelineDescription": { "type": [ "string", "null" ] }, - "roleDescription": { - "type": [ - "string", - "null" - ] - }, - "roleStatus": { - "type": [ - "string", - "null" - ] - }, - "roleCreatedDate": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "roleModifiedDate": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "userrole": { - "type": "array", - "items": { - "$ref": "#/definitions/userrole" - } - } - } - }, - "userrole": { - "type": "object", - "properties": { - "userRoleID": { - "type": "integer" - }, - "userRoleCreatedDate": { + "timelineDate": { "type": "string", "format": "date-time" }, - "role": { - "$ref": "#/definitions/role" + "caseInstance": { + "$ref": "#/definitions/caseInstance" }, "user": { - "$ref": "#/definitions/user" + "anyOf": [ + { + "$ref": "#/definitions/user" + }, + { + "type": "null" + } + ] } } }, @@ -277,13 +208,13 @@ } ] }, - "history": { + "formHistory": { "type": "array", "items": { "$ref": "#/definitions/formHistory" } }, - "tasks": { + "task": { "type": "array", "items": { "$ref": "#/definitions/task" @@ -366,7 +297,7 @@ "form": { "$ref": "#/definitions/form" }, - "savedByUser": { + "user": { "anyOf": [ { "$ref": "#/definitions/user" @@ -507,17 +438,11 @@ } ] }, - "history": { + "processHistory": { "type": "array", "items": { "$ref": "#/definitions/processHistory" } - }, - "cases": { - "type": "array", - "items": { - "$ref": "#/definitions/caseInstance" - } } } }, @@ -625,7 +550,7 @@ "process": { "$ref": "#/definitions/process" }, - "savedByUser": { + "user": { "anyOf": [ { "$ref": "#/definitions/user" @@ -637,93 +562,48 @@ } } }, - "caseInstance": { + "role": { "type": "object", "properties": { - "caseID": { + "roleID": { "type": "integer" }, - "caseUUID": { - "type": "string" - }, - "caseName": { - "type": "string" - }, - "caseStatus": { - "type": "string", - "default": "active" - }, - "caseVariables": { + "roleName": { "type": [ - "number", "string", - "boolean", - "object", - "array", "null" ] }, - "caseSettings": { + "roleDescription": { "type": [ - "number", "string", - "boolean", - "object", - "array", "null" ] }, - "caseDefinition": { + "roleStatus": { "type": [ - "number", "string", - "boolean", - "object", - "array", "null" ] }, - "caseCreatedDate": { - "type": "string", - "format": "date-time" - }, - "caseModifiedDate": { + "roleCreatedDate": { "type": [ "string", "null" ], "format": "date-time" }, - "caseCompletedDate": { + "roleModifiedDate": { "type": [ "string", "null" ], "format": "date-time" }, - "process": { - "$ref": "#/definitions/process" - }, - "startedBy": { - "anyOf": [ - { - "$ref": "#/definitions/user" - }, - { - "type": "null" - } - ] - }, - "tasks": { + "userrole": { "type": "array", "items": { - "$ref": "#/definitions/task" - } - }, - "timeline": { - "type": "array", - "items": { - "$ref": "#/definitions/caseTimeline" + "$ref": "#/definitions/userrole" } } } @@ -775,10 +655,10 @@ ], "format": "date-time" }, - "case": { + "caseInstance": { "$ref": "#/definitions/caseInstance" }, - "assignedTo": { + "user": { "anyOf": [ { "$ref": "#/definitions/user" @@ -800,51 +680,144 @@ } } }, - "caseTimeline": { + "user": { "type": "object", "properties": { - "timelineID": { + "userID": { "type": "integer" }, - "timelineType": { - "type": "string" - }, - "timelineDescription": { + "userSecretKey": { "type": [ "string", "null" ] }, - "timelineDate": { + "userUsername": { + "type": [ + "string", + "null" + ] + }, + "userPassword": { + "type": [ + "string", + "null" + ] + }, + "userFullName": { + "type": [ + "string", + "null" + ] + }, + "userEmail": { + "type": [ + "string", + "null" + ] + }, + "userPhone": { + "type": [ + "string", + "null" + ] + }, + "userStatus": { + "type": [ + "string", + "null" + ] + }, + "userCreatedDate": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "userModifiedDate": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "caseInstance": { + "type": "array", + "items": { + "$ref": "#/definitions/caseInstance" + } + }, + "caseTimeline": { + "type": "array", + "items": { + "$ref": "#/definitions/caseTimeline" + } + }, + "form": { + "type": "array", + "items": { + "$ref": "#/definitions/form" + } + }, + "formHistory": { + "type": "array", + "items": { + "$ref": "#/definitions/formHistory" + } + }, + "process": { + "type": "array", + "items": { + "$ref": "#/definitions/process" + } + }, + "processHistory": { + "type": "array", + "items": { + "$ref": "#/definitions/processHistory" + } + }, + "task": { + "type": "array", + "items": { + "$ref": "#/definitions/task" + } + }, + "userrole": { + "type": "array", + "items": { + "$ref": "#/definitions/userrole" + } + } + } + }, + "userrole": { + "type": "object", + "properties": { + "userRoleID": { + "type": "integer" + }, + "userRoleCreatedDate": { "type": "string", "format": "date-time" }, - "case": { - "$ref": "#/definitions/caseInstance" + "role": { + "$ref": "#/definitions/role" }, - "createdBy": { - "anyOf": [ - { - "$ref": "#/definitions/user" - }, - { - "type": "null" - } - ] + "user": { + "$ref": "#/definitions/user" } } } }, "type": "object", "properties": { - "user": { - "$ref": "#/definitions/user" + "caseInstance": { + "$ref": "#/definitions/caseInstance" }, - "role": { - "$ref": "#/definitions/role" - }, - "userrole": { - "$ref": "#/definitions/userrole" + "caseTimeline": { + "$ref": "#/definitions/caseTimeline" }, "form": { "$ref": "#/definitions/form" @@ -858,14 +831,17 @@ "processHistory": { "$ref": "#/definitions/processHistory" }, - "caseInstance": { - "$ref": "#/definitions/caseInstance" + "role": { + "$ref": "#/definitions/role" }, "task": { "$ref": "#/definitions/task" }, - "caseTimeline": { - "$ref": "#/definitions/caseTimeline" + "user": { + "$ref": "#/definitions/user" + }, + "userrole": { + "$ref": "#/definitions/userrole" } } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b56d256..a9b05f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,50 +12,42 @@ datasource db { url = env("DATABASE_URL") } -model user { - userID Int @id @default(autoincrement()) - userSecretKey String? @db.VarChar(255) - userUsername String? @db.VarChar(255) - userPassword String? @db.VarChar(255) - userFullName String? @db.VarChar(255) - userEmail String? @db.VarChar(255) - userPhone String? @db.VarChar(255) - 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") - caseTimelineEntries caseTimeline[] +model caseInstance { + caseID Int @id @default(autoincrement()) + caseUUID String @unique @db.VarChar(36) + processID Int + caseName String @db.VarChar(255) + caseStatus String @default("active") @db.VarChar(50) + caseStartedBy Int? + caseVariables Json? + caseSettings Json? + caseDefinition Json? + caseCreatedDate DateTime @default(now()) @db.DateTime(0) + caseModifiedDate DateTime? @db.DateTime(0) + caseCompletedDate DateTime? @db.DateTime(0) + startedBy user? @relation(fields: [caseStartedBy], references: [userID]) + process process @relation(fields: [processID], references: [processID]) + caseTimeline caseTimeline[] + task task[] + + @@index([processID], map: "FK_case_process") + @@index([caseStartedBy], map: "FK_case_startedBy") + @@index([caseStatus], map: "IDX_case_status") } -model role { - roleID Int @id @default(autoincrement()) - roleName String? @db.VarChar(255) - roleDescription String? @db.VarChar(255) - roleStatus String? @db.VarChar(255) - roleCreatedDate DateTime? @db.DateTime(0) - roleModifiedDate DateTime? @db.DateTime(0) - userrole userrole[] -} +model caseTimeline { + timelineID Int @id @default(autoincrement()) + caseID Int + timelineType String @db.VarChar(50) + timelineDescription String? @db.Text + timelineDate DateTime @default(now()) @db.DateTime(0) + timelineCreatedBy Int? + caseInstance caseInstance @relation(fields: [caseID], references: [caseID]) + user user? @relation(fields: [timelineCreatedBy], references: [userID]) -model userrole { - userRoleID Int @id @default(autoincrement()) - userRoleUserID Int @default(0) - userRoleRoleID Int @default(0) - userRoleCreatedDate DateTime @db.DateTime(0) - role role @relation(fields: [userRoleRoleID], references: [roleID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_role") - user user @relation(fields: [userRoleUserID], references: [userID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_user") - - @@index([userRoleRoleID], map: "FK_userrole_role") - @@index([userRoleUserID], map: "FK_userrole_user") + @@index([caseID], map: "FK_caseTimeline_case") + @@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy") + @@index([timelineDate], map: "IDX_caseTimeline_date") } model form { @@ -67,14 +59,14 @@ model form { formStatus String @default("active") @db.VarChar(50) formCreatedBy Int? formCreatedDate DateTime @default(now()) @db.DateTime(0) - formModifiedDate DateTime? @updatedAt @db.DateTime(0) + formModifiedDate DateTime? @db.DateTime(0) customCSS String? @db.Text customScript String? @db.LongText formEvents Json? scriptMode String? @default("safe") @db.VarChar(20) - creator user? @relation("FormCreator", fields: [formCreatedBy], references: [userID]) - history formHistory[] @relation("FormHistoryEntries") - tasks task[] + creator user? @relation(fields: [formCreatedBy], references: [userID]) + formHistory formHistory[] + task task[] @@index([formCreatedBy], map: "FK_form_creator") } @@ -95,13 +87,13 @@ model formHistory { changeDescription String? @db.Text savedBy Int? savedDate DateTime @default(now()) @db.DateTime(0) - form form @relation("FormHistoryEntries", fields: [formID], references: [formID], onDelete: Cascade) - savedByUser user? @relation(fields: [savedBy], references: [userID]) + form form @relation(fields: [formID], references: [formID], onDelete: Cascade) + user user? @relation(fields: [savedBy], references: [userID]) @@index([formID], map: "FK_formHistory_form") @@index([savedBy], map: "FK_formHistory_savedBy") - @@index([formUUID], map: "IDX_formHistory_uuid") @@index([savedDate], map: "IDX_formHistory_date") + @@index([formUUID], map: "IDX_formHistory_uuid") } model process { @@ -114,7 +106,7 @@ model process { processStatus String @default("draft") @db.VarChar(50) processCreatedBy Int? processCreatedDate DateTime @default(now()) @db.DateTime(0) - processModifiedDate DateTime? @updatedAt @db.DateTime(0) + processModifiedDate DateTime? @db.DateTime(0) isTemplate Boolean @default(false) processCategory String? @db.VarChar(100) processOwner String? @db.VarChar(255) @@ -125,13 +117,12 @@ model process { 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[] + creator user? @relation(fields: [processCreatedBy], references: [userID]) + processHistory processHistory[] @@index([processCreatedBy], map: "FK_process_creator") - @@index([processStatus], map: "IDX_process_status") @@index([processCategory], map: "IDX_process_category") + @@index([processStatus], map: "IDX_process_status") @@index([isTemplate], map: "IDX_process_template") } @@ -155,72 +146,77 @@ model processHistory { changeDescription String? @db.Text savedBy Int? savedDate DateTime @default(now()) @db.DateTime(0) - process process @relation("ProcessHistoryEntries", fields: [processID], references: [processID], onDelete: Cascade) - savedByUser user? @relation(fields: [savedBy], references: [userID]) + process process @relation(fields: [processID], references: [processID], onDelete: Cascade) + user user? @relation(fields: [savedBy], references: [userID]) @@index([processID], map: "FK_processHistory_process") @@index([savedBy], map: "FK_processHistory_savedBy") - @@index([processUUID], map: "IDX_processHistory_uuid") @@index([savedDate], map: "IDX_processHistory_date") + @@index([processUUID], map: "IDX_processHistory_uuid") } -model caseInstance { - caseID Int @id @default(autoincrement()) - caseUUID String @unique @db.VarChar(36) - processID Int - caseName String @db.VarChar(255) - caseStatus String @default("active") @db.VarChar(50) - caseStartedBy Int? - caseVariables Json? - caseSettings Json? - caseDefinition Json? - caseCreatedDate DateTime @default(now()) @db.DateTime(0) - caseModifiedDate DateTime? @updatedAt @db.DateTime(0) - caseCompletedDate DateTime? @db.DateTime(0) - process process @relation(fields: [processID], references: [processID]) - startedBy user? @relation("CaseStartedBy", fields: [caseStartedBy], references: [userID]) - tasks task[] - timeline caseTimeline[] - - @@index([processID], map: "FK_case_process") - @@index([caseStartedBy], map: "FK_case_startedBy") - @@index([caseStatus], map: "IDX_case_status") +model role { + roleID Int @id @default(autoincrement()) + roleName String? @db.VarChar(255) + roleDescription String? @db.VarChar(255) + roleStatus String? @db.VarChar(255) + roleCreatedDate DateTime? @db.DateTime(0) + roleModifiedDate DateTime? @db.DateTime(0) + userrole userrole[] } model task { - taskID Int @id @default(autoincrement()) - taskUUID String @unique @db.VarChar(36) - caseID Int - taskName String @db.VarChar(255) - taskType String @db.VarChar(50) - taskStatus String @default("pending") @db.VarChar(50) - taskAssignedTo Int? - taskFormID Int? - taskData Json? - taskCreatedDate DateTime @default(now()) @db.DateTime(0) - taskModifiedDate DateTime? @updatedAt @db.DateTime(0) + taskID Int @id @default(autoincrement()) + taskUUID String @unique @db.VarChar(36) + caseID Int + taskName String @db.VarChar(255) + taskType String @db.VarChar(50) + taskStatus String @default("pending") @db.VarChar(50) + taskAssignedTo Int? + taskFormID Int? + taskData Json? + taskCreatedDate DateTime @default(now()) @db.DateTime(0) + taskModifiedDate DateTime? @db.DateTime(0) taskCompletedDate DateTime? @db.DateTime(0) - case caseInstance @relation(fields: [caseID], references: [caseID]) - assignedTo user? @relation("TaskAssignedTo", fields: [taskAssignedTo], references: [userID]) - form form? @relation(fields: [taskFormID], references: [formID]) + caseInstance caseInstance @relation(fields: [caseID], references: [caseID]) + user user? @relation(fields: [taskAssignedTo], references: [userID]) + form form? @relation(fields: [taskFormID], references: [formID]) - @@index([caseID], map: "FK_task_case") @@index([taskAssignedTo], map: "FK_task_assignedTo") + @@index([caseID], map: "FK_task_case") @@index([taskFormID], map: "FK_task_form") @@index([taskStatus], map: "IDX_task_status") } -model caseTimeline { - timelineID Int @id @default(autoincrement()) - caseID Int - timelineType String @db.VarChar(50) - timelineDescription String? @db.Text - timelineDate DateTime @default(now()) @db.DateTime(0) - timelineCreatedBy Int? - case caseInstance @relation(fields: [caseID], references: [caseID]) - createdBy user? @relation(fields: [timelineCreatedBy], references: [userID]) - - @@index([caseID], map: "FK_caseTimeline_case") - @@index([timelineCreatedBy], map: "FK_caseTimeline_createdBy") - @@index([timelineDate], map: "IDX_caseTimeline_date") +model user { + userID Int @id @default(autoincrement()) + userSecretKey String? @db.VarChar(255) + userUsername String? @db.VarChar(255) + userPassword String? @db.VarChar(255) + userFullName String? @db.VarChar(255) + userEmail String? @db.VarChar(255) + userPhone String? @db.VarChar(255) + userStatus String? @db.VarChar(255) + userCreatedDate DateTime? @db.DateTime(0) + userModifiedDate DateTime? @db.DateTime(0) + caseInstance caseInstance[] + caseTimeline caseTimeline[] + form form[] + formHistory formHistory[] + process process[] + processHistory processHistory[] + task task[] + userrole userrole[] +} + +model userrole { + userRoleID Int @id @default(autoincrement()) + userRoleUserID Int @default(0) + userRoleRoleID Int @default(0) + userRoleCreatedDate DateTime @db.DateTime(0) + role role @relation(fields: [userRoleRoleID], references: [roleID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_role") + user user @relation(fields: [userRoleUserID], references: [userID], onDelete: NoAction, onUpdate: NoAction, map: "FK_userrole_user") + + @@index([userRoleRoleID], map: "FK_userrole_role") + @@index([userRoleUserID], map: "FK_userrole_user") } diff --git a/server/api/process/workflow-api-call.post.js b/server/api/process/workflow-api-call.post.js new file mode 100644 index 0000000..321d5d7 --- /dev/null +++ b/server/api/process/workflow-api-call.post.js @@ -0,0 +1,198 @@ +/** + * Workflow API Call Proxy Endpoint + * + * This endpoint acts as a proxy for API calls made during workflow execution. + * It handles the new API node structure with proper authorization and avoids CORS issues. + */ + +// Helper function to substitute variables in a string +function substituteVariables(str, variables) { + if (typeof str !== 'string') return str; + + // Replace {{variable}} first + str = str.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, varName) => { + const value = variables[varName]; + if (value === undefined || value === null) return ''; + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + }); + + // Then replace {variable} + str = str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => { + const value = variables[varName]; + if (value === undefined || value === null) return ''; + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + }); + + return str; +} + +// Build API request from node data +function buildApiRequest(nodeData, variables) { + // 1. URL (with param substitution) + let url = substituteVariables(nodeData.apiUrl, variables); + + // 2. Params (for GET, DELETE, etc.) + let params = Array.isArray(nodeData.params) ? nodeData.params : []; + if (params.length) { + const query = params + .filter(p => p.key) + .map(p => `${encodeURIComponent(substituteVariables(p.key, variables))}=${encodeURIComponent(substituteVariables(p.value, variables))}`) + .join('&'); + if (query) { + url += (url.includes('?') ? '&' : '?') + query; + } + } + + // 3. Headers + let headers = {}; + if (Array.isArray(nodeData.headers)) { + nodeData.headers.forEach(h => { + if (h.key) headers[substituteVariables(h.key, variables)] = substituteVariables(h.value, variables); + }); + } else if (typeof nodeData.headers === 'object') { + headers = { ...nodeData.headers }; + } + + // 4. Authorization + if (nodeData.authorization && nodeData.authorization.type && nodeData.authorization.type !== 'none') { + const auth = nodeData.authorization; + if (auth.type === 'bearer' && auth.token) { + headers['Authorization'] = `Bearer ${substituteVariables(auth.token, variables)}`; + } else if (auth.type === 'basic') { + if (auth.token) { + // Basic Auth with token (JWT or other token) + headers['Authorization'] = `Basic ${substituteVariables(auth.token, variables)}`; + } else if (auth.username && auth.password) { + // Basic Auth with username/password + const token = Buffer.from(`${substituteVariables(auth.username, variables)}:${substituteVariables(auth.password, variables)}`).toString('base64'); + headers['Authorization'] = `Basic ${token}`; + } + } else if (auth.type === 'apiKey' && auth.key && auth.value) { + if (auth.in === 'header') { + headers[substituteVariables(auth.key, variables)] = substituteVariables(auth.value, variables); + } else if (auth.in === 'query') { + url += (url.includes('?') ? '&' : '?') + `${encodeURIComponent(substituteVariables(auth.key, variables))}=${encodeURIComponent(substituteVariables(auth.value, variables))}`; + } + } + } + + // 5. Body + let body; + if (nodeData.body && nodeData.body.type && nodeData.body.type !== 'none') { + if (['form-data', 'x-www-form-urlencoded'].includes(nodeData.body.type)) { + const dataArr = Array.isArray(nodeData.body.data) ? nodeData.body.data : []; + if (nodeData.body.type === 'form-data') { + // For server-side, we'll use URLSearchParams for form-data + const formData = new URLSearchParams(); + dataArr.forEach(item => { + if (item.key) formData.append(substituteVariables(item.key, variables), substituteVariables(item.value, variables)); + }); + body = formData.toString(); + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } else { + // x-www-form-urlencoded + body = dataArr + .filter(item => item.key) + .map(item => `${encodeURIComponent(substituteVariables(item.key, variables))}=${encodeURIComponent(substituteVariables(item.value, variables))}`) + .join('&'); + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + } else if (nodeData.body.type === 'raw') { + body = substituteVariables(nodeData.body.data, variables); + // Try to detect JSON + if (body && body.trim().startsWith('{')) { + headers['Content-Type'] = 'application/json'; + } + } + } + + return { url, headers, body }; +} + +export default defineEventHandler(async (event) => { + try { + // Get request body + const body = await readBody(event); + + // Extract node configuration and process variables + const { nodeData, processVariables } = body; + + // Validate input + if (!nodeData || !nodeData.apiUrl) { + return { + success: false, + error: { + message: 'Invalid API node configuration. Missing apiUrl.' + } + }; + } + + // Build the API request + const { url, headers, body: requestBody } = buildApiRequest(nodeData, processVariables); + const apiMethod = nodeData.apiMethod || 'GET'; + const outputVariable = nodeData.outputVariable || 'apiResponse'; + const errorVariable = nodeData.errorVariable || 'apiError'; + const continueOnError = nodeData.continueOnError || false; + + + + // Prepare fetch options + const fetchOptions = { + method: apiMethod, + headers + }; + + // Add body for non-GET requests + if (!['GET', 'HEAD'].includes(apiMethod) && requestBody) { + fetchOptions.body = requestBody; + } + + // Make the API call + const response = await fetch(url, fetchOptions); + + // Get response data + let responseData; + const contentType = response.headers.get('content-type'); + + if (contentType && contentType.includes('application/json')) { + responseData = await response.json(); + } else { + responseData = await response.text(); + } + + // Prepare result + const result = { + success: response.ok, + data: responseData, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries([...response.headers.entries()]) + }; + + if (!response.ok) { + result.error = { + message: `API call failed with status ${response.status}`, + status: response.status, + statusText: response.statusText, + data: responseData + }; + } + + return result; + + } catch (error) { + return { + success: false, + error: { + message: error.message || 'An error occurred while making the API call', + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + } + }; + } +}); \ No newline at end of file From 707d8fe3b08a3785442bc5265f0420b4771a2fcd Mon Sep 17 00:00:00 2001 From: Afiq Date: Mon, 4 Aug 2025 15:37:07 +0800 Subject: [PATCH 03/14] Enhance Component Preview and Form Builder Functionality - Updated ComponentPreview.vue to improve handling of readonly states for select, checkbox, and radio components, ensuring proper styling and interaction. - Modified button component in ComponentPreview.vue to conditionally display button text and icon based on new props for better customization. - Enhanced FormBuilderComponents.vue by adding new properties for button configuration, including showLabel, showButtonText, buttonText, and icon. - Introduced new form field settings in FormBuilderFieldSettingsModal.vue to allow users to customize button size, icon, and visibility options for labels and text. - Improved overall user experience by refining placeholder visibility in builder mode and enhancing the button action script template functionality. --- components/ComponentPreview.vue | 67 +++++-- components/FormBuilderComponents.vue | 5 + components/FormBuilderFieldSettingsModal.vue | 201 ++++++++++++++++++- 3 files changed, 244 insertions(+), 29 deletions(-) diff --git a/components/ComponentPreview.vue b/components/ComponentPreview.vue index bcbed82..a138c07 100644 --- a/components/ComponentPreview.vue +++ b/components/ComponentPreview.vue @@ -27,14 +27,20 @@ :label="component.props.label" :help="component.props.help" :placeholder="component.props.placeholder" :validation="component.props.validation" :validation-visibility="isPreview ? 'live' : 'blur'" :readonly="component.props.readonly || isPreview" - :disabled="isPreview" :options="component.props.options || undefined" :value="component.props.value || undefined" + :disabled="isPreview || (component.props.readonly && ['select', 'checkbox', 'radio'].includes(component.type))" + :options="component.props.options || undefined" :value="component.props.value || undefined" :accept="component.props.accept || undefined" :max="component.props.max || undefined" :mask="component.props.mask || undefined" :digits="component.props.digits || undefined" :multiple="component.props.multiple || undefined" :maxSize="component.props.maxSize || undefined" :maxFiles="component.props.maxFiles || undefined" :classes="component.type === 'checkbox' ? { wrapper: 'mb-1', options: 'space-y-0.5' - } : {}" :class="{ 'canvas-component': isPreview }" /> + } : {}" :class="{ + 'canvas-component': isPreview, + 'readonly-select': component.props.readonly && component.type === 'select', + 'readonly-checkbox': component.props.readonly && component.type === 'checkbox', + 'readonly-radio': component.props.readonly && component.type === 'radio' + }" />
@@ -325,14 +331,15 @@
-
@@ -188,6 +189,7 @@ v-model="localNodeData.errorVariable" :availableVariables="availableVariables" :allowCreate="true" + :allowObjectPath="false" @change="saveChanges" />
diff --git a/components/process-flow/VariableBrowser.vue b/components/process-flow/VariableBrowser.vue index 41d9aa3..0903148 100644 --- a/components/process-flow/VariableBrowser.vue +++ b/components/process-flow/VariableBrowser.vue @@ -4,7 +4,7 @@
+
+
+ +
+ + +
+
+ Examples +
+
data.user.name - Access nested object property
+
items[0].title - Access first array item property
+
results.users[2].email - Complex nested access
+
response.data.attributes.value - Deep nesting
+
+
+
+ + +
+ + {{ propertyPathError }} +
+ + +
+
Full Variable Path:
+ {{ finalVariablePath }} +
+ +
@@ -224,6 +281,10 @@ const props = defineProps({ required: { type: Boolean, default: false + }, + allowObjectPath: { + type: Boolean, + default: true // Enable object property path input } }); @@ -239,14 +300,32 @@ const newVariableDefaultValue = ref(''); const newVariableDescription = ref(''); const nameValidationError = ref(''); +// Object property path state +const propertyPath = ref(''); +const propertyPathError = ref(''); + // Computed properties +const baseVariableName = computed(() => { + // Extract base variable name from modelValue (strip property path) + const value = props.modelValue || ''; + const dotIndex = value.indexOf('.'); + return dotIndex > -1 ? value.substring(0, dotIndex) : value; +}); + const selectedVariable = computed(() => { - return props.availableVariables.find(v => v.name === props.modelValue); + return props.availableVariables.find(v => v.name === baseVariableName.value); +}); + +const finalVariablePath = computed(() => { + if (!baseVariableName.value) return ''; + if (!propertyPath.value) return baseVariableName.value; + return `${baseVariableName.value}.${propertyPath.value}`; }); const hasError = computed(() => { if (!props.required && !props.modelValue) return false; if (props.modelValue && !selectedVariable.value) return true; + if (propertyPathError.value) return true; return false; }); @@ -255,7 +334,10 @@ const errorMessage = computed(() => { return 'Variable selection is required'; } if (props.modelValue && !selectedVariable.value) { - return `Variable "${props.modelValue}" not found`; + return `Variable "${baseVariableName.value}" not found`; + } + if (propertyPathError.value) { + return propertyPathError.value; } return ''; }); @@ -305,9 +387,74 @@ const canCreateVariable = computed(() => { // Methods const handleVariableSelect = (event) => { - emit('update:modelValue', event.target.value); + const selectedVar = event.target.value; + propertyPath.value = ''; // Reset property path when changing variable + propertyPathError.value = ''; + emit('update:modelValue', selectedVar); }; +const handlePropertyPathChange = () => { + propertyPathError.value = ''; // Clear error on input + emit('update:modelValue', finalVariablePath.value); +}; + +const validatePropertyPath = () => { + if (!propertyPath.value) { + propertyPathError.value = ''; + return; + } + + // Basic validation for property path syntax + const path = propertyPath.value.trim(); + + // Check for invalid characters or patterns + if (path.includes('..') || path.startsWith('.') || path.endsWith('.')) { + propertyPathError.value = 'Invalid property path format'; + return; + } + + // Check for balanced brackets + const openBrackets = (path.match(/\[/g) || []).length; + const closeBrackets = (path.match(/\]/g) || []).length; + if (openBrackets !== closeBrackets) { + propertyPathError.value = 'Unmatched brackets in property path'; + return; + } + + propertyPathError.value = ''; +}; + +const clearPropertyPath = () => { + propertyPath.value = ''; + propertyPathError.value = ''; + emit('update:modelValue', baseVariableName.value); +}; + +const isObjectType = (type) => { + return ['object', 'array', 'map', 'set'].includes(type); +}; + +// Watch for external changes to modelValue to sync property path +watch(() => props.modelValue, (newValue) => { + if (!newValue) { + propertyPath.value = ''; + propertyPathError.value = ''; + return; + } + + const dotIndex = newValue.indexOf('.'); + if (dotIndex > -1) { + // modelValue contains a property path + const baseName = newValue.substring(0, dotIndex); + const path = newValue.substring(dotIndex + 1); + propertyPath.value = path; + } else { + // No property path in modelValue + propertyPath.value = ''; + } + propertyPathError.value = ''; +}, { immediate: true }); + const openCreateVariable = () => { showCreateVariable.value = true; resetCreateForm(); diff --git a/pages/form-builder/index.vue b/pages/form-builder/index.vue index 11a3cf1..6565a6e 100644 --- a/pages/form-builder/index.vue +++ b/pages/form-builder/index.vue @@ -295,16 +295,20 @@
- +
@@ -576,6 +580,102 @@ + + + @@ -1067,6 +1136,13 @@ const router = useRouter(); const formStore = useFormBuilderStore(); let toast; +// Track if we're creating a new form +const isCreatingForm = ref(false); +const newFormName = ref(''); +const newFormDescription = ref(''); +const newFormCategory = ref(''); +const newFormGroup = ref(''); + // Try to use the toast composable if available try { toast = useToast(); @@ -1166,6 +1242,30 @@ const formName = computed({ } }); +// Category options for form creation +const categoryOptions = [ + { label: 'Select Category', value: '' }, + { label: 'Forms', value: 'forms' }, + { label: 'Surveys', value: 'surveys' }, + { label: 'Applications', value: 'applications' }, + { label: 'Feedback', value: 'feedback' }, + { label: 'Registration', value: 'registration' }, + { label: 'Other', value: 'other' } +]; + +// Computed to check if we have a current form loaded +const hasCurrentForm = computed(() => { + // Consider a form loaded if any of these conditions are true: + // 1. Form has components (user has added fields) + // 2. Form has an ID (saved form) + // 3. Form name has been changed from default (form creation completed) + return formStore.formComponents.length > 0 || + formStore.currentFormId || + (formStore.formName && + formStore.formName.trim() !== '' && + formStore.formName !== 'New Form'); +}); + // Form JSON representation for developer view const formJson = computed(() => { return { @@ -1705,6 +1805,9 @@ onMounted(async () => { console.error('Error loading form from ID:', error); toast.error(`Failed to load form: ${error.message || 'Unknown error'}`); } + } else { + // No form ID provided, ensure we start with a clean slate + formStore.clearForm(); } } catch (error) { console.error('Error initializing form builder:', error); @@ -2199,6 +2302,54 @@ const navigateToManage = () => { navigationTarget.value = "/form-builder/manage"; }; +// Handle creating a new form +const createNewForm = async () => { + if (!newFormName.value.trim()) { + toast.error('Please enter a form name'); + return; + } + + try { + // Set the form name and description in the store + formStore.setFormName(newFormName.value.trim()); + formStore.formDescription = newFormDescription.value.trim(); + formStore.formCategory = newFormCategory.value.trim() || null; + formStore.formGroup = newFormGroup.value.trim() || null; + + // Clear existing form components to start fresh + formStore.formComponents = []; + formStore.currentFormId = null; // This ensures we create a new form instead of updating existing + + // Save the form to the database immediately + const savedForm = await formStore.saveForm(); + + if (savedForm) { + // Update URL to include the form ID + const newPath = `/form-builder?id=${savedForm.formUUID}`; + window.history.replaceState({}, '', newPath); + + // Reset navigation confirmation + navigationConfirmed.value = false; + + // Reset form variables + isCreatingForm.value = false; + newFormName.value = ''; + newFormDescription.value = ''; + newFormCategory.value = ''; + newFormGroup.value = ''; + + // Show success message + toast.success(`Form "${formStore.formName}" created and saved successfully`); + } else { + toast.error('Failed to save form to database. Please try again.'); + } + + } catch (error) { + console.error('Error creating form:', error); + toast.error(`Failed to create form: ${error.message || 'Unknown error'}`); + } +}; + const handleOptimizeLayout = () => { formStore.optimizeGridLayout(); }; diff --git a/pages/form-builder/manage.vue b/pages/form-builder/manage.vue index e28460a..0bf7148 100644 --- a/pages/form-builder/manage.vue +++ b/pages/form-builder/manage.vue @@ -52,7 +52,7 @@ - - - Clear Filters - - - - - Refresh - - +
+ +
+ +
+ + +
+ +
+ + + + Clear Filters + + + + + Refresh + +
@@ -92,18 +120,19 @@
- - - - - - - - + + + + + + + + + - - + @@ -137,18 +166,38 @@
ID: {{ form.id }}
- - +
+
+ {{ form.description }} +
+
+ No description +
+
+ + + + @@ -385,6 +434,8 @@ try { // State const searchQuery = ref(''); +const selectedCategory = ref(''); +const selectedGroup = ref(''); const loading = ref(false); const showDeleteConfirm = ref(false); const formToDelete = ref(null); @@ -398,6 +449,27 @@ const jsonContent = ref(''); const jsonValidationMessage = ref(''); const jsonIsValid = ref(false); +// Computed properties for grouping +const categoryOptions = computed(() => { + const categories = [...new Set(formStore.savedForms.map(form => form.category).filter(Boolean))]; + return [ + { label: 'All Categories', value: '' }, + ...categories.map(cat => ({ label: cat, value: cat })) + ]; +}); + +const groupOptions = computed(() => { + const groups = [...new Set(formStore.savedForms.map(form => form.group).filter(Boolean))]; + return [ + { label: 'All Groups', value: '' }, + ...groups.map(group => ({ label: group, value: group })) + ]; +}); + +const hasActiveFilters = computed(() => { + return searchQuery.value || selectedCategory.value || selectedGroup.value; +}); + // Filtered forms const filteredForms = computed(() => { let filtered = formStore.savedForms; @@ -412,6 +484,16 @@ const filteredForms = computed(() => { ); } + // Filter by category + if (selectedCategory.value) { + filtered = filtered.filter(form => form.category === selectedCategory.value); + } + + // Filter by group + if (selectedGroup.value) { + filtered = filtered.filter(form => form.group === selectedGroup.value); + } + return filtered; }); @@ -552,8 +634,8 @@ onMounted(async () => { await loadForms(); }); -// Watch for changes in search and reload forms -watch([searchQuery], () => { +// Watch for changes in search and filters +watch([searchQuery, selectedCategory, selectedGroup], () => { // Debounce the search to avoid too many API calls clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { @@ -566,9 +648,24 @@ let searchTimeout = null; // Clear all filters const clearFilters = () => { searchQuery.value = ''; + selectedCategory.value = ''; + selectedGroup.value = ''; // loadForms will be called automatically by the watcher }; +// Helper function to get category color +const getCategoryColor = (category) => { + const colors = { + 'forms': 'primary', + 'surveys': 'success', + 'applications': 'info', + 'feedback': 'warning', + 'registration': 'danger', + 'default': 'secondary' + }; + return colors[category?.toLowerCase()] || colors.default; +}; + // Clean up the search timeout on component unmount onUnmounted(() => { clearTimeout(searchTimeout); @@ -835,4 +932,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Line clamp utility for description text */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/pages/process-builder/index.vue b/pages/process-builder/index.vue index 9f11333..ff65be0 100644 --- a/pages/process-builder/index.vue +++ b/pages/process-builder/index.vue @@ -3003,10 +3003,6 @@ const sendToBack = () => { Process Settings - diff --git a/prisma/json/json-schema.json b/prisma/json/json-schema.json index 65e9f23..421aa37 100644 --- a/prisma/json/json-schema.json +++ b/prisma/json/json-schema.json @@ -208,6 +208,28 @@ "null" ] }, + "formCategory": { + "type": [ + "string", + "null" + ] + }, + "formTags": { + "type": [ + "number", + "string", + "boolean", + "object", + "array", + "null" + ] + }, + "formGroup": { + "type": [ + "string", + "null" + ] + }, "creator": { "anyOf": [ { diff --git a/prisma/migrations/20241201_add_form_grouping.sql b/prisma/migrations/20241201_add_form_grouping.sql new file mode 100644 index 0000000..be61e96 --- /dev/null +++ b/prisma/migrations/20241201_add_form_grouping.sql @@ -0,0 +1,9 @@ +-- Add form grouping fields +ALTER TABLE `form` ADD COLUMN `formCategory` VARCHAR(100) NULL; +ALTER TABLE `form` ADD COLUMN `formTags` JSON NULL; +ALTER TABLE `form` ADD COLUMN `formGroup` VARCHAR(100) NULL; + +-- Add indexes for better performance +CREATE INDEX `IDX_form_category` ON `form`(`formCategory`); +CREATE INDEX `IDX_form_group` ON `form`(`formGroup`); +CREATE INDEX `IDX_form_status` ON `form`(`formStatus`); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b793d7c..d80ec3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,11 +65,17 @@ model form { formEvents Json? scriptMode String? @default("safe") @db.VarChar(20) submitButton Json? + formCategory String? @db.VarChar(100) + formTags Json? + formGroup String? @db.VarChar(100) creator user? @relation(fields: [formCreatedBy], references: [userID]) formHistory formHistory[] task task[] @@index([formCreatedBy], map: "FK_form_creator") + @@index([formCategory], map: "IDX_form_category") + @@index([formGroup], map: "IDX_form_group") + @@index([formStatus], map: "IDX_form_status") } model formHistory { diff --git a/server/api/forms/[id].put.js b/server/api/forms/[id].put.js index a66cf59..f12e462 100644 --- a/server/api/forms/[id].put.js +++ b/server/api/forms/[id].put.js @@ -61,7 +61,7 @@ export default defineEventHandler(async (event) => { submitButton: currentForm.submitButton, versionNumber: nextVersionNumber, changeDescription: body.changeDescription || null, - savedBy: body.savedBy || currentForm.formCreatedBy, + savedBy: event.context.user?.userID || currentForm.formCreatedBy, savedDate: currentForm.formModifiedDate || currentForm.formCreatedDate } }); @@ -109,6 +109,18 @@ export default defineEventHandler(async (event) => { if (body.submitButton !== undefined) { updateData.submitButton = body.submitButton; } + + if (body.formCategory !== undefined) { + updateData.formCategory = body.formCategory && body.formCategory.trim() ? body.formCategory.trim() : null; + } + + if (body.formTags !== undefined) { + updateData.formTags = body.formTags; + } + + if (body.formGroup !== undefined) { + updateData.formGroup = body.formGroup && body.formGroup.trim() ? body.formGroup.trim() : null; + } // Try to update by UUID first let form; diff --git a/server/api/forms/create.post.js b/server/api/forms/create.post.js index 5bdeaf0..7c5d31d 100644 --- a/server/api/forms/create.post.js +++ b/server/api/forms/create.post.js @@ -25,12 +25,15 @@ export default defineEventHandler(async (event) => { formDescription: body.formDescription || null, formComponents: body.components || [], formStatus: body.status || 'active', - formCreatedBy: body.createdBy || null, // In a real app, this would come from the authenticated user customScript: body.customScript || null, customCSS: body.customCSS || null, formEvents: body.formEvents || null, scriptMode: body.scriptMode || 'safe', - submitButton: body.submitButton || null + submitButton: body.submitButton || null, + formCategory: body.formCategory && body.formCategory.trim() ? body.formCategory.trim() : null, + formTags: body.formTags || null, + formGroup: body.formGroup && body.formGroup.trim() ? body.formGroup.trim() : null, + formCreatedBy: event.context.user?.userID || undefined, } }); diff --git a/server/api/forms/index.get.js b/server/api/forms/index.get.js index 86a5d69..9a5d716 100644 --- a/server/api/forms/index.get.js +++ b/server/api/forms/index.get.js @@ -21,6 +21,9 @@ export default defineEventHandler(async (event) => { formStatus: true, formCreatedDate: true, formModifiedDate: true, + formCategory: true, + formGroup: true, + formTags: true, // Don't include the full components data to keep response size small creator: { select: { diff --git a/stores/formBuilder.js b/stores/formBuilder.js index d3d47a9..b04fd81 100644 --- a/stores/formBuilder.js +++ b/stores/formBuilder.js @@ -37,8 +37,13 @@ export const useFormBuilderStore = defineStore('formBuilder', { // Form preview data previewFormData: {}, - // Form history tracking - lastChangeDescription: null, + // Form history tracking + lastChangeDescription: null, + + // Form grouping + formCategory: '', + formTags: [], + formGroup: '', }), getters: { @@ -646,15 +651,18 @@ export const useFormBuilderStore = defineStore('formBuilder', { // Fetch forms from the API const forms = await this.getForms(); - // Transform to the format expected by the UI - this.savedForms = forms.map(form => ({ - id: form.formUUID, - name: form.formName, - description: form.formDescription || '', - components: form.formComponents || [], - createdAt: form.formCreatedDate, - updatedAt: form.formModifiedDate - })); + // Transform to the format expected by the UI + this.savedForms = forms.map(form => ({ + id: form.formUUID, + name: form.formName, + description: form.formDescription || '', + components: form.formComponents || [], + category: form.formCategory || '', + group: form.formGroup || '', + tags: form.formTags || [], + createdAt: form.formCreatedDate, + updatedAt: form.formModifiedDate + })); return this.savedForms; } catch (error) { @@ -676,12 +684,15 @@ export const useFormBuilderStore = defineStore('formBuilder', { })), customScript: this.formCustomScript, customCSS: this.formCustomCSS, - formEvents: this.formEvents, - scriptMode: this.scriptMode, - submitButton: this.submitButton, - // Add user info and change description for history tracking - savedBy: 1, // TODO: Get from authenticated user - changeDescription: this.lastChangeDescription || null + formEvents: this.formEvents, + scriptMode: this.scriptMode, + submitButton: this.submitButton, + formCategory: this.formCategory, + formTags: this.formTags, + formGroup: this.formGroup, + // Add change description for history tracking + changeDescription: this.lastChangeDescription || null, + // Note: savedBy will be handled by the server using the authenticated user from auth middleware }; // Determine if this is a new form or an update @@ -755,10 +766,15 @@ export const useFormBuilderStore = defineStore('formBuilder', { this.formComponents = []; this.selectedComponentId = null; - // Set form data - this.formName = result.form.formName; - this.formDescription = result.form.formDescription || ''; - this.currentFormId = result.form.formUUID; + // Set form data + this.formName = result.form.formName; + this.formDescription = result.form.formDescription || ''; + this.currentFormId = result.form.formUUID; + + // Load grouping data + this.formCategory = result.form.formCategory || ''; + this.formTags = result.form.formTags || []; + this.formGroup = result.form.formGroup || ''; // Load custom scripts and settings this.formCustomScript = result.form.customScript || ''; @@ -835,10 +851,15 @@ export const useFormBuilderStore = defineStore('formBuilder', { // Clear form data this.formComponents = []; this.selectedComponentId = null; - this.formName = 'New Form'; - this.formDescription = ''; - this.currentFormId = null; - this.hasUnsavedChanges = false; + this.formName = 'New Form'; + this.formDescription = ''; + this.currentFormId = null; + this.hasUnsavedChanges = false; + + // Reset grouping fields + this.formCategory = ''; + this.formTags = []; + this.formGroup = ''; // Reset custom scripts and settings this.formCustomScript = ''; From 0c93e93460f51ffe68d4cd9933ebd4d592b37434 Mon Sep 17 00:00:00 2001 From: Md Afiq Iskandar Date: Tue, 5 Aug 2025 17:28:11 +0800 Subject: [PATCH 06/14] Enhance Form Management and Process Handling - Updated the form management interface to improve the search and filter functionality, including new filters for category and group, enhancing user experience and organization. - Redesigned the forms display to utilize a grid layout, providing a more visually appealing and user-friendly interface for form management. - Introduced loading states and improved error handling for process publishing and unpublishing, ensuring better feedback for users during these actions. - Added functionality to prevent deletion of published processes, guiding users to unpublish first, thereby improving data integrity and user guidance. - Enhanced the process store with a new unpublish method, allowing for better management of process states and updates to the UI accordingly. --- pages/form-builder/manage.vue | 392 +++++++++++----------- pages/process-builder/manage.vue | 42 ++- server/api/process/[id]/unpublish.post.js | 84 +++++ stores/processBuilder.js | 33 ++ 4 files changed, 351 insertions(+), 200 deletions(-) create mode 100644 server/api/process/[id]/unpublish.post.js diff --git a/pages/form-builder/manage.vue b/pages/form-builder/manage.vue index 0bf7148..de2100d 100644 --- a/pages/form-builder/manage.vue +++ b/pages/form-builder/manage.vue @@ -47,65 +47,71 @@
-
-
- - - +
+
+
+ + + +
+ +
+ +
+ +
+ +
+ +
+ + + Clear + + + + + Refresh + +
- -
- -
- -
- - -
- -
- - - - Clear Filters - - - - - Refresh - -
@@ -116,133 +122,115 @@
- -
-
NameDescriptionComponentsCreatedLast UpdatedActions
NameDescriptionCategoryGroupCreatedLast UpdatedActions
+

@@ -118,16 +147,16 @@

Search: "{{ searchQuery }}"
-
- - - Clear Filters - - - - Create New Form - -
+
+ + + Clear Filters + + + + Create New Form + +
-
- {{ form.description || 'No description' }} -
-
-
- {{ form.components ? form.components.length : 0 }} components -
-
-
{{ formatDate(form.createdAt) }}
-
+
+ + {{ form.category }} + +
+
No category
+
+
+ + {{ form.group }} + +
+
No group
+
+
{{ formatDate(form.createdAt) }}
+
{{ formatDate(form.updatedAt) }}
- - - - - - - - - - - - - - - - - - - - - - - - - -
NameDescriptionCategoryGroupCreatedLast UpdatedActions
-
- -

- {{ searchQuery ? 'No forms match your search' : 'No forms found' }} -

-

- {{ searchQuery ? 'Try adjusting your search terms' : 'Create your first form to get started' }} -

- - -
-
Search: "{{ searchQuery }}"
-
- -
- - - Clear Filters - - - - Create New Form - -
+ +
+
+
+
+
+

{{ form.name || 'Untitled Form' }}

+
+ + {{ form.category }} + + + {{ form.group }} +
-
-
{{ form.name || 'Untitled Form' }}
-
ID: {{ form.id }}
-
-
-
- {{ form.description }} -
-
- No description -
-
-
-
- - {{ form.category }} - -
-
No category
-
-
- - {{ form.group }} - -
-
No group
-
-
{{ formatDate(form.createdAt) }}
-
-
{{ formatDate(form.updatedAt) }}
-
-
- - - - - - - +
+ +

+ {{ form.description || 'No description available' }} +

+ +
+
+ + ID: {{ form.id }}
-
+
+ + Created {{ formatDate(form.createdAt) }} +
+
+ + Updated {{ formatDate(form.updatedAt) }} +
+
+ + +
+ + + + + + + +
+ + + + + +
+ +

+ {{ hasActiveFilters ? 'No forms match your filters' : 'No forms found' }} +

+

+ {{ hasActiveFilters ? 'Try adjusting your search or filter criteria' : 'Create your first form to get started' }} +

+ + +
+
Search: "{{ searchQuery }}"
+
Category: {{ selectedCategory }}
+
Group: {{ selectedGroup }}
+
+ +
+ + + Clear Filters + + + + Create New Form + +
@@ -541,18 +529,17 @@ const duplicateForm = async (form) => { loading.value = true; const newName = `${form.name || 'Form'} (Copy)`; - // Create a copy of the form - const duplicatedForm = { - ...form, - id: null, // Will get new ID when saved - name: newName, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - - // Load the original form, duplicate it, and save as new + // Load the original form first await formStore.loadForm(form.id); + + // Clear the current form ID to ensure a new form is created + formStore.currentFormId = null; + + // Update the form name for the duplicate formStore.setFormName(newName); + + // Mark as having unsaved changes and save as new form + formStore.hasUnsavedChanges = true; await formStore.saveForm(); // Refresh the form list @@ -923,9 +910,17 @@ const closeImportModal = () => { \ No newline at end of file diff --git a/components/FormBuilderFieldSettingsModal.vue b/components/FormBuilderFieldSettingsModal.vue index b6f5fcb..0045192 100644 --- a/components/FormBuilderFieldSettingsModal.vue +++ b/components/FormBuilderFieldSettingsModal.vue @@ -503,13 +503,111 @@ { label: 'Secondary (Gray)', value: 'secondary' }, { label: 'Success (Green)', value: 'success' }, { label: 'Danger (Red)', value: 'danger' }, - { label: 'Warning (Orange)', value: 'warning' } + { label: 'Warning (Orange)', value: 'warning' }, + { label: 'Custom Color', value: 'custom' } ]" help="Visual appearance of the button" :classes="{ outer: 'field-wrapper' }" /> + +
+
Custom Color Settings
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + +
+
Color Preview
+
+ +
+
Background: {{ configModel.customBackgroundColor || '#3b82f6' }}
+
Text: {{ configModel.customTextColor || '#ffffff' }}
+
Border: {{ configModel.customBorderColor || 'transparent' }}
+
+
+
+
+
{ return queryString ? `${baseUrl}?${queryString}` : baseUrl } +// Generate custom button styles for preview +const getCustomButtonStyles = () => { + const styles = { + backgroundColor: configModel.value.customBackgroundColor || '#3b82f6', + color: configModel.value.customTextColor || '#ffffff', + border: 'none', + cursor: 'pointer' + } + + // Add border if specified + if (configModel.value.customBorderColor && configModel.value.customBorderWidth) { + styles.border = `${configModel.value.customBorderWidth}px solid ${configModel.value.customBorderColor}` + } + + // Add border radius + if (configModel.value.customBorderRadius) { + styles.borderRadius = `${configModel.value.customBorderRadius}px` + } + + // Add hover effects + const hoverEffect = configModel.value.customHoverEffect + if (hoverEffect && hoverEffect !== 'none') { + switch (hoverEffect) { + case 'darken': + styles[':hover'] = { filter: 'brightness(0.9)' } + break + case 'lighten': + styles[':hover'] = { filter: 'brightness(1.1)' } + break + case 'scale': + styles[':hover'] = { transform: 'scale(1.05)' } + break + case 'glow': + styles[':hover'] = { + boxShadow: `0 0 10px ${configModel.value.customBackgroundColor || '#3b82f6'}` + } + break + } + } + + return styles +} + // Type changing functionality const compatibilityGroups = { // Text-based inputs (can switch between each other)