corrad-bp/composables/useNodeValidation.js
Md Afiq Iskandar ed00664882 Implement Node Validation System for Process Flow
- Introduced a comprehensive node validation system in the process flow builder, enhancing the user experience by providing real-time feedback on node configurations and process integrity.
- Added `useNodeValidation` composable to manage validation logic, including checks for required nodes, configuration completeness, and flow logic.
- Integrated validation indicators in node components (ApiNode, FormNode, GatewayNode, ScriptNode) to visually represent validation issues.
- Created `ValidationIndicator` and `ValidationTooltip` components for displaying validation statuses and detailed messages.
- Updated `ProcessFlowCanvas.vue` to trigger validation on node and edge changes, ensuring immediate feedback during process design.
- Enhanced `processBuilder` store to manage validation results and summary statistics, allowing for a centralized validation state.
- Documented the validation system implementation plan to guide future enhancements and user training.
2025-07-28 11:35:38 +08:00

589 lines
16 KiB
JavaScript

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
}
}