Md Afiq Iskandar 03000b710b Enhance API Node Configuration Component with Authorization and Request Features
- Added support for additional HTTP methods (HEAD, OPTIONS) in the API node configuration.
- Refactored the request configuration section to focus on authorization, including dynamic fields for various auth types (Bearer, Basic, API Key).
- Introduced a new KeyValueTable component for managing query parameters and headers, improving user experience in configuring API requests.
- Updated the request body handling to support multiple body types (form-data, x-www-form-urlencoded, raw, binary) with appropriate UI elements.
- Enhanced response handling and testing steps to align with the new configuration structure, ensuring a seamless API interaction experience.
2025-07-16 08:39:43 +08:00

1294 lines
51 KiB
Vue

<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import FormScriptEngine from '~/components/FormScriptEngine.vue';
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
import ComponentPreview from '~/components/ComponentPreview.vue';
import { useFormBuilderStore } from '~/stores/formBuilder';
import { FormKit } from '@formkit/vue';
// Define page meta
definePageMeta({
title: "Process Execution",
description: "Execute and run through a business process workflow",
layout: "empty",
middleware: ["auth"],
requiresAuth: true,
});
// Get route and router
const route = useRoute();
const router = useRouter();
// Get form builder store for ComponentPreview data sharing
const formStore = useFormBuilderStore();
// State
const loading = ref(true);
const process = ref(null);
const currentStep = ref(0);
const caseInstance = ref(null);
const formData = ref({});
const processVariables = ref({});
const error = ref(null);
const stepLoading = ref(false);
const currentForm = ref(null);
const tasks = ref([]);
const conditionalLogicScript = ref('');
const combinedScript = ref('');
const fieldStates = ref({});
// Get process ID from route
const processId = computed(() => route.params.id);
// Get current workflow definition
const workflowData = computed(() => {
if (!process.value?.processDefinition) return null;
return process.value.processDefinition;
});
// Get current step node
const currentNode = computed(() => {
if (!workflowData.value?.nodes || currentStep.value >= workflowData.value.nodes.length) {
return null;
}
return workflowData.value.nodes[currentStep.value];
});
// Get next step
const nextNode = computed(() => {
if (!workflowData.value?.nodes || currentStep.value + 1 >= workflowData.value.nodes.length) {
return null;
}
return workflowData.value.nodes[currentStep.value + 1];
});
// Check if process is complete
const isProcessComplete = computed(() => {
return currentNode.value?.type === 'end' || currentStep.value >= (workflowData.value?.nodes?.length || 0);
});
// Load process data
const loadProcess = async () => {
try {
loading.value = true;
error.value = null;
console.log('[Workflow] Loading process definition...');
const response = await $fetch(`/api/process/${processId.value}`);
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';
if (status !== 'published') {
error.value = 'Process must be published before execution';
return;
}
// Initialize process variables from DB (process.processVariables)
processVariables.value = process.value.processVariables ? { ...process.value.processVariables } : {};
// Start the process execution (case instance)
await startProcessExecution();
} else {
error.value = response.message || 'Failed to load process';
}
} catch (err) {
console.error('[Workflow] Error loading process:', err);
error.value = 'Failed to load process data';
} finally {
loading.value = false;
}
};
// Start process execution (create case instance)
const startProcessExecution = async () => {
try {
console.log('[Workflow] Starting process execution (creating case instance)...');
const response = await $fetch(`/api/process/${processId.value}/start`, {
method: 'POST'
});
if (response.success) {
caseInstance.value = response.data.case;
tasks.value = response.data.tasks;
console.log('[Workflow] Case instance created:', caseInstance.value);
// Find the start node
const startNodeIndex = workflowData.value.nodes.findIndex(node => node.type === 'start');
currentStep.value = startNodeIndex >= 0 ? startNodeIndex : 0;
console.log('[Workflow] Starting at node index:', currentStep.value, workflowData.value.nodes[currentStep.value]);
moveToNextStep();
} else {
throw new Error(response.error || 'Failed to start process');
}
} catch (err) {
console.error('[Workflow] Error starting process execution:', err);
error.value = 'Failed to start process execution';
}
};
// Helper: Get all outgoing edges for a node
function getOutgoingEdges(currentNodeId) {
return workflowData.value.edges.filter(e => e.source === currentNodeId);
}
// Helper: Get next node ID by following edges (for single-path nodes)
function getNextNodeId(currentNodeId) {
const edges = getOutgoingEdges(currentNodeId);
return edges.length > 0 ? edges[0].target : null;
}
// Helper: Check if a node can auto-progress (has single outgoing edge)
function canAutoProgress(node) {
const outgoingEdges = getOutgoingEdges(node.id);
return outgoingEdges.length === 1;
}
// Helper: Get next node ID for decision/gateway nodes based on conditions
function getNextNodeIdForDecision(currentNodeId) {
const currentNodeObj = workflowData.value.nodes.find(n => n.id === currentNodeId);
const outgoingEdges = getOutgoingEdges(currentNodeId);
if (!currentNodeObj || !outgoingEdges.length) return null;
const { conditions = [], defaultPath } = currentNodeObj.data || {};
// Evaluate conditions
for (const condition of conditions) {
if (evaluateCondition(condition, processVariables.value)) {
// Find the edge that matches this condition's target
const edge = outgoingEdges.find(e => e.data?.conditionId === condition.id || e.label === condition.label);
if (edge) return edge.target;
}
}
// If no conditions match, use default path
if (defaultPath) {
const defaultEdge = outgoingEdges.find(e => e.data?.isDefault || e.label?.toLowerCase().includes('default'));
if (defaultEdge) return defaultEdge.target;
}
// Fallback to first edge
return outgoingEdges[0]?.target || null;
}
// Helper: Evaluate a single condition
function evaluateCondition(condition, variables) {
const { variable, operator, value } = condition;
const variableValue = variables[variable];
switch (operator) {
case 'equals':
case '==':
return variableValue == value;
case 'not_equals':
case '!=':
return variableValue != value;
case 'greater_than':
case '>':
return Number(variableValue) > Number(value);
case 'less_than':
case '<':
return Number(variableValue) < Number(value);
case 'greater_than_or_equal':
case '>=':
return Number(variableValue) >= Number(value);
case 'less_than_or_equal':
case '<=':
return Number(variableValue) <= Number(value);
case 'contains':
return String(variableValue).includes(String(value));
case 'not_contains':
return !String(variableValue).includes(String(value));
case 'is_empty':
return !variableValue || variableValue === '' || variableValue === null || variableValue === undefined;
case 'is_not_empty':
return variableValue && variableValue !== '' && variableValue !== null && variableValue !== undefined;
default:
console.warn('[Workflow] Unknown condition operator:', operator);
return false;
}
}
// Move to a specific node by its ID
const moveToSpecificNode = (targetNodeId) => {
const nextIndex = workflowData.value.nodes.findIndex(n => n.id === targetNodeId);
if (nextIndex !== -1) {
currentStep.value = nextIndex;
const node = workflowData.value.nodes[currentStep.value];
console.log(`[Workflow] Moved to node: ${node.type} - ${node.data?.label || node.label}`);
return true;
}
return false;
};
// Move to next step in workflow (only for single-path auto-progressable nodes)
const moveToNextStep = () => {
const currentNode = workflowData.value.nodes[currentStep.value];
if (!currentNode) return;
// Only auto-progress if there's exactly one outgoing edge
if (!canAutoProgress(currentNode)) {
console.log(`[Workflow] Node ${currentNode.id} has multiple paths, waiting for user choice`);
return;
}
const nextNodeId = getNextNodeId(currentNode.id);
if (nextNodeId) {
moveToSpecificNode(nextNodeId);
}
};
// Handle form submission
const handleFormSubmit = async () => {
try {
stepLoading.value = true;
console.log('[Workflow] Form submitted. Data:', formData.value);
// Apply output mappings if configured
if (currentNode.value.data?.outputMappings?.length > 0) {
applyOutputMappings(
currentNode.value.data,
formData.value,
processVariables.value
);
} else {
// Fallback to current flat assignment behavior
Object.assign(processVariables.value, formData.value);
}
// Handle assignment variables
if (currentNode.value.data?.assignmentVariable) {
processVariables.value[currentNode.value.data.assignmentVariable] = {
completedBy: 'currentUser', // TODO: Get from auth context
completedAt: new Date().toISOString(),
formData: { ...formData.value }
};
}
moveToNextStep();
console.log('[Workflow] After form submit, current node:', currentNode.value);
if (currentNode.value && ['api', 'script'].includes(currentNode.value.type)) {
await executeCurrentStep();
}
} catch (err) {
console.error('[Workflow] Error submitting form:', err);
error.value = 'Failed to submit form';
} finally {
stepLoading.value = false;
}
};
// --- Utility: Substitute variables in a string ---
function substituteVariables(str, variables) {
if (typeof str !== 'string') return str;
return 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);
});
}
// --- Variable Mapping Functions ---
// Apply input mappings to pre-fill form with process variables
function applyInputMappings(nodeData, processVars, formData) {
const { inputMappings = [] } = nodeData;
console.log('[Workflow] Applying input mappings:', inputMappings);
inputMappings.forEach(mapping => {
const { processVariable, formField } = mapping;
if (processVars[processVariable] !== undefined) {
formData[formField] = processVars[processVariable];
console.log(`[Workflow] Mapped ${processVariable} -> ${formField}:`, processVars[processVariable]);
}
});
}
// Apply output mappings to save form data to process variables
function applyOutputMappings(nodeData, formData, processVars) {
const { outputMappings = [] } = nodeData;
console.log('[Workflow] Applying output mappings:', outputMappings);
outputMappings.forEach(mapping => {
const { formField, processVariable } = mapping;
if (formData[formField] !== undefined) {
processVars[processVariable] = formData[formField];
console.log(`[Workflow] Mapped ${formField} -> ${processVariable}:`, formData[formField]);
}
});
}
// Apply field conditions for dynamic form behavior
function applyFieldConditions(nodeData, processVars) {
const { fieldConditions = [] } = nodeData;
const fieldStates = {};
fieldConditions.forEach(condition => {
const { processVariable, operator, value, targetField, action } = condition;
if (evaluateCondition({ variable: processVariable, operator, value }, processVars)) {
fieldStates[targetField] = {
...fieldStates[targetField],
[action]: true
};
console.log(`[Workflow] Field condition applied: ${targetField} -> ${action}`);
}
});
return fieldStates;
}
// --- Utility: 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' && auth.username && auth.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);
} 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') {
body = new FormData();
dataArr.forEach(item => {
if (item.key) body.append(substituteVariables(item.key, variables), substituteVariables(item.value, variables));
});
// Remove content-type header for FormData (browser will set it)
if (headers['Content-Type']) delete headers['Content-Type'];
} 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';
}
} else if (nodeData.body.type === 'binary') {
// Not supported in workflow execution context
body = undefined;
}
}
return { url, headers, body };
}
// Update executeCurrentStep for API node
const executeCurrentStep = async () => {
try {
stepLoading.value = true;
console.log('[Workflow] Executing current step:', currentNode.value);
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';
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,
});
processVariables.value[outputVariable] = response;
processVariables.value[errorVariable] = null;
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
}
} catch (err) {
processVariables.value[errorVariable] = err;
console.error('[Workflow] API call failed:', err);
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: ' + (err.message || err);
}
}
} else {
// Fallback: old structure
const {
apiUrl,
apiMethod = 'GET',
headers = '{}',
requestBody = '',
outputVariable = 'apiResponse',
errorVariable = 'apiError',
continueOnError = false
} = nodeData;
try {
const response = await $fetch(apiUrl, {
method: apiMethod,
headers: headers ? JSON.parse(headers) : {},
body: requestBody ? JSON.parse(requestBody) : undefined,
});
processVariables.value[outputVariable] = response;
processVariables.value[errorVariable] = null;
console.log('[Workflow] API call success. Output variable set:', outputVariable, response);
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] API completed, multiple paths available - waiting for user choice');
}
} catch (err) {
processVariables.value[errorVariable] = err;
console.error('[Workflow] API call failed:', err);
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: ' + (err.message || err);
}
}
}
} else if (currentNode.value?.type === 'script') {
console.log(`[Workflow] Executing script node: ${currentNode.value.data?.label || currentNode.value.label}`);
// Simulate script execution
await new Promise(resolve => setTimeout(resolve, 500));
// Only auto-progress if there's a single outgoing edge
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] Script completed, multiple paths available - waiting for user choice');
}
} else if (currentNode.value?.type === 'html') {
console.log(`[Workflow] Processing HTML node: ${currentNode.value.data?.label || currentNode.value.label}`);
// HTML nodes are user-interactive, so we don't auto-advance
// The user will click a button to continue
return;
} else if (currentNode.value?.type === 'notification') {
console.log(`[Workflow] Sending notification: ${currentNode.value.data?.label || currentNode.value.label}`);
// Enhanced notification node execution
const {
notificationType = 'info',
recipients = [],
message = '',
subject = '',
continueOnError = true
} = currentNode.value.data || {};
try {
// Simulate notification sending
console.log('[Workflow] Notification sent:', { type: notificationType, recipients, message, subject });
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call delay
// Only auto-progress if there's a single outgoing edge
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] Notification sent, multiple paths available - waiting for user choice');
}
} catch (err) {
console.error('[Workflow] Notification failed:', err);
if (continueOnError) {
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
} else {
console.log('[Workflow] Notification failed but continuing, multiple paths available - waiting for user choice');
}
} else {
error.value = 'Notification failed: ' + (err.message || err);
}
}
} else if (['decision', 'gateway'].includes(currentNode.value?.type)) {
console.log(`[Workflow] Processing decision node: ${currentNode.value.data?.label || currentNode.value.label}`);
// Decision nodes require user interaction or automatic condition evaluation
// We'll handle this in the template section
return;
}
// Add more node types as needed
} catch (err) {
console.error('[Workflow] Error executing step:', err);
error.value = 'Failed to execute step';
} finally {
stepLoading.value = false;
}
};
// Get step type display info
const getStepInfo = (node) => {
const stepTypes = {
'start': { label: 'Process Started', icon: 'material-symbols:play-circle', color: 'green' },
'form': { label: 'User Form', icon: 'material-symbols:description', color: 'blue' },
'api': { label: 'API Call', icon: 'material-symbols:api', color: 'purple' },
'script': { label: 'Script Execution', icon: 'material-symbols:code', color: 'orange' },
'decision': { label: 'Decision Point', icon: 'material-symbols:alt-route', color: 'yellow' },
'gateway': { label: 'Decision Gateway', icon: 'material-symbols:fork-right', color: 'yellow' },
'html': { label: 'HTML Content', icon: 'material-symbols:code-blocks', color: 'indigo' },
'notification': { label: 'Notification', icon: 'material-symbols:notifications', color: 'pink' },
'end': { label: 'Process Complete', icon: 'material-symbols:check-circle', color: 'green' }
};
return stepTypes[node?.type] || { label: 'Unknown Step', icon: 'material-symbols:help', color: 'gray' };
};
// Load form data from database
const loadFormData = async (formId) => {
try {
if (!formId) return null;
const response = await $fetch(`/api/forms/${formId}`);
if (response.success) {
return response.form;
}
return null;
} catch (error) {
console.error('Error loading form:', error);
return null;
}
};
// Note: isInputType function removed as ComponentPreview handles all component types
// Get truncated custom script for display
const customScriptPreview = computed(() => {
if (!currentForm.value?.customScript) return '';
return currentForm.value.customScript.substring(0, 200) + '...';
});
// Handle script-driven field changes
const handleScriptFieldChange = ({ fieldName, value }) => {
console.log('[WorkflowExecution] Script field change:', fieldName, '=', value);
// Update form data with script changes
formData.value[fieldName] = value;
// Also update form store for ComponentPreview
formStore.updatePreviewFormData(formData.value);
};
// Handle conditional logic script generation
const handleConditionalLogicGenerated = (generatedScript) => {
console.log('[WorkflowExecution] Conditional logic script generated');
conditionalLogicScript.value = generatedScript;
// Combine conditional logic with custom script
const customScript = currentForm.value?.customScript || '';
combinedScript.value = [conditionalLogicScript.value, customScript].filter(Boolean).join('\n\n');
};
// Navigation functions
const goHome = () => {
router.push('/');
};
// Load process on mount
onMounted(() => {
loadProcess();
});
// Watch for step changes to auto-execute non-form steps or load form data
watch(currentStep, async (newStep) => {
if (currentNode.value) {
if (['api', 'script', 'notification'].includes(currentNode.value.type)) {
await executeCurrentStep();
} else if (currentNode.value.type === 'form') {
// Load form data for form nodes
const formId = currentNode.value.data?.formId;
if (formId) {
currentForm.value = await loadFormData(formId);
// Apply input mappings to pre-fill form
if (currentNode.value.data?.inputMappings) {
applyInputMappings(
currentNode.value.data,
processVariables.value,
formData.value
);
}
// Apply field conditions
if (currentNode.value.data?.fieldConditions) {
fieldStates.value = applyFieldConditions(
currentNode.value.data,
processVariables.value
);
}
// Update form store with form components and data for ComponentPreview
if (currentForm.value?.formComponents) {
formStore.formComponents = currentForm.value.formComponents;
formStore.updatePreviewFormData(formData.value);
}
}
} else if (['decision', 'gateway'].includes(currentNode.value.type)) {
// Handle decision nodes
await executeDecisionNode();
}
// html nodes are handled in template - no auto-execution needed
}
});
// Watch formData changes and sync with form store
watch(formData, (newData) => {
formStore.updatePreviewFormData(newData);
}, { deep: true });
// Watch for process variable changes and re-evaluate field conditions
watch(processVariables, (newVars) => {
if (currentNode.value?.type === 'form' && currentNode.value.data?.fieldConditions) {
fieldStates.value = applyFieldConditions(currentNode.value.data, newVars);
}
}, { deep: true });
// Add FormKit form ref
const formRef = ref(null);
// New: handle FormKit form submit
const onFormKitSubmit = () => {
handleFormSubmit();
};
// New: validate and submit handler for button
const validateAndSubmit = () => {
if (formRef.value && formRef.value.node && typeof formRef.value.node.submit === 'function') {
formRef.value.node.submit();
}
};
// Handle decision node execution (automatic or manual)
const executeDecisionNode = async () => {
try {
stepLoading.value = true;
const currentNodeObj = workflowData.value.nodes[currentStep.value];
const { executionType = 'automatic' } = currentNodeObj.data || {};
if (executionType === 'automatic') {
// Automatic decision based on conditions
const nextNodeId = getNextNodeIdForDecision(currentNodeObj.id);
if (nextNodeId) {
if (moveToSpecificNode(nextNodeId)) {
const node = workflowData.value.nodes[currentStep.value];
console.log(`[Workflow] Decision made automatically, moving to: ${node.data?.label || node.label}`);
}
}
}
// Manual decisions are handled in the template with buttons
} catch (err) {
console.error('[Workflow] Error executing decision:', err);
error.value = 'Failed to execute decision';
} finally {
stepLoading.value = false;
}
};
// Handle manual decision choice
const makeDecision = (targetNodeId) => {
if (moveToSpecificNode(targetNodeId)) {
const node = workflowData.value.nodes[currentStep.value];
console.log(`[Workflow] Manual decision made, moving to: ${node.data?.label || node.label}`);
}
};
// Helper: Interpolate process variables in HTML content
const interpolateHtmlContent = (htmlContent) => {
if (!htmlContent || typeof htmlContent !== 'string') return htmlContent;
// Replace {{ processVariables.variableName }} with actual values
return htmlContent.replace(/\{\{\s*processVariables\.(\w+)\s*\}\}/g, (match, variableName) => {
const value = processVariables.value[variableName];
if (value === undefined || value === null) return '';
if (typeof value === 'object') {
// Pretty print object as JSON, wrapped in <pre> if not already inside <pre>
return `<pre style='display:inline;white-space:pre-wrap;'>${JSON.stringify(value, null, 2)}</pre>`;
}
return String(value);
});
};
// Compute the workflow path by following edges from start to end
function computeWorkflowPath() {
const path = [];
const nodes = workflowData.value?.nodes || [];
const edges = workflowData.value?.edges || [];
if (!nodes.length) return path;
let node = nodes.find(n => n.type === 'start');
while (node) {
path.push(node.id);
const outgoingEdges = edges.filter(e => e.source === node.id);
if (outgoingEdges.length > 1) {
console.warn('[Workflow] Multiple outgoing edges found for node', node.id, outgoingEdges);
// For progress, just follow the first edge for now
}
const edge = outgoingEdges[0];
node = edge ? nodes.find(n => n.id === edge.target) : null;
}
return path;
}
const workflowPath = ref([]);
watch(
() => workflowData.value,
() => {
workflowPath.value = computeWorkflowPath();
},
{ immediate: true }
);
const currentStepIndex = computed(() => {
return workflowPath.value.indexOf(currentNode.value?.id) + 1;
});
const totalSteps = computed(() => workflowPath.value.length);
// Computed: Interpolated HTML content for HTML nodes
const interpolatedHtmlContent = computed(() => {
if (currentNode.value?.type !== 'html') return '';
const htmlContent = currentNode.value?.data?.htmlCode || currentNode.value?.data?.htmlContent || '';
return interpolateHtmlContent(htmlContent);
});
// Computed: CSS styles for HTML nodes
const htmlNodeStyles = computed(() => {
if (currentNode.value?.type !== 'html') return '';
return currentNode.value?.data?.cssCode || '';
});
// CSS injection for HTML nodes
let currentStyleElement = null;
// Function to inject CSS
const injectHtmlNodeCSS = (cssCode) => {
// Remove previous style element
if (currentStyleElement) {
currentStyleElement.remove();
currentStyleElement = null;
}
// Add new styles if available
if (cssCode && cssCode.trim()) {
currentStyleElement = document.createElement('style');
currentStyleElement.textContent = cssCode;
currentStyleElement.setAttribute('data-workflow-html-node', 'true');
document.head.appendChild(currentStyleElement);
}
};
// Watch for HTML node CSS changes and inject styles
watch(htmlNodeStyles, (newStyles) => {
injectHtmlNodeCSS(newStyles);
}, { immediate: true });
// Cleanup styles on unmount
onUnmounted(() => {
if (currentStyleElement) {
currentStyleElement.remove();
currentStyleElement = null;
}
});
// Helper: Get next node object for single-path nodes
function getNextNodeObject(currentNode) {
if (!currentNode || !workflowData.value?.nodes) return null;
const edges = getOutgoingEdges(currentNode.id);
if (edges.length !== 1) return null;
const nextNodeId = edges[0].target;
return workflowData.value.nodes.find(n => n.id === nextNodeId) || null;
}
// Helper: Get next node label by node ID
function getNodeLabel(nodeId) {
const node = workflowData.value.nodes.find(n => n.id === nodeId);
return node?.data?.label || node?.label || 'Next Step';
}
</script>
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white border-b border-gray-200 px-6 py-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Icon
@click="goHome"
name="ph:arrow-circle-left-duotone"
class="cursor-pointer w-6 h-6 hover:text-gray-600 text-gray-500"
/>
<div class="flex items-center gap-3">
<img
src="@/assets/img/logo/logo-word-black.svg"
alt="Corrad Logo"
class="h-8"
/>
<div class="border-l border-gray-300 pl-3">
<h1 class="text-xl font-semibold text-gray-900">Process Execution</h1>
<p class="text-sm text-gray-500">
{{ process?.processName || 'Loading...' }}
</p>
</div>
</div>
</div>
<!-- Progress indicator -->
<div v-if="!loading && !error" class="flex items-center gap-2 text-sm text-gray-600">
<span>Step {{ currentStepIndex }} of {{ totalSteps }}</span>
<div class="w-32 bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${((currentStepIndex) / (totalSteps || 1)) * 100}%` }"
></div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8 max-w-4xl">
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-12">
<div class="text-center">
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-2" />
<p class="text-gray-500">Loading process...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Icon name="material-symbols:error-outline" class="w-16 h-16 text-red-400 mx-auto mb-4" />
<h3 class="text-lg font-medium text-gray-900 mb-2">Error</h3>
<p class="text-gray-600 mb-6">{{ error }}</p>
<RsButton @click="goHome" variant="primary">
<Icon name="material-symbols:home" class="mr-2" />
Go Home
</RsButton>
</div>
<!-- Process Complete -->
<div v-else-if="isProcessComplete" class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Icon name="material-symbols:check-circle" class="w-16 h-16 text-green-500 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-gray-900 mb-2">Process Complete!</h2>
<p class="text-gray-600 mb-6">
The workflow "{{ process.processName }}" has been completed successfully.
</p>
<div class="flex justify-center gap-3">
<RsButton @click="loadProcess" variant="secondary">
<Icon name="material-symbols:refresh" class="mr-2" />
Run Again
</RsButton>
<RsButton @click="goHome" variant="primary">
<Icon name="material-symbols:home" class="mr-2" />
Go Home
</RsButton>
</div>
</div>
<!-- Current Step -->
<div v-else-if="currentNode" class="space-y-6">
<!-- Step Info -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex items-center gap-4 mb-4">
<div :class="[
'p-3 rounded-lg',
getStepInfo(currentNode).color === 'blue' ? 'bg-blue-100 text-blue-600' :
getStepInfo(currentNode).color === 'green' ? 'bg-green-100 text-green-600' :
getStepInfo(currentNode).color === 'purple' ? 'bg-purple-100 text-purple-600' :
getStepInfo(currentNode).color === 'orange' ? 'bg-orange-100 text-orange-600' :
getStepInfo(currentNode).color === 'yellow' ? 'bg-yellow-100 text-yellow-600' :
getStepInfo(currentNode).color === 'indigo' ? 'bg-indigo-100 text-indigo-600' :
getStepInfo(currentNode).color === 'pink' ? 'bg-pink-100 text-pink-600' :
'bg-gray-100 text-gray-600'
]">
<Icon :name="getStepInfo(currentNode).icon" class="w-6 h-6" />
</div>
<div>
<h2 class="text-xl font-semibold text-gray-900">{{ getStepInfo(currentNode).label }}</h2>
<p class="text-gray-600">{{ currentNode.data?.label || currentNode.data?.name || `Step ${currentStepIndex}` }}</p>
</div>
</div>
</div>
<!-- Form Step -->
<div v-if="currentNode.type === 'form'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{ currentNode.data?.formName || currentNode.data?.label || 'Please fill out the form' }}
</h3>
<p v-if="currentForm?.formDescription" class="text-gray-600 text-sm">
{{ currentForm.formDescription }}
</p>
</div>
<!-- Loading form data -->
<div v-if="!currentForm && currentNode.data?.formId" class="flex justify-center py-8">
<div class="text-center">
<Icon name="material-symbols:progress-activity" class="w-6 h-6 animate-spin text-blue-500 mx-auto mb-2" />
<p class="text-gray-500">Loading form...</p>
</div>
</div>
<!-- Form rendering -->
<div v-else-if="currentForm && currentForm.formComponents" class="form-container">
<!-- Conditional Logic Engine for FormKit conditional logic -->
<ConditionalLogicEngine
:form-components="currentForm.formComponents"
:form-data="formData"
@script-generated="handleConditionalLogicGenerated"
/>
<!-- Form Script Engine for conditional logic + custom script -->
<FormScriptEngine
v-if="combinedScript"
:form-data="formData"
:custom-script="combinedScript"
:custom-css="currentForm.customCSS"
:form-events="currentForm.formEvents || { onLoad: true, onFieldChange: true }"
:script-mode="currentForm.scriptMode || 'safe'"
@field-change="handleScriptFieldChange"
/>
<!-- FormKit form wrapper -->
<FormKit
type="form"
v-model="formData"
ref="formRef"
@submit="onFormKitSubmit"
:actions="false"
:incomplete-message="false"
validation-visibility="submit"
>
<div class="space-y-6">
<template v-for="(component, index) in currentForm.formComponents" :key="index">
<ComponentPreview
:component="component"
:is-preview="false"
:field-states="fieldStates"
/>
</template>
<div class="flex justify-end pt-6 border-t border-gray-200">
<RsButton
@click="validateAndSubmit"
:disabled="stepLoading"
variant="primary"
>
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
{{ stepLoading ? 'Processing...' : 'Submit & Continue' }}
</RsButton>
</div>
</div>
</FormKit>
<!-- Script Info (for debugging) -->
<div v-if="combinedScript" class="mt-4 p-3 bg-gray-50 rounded-lg border">
<details>
<summary class="text-sm font-medium text-gray-700 cursor-pointer">Form Logic Available (Development)</summary>
<div class="mt-2 space-y-2">
<div v-if="conditionalLogicScript">
<p class="text-xs font-medium text-gray-600">Conditional Logic:</p>
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto max-h-32">{{ conditionalLogicScript.substring(0, 200) }}...</pre>
</div>
<div v-if="currentForm?.customScript">
<p class="text-xs font-medium text-gray-600">Custom Script:</p>
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto max-h-32">{{ customScriptPreview }}</pre>
</div>
</div>
</details>
</div>
</div>
<!-- Fallback if no form data -->
<div v-else class="text-center py-8">
<Icon name="material-symbols:description-outline" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h4 class="text-lg font-medium text-gray-900 mb-2">Form Not Found</h4>
<p class="text-gray-600 mb-4">The form associated with this step could not be loaded.</p>
<RsButton @click="moveToNextStep" variant="primary">
<Icon name="material-symbols:skip-next" class="mr-2" />
Skip to Next Step
</RsButton>
</div>
</div>
<!-- API/Script/Notification Step -->
<div v-else-if="['api', 'script', 'notification'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<!-- Processing State -->
<div v-if="stepLoading" class="text-center py-8">
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-blue-500 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{
currentNode.type === 'api' ? 'Calling API...' :
currentNode.type === 'script' ? 'Executing Script...' :
'Sending Notification...'
}}
</h3>
<p class="text-gray-600">Please wait while we process this step</p>
</div>
<!-- Completed State with Multiple Paths -->
<div v-else-if="!canAutoProgress(currentNode)" class="space-y-4">
<div class="text-center">
<Icon name="material-symbols:check-circle" class="w-8 h-8 text-green-500 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{
currentNode.type === 'api' ? 'API Call Complete' :
currentNode.type === 'script' ? 'Script Executed' :
'Notification Sent'
}}
</h3>
<p class="text-gray-600 mb-6">{{ currentNode.data?.label || currentNode.label }}</p>
</div>
<!-- Multiple Path Options -->
<div class="border-t border-gray-200 pt-4">
<h4 class="font-medium text-gray-700 mb-3">Choose your next step:</h4>
<div class="grid gap-2">
<template v-for="edge in getOutgoingEdges(currentNode.id)" :key="edge.id">
<RsButton
@click="makeDecision(edge.target)"
variant="outline"
class="justify-between p-3"
>
<span>Continue to: {{ getNodeLabel(edge.target) }}</span>
<Icon name="material-symbols:arrow-right-alt" />
</RsButton>
</template>
</div>
</div>
</div>
</div>
<!-- HTML Content Step -->
<div v-else-if="currentNode.type === 'html'" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{ currentNode.data?.title || currentNode.data?.label || 'Content Display' }}
</h3>
<p v-if="currentNode.data?.description" class="text-gray-600 text-sm">
{{ currentNode.data.description }}
</p>
</div>
<!-- HTML Content -->
<div class="html-content-container border border-gray-200 rounded-lg p-4 mb-6">
<div
v-if="interpolatedHtmlContent"
v-html="interpolatedHtmlContent"
class="prose max-w-none"
></div>
<div v-else class="text-center text-gray-500 py-8">
<Icon name="material-symbols:code-blocks" class="w-12 h-12 mx-auto mb-2" />
<p>No HTML content provided</p>
</div>
</div>
<!-- Continue Options -->
<div class="pt-4 border-t border-gray-200">
<!-- Single Path - Simple Continue Button -->
<div v-if="canAutoProgress(currentNode)" class="flex justify-end">
<RsButton
@click="moveToNextStep"
:disabled="stepLoading"
variant="primary"
>
<Icon v-if="stepLoading" name="material-symbols:progress-activity" class="w-4 h-4 animate-spin mr-2" />
<template v-if="!stepLoading">
Continue to: {{ getNextNodeObject(currentNode)?.data?.label || getNextNodeObject(currentNode)?.label || 'Next Step' }}
</template>
<template v-else>
Processing...
</template>
</RsButton>
</div>
<!-- Multiple Paths - Show Options -->
<div v-else class="space-y-3">
<h4 class="font-medium text-gray-700">Choose your next step:</h4>
<div class="grid gap-2">
<template v-for="edge in getOutgoingEdges(currentNode.id)" :key="edge.id">
<RsButton
@click="makeDecision(edge.target)"
variant="outline"
class="justify-between p-3"
>
<span>Continue to: {{ getNodeLabel(edge.target) }}</span>
<Icon name="material-symbols:arrow-right-alt" />
</RsButton>
</template>
</div>
</div>
</div>
</div>
<!-- Decision/Gateway Step -->
<div v-else-if="['decision', 'gateway'].includes(currentNode.type)" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">
{{ currentNode.data?.title || currentNode.data?.label || 'Make a Decision' }}
</h3>
<p v-if="currentNode.data?.description" class="text-gray-600 text-sm">
{{ currentNode.data.description }}
</p>
</div>
<!-- Manual Decision Options -->
<div v-if="currentNode.data?.executionType === 'manual'" class="space-y-4">
<p class="text-gray-700 mb-4">Please choose one of the following options:</p>
<div class="grid gap-3">
<template v-for="edge in workflowData.edges.filter(e => e.source === currentNode.id)" :key="edge.id">
<RsButton
@click="makeDecision(edge.target)"
variant="outline"
class="justify-start p-4 h-auto"
>
<div class="text-left">
<div class="font-medium">{{ edge.label || 'Option' }}</div>
<div v-if="edge.data?.description" class="text-sm text-gray-500 mt-1">
{{ edge.data.description }}
</div>
</div>
<Icon name="material-symbols:arrow-right-alt" class="ml-auto" />
</RsButton>
</template>
</div>
</div>
<!-- Automatic Decision Processing -->
<div v-else class="text-center py-8">
<Icon name="material-symbols:progress-activity" class="w-8 h-8 animate-spin text-yellow-500 mx-auto mb-4" />
<h4 class="text-lg font-medium text-gray-900 mb-2">Evaluating Conditions</h4>
<p class="text-gray-600">Please wait while we determine the next step...</p>
<!-- Show conditions being evaluated -->
<div v-if="currentNode.data?.conditions?.length" class="mt-6 text-left">
<h5 class="font-medium text-gray-700 mb-3">Conditions:</h5>
<div class="space-y-2">
<div v-for="(condition, index) in currentNode.data.conditions" :key="index"
class="flex items-center gap-2 text-sm text-gray-600 bg-gray-50 p-2 rounded">
<Icon name="material-symbols:rule" class="w-4 h-4" />
<span>{{ condition.variable }} {{ condition.operator }} {{ condition.value }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Other Step Types -->
<div v-else class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<Icon name="material-symbols:info" class="w-8 h-8 text-blue-500 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">{{ getStepInfo(currentNode).label }}</h3>
<p class="text-gray-600 mb-6">This step type is not yet implemented</p>
<RsButton @click="moveToNextStep" variant="primary">
<Icon name="material-symbols:skip-next" class="mr-2" />
Skip to Next Step
</RsButton>
</div>
<!-- Variable Mapping Debug (only in development) -->
<div v-if="currentNode.type === 'form'" class="bg-gray-100 rounded-lg p-4">
<details>
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Variable Mapping Debug</summary>
<div class="space-y-3 text-xs">
<!-- Input Mappings -->
<div v-if="currentNode.data?.inputMappings?.length">
<p class="font-medium text-gray-700">Input Mappings (Process Form):</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.inputMappings, null, 2) }}</pre>
</div>
<!-- Output Mappings -->
<div v-if="currentNode.data?.outputMappings?.length">
<p class="font-medium text-gray-700">Output Mappings (Form Process):</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.outputMappings, null, 2) }}</pre>
</div>
<!-- Field Conditions -->
<div v-if="currentNode.data?.fieldConditions?.length">
<p class="font-medium text-gray-700">Field Conditions:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.fieldConditions, null, 2) }}</pre>
</div>
<!-- Current Field States -->
<div v-if="Object.keys(fieldStates).length">
<p class="font-medium text-gray-700">Active Field States:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(fieldStates, null, 2) }}</pre>
</div>
<!-- Form Data -->
<div v-if="Object.keys(formData).length">
<p class="font-medium text-gray-700">Current Form Data:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(formData, null, 2) }}</pre>
</div>
</div>
</details>
</div>
<!-- Process Variables Debug (only in development) -->
<div v-if="Object.keys(processVariables).length > 0" class="bg-gray-100 rounded-lg p-4 mt-4">
<details>
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Process Variables (Debug)</summary>
<pre class="text-xs text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(processVariables, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
</style>