Files
resolutionflow/frontend/src/store/proceduralEditorStore.ts
2026-03-12 23:32:52 -04:00

507 lines
17 KiB
TypeScript

import { create } from 'zustand'
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> = {
text: 'text',
textarea: 'textarea',
number: 'number',
ip_address: 'ip',
email: 'email',
url: 'url',
select: 'select',
multi_select: 'multiselect',
checkbox: 'checkbox',
password: 'password',
}
function generateVariableName(fieldType: IntakeFieldType, existingFields: IntakeFormField[]): string {
const prefix = FIELD_TYPE_PREFIX[fieldType] || 'field'
const count = existingFields.filter((f) => f.field_type === fieldType).length
return `${prefix}_${count + 1}`
}
function createDefaultStep(index: number): ProceduralStep {
return {
id: generateId(),
type: 'procedure_step',
title: `Step ${index + 1}`,
description: '',
content_type: 'action',
notes_enabled: true,
}
}
function createEndStep(): ProceduralStep {
return {
id: generateId(),
type: 'procedure_end',
title: 'Procedure Complete',
}
}
function createSectionHeader(title: string): ProceduralStep {
return {
id: generateId(),
type: 'section_header',
title,
}
}
function createDefaultField(index: number, existingFields: IntakeFormField[]): IntakeFormField {
return {
variable_name: generateVariableName('text', existingFields),
label: `Field ${index + 1}`,
field_type: 'text',
required: true,
display_order: index + 1,
}
}
interface ProceduralEditorState {
// Tree metadata
treeId: string | null
treeType: TreeType
name: string
description: string
categoryId: string | null
tags: string[]
isPublic: boolean
status: 'draft' | 'published'
// Procedural data
steps: ProceduralStep[]
intakeForm: IntakeFormField[]
// UI state
selectedStepId: string | null
expandedStepId: string | null
isDirty: boolean
isLoading: boolean
isSaving: boolean
validationErrors: ProceduralValidationError[]
// Actions - Init
initNew: (type?: TreeType) => void
loadTree: (tree: Tree) => void
reset: () => void
// Actions - Metadata
setTreeType: (treeType: TreeType) => void
setName: (name: string) => void
setDescription: (description: string) => void
setCategoryId: (categoryId: string | null) => void
setTags: (tags: string[]) => void
setIsPublic: (isPublic: boolean) => void
setStatus: (status: 'draft' | 'published') => void
// Actions - Steps
addStep: (afterIndex?: number) => void
addSectionHeader: (afterIndex?: number) => void
removeStep: (stepId: string) => void
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
moveStep: (fromIndex: number, toIndex: number) => void
setSelectedStepId: (stepId: string | null) => void
setExpandedStepId: (stepId: string | null) => void
// Actions - Intake Form
addField: () => void
removeField: (index: number) => void
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
// Actions - Save
setIsSaving: (saving: boolean) => void
markSaved: () => void
getTreeForSave: () => {
name: string
description: string
tree_type: TreeType
tree_structure: ProceduralTreeStructure
intake_form: IntakeFormField[] | undefined
category_id: string | null
tags: string[]
is_public: boolean
status: 'draft' | 'published'
}
}
export const useProceduralEditorStore = create<ProceduralEditorState>()(
temporal(
immer((set, get) => ({
// Initial state
treeId: null,
treeType: 'procedural' as TreeType,
name: '',
description: '',
categoryId: null,
tags: [],
isPublic: false,
status: 'draft' as const,
steps: [],
intakeForm: [],
selectedStepId: null,
expandedStepId: null,
isDirty: false,
isLoading: false,
isSaving: false,
validationErrors: [],
// Init
initNew: (type?: TreeType) => {
set((state) => {
state.treeId = null
state.treeType = type || 'procedural'
state.name = ''
state.description = ''
state.categoryId = null
state.tags = []
state.isPublic = false
state.status = 'draft'
state.steps = [createDefaultStep(0), createEndStep()]
state.intakeForm = []
state.selectedStepId = null
state.expandedStepId = null
state.isDirty = false
state.isLoading = false
state.isSaving = false
})
},
loadTree: (tree: Tree) => {
const structure = tree.tree_structure as unknown as ProceduralTreeStructure
set((state) => {
state.treeId = tree.id
state.treeType = tree.tree_type
state.name = tree.name
state.description = tree.description || ''
state.categoryId = tree.category_id
state.tags = tree.tags || []
state.isPublic = tree.is_public
state.status = tree.status
state.steps = structure.steps || [createDefaultStep(0), createEndStep()]
state.intakeForm = tree.intake_form || []
state.selectedStepId = null
state.expandedStepId = null
state.isDirty = false
state.isLoading = false
state.isSaving = false
})
},
reset: () => {
set((state) => {
state.treeId = null
state.treeType = 'procedural'
state.name = ''
state.description = ''
state.categoryId = null
state.tags = []
state.isPublic = false
state.status = 'draft'
state.steps = []
state.intakeForm = []
state.selectedStepId = null
state.expandedStepId = null
state.isDirty = false
state.isLoading = false
state.isSaving = false
})
},
// Metadata
setTreeType: (treeType) => set((state) => { state.treeType = treeType }),
setName: (name) => set((state) => { state.name = name; state.isDirty = true }),
setDescription: (description) => set((state) => { state.description = description; state.isDirty = true }),
setCategoryId: (categoryId) => set((state) => { state.categoryId = categoryId; state.isDirty = true }),
setTags: (tags) => set((state) => { state.tags = tags; state.isDirty = true }),
setIsPublic: (isPublic) => set((state) => { state.isPublic = isPublic; state.isDirty = true }),
setStatus: (status) => set((state) => { state.status = status; state.isDirty = true }),
// Steps
addStep: (afterIndex) => {
set((state) => {
// Find the insert position (before the end step)
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
const insertAt = afterIndex !== undefined
? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length)
: (endIndex >= 0 ? endIndex : state.steps.length)
const newStep = createDefaultStep(insertAt)
state.steps.splice(insertAt, 0, newStep)
state.expandedStepId = newStep.id
state.isDirty = true
})
},
addSectionHeader: (afterIndex) => {
set((state) => {
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
const insertAt = afterIndex !== undefined
? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length)
: (endIndex >= 0 ? endIndex : state.steps.length)
const newHeader = createSectionHeader('New Section')
state.steps.splice(insertAt, 0, newHeader)
state.expandedStepId = newHeader.id
state.isDirty = true
})
},
removeStep: (stepId) => {
set((state) => {
const index = state.steps.findIndex((s) => s.id === stepId)
if (index === -1) return
// Don't remove the end step
if (state.steps[index].type === 'procedure_end') return
// Don't remove if it's the only procedure_step (section headers can always be removed)
if (state.steps[index].type === 'procedure_step') {
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
if (stepCount <= 1) return
}
state.steps.splice(index, 1)
if (state.selectedStepId === stepId) state.selectedStepId = null
if (state.expandedStepId === stepId) state.expandedStepId = null
state.isDirty = true
})
},
updateStep: (stepId, updates) => {
set((state) => {
const step = state.steps.find((s) => s.id === stepId)
if (step) {
Object.assign(step, updates)
state.isDirty = true
}
})
},
moveStep: (fromIndex, toIndex) => {
set((state) => {
// Don't move the end step
if (state.steps[fromIndex]?.type === 'procedure_end') return
// Don't move past the end step
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
if (toIndex >= endIndex) return
const [moved] = state.steps.splice(fromIndex, 1)
state.steps.splice(toIndex, 0, moved)
state.isDirty = true
})
},
setSelectedStepId: (stepId) => set((state) => { state.selectedStepId = stepId }),
setExpandedStepId: (stepId) => set((state) => {
state.expandedStepId = state.expandedStepId === stepId ? null : stepId
}),
// Intake Form
addField: () => {
set((state) => {
const newField = createDefaultField(state.intakeForm.length, state.intakeForm)
state.intakeForm.push(newField)
state.isDirty = true
})
},
removeField: (index) => {
set((state) => {
if (index < 0 || index >= state.intakeForm.length) return
state.intakeForm.splice(index, 1)
// Reorder display_order
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.isDirty = true
})
},
updateField: (index, updates) => {
set((state) => {
const field = state.intakeForm[index]
if (field) {
// If field_type changed, auto-generate a new variable name
if (updates.field_type && updates.field_type !== field.field_type) {
const otherFields = state.intakeForm.filter((_, i) => i !== index)
updates.variable_name = generateVariableName(updates.field_type, otherFields)
}
Object.assign(field, updates)
state.isDirty = true
}
})
},
moveField: (fromIndex, toIndex) => {
set((state) => {
const [moved] = state.intakeForm.splice(fromIndex, 1)
state.intakeForm.splice(toIndex, 0, moved)
// Reorder display_order
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.isDirty = true
})
},
// 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) => {
state.steps = steps
if (intakeForm) state.intakeForm = intakeForm
state.isDirty = true
})
},
// Save
setIsSaving: (saving) => set((state) => { state.isSaving = saving }),
markSaved: () => set((state) => { state.isDirty = false }),
getTreeForSave: () => {
const state = get()
return {
name: state.name,
description: state.description,
tree_type: state.treeType,
tree_structure: { steps: state.steps },
intake_form: state.intakeForm.length > 0 ? state.intakeForm : undefined,
category_id: state.categoryId,
tags: state.tags,
is_public: state.isPublic,
status: state.status,
}
},
})),
{ limit: 50 }
)
)
export default useProceduralEditorStore