From b89d0859d4d3be33195a907d8ca7227ac03310dd Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 12 Mar 2026 22:57:58 -0400 Subject: [PATCH] feat: add validation state and validate() to proceduralEditorStore Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/store/proceduralEditorStore.ts | 127 ++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/frontend/src/store/proceduralEditorStore.ts b/frontend/src/store/proceduralEditorStore.ts index ae85559b..e8ac1cfa 100644 --- a/frontend/src/store/proceduralEditorStore.ts +++ b/frontend/src/store/proceduralEditorStore.ts @@ -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 = { @@ -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) => 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()( isDirty: false, isLoading: false, isSaving: false, + validationErrors: [], // Init initNew: (type?: TreeType) => { @@ -344,6 +357,120 @@ export const useProceduralEditorStore = create()( }) }, + // 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() + 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() + 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) => {