import { ref, computed, watch } from 'vue' /** * Node Validation Composable * Provides comprehensive validation for process flow nodes and overall process structure */ export function useNodeValidation() { // Validation state const validationResults = ref(new Map()) const isValidating = ref(false) const lastValidationTime = ref(null) // Validation configuration const validationConfig = ref({ processStructure: { requireStartNode: true, requireEndNode: true, allowMultipleStarts: false }, nodeConfiguration: { validateFormSelection: true, validateApiUrls: true, validateGatewayPaths: true, validateScriptContent: true, validateNotificationContent: true }, flowLogic: { checkReachability: true, checkDeadEnds: true } }) // Validation severity levels const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' } /** * Validate individual node configuration */ const validateNode = (node) => { const issues = [] if (!node || !node.type) { issues.push({ type: 'configuration', severity: SEVERITY.ERROR, message: 'Node is missing or has no type', nodeId: node?.id || 'unknown' }) return issues } // Validate based on node type switch (node.type) { case 'form': validateFormNode(node, issues) break case 'api': validateApiNode(node, issues) break case 'gateway': validateGatewayNode(node, issues) break case 'script': validateScriptNode(node, issues) break case 'notification': validateNotificationNode(node, issues) break case 'businessRule': validateBusinessRuleNode(node, issues) break case 'html': validateHtmlNode(node, issues) break case 'subprocess': validateSubprocessNode(node, issues) break case 'start': case 'end': // Start and end nodes have minimal validation requirements validateBasicNode(node, issues) break } return issues } /** * Validate form node configuration */ const validateFormNode = (node, issues) => { if (validationConfig.value.nodeConfiguration.validateFormSelection) { if (!node.data?.formId) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Form node has no form selected', nodeId: node.id, recommendation: 'Select a form for this node to collect user input' }) } if (!node.data?.formName) { issues.push({ type: 'configuration', severity: SEVERITY.INFO, message: 'Form node has no display name', nodeId: node.id, recommendation: 'Add a display name for better process readability' }) } } } /** * Validate API node configuration */ const validateApiNode = (node, issues) => { if (validationConfig.value.nodeConfiguration.validateApiUrls) { if (!node.data?.apiUrl || node.data.apiUrl.trim() === '') { issues.push({ type: 'configuration', severity: SEVERITY.ERROR, message: 'API node has no URL configured', nodeId: node.id, recommendation: 'Configure the API endpoint URL' }) } if (!node.data?.apiMethod) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'API node has no HTTP method specified', nodeId: node.id, recommendation: 'Specify the HTTP method (GET, POST, etc.)' }) } // Validate URL format if present if (node.data?.apiUrl) { try { new URL(node.data.apiUrl) } catch { issues.push({ type: 'configuration', severity: SEVERITY.ERROR, message: 'API node has invalid URL format', nodeId: node.id, recommendation: 'Ensure the URL is properly formatted (e.g., https://example.com/api)' }) } } } } /** * Validate gateway node configuration */ const validateGatewayNode = (node, issues) => { if (validationConfig.value.nodeConfiguration.validateGatewayPaths) { const conditions = node.data?.conditions || [] if (conditions.length === 0) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Gateway node has no conditions defined', nodeId: node.id, recommendation: 'Add conditions to control process flow branching' }) } // Check for incomplete conditions conditions.forEach((condition, index) => { if (!condition.variable || !condition.operator || condition.value === undefined) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: `Gateway condition ${index + 1} is incomplete`, nodeId: node.id, recommendation: 'Complete all condition fields (variable, operator, value)' }) } }) } } /** * Validate script node configuration */ const validateScriptNode = (node, issues) => { if (validationConfig.value.nodeConfiguration.validateScriptContent) { if (!node.data?.code || node.data.code.trim() === '') { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Script node has no code defined', nodeId: node.id, recommendation: 'Add JavaScript code to execute in this node' }) } } } /** * Validate notification node configuration */ const validateNotificationNode = (node, issues) => { if (validationConfig.value.nodeConfiguration.validateNotificationContent) { if (!node.data?.content || node.data.content.trim() === '') { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Notification node has no content', nodeId: node.id, recommendation: 'Add message content for the notification' }) } if (!node.data?.notificationType) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Notification node has no type specified', nodeId: node.id, recommendation: 'Specify notification type (email, SMS, in-app)' }) } } } /** * Validate business rule node configuration */ const validateBusinessRuleNode = (node, issues) => { const rules = node.data?.rules || [] if (rules.length === 0) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Business rule node has no rules defined', nodeId: node.id, recommendation: 'Add business rules to evaluate conditions' }) } rules.forEach((rule, index) => { if (!rule.variable || !rule.operator || rule.value === undefined) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: `Business rule ${index + 1} is incomplete`, nodeId: node.id, recommendation: 'Complete all rule fields (variable, operator, value)' }) } }) } /** * Validate HTML node configuration */ const validateHtmlNode = (node, issues) => { if (!node.data?.htmlContent || node.data.htmlContent.trim() === '') { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'HTML node has no content', nodeId: node.id, recommendation: 'Add HTML content to display in this node' }) } } /** * Validate subprocess node configuration */ const validateSubprocessNode = (node, issues) => { if (!node.data?.subprocessId) { issues.push({ type: 'configuration', severity: SEVERITY.WARNING, message: 'Subprocess node has no subprocess selected', nodeId: node.id, recommendation: 'Select a subprocess to execute' }) } } /** * Validate basic node properties */ const validateBasicNode = (node, issues) => { if (!node.label || node.label.trim() === '') { issues.push({ type: 'configuration', severity: SEVERITY.INFO, message: 'Node has no label', nodeId: node.id, recommendation: 'Add a descriptive label for better readability' }) } } /** * Validate overall process structure */ const validateProcessStructure = (nodes, edges) => { const issues = [] // Check for start nodes const startNodes = nodes.filter(node => node.type === 'start') if (validationConfig.value.processStructure.requireStartNode) { if (startNodes.length === 0) { issues.push({ type: 'structure', severity: SEVERITY.ERROR, message: 'Process has no start node', nodeId: null, recommendation: 'Add a start node to define where the process begins' }) } else if (startNodes.length > 1 && !validationConfig.value.processStructure.allowMultipleStarts) { issues.push({ type: 'structure', severity: SEVERITY.WARNING, message: `Process has ${startNodes.length} start nodes`, nodeId: null, recommendation: 'Consider having only one start node for clearer process flow' }) } } // Check for end nodes const endNodes = nodes.filter(node => node.type === 'end') if (validationConfig.value.processStructure.requireEndNode) { if (endNodes.length === 0) { issues.push({ type: 'structure', severity: SEVERITY.WARNING, message: 'Process has no end node', nodeId: null, recommendation: 'Add an end node to properly conclude the process' }) } } return issues } /** * Find nodes that are unreachable from start nodes */ const findUnreachableNodes = (nodes, edges) => { if (!validationConfig.value.flowLogic.checkReachability) return [] const startNodes = nodes.filter(n => n.type === 'start') if (startNodes.length === 0) return [] const reachable = new Set() const queue = [...startNodes.map(n => n.id)] while (queue.length > 0) { const nodeId = queue.shift() if (reachable.has(nodeId)) continue reachable.add(nodeId) const outgoingEdges = edges.filter(e => e.source === nodeId) queue.push(...outgoingEdges.map(e => e.target)) } const unreachableNodes = nodes.filter(n => !reachable.has(n.id)) return unreachableNodes.map(node => ({ type: 'flow', severity: SEVERITY.WARNING, message: 'Node is unreachable from start', nodeId: node.id, recommendation: 'Connect this node to the main process flow or remove it' })) } /** * Find nodes that cannot reach any end node (dead ends) */ const findDeadEnds = (nodes, edges) => { if (!validationConfig.value.flowLogic.checkDeadEnds) return [] const endNodes = nodes.filter(n => n.type === 'end') if (endNodes.length === 0) return [] const canReachEnd = new Set() const queue = [...endNodes.map(n => n.id)] while (queue.length > 0) { const nodeId = queue.shift() if (canReachEnd.has(nodeId)) continue canReachEnd.add(nodeId) const incomingEdges = edges.filter(e => e.target === nodeId) queue.push(...incomingEdges.map(e => e.source)) } const deadEndNodes = nodes.filter(n => n.type !== 'end' && !canReachEnd.has(n.id)) return deadEndNodes.map(node => ({ type: 'flow', severity: SEVERITY.WARNING, message: 'Node cannot reach any end node', nodeId: node.id, recommendation: 'Ensure this node has a path to an end node' })) } /** * Validate entire process (nodes + edges + structure) */ const validateProcess = (nodes, edges) => { isValidating.value = true const allIssues = [] try { // Clear previous results validationResults.value.clear() // Validate process structure const structureIssues = validateProcessStructure(nodes, edges) allIssues.push(...structureIssues) // Validate individual nodes nodes.forEach(node => { const nodeIssues = validateNode(node) allIssues.push(...nodeIssues) }) // Validate flow logic const unreachableIssues = findUnreachableNodes(nodes, edges) const deadEndIssues = findDeadEnds(nodes, edges) allIssues.push(...unreachableIssues) allIssues.push(...deadEndIssues) // Group issues by node allIssues.forEach(issue => { const nodeId = issue.nodeId || 'process' if (!validationResults.value.has(nodeId)) { validationResults.value.set(nodeId, []) } validationResults.value.get(nodeId).push(issue) }) lastValidationTime.value = new Date() } finally { isValidating.value = false } return allIssues } /** * Get validation results for a specific node */ const getNodeValidation = (nodeId) => { return validationResults.value.get(nodeId) || [] } /** * Get validation icon for a node */ const getValidationIcon = (nodeId) => { const issues = getNodeValidation(nodeId) if (issues.length === 0) return null const hasError = issues.some(issue => issue.severity === SEVERITY.ERROR) const hasWarning = issues.some(issue => issue.severity === SEVERITY.WARNING) if (hasError) return 'material-symbols:error' if (hasWarning) return 'material-symbols:warning' return 'material-symbols:info' } /** * Get validation tooltip content for a node */ const getValidationTooltip = (nodeId) => { const issues = getNodeValidation(nodeId) if (issues.length === 0) return null const lines = issues.map(issue => `• ${issue.message}`) return lines.join('\n') } /** * Check if a node has validation issues */ const hasValidationIssues = (nodeId) => { return getNodeValidation(nodeId).length > 0 } /** * Get overall validation status */ const overallValidationStatus = computed(() => { let hasErrors = false let hasWarnings = false for (const issues of validationResults.value.values()) { for (const issue of issues) { if (issue.severity === SEVERITY.ERROR) hasErrors = true if (issue.severity === SEVERITY.WARNING) hasWarnings = true } } if (hasErrors) return 'error' if (hasWarnings) return 'warning' return 'valid' }) /** * Get validation summary statistics */ const validationSummary = computed(() => { let totalIssues = 0 let errors = 0 let warnings = 0 let infos = 0 for (const issues of validationResults.value.values()) { totalIssues += issues.length for (const issue of issues) { switch (issue.severity) { case SEVERITY.ERROR: errors++; break case SEVERITY.WARNING: warnings++; break case SEVERITY.INFO: infos++; break } } } return { totalIssues, errors, warnings, infos, isValid: totalIssues === 0 } }) /** * Clear validation results */ const clearValidation = () => { validationResults.value.clear() lastValidationTime.value = null } /** * Update validation configuration */ const updateValidationConfig = (newConfig) => { validationConfig.value = { ...validationConfig.value, ...newConfig } } return { // State validationResults, isValidating, lastValidationTime, validationConfig, // Methods validateNode, validateProcess, getNodeValidation, getValidationIcon, getValidationTooltip, hasValidationIssues, clearValidation, updateValidationConfig, // Computed overallValidationStatus, validationSummary, // Constants SEVERITY } }