Wizard-Form Engine (apps/web)
SkillDev toolsHow the apps/web wizard-form engine works and how to extend it — the generic multi-step form engine (ADR-0015) behind urban & photovoltaic project creation and editing. Use when working on any guided multi-step form, adding a new wizard-form flow or step, touching shared/core/wizard-form, or wiring create/update slices, selectors, or step containers.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Wizard-Form Engine (apps/web) skill
What this skill tells your AI
The instructions your AI receives, as published by incubateur-ademe/benefriches in .claude/skills/wizard-form/SKILL.md and read by ahel’s review.
One generic algorithm drives every guided, multi-step form in the app — urban & photovoltaic, creation & editing. Each form is a thin definition plugged into a shared engine through an injected lens. Decision record:
docs/adr/0015-extract-wizard-form-engine-via-injected-lens.md.Glossary: Wizard form — one guided flow instance (e.g. urban-create). Context — the eager, guaranteed-present data
buildContextderives from the slice (e.g. the site). Answers — the per-step user input accumulated inWizardFormSubState, keyed byStepId.
Read this before reading the files — it saves you from reverse-engineering the abstraction. Paths below are the source of truth; open them when you need detail.
The one idea: engine + definition, joined by a lens
The engine knows one abstract state shape, generic over <StepId, TAnswers>. It knows nothing about
projects, sites, urban, or PV. A consumer connects its concrete form to the engine by supplying a
WizardFormDefinition whose two key functions are the injected lens:
selectForm(state)— locates this instance'sWizardFormSubStateinside the consumer's slice.buildContext(state)— builds the eager context (the site) handlers read.
That lens is why the same engine + same handler registry serve four live instances: urban-create, urban-update, pv-create, pv-update.
Three layers
L1 shared/core/wizard-form/ generic engine — ZERO domain, no slice/reducer
stepHandler.type.ts handler contract: AnswerStepHandler / InfoStepHandler
wizardForm.reducer.ts the shapes: WizardFormSubState, WizardFormDefinition
wizardForm.actions.ts makeWizardFormActionType(prefix, name) — namespacing
helpers/ the algorithm (pure fns): computeStepChanges, applyStepChanges,
navigateToStep, mutateState, readState, stepsSequence
L2 features/create-project/core/project-form/ shared "reconversion-project-on-a-site" domain
site · stakeholders · soils-carbon · local-authorities · project-name
(used by BOTH project types AND by create + update)
L3 features/create-project/core/{urban-project, renewable-energy}/ one per form type
{type}Steps.ts StepId union + AnswersByStep map (this is TAnswers)
step-handlers/** one pure handler per step
stepHandlerRegistry.ts aggregates them (answerStepHandlers + full nav registry)
{type}Form.reducer.ts add{Type}FormCasesToBuilder(builder, actions, definition)
{type}Form.actions.ts create{Type}FormActions(prefix)
{type}Form.selectors.ts create{Type}FormSelectors(prefix)
Views (feature-owned, shared by create + update):
features/create-project/views/{urban-project, photovoltaic-power-station}/** containers
features/create-project/views/{project-form, .../renewable-energy-form}/ context seam
.../stepToComponent.tsx maps StepId -> the lazy-loaded container
The handler contract (stepHandler.type.ts)
Handlers are pure functions of { context, answers } — no store, no dispatch, no create-vs-update
awareness. An AnswerStepHandler implements:
getNextStepId(params, answers)— required; the branching logic.getPreviousStepId?,getDefaultAnswers?— optional nav / pre-fill.getRecomputedStepAnswers?,getDependencyRules?,getShortcut?,updateAnswersMiddleware?— optional cascade hooks. Urban uses them; PV implements none (the "degenerate" path), socomputeStepChangesalways yields empty changes andapplyStepChangesruns unconditionally.
Runtime: one step transition
dispatch stepCompletionRequested({stepId, answers}) (prefixed action)
-> buildContext(state) + computeStepChanges(registry, ctx, steps, payload)
· getDependencyRules -> invalidate downstream answers
· getShortcut -> maybe skip ahead
-> applyStepChanges: writes answers, walks stepsSequence, sets currentStep
-> selector (via Provider, resolved to the mode's prefix) re-derives current step
-> stepToComponent maps stepId -> next container mounts
The reducer wiring lives in add{Type}FormCasesToBuilder and maps exactly four actions onto engine
helpers: stepCompletionRequested, previousStepRequested, nextStepRequested,
stepNavigationRequested. Read renewable-energy/renewableEnergyForm.reducer.ts — it's the smallest
complete example (~120 lines).
Create vs. edit — only the lens differs
Both instances call the same add{Type}FormCasesToBuilder with the same registry. The one
line that changes is selectForm:
// creation (features/create-project/core/renewable-energy/renewableEnergy.reducer.ts)
selectForm: (state) => state.renewableEnergyProject,
// update (features/update-project/core/updateProject.reducer.ts)
selectForm: (state) => state.projectUpdate.renewableEnergyProject,
Editing adds only: a hydration converter (saved project -> answered steps, reconstructing the branch path, e.g. PV's POWER vs SURFACE) and a save-in-place thunk. Everything else is shared.
Adding a new wizard form (e.g. a 3rd consumer)
You never open shared/core/wizard-form/.
- Steps — a
StepIdunion (answer vs info) + theAnswersByStepmap. - Handlers — one pure handler per step; aggregate into a registry.
- Reducer wiring — an
addXxxFormCasesToBuildermapping the 4 actions onto the engine helpers (copy PV's file — it's the degenerate template). - Factories —
createXxxFormActions(prefix),createXxxFormSelectors(prefix). - Slice — nest a
WizardFormSubState-shaped sub-state under your key; call the case-builder with{ registry, selectForm, buildContext, config }. - Views — containers reading a
useXxxForm()hook, a Provider that resolves mode, astepToComponentmap. - Editing too? — reuse 1–6 with a second definition (different
selectForm) + a hydration converter + a save-in-place thunk.
Guardrails (things that have actually gone wrong here)
- No domain in the engine.
shared/core/wizard-form/must contain noproject/urban/sitetokens and import nothing fromfeatures/*. This is now machine-enforced — the oxlint rulearchitecture-boundaries/no-cross-layer-import(shared/** -> features/**) is set toerrorinapps/web/.oxlintrc.json. If you want to reach into a feature from here, the abstraction is wrong. - Update consumes create; never re-mirror.
features/update-projectimports create-project's form-definitions (registry, selectors, containers) via the accepted feature→feature edge and reuses them through the"projectUpdate"prefix lens. Do not copy the selector/container layer into update — a line-for-line mirror was introduced under time pressure (ticket 09) and then deleted again (tickets 10a/10b). Reuse via the lens. - Context is eager, guaranteed present.
buildContextreturns the site — handlers never defend against it being missing. Lazily-fetched, step-scoped data (carbon storage, expected production) stays in the slice, out of the handler context. - Handlers stay pure and mode-blind. No store access, no "am I creating or editing?" — that distinction lives only in the lens.
Reference
- Visual architecture diagram: https://claude.ai/code/artifact/cd38a538-d6d3-48b2-bfaa-888f804eef09
- ADR-0015 (decision + rationale):
docs/adr/0015-extract-wizard-form-engine-via-injected-lens.md
Signals
- GitHub stars
- 45
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
wizard-form- Source
- github.com/incubateur-ademe/benefriches