feat: add validation state and validate() to proceduralEditorStore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,13 @@ import { temporal } from 'zundo'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import type { Tree, IntakeFormField, IntakeFieldType, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
|
||||
|
||||
export interface ProceduralValidationError {
|
||||
stepId?: string
|
||||
field?: string
|
||||
message: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
const generateId = () => crypto.randomUUID()
|
||||
|
||||
const FIELD_TYPE_PREFIX: Record<IntakeFieldType, string> = {
|
||||
@@ -82,6 +89,7 @@ interface ProceduralEditorState {
|
||||
isDirty: boolean
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
validationErrors: ProceduralValidationError[]
|
||||
|
||||
// Actions - Init
|
||||
initNew: (type?: TreeType) => void
|
||||
@@ -112,6 +120,10 @@ interface ProceduralEditorState {
|
||||
updateField: (index: number, updates: Partial<IntakeFormField>) => void
|
||||
moveField: (fromIndex: number, toIndex: number) => void
|
||||
|
||||
// Actions - Validation
|
||||
validate: () => ProceduralValidationError[]
|
||||
clearValidation: () => void
|
||||
|
||||
// Actions - AI Integration
|
||||
replaceSteps: (steps: ProceduralStep[], intakeForm?: IntakeFormField[]) => void
|
||||
|
||||
@@ -150,6 +162,7 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
validationErrors: [],
|
||||
|
||||
// Init
|
||||
initNew: (type?: TreeType) => {
|
||||
@@ -344,6 +357,120 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
||||
})
|
||||
},
|
||||
|
||||
// Validation
|
||||
validate: () => {
|
||||
const state = get()
|
||||
const errors: ProceduralValidationError[] = []
|
||||
|
||||
// Rule 1: Name required
|
||||
if (!state.name || state.name.trim() === '') {
|
||||
errors.push({ message: 'Flow name is required', severity: 'error' })
|
||||
}
|
||||
|
||||
// Exclude ghost/suggestion steps from validation
|
||||
const realSteps = state.steps.filter((s: ProceduralStep) => !(s as ProceduralStep & { _suggestion?: boolean })._suggestion)
|
||||
|
||||
// Rule 2: Must have at least one step
|
||||
if (realSteps.length === 0) {
|
||||
errors.push({ message: 'Flow must have at least one step', severity: 'error' })
|
||||
} else {
|
||||
// Rule 3: Must have exactly one procedure_end step, and it must be last.
|
||||
// NOTE: procedure_end errors intentionally omit stepId so they render as
|
||||
// non-navigable in ValidationSummary (there is no data-step-id on the end step).
|
||||
const endSteps = realSteps.filter((s: ProceduralStep) => s.type === 'procedure_end')
|
||||
if (endSteps.length === 0) {
|
||||
errors.push({ message: 'Flow must have a completion step at the end', severity: 'error' })
|
||||
} else if (endSteps.length > 1) {
|
||||
errors.push({
|
||||
message: `Flow has ${endSteps.length} completion steps — only one is allowed`,
|
||||
severity: 'error',
|
||||
})
|
||||
} else if (realSteps[realSteps.length - 1]?.type !== 'procedure_end') {
|
||||
errors.push({
|
||||
message: 'Completion step must be the last step',
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
// Rule 4: Each procedure_step must have a non-empty title
|
||||
realSteps
|
||||
.filter((s: ProceduralStep) => s.type === 'procedure_step')
|
||||
.forEach((s: ProceduralStep) => {
|
||||
if (!s.title || s.title.trim() === '') {
|
||||
errors.push({
|
||||
stepId: s.id,
|
||||
field: 'title',
|
||||
message: 'Step is missing a title',
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Rule 5: No duplicate step IDs
|
||||
const seen = new Set<string>()
|
||||
realSteps.forEach((s: ProceduralStep) => {
|
||||
if (seen.has(s.id)) {
|
||||
errors.push({ stepId: s.id, message: `Duplicate step ID: ${s.id}`, severity: 'error' })
|
||||
}
|
||||
seen.add(s.id)
|
||||
})
|
||||
|
||||
// Rule 6: section_header with no procedure_steps following it (warning)
|
||||
realSteps.forEach((s: ProceduralStep, idx: number) => {
|
||||
if (s.type === 'section_header') {
|
||||
const next = realSteps[idx + 1]
|
||||
if (!next || next.type === 'section_header' || next.type === 'procedure_end') {
|
||||
errors.push({
|
||||
stepId: s.id,
|
||||
message: 'Section header has no steps beneath it',
|
||||
severity: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Rule 7: Intake form validation
|
||||
if (state.intakeForm && state.intakeForm.length > 0) {
|
||||
const varNames = new Set<string>()
|
||||
state.intakeForm.forEach((field: IntakeFormField) => {
|
||||
if (!field.variable_name || field.variable_name.trim() === '') {
|
||||
errors.push({
|
||||
field: 'intake_form',
|
||||
message: `Intake field "${field.label || 'unnamed'}" is missing a variable name`,
|
||||
severity: 'error',
|
||||
})
|
||||
} else {
|
||||
if (varNames.has(field.variable_name)) {
|
||||
errors.push({
|
||||
field: 'intake_form',
|
||||
message: `Duplicate intake field variable name: ${field.variable_name}`,
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
varNames.add(field.variable_name)
|
||||
}
|
||||
if (
|
||||
(field.field_type === 'select' || field.field_type === 'multi_select') &&
|
||||
(!field.options || field.options.length === 0)
|
||||
) {
|
||||
errors.push({
|
||||
field: 'intake_form',
|
||||
message: `Intake field "${field.label}" needs at least one option`,
|
||||
severity: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
set((state) => { state.validationErrors = errors })
|
||||
return errors
|
||||
},
|
||||
|
||||
clearValidation: () => {
|
||||
set((state) => { state.validationErrors = [] })
|
||||
},
|
||||
|
||||
// AI Integration
|
||||
replaceSteps: (steps, intakeForm) => {
|
||||
set((state) => {
|
||||
|
||||
Reference in New Issue
Block a user