- 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.
589 lines
16 KiB
JavaScript
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
|
|
}
|
|
} |