diff --git a/README.md b/README.md
index 7c301b9..a29a2f0 100644
--- a/README.md
+++ b/README.md
@@ -1,85 +1,88 @@
-# DfE::Wizard — Multi-Step Form Framework for Ruby on Rails
+# DfE::Wizard
-**A powerful, flexible wizard framework for building complex multi-step forms with
-conditional branching, state management, logging and testing and auto generated documentation.**
+A multi-step form framework for Ruby on Rails applications.
-- **Version**: 1.0.0.beta
-
-DfE::Wizard 1.0.0 is currently in **beta**, and is ideal for people who want to experiment with its API,
-patterns, and behaviours before the official 1.0 release.
-
-Please use it in non-critical projects or in development environment first, report bugs, and share
-feedback so the final version can be more stable and developer-friendly.
-
-For history purposes 0.1.x version will be maintained in a separate branch but will be only to fix
-bugs (see 0-stable).
+**Version**: 1.0.0.beta
---
## Table of Contents
-1. [Architecture Overview](#architecture-overview)
+1. [Introduction](#introduction)
2. [Installation](#installation)
-3. [Core Concepts](#core-concepts)
- - [Steps Processor (Flow)](#steps-processor-flow)
- - [Step (Form Object)](#step-form-object)
- - [State Store](#state-store)
- - [Repository](#repository)
- - [Step Operators (Optional)](#step-operators-optional)
- - [Routing (Optional)](#routing-optional)
- - [Logging (Optional)](#logging-optional)
- - [Inspect (Optional)](#inspect-optional)
-4. [Quick Start](#quick-start)
-5. [Usage Guide](#usage-guide)
-5. [Testing](#testing)
-7. [Auto generated documentation](#auto-generated-documentation)
-8. [Wizard Examples](#wizard-examples)
-9. [API Reference](#api-reference)
-10. [Troubleshooting](#troubleshooting)
-11. [Support](#support)
-12. [Contact](#contact)
+3. [Getting Started](#getting-started)
+4. [Core Concepts](#core-concepts)
+5. [Data Flow](#data-flow)
+6. [Navigation](#navigation)
+7. [Conditional Branching](#conditional-branching)
+8. [Check Your Answers](#check-your-answers)
+9. [Step Operators](#step-operators)
+10. [Testing](#testing)
+11. [Auto-generated Documentation](#auto-generated-documentation)
+12. [In Depth: Repositories](#in-depth-repositories)
+13. [In Depth: Steps](#in-depth-steps)
+14. [In Depth: Conditional Edges](#in-depth-conditional-edges)
+15. [In Depth: Route Strategies](#in-depth-route-strategies)
+16. [Advanced: Custom Implementations](#advanced-custom-implementations)
+17. [Examples](#examples)
+18. [Troubleshooting](#troubleshooting)
---
-## Architecture Overview
-
-```
-┌─────────────────────────────────────────────────────────────────────────┐
-│ WIZARD (Orchestrator) │
-│ - Manages step lifecycle │
-│ - Provides high-level navigation API │
-│ - Handles wizard high level orchestration │
-└──────────────────┬─────────────────────────────┬────────────────────────┘
- │ │
- ┌─────────▼────────────┐ ┌─────────▼──────────────┐
- │ STEPS PROCESSOR │ │ STATE MANAGEMENT │
- │ (Flow Definition) │ │ │
- │ │ │ - StateStore │
- │ - Graph structure │ │ - Flat→Nested transform│
- │ - Transitions │ │ - Metadata tracking │
- │ - Branching flow │ │ - Branching predicates │
- │ - Path resolution │ └────────┬───────────────┘
- └──────────┬───────────┘ │
- │ │
- │ ┌──────────────────┴─────────────────┐
- │ │ │
- ┌──────────▼────────▼──────────────────┐ ┌────────────▼──────┐
- │ STATE STORE (Bridge) │ │ REPOSITORY │
- │ │ │ (Persistence) │
- │ - Read/Write delegation │ │ - Redis │
- │ - Attribute validation │ │ - Session/Cache │
- │ - Context binding │ │ - In-Memory │
- │ - Operation execution │ │ - Database │
- └──────────────────────────────────────┘ └───────────────────┘
-
- ┌──────────────────────────────────────┐ ┌───────────────────┐
- │ STEP (Form Object) │ │ OPERATIONS │
- │ │ │ (Pipelines) │
- │ - ActiveModel validations │ │ │
- │ - Attributes with types │ │ - Validate │
- │ - serializable_data for persistence │ │ - Persist │
- │ - Rails form helpers support │ │ - Custom ops │
- └──────────────────────────────────────┘ └───────────────────┘
+## Introduction
+
+DfE::Wizard helps you build multi-step forms (wizards) with:
+
+- Conditional branching based on user answers
+- State persistence across requests
+- Validation at each step
+- "Check your answers" review pages
+- Auto-generated documentation
+
+### When to use this gem
+
+Use DfE::Wizard when you need:
+
+- A form split across multiple pages
+- Different paths based on user input
+- Data saved between steps
+- A review page before final submission
+
+### Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ WIZARD │
+│ (Orchestrator) │
+│ │
+│ Coordinates everything: steps, navigation, validation, state │
+└───────────────┬─────────────────────────────┬───────────────────┘
+ │ │
+ ┌─────────▼──────────┐ ┌─────────▼──────────┐
+ │ STEPS PROCESSOR │ │ STATE STORE │
+ │ (Flow Definition) │ │ (Data + Logic) │
+ │ │ │ │
+ │ Defines which │ │ Holds all data │
+ │ steps exist and │ │ and branching │
+ │ how they connect │ │ predicates │
+ └────────────────────┘ └─────────┬──────────┘
+ │
+ ┌─────────▼──────────┐
+ │ REPOSITORY │
+ │ (Persistence) │
+ │ │
+ │ Session, Redis, │
+ │ Database, Memory │
+ └────────────────────┘
+
+ ┌────────────────────┐
+ │ STEP │
+ │ (Form Object) │
+ │ │
+ │ Attributes, │
+ │ validations, │
+ │ one per screen │
+ └────────────────────┘
```
---
@@ -92,1754 +95,2016 @@ Add to your Gemfile:
gem 'dfe-wizard', require: 'dfe/wizard', github: 'DFE-Digital/dfe-wizard', tag: 'v1.0.0.beta'
```
+Then run:
+
```bash
bundle install
```
---
-## Core Concepts
+## Getting Started
-### Steps Processor (Flow)
+Let's build a registration wizard with conditional branching. Users provide their name, nationality, and email. Non-UK nationals are asked for visa details.
-The **Steps Processor** defines the wizard's structure: which steps exist, how they connect,
-and how to navigate between them.
+```
+┌──────┐ ┌─────────────┐ ┌──────┐ ┌────────┐
+│ name │ ──▶ │ nationality │ ──▶ │ visa │ ──▶ │ email │ ──▶ review
+└──────┘ └─────────────┘ └──────┘ └────────┘
+ │ ▲
+ │ (UK national) │
+ └──────────────────────────────┘
+```
-#### What it does:
+### Step 1: Create the Steps
-- Defines the step graph (linear or conditional branching)
-- Manages transitions and path traversal
-- Calculates next/previous steps based on current state
-- Determines which steps are reachable given user data
+```ruby
+# app/wizards/steps/registration/name_step.rb
+module Steps
+ module Registration
+ class NameStep
+ include DfE::Wizard::Step
-The gem provides two steps processors:
+ attribute :first_name, :string
+ attribute :last_name, :string
-* Linear: very simple wizards. Very rarely usage.
-* Graph: real world wizards.
+ validates :first_name, :last_name, presence: true
-Although you can create your own if the above doesn't solve your problem, as long you respect
-the interface:
+ def self.permitted_params
+ %i[first_name last_name]
+ end
+ end
+ end
+end
-| Method | Short explanation |
-|--------------------|----------------------------------------------------------------------------------------------------------|
-| `next_step` | Returns next step ID from a given or current step, returns `nil` when there is no valid transition. |
-| `previous_step` | Returns the previous step ID by reversing traversal from a given or current step, returns `nil` at root. |
-| `find_step` | Looks up and returns the step class for a given step/node ID, or `nil` if the step is not defined. |
-| `step_definitions` | Returns a hash of all steps `{ step_id => step_class }` defined in the processor. |
-| `path_traversal` | Returns an array, path from root step to a target (or current) step, or `[]` if unreachable. |
-| `metadata` | Returns a rich hash describing structure type, root, steps, etc for documentation and any custom tools. |
+# app/wizards/steps/registration/nationality_step.rb
+module Steps
+ module Registration
+ class NationalityStep
+ include DfE::Wizard::Step
+ NATIONALITIES = %w[british irish european other].freeze
-#### Example: Linear Processor
+ attribute :nationality, :string
-```ruby
-class SimpleWizard
- include DfE::Wizard
+ validates :nationality, presence: true, inclusion: { in: NATIONALITIES }
- def steps_processor
- processor = Linear.draw(self) do |linear|
- linear.add_step :name, NameStep
- linear.add_step :email, EmailStep
- linear.add_step :phone, PhoneStep
- linear.add_step :review, ReviewStep
+ def self.permitted_params
+ %i[nationality]
+ end
end
end
end
-# Usage
-wizard = SimpleWizard.new
-wizard.current_step_name # => :name
-wizard.next_step # => :email
-wizard.previous_step # => nil
-wizard.flow_path # => [:name, :email, :phone, :review]
-wizard.in_flow?(:phone) # => true
-wizard.saved?(:phone) # => false
-wizard.valid?(:phone) # => false
-```
+# app/wizards/steps/registration/visa_step.rb
+module Steps
+ module Registration
+ class VisaStep
+ include DfE::Wizard::Step
+
+ attribute :visa_type, :string
+ attribute :visa_expiry, :date
+
+ validates :visa_type, :visa_expiry, presence: true
+
+ def self.permitted_params
+ %i[visa_type visa_expiry]
+ end
+ end
+ end
+end
-#### Example: Graph Processor (Conditional Branching)
+# app/wizards/steps/registration/email_step.rb
+module Steps
+ module Registration
+ class EmailStep
+ include DfE::Wizard::Step
-Linear is too basic. Usually many wizard has conditional and more complicated scenarios.
+ attribute :email, :string
-For this we will use a graph data structure.
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
-**As an example**, let's implement a simple wizard like a Personal Information Wizard.
+ def self.permitted_params
+ %i[email]
+ end
+ end
+ end
+end
-A wizard that collects personal information with conditional flows
+# app/wizards/steps/registration/review_step.rb
+module Steps
+ module Registration
+ class ReviewStep
+ include DfE::Wizard::Step
-**Steps:**
-1. Name & Date of Birth
-2. Nationality
-3. Right to Work/Study *(conditional)*
-4. Immigration Status *(conditional)*
-5. Review
+ def self.permitted_params
+ []
+ end
+ end
+ end
+end
+```
+
+### Step 2: Create the State Store
-**Conditionals:**
-- UK/Irish nationals skip visa questions
-- Non-UK nationals may need immigration status info if they have right to work
+```ruby
+# app/wizards/state_stores/registration_store.rb
+module StateStores
+ class RegistrationStore
+ include DfE::Wizard::StateStore
-Here a diagram of the wizard that we will implement below:
+ # Branching predicate - decides if visa step is shown
+ def needs_visa?
+ !%w[british irish].include?(nationality)
+ end
-```mermaid
-flowchart TD
- name_and_date_of_birth["Name And Date Of Birth"]
- nationality["Nationality"]
- right_to_work_or_study["Right To Work Or Study"]
- immigration_status["Immigration Status"]
- review["Review"]
- name_and_date_of_birth --> nationality
- immigration_status --> review
- nationality -->|Non-UK/Non-Irish: ✓ yes| right_to_work_or_study
- nationality -->|Non-UK/Non-Irish: ✗ no| review
- right_to_work_or_study -->|Right to work or study?: ✓ yes| immigration_status
- right_to_work_or_study -->|Right to work or study?: ✗ no| review
+ # Helper methods
+ def full_name
+ "#{first_name} #{last_name}"
+ end
+ end
+end
```
-So now we need to define a method called #steps_processor which will contain only the definitions
-of the flow and will be evaluated when calling #next_step, #previous_step, etc.
+### Step 3: Create the Wizard
```ruby
-class PersonalInformationWizard
+# app/wizards/registration_wizard.rb
+class RegistrationWizard
include DfE::Wizard
- delegate :needs_permission_to_work_or_study?,
- :right_to_work_or_study?,
- to: :state_store
-
def steps_processor
- DfE::Wizard::StepsProcessor::Graph.draw(self) do |graph|
- graph.add_node :name_and_date_of_birth, Steps::NameAndDateOfBirth
- graph.add_node :nationality, Steps::Nationality
- graph.add_node :right_to_work_or_study, Steps::RightToWorkOrStudy
- graph.add_node :immigration_status, Steps::ImmigrationStatus
- graph.add_node :review, Steps::Review
+ DfE::Wizard::StepsProcessor::Graph.draw(self, predicate_caller: state_store) do |graph|
+ # Register all steps
+ graph.add_node :name, Steps::Registration::NameStep
+ graph.add_node :nationality, Steps::Registration::NationalityStep
+ graph.add_node :visa, Steps::Registration::VisaStep
+ graph.add_node :email, Steps::Registration::EmailStep
+ graph.add_node :review, Steps::Registration::ReviewStep
+
+ # Set starting step
+ graph.root :name
- graph.root :name_and_date_of_birth
+ # Define flow
+ graph.add_edge from: :name, to: :nationality
- graph.add_edge from: :name_and_date_of_birth, to: :nationality
+ # Conditional: non-UK nationals go to visa, UK nationals skip to email
graph.add_conditional_edge(
from: :nationality,
- when: :needs_permission_to_work_or_study?,
- then: :right_to_work_or_study,
- else: :review,
- )
- graph.add_conditional_edge(
- from: :right_to_work_or_study,
- when: :right_to_work_or_study?,
- then: :immigration_status,
- else: :review,
+ when: :needs_visa?,
+ then: :visa,
+ else: :email
)
- graph.add_edge from: :immigration_status, to: :review
- end
- end
-end
-
-module StateStores
- class PersonalInformation
- include DfE::Wizard::StateStore
- def needs_permission_to_work_or_study?
- # nationalities is an attribute from the Steps::Nationality
- !Array(nationalities).intersect?(%w[british irish])
+ graph.add_edge from: :visa, to: :email
+ graph.add_edge from: :email, to: :review
end
+ end
- def right_to_work_or_study?
- # right_to_work_or_study is an attribute from the Steps::RightToWorkOrStudy
- right_to_work_or_study == 'yes'
- end
+ def route_strategy
+ DfE::Wizard::RouteStrategy::NamedRoutes.new(
+ wizard: self,
+ namespace: 'registration'
+ )
end
end
```
-Now we can define a controller to handle everything now:
+### Step 4: Create the Controller
```ruby
- class WizardController < ApplicationController
- before_action :assign_wizard
+# app/controllers/registration_controller.rb
+class RegistrationController < ApplicationController
+ before_action :set_wizard
- def new; end
+ def show
+ @step = @wizard.current_step
+ end
- def create
- if @wizard.save_current_step
- redirect_to @wizard.next_step_path
- else
- render :new
- end
+ def update
+ if @wizard.save_current_step
+ redirect_to @wizard.next_step_path
+ else
+ @step = @wizard.current_step
+ render :show
end
+ end
- def assign_wizard
- state_store = StateStores::PersonalInformation.new(
- repository: DfE::Wizard::Repository::Session.new(session:, key: :personal_information),
- )
+ private
- @wizard = PersonalInformationWizard.new(
- current_step:,
- current_step_params: params,
- state_store:,
+ def set_wizard
+ state_store = StateStores::RegistrationStore.new(
+ repository: DfE::Wizard::Repository::Session.new(
+ session:,
+ key: :registration
)
- end
+ )
- def current_step
- # define current step here. Could be via params (but needs validation!) or hard coded, etc
- :name_and_date_of_birth # or params[:step]
- end
+ @wizard = RegistrationWizard.new(
+ current_step: params[:step]&.to_sym || :name,
+ current_step_params: params,
+ state_store:
+ )
end
+end
```
-Now we can play with both wizard and the state store:
+### Step 5: Create Routes
+
```ruby
-# Usage with conditional data
-state_store = StateStore::PersonalInformation.new # we will see state stores more below
-wizard = PersonalInformationWizard.new(state_store:)
-wizard.current_step_name # => :name_and_date_of_birth
+# config/routes.rb
+Rails.application.routes.draw do
+ get 'registration/:step', to: 'registration#show', as: :registration
+ patch 'registration/:step', to: 'registration#update'
+end
+```
-wizard.previous_step # => nil
-wizard.next_step # => :nationality
+### Step 6: Create the Views
-wizard.current_step_name = :nationality
+Use a shared layout and step-specific form partials:
-# User selects UK
-state_store.write(nationality: 'British')
-wizard.next_step # => :review (skips work visa)
+```erb
+
+<%= govuk_link_to 'Back', @wizard.previous_step_path(fallback: root_path) %>
-# User selects another nationality
-state_store.write(nationality: 'Brazil')
-wizard.next_step # => :right_to_work_or_study (needs visa info)
-wizard.previous_step # => :name_and_date_of_birth
+
+
+
<%= yield(:page_title) %>
-wizard.current_step_name = :right_to_work_or_study
-wizard.previous_step # => :nationality
-state_store.write(right_to_work_or_study: 'yes')
-wizard.next_step # => :immigration_status
+ <%= form_with model: @wizard.current_step,
+ url: @wizard.current_step_path,
+ scope: @wizard.current_step_name do |form| %>
-state_store.write(right_to_work_or_study: 'no')
-wizard.next_step # => :review (skips immigration_status)
+ <%= form.govuk_error_summary %>
+ <%= render "registration/#{@wizard.current_step_name}/form", form: %>
+ <%= form.govuk_submit 'Continue' %>
+ <% end %>
+
+
+```
-state_store.write(first_name: 'John', last_name: 'Smith', nationality: 'British')
-wizard.current_step_name = :review
-wizard.flow_path # => [:name_and_date_of_birth, :nationality, :review]
-wizard.saved_path # => [:name_and_date_of_birth, :nationality]
-wizard.valid_path # => [:name_and_date_of_birth, :nationality]
+```erb
+
+<% content_for :page_title, 'What is your name?' %>
-wizard.flow_steps # => [#, #, #]
-wizard.saved_steps # => [#, #]
-wizard.valid_steps # => [#, #]
+<%= form.govuk_text_field :first_name %>
+<%= form.govuk_text_field :last_name %>
+```
-wizard.in_flow?(:review) # => true
-wizard.in_flow?(:right_to_work_or_study) # => false
+```erb
+
+<% content_for :page_title, 'What is your nationality?' %>
-wizard.saved?(:right_to_work_or_study) # => false
-wizard.saved?(:nationality) # => true
+<%= form.govuk_collection_radio_buttons :nationality,
+ Steps::Registration::NationalityStep::NATIONALITIES,
+ :to_s, :humanize %>
+```
-wizard.valid_path_to?(:review) # => true
+```erb
+
+<% content_for :page_title, 'Visa details' %>
-state_store.write(first_name: nil) # Assuming Steps::NameAndDateOfBirth has validates presence
-wizard.valid_path_to?(:review) # => false
+<%= form.govuk_text_field :visa_type %>
+<%= form.govuk_date_field :visa_expiry %>
```
-#### Binary conditionals
+---
-Simple transition from one step to the other. **Use case:** Always proceed to next step:
+## Core Concepts
+
+Before building a wizard, understand these five components:
+
+```
+| Component | Purpose | You Create |
+|---------------------|-------------------------------------------------|---------------------------------------|
+| **Repository** | Where data is stored (Session, Redis, DB) | Choose one per wizard or one per page |
+| **State Store** | Holds data + branching logic | Yes, one per wizard |
+| **Step** | One form screen with fields + validations | Yes, one per page |
+| **Steps Processor** | Defines flow between steps | Yes, inside wizard |
+| **Wizard** | Orchestrates everything | Yes, one per wizard |
+```
+
+### 1. Repository
+
+The Repository is the **storage backend**. It persists wizard data between HTTP requests.
-```ruby
-graph.add_edge from: :name_and_date_of_birth, to: :nationality
-graph.add_edge from: :immigration_status, to: :review
+```
+| Repository | Storage | Use Case |
+|---------------|--------------------|-------------------------|
+| `InMemory` | Ruby hash | Testing only |
+| `Session` | Rails session | Simple wizards |
+| `Cache` | Rails.cache | Fast, temporary |
+| `Redis` | Redis server | Production, distributed |
+| `Model` | ActiveRecord model | Save to model each step |
+| `WizardState` | Database (JSONB) | Persistent wizard state |
```
-Simple conditionals. **Use case**: when there is only one decision that could go to 2 steps.
+See [In Depth: Repositories](#in-depth-repositories) for detailed examples and encryption.
```ruby
-# Branch based on nationality
-graph.add_conditional_edge(
- from: :nationality,
- when: :needs_permission_to_work_or_study?,
- then: :right_to_work_or_study,
- else: :review,
-)
+# Testing
+repository = DfE::Wizard::Repository::InMemory.new
-# Branch based on visa status
-graph.add_conditional_edge(
- from: :right_to_work_or_study,
- when: :right_to_work_or_study?,
- then: :immigration_status,
- else: :review,
- label: 'Right to work or study?' # for auto-generated documentation
+# Production - Session
+repository = DfE::Wizard::Repository::Session.new(
+ session: session,
+ key: :my_wizard
)
+
+# Production - Database
+model = WizardState.find_or_create_by(key: :my_wizard, user_id: current_user.id)
+repository = DfE::Wizard::Repository::WizardState.new(model: model)
```
-**Predicates** defined in state store determine path.
+### 2. State Store
+
+The State Store is the **bridge between your wizard and the repository**. It:
+
+- Reads/writes data via the repository
+- Provides attribute access (`state_store.first_name`)
+- Contains **branching predicates** (methods that decide which path to take)
```ruby
-# State store predicates determine branching
module StateStores
- class PersonalInformation
- def needs_permission_to_work_or_study?
- !['british', 'irish'].include?(nationality)
+ class Registration
+ include DfE::Wizard::StateStore
+
+ # Branching predicates - these decide the flow
+ def needs_visa?
+ nationality != 'british'
+ end
+
+ def has_right_to_work?
+ right_to_work == 'yes'
end
- def right_to_work_or_study?
- right_to_work_or_study == 'yes'
+ # Helper methods
+ def full_name
+ "#{first_name} #{last_name}"
end
end
end
```
-### Important gotcha when building predicates
+**Available methods:**
-**Observation**: The wizard calls the flow path at least 1 or 2 times on next step/previous step,
-so **if you are calling an API** you need to cache that somehow!!!!!!!
+```
+| Method | Description |
+|---------------|-------------------------------------------|
+| `repository` | Access the underlying repository |
+| `read` | Read all data from repository |
+| `write(hash)` | Write data to repository |
+| `clear` | Clear all data |
+| `[attribute]` | Dynamic accessors for all step attributes |
+```
-### Multiple Conditional Edges. **Use case**: N-Way Branching.
+**Dynamic attribute accessors**: After wizard initialisation, all step attributes become methods on the state store. If your steps define `first_name`, `email`, and `nationality` attributes, you can call `state_store.first_name`, `state_store.email`, and `state_store.nationality`.
-Problem: Need to route to 3+ different steps based on state
-Use when: More than 2 possible next steps from one step
+### 3. Step
-```ruby
-graph.add_multiple_conditional_edges(
- from: :visa_type_selection,
- branches: [
- { when: :student_visa?, then: :student_visa_details },
- { when: :work_visa?, then: :work_visa_details },
- { when: :family_visa?, then: :family_visa_details },
- { when: :tourist_visa?, then: :tourist_visa_details }
- ],
- default: :other_visa_details
-)
-```
-
-- **Evaluated in order** - First match wins
-- **Default fallback** - When no condition matches
-- **4+ destinations** from one step
+A Step is a **form object representing one screen**. Each step has:
-Example: Visa Type Routing
+- Attributes (form fields)
+- Validations
+- Permitted parameters
```ruby
-# State store predicates
-def student_visa?
- visa_type == 'student'
-end
+module Steps
+ class PersonalDetails
+ include DfE::Wizard::Step
-def work_visa?
- visa_type == 'work'
-end
+ # Form fields
+ attribute :first_name, :string
+ attribute :last_name, :string
+ attribute :date_of_birth, :date
-def family_visa?
- visa_type == 'family'
-end
+ # Validations
+ validates :first_name, :last_name, presence: true
+ validates :date_of_birth, presence: true
-def tourist_visa?
- visa_type == 'tourist'
+ # Strong parameters
+ def self.permitted_params
+ %i[first_name last_name date_of_birth]
+ end
+ end
end
```
-Order Matters!:
+### 4. Steps Processor
-```ruby
-graph.add_multiple_conditional_edges(
- from: :age_verification,
- branches: [
- { when: :under_18?, then: :parental_consent }, # Check FIRST
- { when: :over_65?, then: :senior_discount },
- { when: :adult?, then: :standard_process }, # More general
- ],
-)
-```
+The Steps Processor **defines the flow** - which steps exist and how they connect.
-- **Specific conditions first** - Avoid unreachable branches
-- **More general last** - Catches remaining cases
-- **Order = priority**
+```ruby
+def steps_processor
+ DfE::Wizard::StepsProcessor::Graph.draw(self, predicate_caller: state_store) do |graph|
+ # Register all steps
+ graph.add_node :personal_details, Steps::PersonalDetails
+ graph.add_node :contact, Steps::Contact
+ graph.add_node :nationality, Steps::Nationality
+ graph.add_node :visa, Steps::Visa
+ graph.add_node :review, Steps::Review
-### Custom branching
+ # Set the starting step
+ graph.root :personal_details
-Custom Branching Edge. **Use case:** If you wanna custom and not use DSL:
+ # Define transitions
+ graph.add_edge from: :personal_details, to: :contact
+ graph.add_edge from: :contact, to: :nationality
-**Use when:**
-- Step determines multiple possible destinations
-- Complex business logic decides routing
-- Destination depends on external service
+ graph.add_conditional_edge(
+ from: :nationality,
+ when: :needs_visa?,
+ then: :visa,
+ else: :review
+ )
-```ruby
-graph.add_custom_branching_edge(
- from: :payment_processing,
- conditional: :determine_payment_outcome,
- potential_transitions: [
- { label: 'Success', nodes: [:receipt] },
- { label: 'Partial', nodes: [:payment_plan] },
- { label: 'Failed', nodes: [:payment_retry] },
- { label: 'Fraud', nodes: [:security_check] },
- { label: 'Manual Review', nodes: [:admin_review] }
- ]
-)
+ graph.add_edge from: :visa, to: :review
+ end
+end
```
-- **Method returns step symbol** directly
-- **potential_transitions** - For documentation only
-- **Full control** over routing logic
+**Key point**: `predicate_caller: state_store` tells the graph where to find branching methods (like `needs_visa?`).
+
+### 5. Wizard
+
+The Wizard **orchestrates everything**. It must implement some methods:
```ruby
-def determine_payment_outcome
- payment_result = PaymentService.process(
- amount: state_store.amount,
- card: state_store.card_details
- )
+class RegistrationWizard
+ include DfE::Wizard
- case payment_result.status
- when 'success' then :receipt
- when 'partial' then :payment_plan
- when 'failed' then :payment_retry
- when 'fraud_detected' then :security_check
- else :admin_review
+ def steps_processor
+ # Define your flow (see above)
end
-end
-# Returns step symbol - wizard navigates there
-# Can include ANY Ruby logic
-```
-
-Custom Branching: Real-World Example
+ def route_strategy
+ # Define URL generation (see Optional Features)
+ end
-```ruby
-def route_application
- # Call external API this needs to be cached!!!!!
- eligibility = EligibilityService.check(application_data)
+ def steps_operator
+ # Define steps operatons on #save_current_step (see Optional Features)
+ end
- return :approved if eligibility.score > 80
- return :additional_documents if eligibility.score > 60
- return :interview_required if eligibility.needs_clarification?
- return :rejected if eligibility.score < 40
+ def inspect
+ # Define inspector for development - useful for debug (see Optional Features)
+ DfE::Wizard::Tooling::Inspect.new(wizard: self) if Rails.env.development?
+ end
- :manual_review # Default
+ def logger
+ # Define logger for development - useful for debug (see Optional Features)
+ DfE::Wizard::Logging::Logger.new(Rails.logger) if Rails.env.development?
+ end
end
```
-**External service determines routing** - Full flexibility
+**`inspect`** - Returns detailed debug output when you `puts wizard`:
-### When to Use Each Type
+```
+#
+┌─ STATE LAYERS ─────────────────────────────┐
+│ Current Step: :email
+│ Flow Path: [:name, :nationality, :email, :review]
+│ Saved Path: [:name, :nationality]
+│ Valid Path: [:name, :nationality]
+└────────────────────────────────────────────┘
+┌─ VALIDATION ───────────────────────────────┐
+│ ✓ All steps valid
+└────────────────────────────────────────────┘
+┌─ STATE STORE ──────────────────────────────┐
+│ Raw Steps:
+│ name: { first_name: "Sarah", last_name: "Smith" }
+│ nationality: { nationality: "british" }
+└────────────────────────────────────────────┘
+```
-**Binary Conditional** (`add_conditional_edge`)
-- ✅ Yes/No decisions
-- ✅ Two possible paths
-- ✅ Simple predicate check
+---
-**Multiple Conditional** (`add_multiple_conditional_edges`)
-- ✅ 3+ mutually exclusive paths
-- ✅ Category-based routing
-- ✅ Clear, discrete options
+## Data Flow
+
+Understanding data flow is essential. Here's what happens during a wizard:
+
+### Write Flow (User submits a form)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ 1. HTTP Request │
+│ POST /wizard/personal_details │
+│ params: { personal_details: { first_name: "Sarah" } } │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 2. Controller creates Wizard │
+│ │
+│ @wizard = RegistrationWizard.new( │
+│ current_step: :personal_details, │
+│ current_step_params: params, │
+│ state_store: state_store │
+│ ) │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 3. Wizard extracts step params │
+│ │
+│ Uses Step.permitted_params to filter: │
+│ { first_name: "Sarah" } │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 4. Step validates │
+│ │
+│ step = Steps::PersonalDetails.new(first_name: "Sarah") │
+│ step.valid? # runs ActiveModel validations │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ┌──────────┴──────────┐
+ │ │
+ Valid? Invalid?
+ │ │
+ ▼ ▼
+┌─────────────────────┐ ┌─────────────────────┐
+│ 5a. Save to State │ │ 5b. Return Errors │
+│ │ │ │
+│ state_store.write( │ │ step.errors │
+│ first_name: │ │ # => { last_name: │
+│ "Sarah" │ │ # ["can't be │
+│ ) │ │ # blank"] } │
+└──────────┬──────────┘ └─────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 6. Repository persists │
+│ │
+│ Session/Redis/Database stores: │
+│ { first_name: "Sarah", last_name: nil, ... } │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### Read Flow (Loading a step)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ 1. HTTP Request │
+│ GET /wizard/contact │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 2. Repository reads persisted data │
+│ │
+│ { first_name: "Sarah", email: "sarah@example.com" } │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 3. State Store provides data │
+│ │
+│ state_store.first_name # => "Sarah" │
+│ state_store.email # => "sarah@example.com" │
+└────────────────────────┬─────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ 4. Wizard hydrates Step │
+│ │
+│ step = wizard.current_step │
+│ # Step is pre-filled with saved data │
+│ step.email # => "sarah@example.com" │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### Key Points
+
+1. **Data is stored flat** - All attributes from all steps go into one hash
+2. **Repository handles persistence** - You choose where (session, Redis, DB)
+3. **State Store adds behaviour** - Predicates, helpers, attribute access
+4. **Steps are form objects** - They validate but don't persist directly
-**Custom Branching** (`add_custom_branching_edge`)
-- ✅ Complex calculation needed
-- ✅ External service determines path
-- ✅ Dynamic destination logic
+---
+## Navigation
-Comparison Example:
+### Step Navigation Methods
-```ruby
-# Binary: UK vs Non-UK
-graph.add_conditional_edge(
- from: :nationality,
- when: :uk_national?,
- then: :employment,
- else: :visa_check
-)
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `current_step_name` | Symbol | Current step ID (`:email`) |
+| `current_step` | Step object | Hydrated step with data |
+| `next_step` | Symbol or nil | Next step ID |
+| `previous_step` | Symbol or nil | Previous step ID |
+| `root_step` | Symbol | First step ID |
-# Multiple: Visa categories
-graph.add_multiple_conditional_edges(
- from: :visa_type,
- branches: [
- { when: :student?, then: :student_path },
- { when: :work?, then: :work_path },
- { when: :family?, then: :family_path }
- ]
-)
+### Path Navigation Methods
-# Custom: Dynamic API result
-graph.add_custom_branching_edge(
- from: :eligibility,
- conditional: :check_external_api,
- potential_transitions: [...]
-)
-```
+| Method | Returns | Description |
+|--------|---------|-------------|
+| `current_step_path` | String | URL for current step |
+| `next_step_path` | String or nil | URL for next step |
+| `previous_step_path` | String or nil | URL for previous step |
-### Dynamic root
+### Back Links
-A dynamic root is configured via graph.conditional_root, which receives a block and a list of potential roots, for example:
+Use `previous_step_path` for GOV.UK back links:
-```ruby
-graph.conditional_root(potential_root: %i[add_a_level_to_a_list what_a_level_is_required]) do |state_store|
- if state_store.any_a_levels?
- :add_a_level_to_a_list
- else
- :what_a_level_is_required
- end
-end
+```erb
+<%= govuk_back_link href: @wizard.previous_step_path(fallback: root_path) %>
```
-At runtime, the block inspects the state_store and returns one of the allowed root step IDs,
-so users with existing A-levels start at :add_a_level_to_a_list while others start at :what_a_level_is_required.
+The `fallback:` option is used on the first step when there is no previous step.
-Potential root is required for documentation!
+### Flow Analysis Methods
-Why potential_root is required:
+The wizard tracks three different "paths":
-* `potential_root:` declares the set of valid root candidates and is mandatory for conditional
-* roots so the graph can still be fully documented, visualised, and validated at design time.
+```ruby
+# Given a wizard where user filled name and email, but email is invalid:
-### Skip steps
+wizard.flow_path
+# => [:name, :email, :review]
+# All steps the user COULD visit based on their answers
-**Skip steps**. Sometimes you need to skip a step because of a feature flag, existing data, etc.
+wizard.saved_path
+# => [:name, :email]
+# Steps that have ANY data saved
-A skipped step is a node that exists in the graph but is dynamically omitted from the user’s path
-when a skip_when predicate evaluates to true, for example
-`skip_when: :single_accredited_provider_or_self_accredited?` on `:accredited_provider`.
+wizard.valid_path
+# => [:name]
+# Steps that have VALID data (stops at first invalid)
-Conceptually, the node stays in the model (so visualisations, docs, tests, and future changes can
-still reason about it), but navigation treats it as if it were already completed and jumps over it.
+wizard.valid_path_to?(:review)
+# => false (email is invalid, can't reach review)
-example:
+wizard.in_flow?(:review)
+# => true (review is reachable based on answers)
+```
-```ruby
-graph.add_node(:name, Name)
-graph.add_node(:age, Age)
-graph.add_node(:visa, Visa)
-graph.add_node(:some_experimental_feature, SomeExperimentalFeature, skip_when: :experimental_feature_inactive?)
-graph.add_node(:schools, Schools, skip_when: :single_school?) # e.g choose single school for user
-graph.add_node(:review, Review)
+### Understanding the Three Paths
-graph.add_edge(from: :name, to: :age)
-graph.add_edge(from: :age, to: :visa)
-graph.add_edge(from: :visa, to: :some_experimental_feature)
-graph.add_edge(from: :some_experimental_feature, to: :schools)
-graph.add_edge(from: :schools, to: :review)
+| Path | Question it answers |
+|------|---------------------|
+| `flow_path` | "What steps would the user visit?" |
+| `saved_path` | "What steps have data?" |
+| `valid_path` | "What steps are complete and valid?" |
-## on state store
-def single_school?
- # logic that returns true or false
-end
+Use cases:
+
+```ruby
+# Progress indicator
+completed = wizard.valid_path.length
+total = wizard.flow_path.length
+progress = (completed.to_f / total * 100).round
+
+# Guard against URL manipulation
+before_action :validate_step_access
-def experimental_feature_inactive?
- # logic that returns true or false
+def validate_step_access
+ unless wizard.valid_path_to?(params[:step].to_sym)
+ redirect_to wizard.current_step_path
+ end
end
```
-Why skipping is important:
-
-* Without explicit skipping, a conditional edge on step A must decide not only whether to go to B,
-but also where to go after B if B is not needed, leading to logic like:
- * “if X then go to B else go to C, but if Y also skip straight to D”, which quickly gets messy as you add “next next next” possibilities.
-* With skipping, conditional edges remain local: A still points to B, B still points to C, and only B knows when it should be removed from the path, keeping branching logic simpler, more testable, and easier to extend over time.
+---
-Be careful with this feature, use this feature wisely!
+## Conditional Branching
-### Flow vs Saved vs Valid
+Most wizards need different paths based on user input.
-The gem provide methods for flow control:
+### Simple Conditional (Yes/No)
-* `flow_path` shows the theoretical route a user would take through the wizard given their answers.
-* `saved_path` shows the steps that already have data stored.
-* `valid_path` shows the subset of those steps whose data passes validation.
+```ruby
+def steps_processor
+ DfE::Wizard::StepsProcessor::Graph.draw(self, predicate_caller: state_store) do |graph|
+ graph.add_node :nationality, Steps::Nationality
+ graph.add_node :visa, Steps::Visa
+ graph.add_node :review, Steps::Review
-So using all three together tells you:
+ graph.root :nationality
-1. `flow_path` where the user could go
-2. `saved_path` where they have been
-3. `valid_path` which parts of their journey are currently valid
+ # If needs_visa? is true → go to :visa
+ # If needs_visa? is false → go to :review
+ graph.add_conditional_edge(
+ from: :nationality,
+ when: :needs_visa?,
+ then: :visa,
+ else: :review
+ )
-Use cases:
+ graph.add_edge from: :visa, to: :review
+ end
+end
+```
-1. If user tries to jump steps through URL manipulation
-2. Progress bars
-3. Percentage completion
+The predicate `needs_visa?` is defined in your **State Store**:
```ruby
-# Theoretical path (based on state - only evaluate branching)
-wizard.flow_path
-# => [:name, :nationality, :right_to_work_or_study, :immigration, :review]
-wizard.flow_path(:nationality) # flow path from root to a target
-# => [:name, :nationality]
-wizard.flow_path(:unreachable_step_by_current_flow) # => []
+class RegistrationStore
+ include DfE::Wizard::StateStore
-wizard.flow_steps # => [<#Name>, ...]
-wizard.in_flow?(:nationality) # => true
+ def needs_visa?
+ !%w[british irish].include?(nationality)
+ end
+end
+```
-# Steps with any data
-wizard.saved_path
-# => [:name, :nationality, :right_to_work_or_study]
-wizard.saved_steps # => [<#Name>, ...]
-wizard.saved?(:immigration_status) # => false
+### Multiple Conditions (3+ paths)
-# Steps with VALID data
-wizard.valid_path
-# => [:name, :nationality]
-# (right_to_work_or_study has validation errors)
-wizard.valid_steps # => [<#Name>, ...]
-wizard.valid_path_to?(:review) # => false # right_to_work_or_study has errors
-wizard.valid_path_to?(:immigration_status) # => false # path is unreachable based on answers
+```ruby
+graph.add_multiple_conditional_edges(
+ from: :visa_type,
+ branches: [
+ { when: :student_visa?, then: :student_details },
+ { when: :work_visa?, then: :employer_details },
+ { when: :family_visa?, then: :family_details }
+ ],
+ default: :other_visa
+)
```
-**Use together** for complete picture:
-- `flow_path` - Where going, theoretical path based on current state & only evaluates branching!
-- `saved_path` - Where been, what the current state holds data for each step
-- `valid_path` - What's confirmed, steps that are on the flow and are valid!
+- Evaluated **in order** - first match wins
+- Always provide a `default`
-With this we can create logic for:
+### Custom Branching
-- All steps leading to target are valid
-- Guards against URL manipulation
-- Enforces step-by-step completion
+For complex logic that doesn't fit the DSL:
-**Usage:**
+```ruby
+graph.add_custom_branching_edge(
+ from: :eligibility_check,
+ conditional: :determine_route,
+ potential_transitions: [
+ { label: 'Eligible', nodes: [:application] },
+ { label: 'Not eligible', nodes: [:rejection] },
+ { label: 'Needs review', nodes: [:manual_review] }
+ ]
+)
+```
+
+The method returns a step symbol directly:
```ruby
-before_action :validate_access
+# In your wizard (for custom branching only)
+def determine_route
+ result = EligibilityService.check(state_store.read)
-def validate_access
- redirect_to wizard_start_path unless wizard.valid_path_to?(params[:step])
+ case result.status
+ when :eligible then :application
+ when :ineligible then :rejection
+ else :manual_review
+ end
end
```
----
-
-### Step (Form Object)
+### Dynamic Root Step
-A **Step** is a standalone form object representing one screen of the wizard. Each step encapsulates:
-- Input fields (attributes)
-- Validation rules
-- Parameter whitelisting
-- Serialization for storage
-
-#### Creating a Step
+Start at different steps based on conditions:
```ruby
-module Steps
- class PersonalDetails
- include DfE::Wizard::Step
-
- # Define fields with types
- attribute :first_name, :string
- attribute :last_name, :string
- attribute :date_of_birth, :date
- attribute :nationality, :string
+graph.conditional_root(potential_root: %i[returning_user new_user]) do |state_store|
+ state_store.has_account? ? :returning_user : :new_user
+end
+```
- # Validation rules
- validates :first_name, :last_name, presence: true
- validates :date_of_birth, presence: true
- validate :some_age_validation
+### Performance: Memoize Expensive Predicates
- # Strong parameters allowlist
- def self.permitted_params
- %i[first_name last_name date_of_birth nationality]
- end
+**Important**: Predicates may be called multiple times per request. Methods like `flow_path`, `previous_step`, and `valid_path` traverse the graph and evaluate predicates along the way.
- private
+If your predicate calls an external API, **memoize the result**:
- def some_age_validation
- # ...
- end
- end
+```ruby
+# BAD - API called multiple times per request
+def eligible?
+ EligibilityService.check(trn).eligible? # Called 3+ times!
end
-```
-#### Using Steps in the Wizard
-
-```ruby
-# Get current step
-current_step = wizard.current_step
-current_step.first_name # => "John"
+# GOOD - Memoize the result
+def eligible?
+ @eligible ||= EligibilityService.check(trn).eligible?
+end
-# Validate step
-if current_step.valid?
- wizard.save_current_step
-else
- current_step.errors[:first_name] # => ["can't be blank"]
+# GOOD - Memoize the whole response if you need multiple values
+def eligibility_result
+ @eligibility_result ||= EligibilityService.check(trn)
end
-# or in the controller you could do:
-@wizard = PersonalInformationWizard.new(
- current_step: :name_and_date_of_birth,
- current_step_params: params,
- state_store:,
-)
+def eligible?
+ eligibility_result.eligible?
+end
-if @wizard.save_current_step
- redirect_to @wizard.next_step_path
-else
- render :new
-end
-
-# Serialize for storage
-current_step.serializable_data
-# => { first_name: "John", last_name: "Doe", date_of_birth: }
-
-# Get a specific step (hydrated from state)
-personal_details = wizard.step(:personal_details)
-personal_details.first_name # => "John"
-```
-
-**Step Data Flow:**
-
-```
-┌─────────────────────────────────────────────┐
-│ HTTP Request (Form Submission) │
-│ { personal_details: { first_name: ... } } │
-└──────────────────┬──────────────────────────┘
- │
- ┌──────────▼──────────────┐
- │ Strong Parameters │
- │ (permitted_params) │
- └──────────┬──────────────┘
- │
- ┌────────────▼─────────────────┐
- │ Step Instantiation │
- │ steps.PersonalDetails.new │
- └────────────┬────────────────┘
- │
- ┌────────────▼────────────────┐
- │ Validation │
- │ step.valid? │
- └────────────┬────────────────┘
- │
- YES ─────────┐
- │ │
- NO │
- │ │
- ┌─────────▼───┐ ┌──────▼────────────┐
- │ Errors │ │ Serialization │
- │ Displayed │ │ { first_name:... }
- │ to User │ │ │
- └─────────────┘ └──────┬───────────┘
- │
- ┌────────▼────────┐
- │ State Store │
- │ .write(data) │
- └────────┬────────┘
- │
- ┌────────▼────────┐
- │ Repository │
- │ (Persist) │
- └─────────────────┘
+def eligibility_reason
+ eligibility_result.reason
+end
```
----
+This applies to any expensive operation: database queries, API calls, or complex calculations.
-### State Store
+---
-The **State Store** bridges the wizard and repository. It:
-- Provides dynamic attribute access from the steps
-- Delegates reads/writes to the repository
-- Binds context (wizard instance)
+## Check Your Answers
-#### State Store Features
+The gem provides `ReviewPresenter` to build "Check your answers" pages.
-Assuming we have a step:
+### Creating a Review Presenter
```ruby
-module Steps
- class NameAndDateOfBirth
- include DfE::Wizard::Step
+# app/presenters/registration_review.rb
+class RegistrationReview
+ include DfE::Wizard::ReviewPresenter
- attribute :first_name, :string
- attribute :last_name, :string
- attribute :date_of_birth, :date
+ def personal_details
+ [
+ row_for(:name, :first_name),
+ row_for(:name, :last_name),
+ row_for(:email, :email)
+ ]
+ end
+
+ def visa_details
+ [
+ row_for(:nationality, :nationality),
+ row_for(:visa, :visa_type, label: 'Type of visa')
+ ]
+ end
+
+ # Override to format specific values
+ def format_value(attribute, value)
+ case attribute
+ when :date_of_birth
+ value&.strftime('%d %B %Y')
+ else
+ value
+ end
end
end
```
-And a simple state store:
-```ruby
-class PersonalInformation
- include DfE::Wizard::StateStore
+### Using the Presenter
- def full_name
- "#{first_name} #{last_name}" # first_name and last_name is available from the steps
- end
+In your controller:
- def date_of_birth?
- date_of_birth.present? # date_of_birth too
+```ruby
+def show
+ if @wizard.current_step_name == :review
+ @review = RegistrationReview.new(@wizard)
end
+ @step = @wizard.current_step
end
```
-The attributes `:first_name, :last_name, :date_of_birth` are available in state store:
+In your view:
-```ruby
-state_store = PersonalInformation.new(
- # memory is default but not recommended in production environment.
- # See repositories section below.
- repository: DfE::Wizard::Repository::InMemory.new,
-)
+```erb
+Check your answers
+
+Personal details
+
+ <% @review.personal_details.each do |row| %>
+
+
- <%= row.label %>
+ - <%= row.formatted_value %>
+ -
+ <%= link_to 'Change', row.change_path %>
+
+
+ <% end %>
+
+```
-# Dynamic attribute access (uses method_missing)
-state_store.write(first_name: "John")
-state_store.first_name # => "John"
+### Row Object
-# Read all state from previous answers
-state_store.read
-# => { first_name: "John", email: "john@example.com", confirmed: true }
+Each row provides:
-# Write (merges with existing)
-state_store.write(email: "jane@example.com")
-# State is now: { first_name: "John", email: "jane@example.com", confirmed: true }
+| Method | Description |
+|--------|-------------|
+| `label` | Human-readable label (from I18n or custom) |
+| `value` | Raw value |
+| `formatted_value` | Formatted via `format_value` |
+| `change_path` | URL with `return_to_review` param |
+| `step_id` | The step ID |
+| `attribute` | The attribute name |
-# Check attribute exists
-state_store.respond_to?(:first_name) # => true
+### Return to Review Flow
-# Clear all
-state_store.clear
-```
----
+When users click "Change", they go to the step with `?return_to_review=step_id`. After saving, they return to the review page.
-### Repository
+Implement this with callbacks:
-The **Repository** is the persistence layer. It stores wizard state and provides a standard interface for reading/writing data.
+```ruby
+def steps_processor
+ DfE::Wizard::StepsProcessor::Graph.draw(self, predicate_caller: state_store) do |graph|
+ # ... steps ...
-The repositories provided in the gem allow transient data or permanent data depending which
-one you use. Use wisely!
+ graph.before_next_step(:handle_return_to_review)
+ graph.before_previous_step(:handle_back_to_review)
+ end
+end
-#### Repository Pattern
+def handle_return_to_review
+ return unless current_step_params[:return_to_review].present?
+ return unless valid_path_to?(:review)
-All repositories inherit from `DfE::Wizard::Repository::Base` and implement:
+ :review
+end
-```ruby
-class CustomRepository < DfE::Wizard::Repository::Base
- def read_data
- # Return flat hash from storage
- end
+def handle_back_to_review
+ return unless current_step_params[:return_to_review].to_s == current_step_name.to_s
+ return unless valid_path_to?(:review)
- def write_data(hash)
- # Persist flat hash to storage
- end
+ :review
end
```
-#### Available Repositories
+---
+
+## Optional Features
+
+### Route Strategy
-| Repository | Storage | Use Case | Persistence |
-|-----------------|-----------------------|---------------------------------|---------------|
-| **InMemory** | Ruby hash | Testing, single-request wizards | None |
-| **Session** | HTTP session | Simple wizards | Rails session |
-| **Cache** | Rails.cache | Fast, transient state | Cache TTL |
-| **Redis** | Redis server | Production, multi-process | Custom TTL |
-| **Model** | Database (a table) | Persistent model on every step | Indefinite |
-| **WizardState** | Database (JSONB/JSON) | Persistent wizards state | Indefinite |
+Route strategies translate step symbols (`:name`, `:email`) into URLs (`/registration/name`). The wizard uses this for `next_step_path`, `previous_step_path`, and `current_step_path`.
-Observation: **The gem doesn't manage the data on any of the above**. This is reponsibility of the developer
-implementing the wizard like handling Session cookie overflow (or move to active record sessions),
-cache expiration, cache size, redis eviction policy, wizard state data being clear every day, etc).
+You must implement `route_strategy` in your wizard. Three strategies are available:
-For wizard state you need to create the table yourself. Create a database migration for
-persistent state (if using WizardState repository):
+**NamedRoutes** (recommended for simple wizards):
+
+Uses Rails named routes. The `namespace` becomes the route helper prefix.
```ruby
-# db/migrate/xxxxx_create_wizard_states.rb
-# Postgres example:
-create_table :wizard_states do |t|
- t.jsonb :state, default: {}, null: false
- t.string :key, null: false
- t.string :state_key
- t.boolean :encrypted, default: false
- t.timestamps
+def route_strategy
+ DfE::Wizard::RouteStrategy::NamedRoutes.new(
+ wizard: self,
+ namespace: 'registration'
+ )
end
-add_index :wizard_states, [:key, :state_key], unique: true
+# Uses: registration_path(:name) → /registration/name
+# Uses: registration_path(:email) → /registration/email
```
-#### Using Repositories
+Requires matching routes:
```ruby
-# In-Memory (testing)
-repository = DfE::Wizard::Repository::InMemory.new
-repository.write({ name: "John" })
-repository.read # => { name: "John" }
+# config/routes.rb
+get 'registration/:step', to: 'registration#show', as: :registration
+patch 'registration/:step', to: 'registration#update'
+```
-# Session (simple web forms)
-repository = DfE::Wizard::Repository::Session.new(session: session, key: :my_wizard)
-repository = DfE::Wizard::Repository::Session.new(
- session: session,
- key: :my_wizard,
- state_key: '123...' # multiple instance of a wizard in different tabs for example
-)
-# Data stored in Rails session automatically
+**ConfigurableRoutes** (for complex URL patterns):
-# Database (persistent)
-model = WizardState.find_or_create_by(key: :application, state_key: 'some-important-value')
-repository = DfE::Wizard::Repository::WizardState.new(model:)
-repository.write({ name: "John" })
-repository.read # => { name: "John" }
-model.reload.state # => { name: "John" }
+```ruby
+def route_strategy
+ DfE::Wizard::RouteStrategy::ConfigurableRoutes.new(
+ wizard: self,
+ namespace: 'courses'
+ ) do |config|
+ config.default_path_arguments = {
+ provider_code: state_store.provider_code,
+ course_code: state_store.course_code
+ }
-# Redis (distributed)
-repository = DfE::Wizard::Repository::Redis.new(redis: Redis.new, expiration: 24.hours)
+ config.map_step :review, to: ->(wizard, opts, helpers) {
+ helpers.course_review_path(**opts)
+ }
+ end
+end
+```
-# Cache
-repository = DfE::Wizard::Repository::Cache.new(cache: Rails.cache, expiration: 24.hours)
+**DynamicRoutes** (for multi-instance wizards):
+```ruby
+def route_strategy
+ DfE::Wizard::RouteStrategy::DynamicRoutes.new(
+ state_store: state_store,
+ path_builder: ->(step_id, state_store, helpers, opts) {
+ helpers.wizard_step_path(
+ state_key: state_store.state_key,
+ step: step_id,
+ **opts
+ )
+ }
+ )
+end
```
-Choose wisely! Or create your own if you need more custom storage.
-#### Encryption
+### Step Operators
-* The gem only calls two methods for encryption and it doesn't handle encryption for you.
-* You can pass any object that responds to #encrypt_and_sign and #decrypt_and_verify.
-* ActiveSupport::MessageEncryptor can be used but advance cases you need to write your own encryptor!
-* All repositories accepts `encrypted: true` and ` encryptor:
- ActiveSupport::MessageEncryptor.new(some_key)` for example.
+Customise what happens when a step is saved. By default, each step runs: `Validate → Persist`.
```ruby
-# With Encryption
-repository = DfE::Wizard::Repository::WizardState.new(
- model:,
- encrypted: true,
- encryptor: ActiveSupport::MessageEncryptor.new(some_key)
-)
-```
+def steps_operator
+ DfE::Wizard::StepsOperator::Builder.draw(wizard: self, callable: state_store) do |b|
+ # use: replaces the entire pipeline
+ b.on_step(:payment, use: [Validate, ProcessPayment, Persist])
-#### Repository Data Flow
+ # add: appends to the default pipeline (Validate → Persist → YourOperator)
+ b.on_step(:notification, add: [SendConfirmationEmail])
-```
-┌────────────────────────────────────────────┐
-│ Flat Hash (Internal Representation) │
-│ { first_name: "John", email: "j@x.com" } │
-└───────────────────┬──────────────────────┘
- │
- ┌──────────────┴──────────────┐
- │ │
- │ (Encryption if enabled) │
- │ encrypt_hash() │
- │ │
- └──────────────┬──────────────┘
- │
- ┌──────────────▼──────────────┐
- │ Storage Backend │
- │ - Database JSONB │
- │ - Redis │
- │ - Session │
- │ - Cache │
- └────────────────────────────┘
+ # use: [] skips all operations (useful for review steps)
+ b.on_step(:review, use: [])
+ end
+end
```
----
+**`use:` - Replace the pipeline**
-### Step Operators (Optional)
+Completely replaces the default Validate → Persist with your operators:
-**Step Operators** allow you to attach custom operations to specific steps (validation,
-persistence, deletions, API calls, email, in service notifications, etc.).
+```ruby
+# Only validate, don't persist (dry run)
+b.on_step(:preview, use: [Validate])
-#### Default Pipeline
+# Custom order: validate, process payment, then persist
+b.on_step(:payment, use: [Validate, ProcessPayment, Persist])
-**By default, the wizard runs two operations per step**: Validate and Persist.
+# Skip everything (review pages that don't need saving)
+b.on_step(:check_answers, use: [])
+```
-You can see each implementation on the gem:
+**`add:` - Extend the pipeline**
-* [Validate](lib/dfe/wizard/operations/validate.rb) operation
-* [Persist](lib/dfe/wizard/operations/persist.rb) operation
+Adds operators after the default Validate → Persist:
-```
-Step Submission
- │
- ▼
-[Validate] ──→ Check validation rules on the Step object
- │
- ├─ INVALID ──→ Return to form with errors
- │
- └─ VALID ──→
- │
- ▼
- [Persist] ──→ Save to state store
- │
- ▼
- Navigation (next/previous)
+```ruby
+# Validate → Persist → SendConfirmationEmail
+b.on_step(:final_step, add: [SendConfirmationEmail])
+
+# Validate → Persist → NotifySlack → UpdateAnalytics
+b.on_step(:submission, add: [NotifySlack, UpdateAnalytics])
```
-#### Custom Operations
+**Custom operation class:**
```ruby
-# Define a custom operation
class ProcessPayment
- def initialize(repository:, step:)
+ def initialize(repository:, step:, callable:)
@repository = repository
@step = step
+ @callable = callable # Your state store
end
def execute
- # Your logic here
- result = PaymentGateway.charge(@step.amount)
+ result = PaymentGateway.charge(
+ amount: @step.amount,
+ email: @callable.email
+ )
if result.success?
{ success: true, transaction_id: result.id }
else
- { success: false, errors: { amount: result.error } }
+ { success: false, errors: { payment: [result.error] } }
end
end
end
+```
-# Configure in wizard
-class PaymentWizard
- include DfE::Wizard
+Operations must return a hash with `:success` key. If `success: false`, include `:errors` to display validation messages.
- def steps_operator
- DfE::Wizard::StepsOperator::Builder.draw(wizard: self, callable: state_store) do |b|
- b.on_step(:remove_recipient, use: [RemoveRecipient])
+### Logger (Recommended)
+
+Enable detailed logging for debugging navigation and branching decisions:
+
+```ruby
+def logger
+ DfE::Wizard::Logging::Logger.new(Rails.logger) if Rails.env.development?
+end
+```
- # use: option replace default pipeline for :payment step
- b.on_step(:payment, use: [Validate, ProcessPayment, Persist])
+Logs include step transitions, predicate evaluations, and path calculations - invaluable for debugging complex flows.
- # Add extra operation to default pipeline (default is validate and persist)
- b.on_step(:notification, add: [SendEmail])
+Exclude noisy categories if needed:
- # Skip all operations for :review
- b.on_step(:review, use: [])
- end
- end
+```ruby
+DfE::Wizard::Logging::Logger.new(Rails.logger).exclude(%i[routing validation])
+```
+
+### Inspect
+
+Debug wizard state in development:
+
+```ruby
+def inspect
+ DfE::Wizard::Tooling::Inspect.new(wizard: self) if Rails.env.development?
+end
+```
+
+```ruby
+puts wizard.inspect
+# ┌─ Wizard: RegistrationWizard
+# ├─ Current Step: :email
+# ├─ Flow Path: [:name, :email, :review]
+# ├─ Saved Steps: [:name]
+# └─ State: { first_name: "Sarah", last_name: "Smith" }
+```
+
+---
+
+## Testing
+
+Include the RSpec matchers:
+
+```ruby
+# spec/rails_helper.rb
+RSpec.configure do |config|
+ config.include DfE::Wizard::Test::RSpecMatchers
end
```
-#### Step Operator API
+### Navigation Matchers
```ruby
-# Save current step with configured operations
-wizard.current_step_name = :remove_recipient
-# Run only RemoveRecipient which probably will delete data instead of saving
-wizard.save_current_step
+# Next step
+expect(wizard).to have_next_step(:email)
+expect(wizard).to have_next_step(:review).from(:email)
+expect(wizard).to have_next_step(:visa).from(:nationality).when(nationality: 'canadian')
-# Save current step with configured operations
-wizard.current_step_name = :payment
-# Run Validate, ProcessPayment, Persist
-wizard.save_current_step # Runs operations defined on #steps_operator, returns true/false
+# Previous step
+expect(wizard).to have_previous_step(:name)
-wizard.current_step_name = :notification
-# Run Validate, Persist, SendEmail
-wizard.save_current_step
+# Root step
+expect(wizard).to have_root_step(:name)
-wizard.current_step_name = :review
-# Don't do anything
-wizard.save_current_step
+# Branching
+expect(wizard).to branch_from(:nationality).to(:visa).when(nationality: 'canadian')
+expect(wizard).to branch_from(:nationality).to(:review).when(nationality: 'british')
```
-**If you need more customization**, you can also create your own methods on wizard and
-manipulate step operations at your will:
+### Path Matchers
```ruby
-class MyWizard
- def my_custom_save
- operations = steps_operator.operations_for(current_step_name)
+expect(wizard).to have_flow_path([:name, :email, :review])
+expect(wizard).to have_saved_path([:name, :email])
+expect(wizard).to have_valid_path([:name])
+```
+
+### Validation Matchers
+
+```ruby
+expect(wizard).to be_valid_to(:review)
+expect(:email).to be_valid_step.in(wizard)
+```
+
+### State Store Matchers
+
+```ruby
+expect(state_store).to have_step_attribute(:first_name)
+expect(state_store).to have_step_attribute(:email).with_value('test@example.com')
+```
+
+---
- operations.each do |operation_class|
- # ... do what your service needs
+## Auto-generated Documentation
+
+Generate Mermaid, GraphViz, and Markdown documentation from your wizard:
+
+```ruby
+# lib/tasks/wizard_docs.rake
+namespace :wizard do
+ namespace :docs do
+ task generate: :environment do
+ output_dir = 'docs/wizards'
+
+ [RegistrationWizard, ApplicationWizard].each do |wizard_class|
+ wizard = wizard_class.new(state_store: OpenStruct.new)
+ wizard.documentation.generate_all(output_dir)
+ puts "Generated docs for #{wizard_class.name}"
+ end
end
end
end
```
+Run with:
+
+```bash
+rake wizard:docs:generate
+```
+
---
-### Routing (Optional)
+## In Depth: Repositories
+
+Repositories handle data persistence. All repositories implement the same interface:
+
+```ruby
+repository.read # => Hash
+repository.write(hash)
+repository.clear
+```
+
+### InMemory Repository
+
+Stores data in a Ruby hash. **Testing only** - data lost on each request.
+
+```ruby
+repository = DfE::Wizard::Repository::InMemory.new
+
+repository.write(first_name: 'Sarah')
+repository.read # => { first_name: 'Sarah' }
-**Routing** strategies determine how step IDs map to URL paths. Routing is optional
+# Data is lost when the object is garbage collected
+```
-the gem provides path resolver to the step identifiers.
+### Session Repository
-Rxample in controller:
+Stores data in Rails session. Good for simple wizards without sensitive data.
```ruby
- wizard.current_step_path # get current step and return url
- wizard.next_step_path # get next step and return url
- wizard.previous_step_path # get previous step and return url or nil if no previous step
+repository = DfE::Wizard::Repository::Session.new(
+ session: session,
+ key: :registration_wizard
+)
+
+# Data stored at session[:registration_wizard]
+repository.write(first_name: 'Sarah')
+session[:registration_wizard] # => { first_name: 'Sarah' }
```
-It is optional but if you don't use you will have to map the identifiers yourself:
+### Cache Repository
+
+Stores data in Rails.cache. Good for distributed systems with expiring data.
```ruby
- next_step = wizard.next_step # => :name_and_date_of_birth
- resolve_step(next_step) # your own method
+repository = DfE::Wizard::Repository::Cache.new(
+ cache: Rails.cache,
+ key: "wizard:#{current_user.id}:registration",
+ expires_in: 1.hour
+)
+
+# Data stored in cache with automatic expiration
+repository.write(first_name: 'Sarah')
```
-In case you opt-in to use the gem provides three different strategies:
+### Redis Repository
+
+Stores data directly in Redis. Good for high-throughput systems.
```ruby
-# Named routes strategy
-strategy = NamedRoutes.new(wizard: self, namespace: 'personal-information')
-strategy.resolve(step_id: :review, options: {})
-# => "/personal-information/review"
-
-# Dynamic routes strategy
-RouteStrategy::DynamicRoutes.new(
- state_store:,
- path_builder: lambda { |step_id, state_store, url_helpers, opts|
- url_helpers.my_custom_wizard_path(
- state_key: state_store.state_key,
- step: step_id,
- some_attribute: state_store.some_attribute,
- **opts,
- )
- },
+repository = DfE::Wizard::Repository::Redis.new(
+ redis: Redis.current,
+ key: "wizard:#{session.id}:registration",
+ expires_in: 24.hours
)
-# Configurable routes
-route_strategy = DfE::Wizard::RouteStrategy::ConfigurableRoutes.new(
- wizard: self,
- namespace: 'a_levels_requirements',
-) do |config|
- # assuming the wizard needs this default arguments
- config.default_path_arguments = {
- recruitment_cycle_year: state_store.recruitment_cycle_year,
- provider_code: state_store.provider_code,
- code: state_store.course_code,
- }
-
- # we need to do something special for the step :course_edit for example but the others
- # follow the named conventions
- config.map_step :course_edit, to: lambda { |_wizard, options, helpers|
- helpers.recruitment_cycle_provider_course_path(**options)
- }
- end
-end
+# Data stored as JSON in Redis
+repository.write(first_name: 'Sarah')
+Redis.current.get("wizard:#{session.id}:registration")
+# => '{"first_name":"Sarah"}'
+```
-# Use in wizard
-class PersonalInformationWizard
- include DfE::Wizard
+### Model Repository
- def route_strategy
- NamedRoutes.new(wizard: self, namespace: 'personal-information')
- end
+Persists data directly to an ActiveRecord model. Each `write` calls `update!`.
+
+```ruby
+# Your model
+class Application < ApplicationRecord
+ # Must have columns matching step attributes
+ # e.g., first_name, last_name, email, etc.
end
-# Access in controller
-wizard.current_step_path # => "/personal-information/email"
-wizard.next_step_path # => "/personal-information/phone"
-wizard.previous_step_path # => "/personal-information/name"
+application = Application.find_or_create_by(user: current_user)
+repository = DfE::Wizard::Repository::Model.new(model: application)
+
+# Each write updates the model
+repository.write(first_name: 'Sarah')
+application.reload.first_name # => 'Sarah'
```
----
+**Use when**: You want each step to immediately persist to your domain model.
+
+### WizardState Repository
-### Logging (Optional)
+Stores all wizard data in a JSONB column. Good for complex wizards with many fields.
-**Logging** captures all major wizard events for debugging and auditing.
+**Migration:**
```ruby
-# Enable detailed logging
-class MyWizard
- include DfE::Wizard
+class CreateWizardStates < ActiveRecord::Migration[7.1]
+ def change
+ create_table :wizard_states do |t|
+ t.string :key, null: false
+ t.string :user_id
+ t.jsonb :state, default: {}
+ t.timestamps
+ end
- def logger
- DfE::Wizard::Logger.new(logger: Rails.logger) if Rails.env.local?
+ add_index :wizard_states, [:key, :user_id], unique: true
end
end
-
-# Log events captured:
-# - Step navigation (next/previous)
-# - Validations (pass/fail with errors)
-# - State changes (read/write)
-# - Operations execution
-# - Parameter extraction
-# - Flow resolution
-
```
-Warning: the logger might be a little noisy. You can exclude categories.
+**Model:**
```ruby
-# If you think is too much noise you can exclude:
- DfE::Wizard::Logger.new(logger: Rails.logger).exclude(%i[routing validation callbacks])
-# See DfE::Wizard::Logger::CATEGORIES for all categories
+class WizardState < ApplicationRecord
+ validates :key, presence: true
+end
```
----
-
-### Inspect (Optional)
-
-**Inspect** methods provide debugging helpers for visualizing wizard state.
-
-**Use inspection only for development environment as it can show sensitive data!**.
+**Usage:**
```ruby
-class PersonalInfoWizard
- include DfE::Wizard
-
- def inspect
- DfE::Wizard::Inspect.new(wizard: self) if Rails.env.local?
- end
-end
+model = WizardState.find_or_create_by(
+ key: 'registration',
+ user_id: current_user&.id
+)
+repository = DfE::Wizard::Repository::WizardState.new(model: model)
-# Inspect complete state
-wizard.inspect
-# =>
-# ┌─ Wizard: PersonalInfoWizard
-# ├─ Current Step: :email
-# ├─ Flow Path: [:name, :email, :phone, :review]
-# ├─ Saved Steps: [:name, :email]
-# ├─ Valid Steps: [:name]
-# ├─ State:
-# │ ├─ name: { first_name: "John", last_name: "Doe" }
-# │ └─ email: { email: "john@example.com" }
-# └─ Orphaned: []
+# All data stored in the JSONB 'state' column
+repository.write(first_name: 'Sarah', email: 'sarah@example.com')
+model.reload.state # => { "first_name" => "Sarah", "email" => "sarah@example.com" }
```
----
-
-## Quick Start
+### Encryption
-### 1. Create Steps
+All repositories that inherit from `Base` support encryption for sensitive data. Pass `encrypted: true` and an `encryptor:` object that responds to `encrypt_and_sign` and `decrypt_and_verify` (like `ActiveSupport::MessageEncryptor`).
```ruby
-# app/steps/email_step.rb
-module Steps
- class Email
- include DfE::Wizard::Step
-
- attribute :email, :string
- attribute :confirmed, :boolean, default: false
+# Create an encryptor
+key = Rails.application.credentials.wizard_encryption_key
+encryptor = ActiveSupport::MessageEncryptor.new(key)
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
- validates :confirmed, acceptance: true
+# Use with any repository
+repository = DfE::Wizard::Repository::Session.new(
+ session: session,
+ key: :secure_wizard,
+ encrypted: true,
+ encryptor: encryptor
+)
- def self.permitted_params
- %i[email confirmed]
- end
- end
-end
+# Data is encrypted before storage
+repository.write(national_insurance: 'AB123456C')
+session[:secure_wizard] # => { national_insurance: "encrypted_string..." }
```
-### 2. Create Wizard
-
-```ruby
-# app/wizards/application_wizard.rb
-class ApplicationWizard
- include DfE::Wizard::Base
+### Multiple Wizard Instances (state_key)
- def steps_processor
- DfE::Wizard::StepsProcessor::Graph.draw(self) do |graph|
- graph.add_node :name, Steps::Name
- graph.add_node :email, Steps::Email
- graph.add_node :review, Steps::Review
- graph.add_node :confirmation, DfE::Wizard::Redirect # virtual step
- graph.root :name
+When users can have multiple instances of the same wizard running simultaneously (e.g., multiple browser tabs, or editing multiple applications), use `state_key` to isolate each instance's data.
- graph.add_edge from: :name, to: :email
- graph.add_edge from: :email, to: :review
- graph.add_edge from: :review, to: :confirmation
- end
- end
-end
-```
+**The problem**: Without `state_key`, all tabs share the same data. User opens two tabs to create two different applications - data from one overwrites the other.
-### 3. Create Controller
+**The solution**: Generate a unique `state_key` for each wizard instance and pass it through the URL.
```ruby
-# app/controllers/wizard_steps_controller.rb
-class WizardStepsController < ApplicationController
+# Controller
+class ApplicationsController < ApplicationController
def new
- @wizard = ApplicationWizard.new
- @wizard.current_step_name = params[:id]
- @step = @wizard.current_step
+ # Generate a new state_key for a fresh wizard instance
+ redirect_to application_step_path(state_key: SecureRandom.uuid, step: :name)
end
- def create
- @wizard = ApplicationWizard.new(current_step: params[:id], current_step_params: params)
+ def show
+ @wizard = build_wizard
+ end
- if @wizard.current_step_valid?
- @wizard.save_current_step
- redirect_to @wizard.next_step_path
+ def update
+ @wizard = build_wizard
+ if @wizard.save_current_step
+ redirect_to application_step_path(state_key: params[:state_key], step: @wizard.next_step)
else
render :show
end
end
-end
-```
-### 4. Create Views
+ private
-Here normally the gem doesn't dictate the views.
+ def build_wizard
+ state_store = StateStores::ApplicationStore.new(
+ repository: DfE::Wizard::Repository::Session.new(
+ session: session,
+ key: :applications,
+ state_key: params[:state_key] # Each instance gets its own namespace
+ )
+ )
-You can pass #current_step as model and #current_step_path as the URL to be submitted,
-the scope #current_step_name so params are submitted correctly.
+ ApplicationWizard.new(
+ current_step: params[:step].to_sym,
+ current_step_params: params,
+ state_store: state_store
+ )
+ end
+end
+```
-```erb
-
- <%= form_with model: @wizard.current_step,
- url: @wizard.current_step_path,
- scope: @wizard.current_step_name do |form| %>
- <% end %>
+```ruby
+# Routes
+get 'applications/:state_key/:step', to: 'applications#show', as: :application_step
+patch 'applications/:state_key/:step', to: 'applications#update'
```
----
+**How it works in Session:**
+
+```ruby
+# Without state_key - single flat hash
+session[:wizard_store] = { first_name: 'Sarah', ... }
-## Usage Guide
+# With state_key - nested by instance
+session[:applications] = {
+ 'abc-123' => { first_name: 'Sarah', ... }, # Tab 1
+ 'def-456' => { first_name: 'James', ... } # Tab 2
+}
+```
-### Navigation
+**With Redis:**
```ruby
-wizard = ApplicationWizard.new
+repository = DfE::Wizard::Repository::Redis.new(
+ redis: Redis.current,
+ key: "wizard:user:#{current_user.id}",
+ state_key: params[:state_key],
+ expiration: 24.hours
+)
+```
-# Current step
-wizard.current_step_name # => :email
-wizard.current_step # =>
-wizard.current_step_path # => "/wizard/email"
+### Choosing a Repository
-# Forward/Back
-wizard.next_step # => :review
-wizard.next_step_path # => "/wizard/review"
-wizard.previous_step # => :name
-wizard.previous_step_path # => "/wizard/name"
+| Scenario | Recommended Repository |
+|--------------------------------------|------------------------|
+| Testing | InMemory |
+| Simple wizard, no sensitive data | Session |
+| Distributed system, temporary data | Cache or Redis |
+| Persist to existing model | Model |
+| Complex wizard, many fields | WizardState |
+| Sensitive data | Any with encryption |
-# Flow analysis
-wizard.flow_path # => [:name, :email, :review]
-wizard.in_flow?(:phone) # => true
-wizard.in_flow?(:foo) # => false
-```
+---
-### State Management
+## In Depth: Steps
-```ruby
-wizard = ApplicationWizard.new
+Steps are ActiveModel objects representing individual screens in your wizard.
-# Read data
-wizard.data # => { steps: { name: {...}, email: {...} } }
-wizard.step_data(:name) # => { first_name: "John" }
-wizard.raw_data # => unfiltered (includes unreachable steps)
+### Step Attributes
-# Write data
-wizard.write_state(steps: { name: { first_name: "Jane" } })
-wizard.set_metadata(:user_id, 123)
+Use ActiveModel attributes with types:
-# Check saved progress
-wizard.saved?(:name) # => true (has data)
-wizard.saved_path # => [:name, :email] (completed steps)
-wizard.saved_steps # => [, ] (hydrated objects)
+```ruby
+class PersonalDetails
+ include DfE::Wizard::Step
-# Completion
-wizard.mark_completed
-wizard.completed? # => true
-wizard.completed_at # => 2025-11-22 22:03:00 +0000
+ attribute :first_name, :string
+ attribute :last_name, :string
+ attribute :date_of_birth, :date
+ attribute :age, :integer
+ attribute :newsletter, :boolean, default: false
+ attribute :preferences, :json # For complex data
+end
```
-### Validation
+### Attribute Uniqueness
+
+**Important**: Step attribute names must be unique across the entire wizard.
```ruby
-# Validate current step
-wizard.current_step_valid? # => true/false
+# BAD - 'email' is defined in two steps
+class ContactStep
+ attribute :email, :string
+end
-# Validate specific step
-wizard.valid?(:name) # => true/false
+class NotificationStep
+ attribute :email, :string # Conflict! Will overwrite ContactStep's email
+end
-# Get valid steps (safe path)
-wizard.valid_path # => [:name] (stops at first invalid)
-wizard.valid_path_to?(:review) # => true (can reach review?)
-wizard.valid_path_to_current_step? # => true/false
+# GOOD - unique attribute names
+class ContactStep
+ attribute :contact_email, :string
+end
-# Get validated step objects
-wizard.valid_steps # => [, ]
+class NotificationStep
+ attribute :notification_email, :string
+end
```
-### Check Your Answers Pattern
+This is because all attributes are stored in a flat hash in the repository.
-Perfect for review/edit flows.
+### Permitted Parameters
-Callbacks override the #next_step and #previous_step. Use cases:
-
-- Run some logic before navigating to next step or previous step
-- Handle "Return to review" pattern
-- Modify wizard state on the fly
-- The callback return a step symbol or nil/false
-- If returns nil/false the callback will be skipped & next step will kick-in
-- If returns a Symbol the callback will be taken into account and next step or previous step will
- not be invoked!
+Always define `permitted_params` to control which params are accepted:
```ruby
-class ApplicationWizard
- include DfE::Wizard
-
- def steps_processor
- DfE::Wizard::StepsProcessor::Graph.draw(self) do |graph|
- graph.add_node(:name, Steps::Name)
- graph.add_node(:email, Steps::Email)
- graph.add_node(:review, Steps::Review)
- graph.root :name
+class AddressStep
+ include DfE::Wizard::Step
- graph.add_edge(from: :name, to: :email)
- graph.add_edge(from: :email, to: :review)
+ attribute :address_line_1, :string
+ attribute :address_line_2, :string
+ attribute :postcode, :string
+ attribute :country, :string
- # Custom callbacks:
- graph.before_next_step(:next_step_override)
- graph.before_previous_step(:previous_step_override)
- end
+ def self.permitted_params
+ %i[address_line_1 address_line_2 postcode country]
end
+end
+```
- # ?return_to_review=name
- # user click to change name
- #
- def next_step_override
- target = @current_step_params[:return_to_review]
+### Nested Attributes
- :check_answers if target.present? && valid_path_to?(:check_answers)
- end
+For nested params (like GOV.UK date inputs):
- # ?return_to_review=name
- # user click to change name
- #
- def previous_step_override
- target = @current_step_params[:return_to_review]
+```ruby
+class DateOfBirthStep
+ include DfE::Wizard::Step
+
+ attribute :date_of_birth, :date
- :check_answers if current_step_name.to_s == target && valid_path_to?(:check_answers)
+ def self.permitted_params
+ [date_of_birth: %i[day month year]]
end
end
```
----
-
-## Testing
-This gem also ships with a suite of RSpec matchers for testing wizards at a higher level,
-letting you assert flow (next_step, previous_step, branches),
-paths (flow_path, saved_path, valid_path), operations, and state store attributes
-using expressive, intention‑revealing specs.
+### Custom Validations
-You can include the module:
+Steps use ActiveModel validations:
```ruby
-RSpec.configure do |config|
- config.include DfE::Wizard::Test::RSpecMatchers
-end
-```
+class EligibilityStep
+ include DfE::Wizard::Step
-Let's explore each matcher.
+ attribute :age, :integer
+ attribute :country, :string
-### `have_next_step_path`
+ validates :age, numericality: { greater_than_or_equal_to: 18 }
+ validates :country, inclusion: { in: %w[england wales scotland] }
-Asserts that a wizard’s `next_step_path` equals the expected path.
+ validate :must_be_eligible
-```ruby
-expect(wizard).to have_next_step_path("/wizard/email")
+ private
+
+ def must_be_eligible
+ if age.present? && age < 21 && country == 'scotland'
+ errors.add(:base, 'Must be 21 or older in Scotland')
+ end
+ end
+end
```
-### `have_previous_step_path`
+### Accessing Other Step Data
-Asserts that a wizard’s `previous_step_path` equals the expected path.
+Steps can access data from other steps via the state store:
```ruby
-expect(wizard).to have_previous_step_path("/wizard/name")
+class SummaryStep
+ include DfE::Wizard::Step
+
+ def full_name
+ "#{state_store.first_name} #{state_store.last_name}"
+ end
+
+ def formatted_address
+ [
+ state_store.address_line_1,
+ state_store.address_line_2,
+ state_store.postcode
+ ].compact.join(', ')
+ end
+end
```
-### `be_next_step_path`
+---
-Asserts that a given path is the `next_step_path` for a specific wizard, using subject-first syntax.
+## In Depth: Conditional Edges
-```ruby
-expect("/wizard/email").to be_next_step_path.in(wizard)
-```
+The Graph processor supports several edge types for branching logic.
-### `be_previous_step_path`
+### Simple Edge
-Asserts that a given path is the `previous_step_path` for a specific wizard, using subject-first syntax.
+Unconditional transition from one step to another:
```ruby
-expect("/wizard/name").to be_previous_step_path.in(wizard)
+graph.add_edge from: :name, to: :email
+```
+
+```
+ ┌──────┐ ┌───────┐
+ │ name │ ───▶ │ email │
+ └──────┘ └───────┘
```
-### `resolve_step`
+### Conditional Edge
-Asserts that a given step ID resolves to a specific URL/path via the wizard’s route strategy.
+Binary branching based on a predicate:
```ruby
-expect(wizard).to resolve_step(:nationality)
- .to(url_helpers.personal_information_nationality_path)
-
-expect(wizard).to resolve_step(:review)
- .to("/personal-information/review")
+graph.add_conditional_edge(
+ from: :nationality,
+ when: :needs_visa?, # Method on state_store
+ then: :visa_details, # If true
+ else: :employment, # If false
+ label: 'Visa required?' # Optional: for documentation
+)
```
-## Step symbol / navigation matchers
-
-### `have_root_step`
+```
+ ┌───────────────┐
+ yes │ visa_details │
+ ┌──────▶ └───────────────┘
+ ┌─────────────┐
+ │ nationality │ ── needs_visa? ──┐
+ └─────────────┘ │
+ ┌───────────────┐
+ no │ employment │
+ └──────▶ └───────────────┘
+```
-Asserts that the wizard’s `steps_processor.root_step` equals the expected step, optionally after writing state to the state store.
+**Predicates can be:**
```ruby
-# Simple root
-expect(wizard).to have_root_step(:name_and_date_of_birth)
-
-# Conditional root based on state
-expect(wizard).to have_root_step(:add_a_level_to_list)
- .when(a_level_subjects: [{ some_a_level: "A" }])
+# Symbol - method on state_store
+when: :needs_visa?
-expect(wizard).to have_root_step(:what_a_level_is_required)
- .when(a_level_subjects: [])
+# Proc - inline logic
+when: -> { state_store.nationality != 'british' }
```
-### `have_next_step`
+### Multiple Conditional Edge
-Asserts that `wizard.next_step` equals the expected step, optionally from a specific starting step and/or with specific state.
+Three or more branches, evaluated in order:
```ruby
-# From current step
-expect(wizard).to have_next_step(:nationality)
+graph.add_multiple_conditional_edges(
+ from: :employment_status,
+ branches: [
+ { when: :employed?, then: :employer_details, label: 'Employed' },
+ { when: :self_employed?, then: :business_details, label: 'Self-employed' },
+ { when: :student?, then: :education_details, label: 'Student' }
+ ],
+ default: :other_status # Required: fallback
+)
+```
+
+```
+ ┌──────────────────┐
+ employed │ employer_details │
+ ┌──────────▶ └──────────────────┘
+ │
+ ┌────────────────┐ ┌──────────────────┐
+ │employment_status│ ─▶│ business_details │ (self-employed)
+ └────────────────┘ └──────────────────┘
+ │
+ │ ┌──────────────────┐
+ └──────────▶ │ education_details│ (student)
+ │ └──────────────────┘
+ │
+ │ ┌──────────────────┐
+ └──(default)▶│ other_status │
+ └──────────────────┘
+```
-# From a specific step
-expect(wizard).to have_next_step(:review).from(:nationality)
+**Evaluation order matters** - first matching predicate wins:
-# With params influencing branching
-expect(wizard).to have_next_step(:review)
- .from(:nationality)
- .when(nationality: "British")
+```ruby
+branches: [
+ { when: :high_priority?, then: :fast_track }, # Checked first
+ { when: :medium_priority?, then: :standard }, # Checked second
+ { when: :any_priority?, then: :slow_track } # Checked last
+]
```
-### `be_next_step`
+### Custom Branching Edge
-Asserts that a given step symbol is the wizard’s `next_step`, using subject-first syntax.
+For complex logic that returns the next step directly:
```ruby
-expect(:nationality).to be_next_step.in(wizard)
+graph.add_custom_branching_edge(
+ from: :assessment,
+ conditional: :calculate_route, # Method that returns step symbol
+ potential_transitions: [ # For documentation only
+ { label: 'Score > 80', nodes: [:fast_track] },
+ { label: 'Score 50-80', nodes: [:standard] },
+ { label: 'Score < 50', nodes: [:remedial] }
+ ]
+)
```
-### `have_previous_step`
-
-Asserts that `wizard.previous_step` equals the expected step, optionally from a specific starting step and/or with specific state.
+Define the method in your **wizard** (not state store):
```ruby
-# From current step
-expect(wizard).to have_previous_step(:name_and_date_of_birth)
+class ApplicationWizard
+ include DfE::Wizard
-# From a specific step
-expect(wizard).to have_previous_step(:nationality)
- .from(:right_to_work_or_study)
+ def calculate_route
+ score = AssessmentService.score(state_store.read)
-# With branching params
-expect(wizard).to have_previous_step(:nationality)
- .from(:right_to_work_or_study)
- .when(nationality: "non-uk")
+ case score
+ when 81..100 then :fast_track
+ when 50..80 then :standard
+ else :remedial
+ end
+ end
+end
```
-### `be_previous_step`
+### Dynamic Root
-Asserts that a given step symbol is the wizard’s `previous_step`, using subject-first syntax.
+Start the wizard at different steps based on conditions:
```ruby
-expect(:nationality).to be_previous_step.in(wizard)
+# Using a block
+graph.conditional_root(potential_root: %i[new_user returning_user]) do |state_store|
+ state_store.existing_user? ? :returning_user : :new_user
+end
+
+# Using a method
+graph.conditional_root(:determine_start, potential_root: %i[new_user returning_user])
```
-### `branch_from`
+### Skip When
-Asserts that, from a given step and optional state, `wizard.next_step` is the expected target step (tests conditional branching).
+Skip steps based on conditions. The step remains in the graph but is jumped over during navigation.
```ruby
-expect(wizard).to branch_from(:nationality)
- .to(:review)
- .when(nationality: "british")
+graph.add_node :school_selection, SchoolSelection, skip_when: :only_one_school?
-expect(wizard).to branch_from(:nationality)
- .to(:right_to_work_or_study)
- .when(nationality: "canadian")
+# In state store
+def only_one_school?
+ available_schools.count == 1
+end
```
-### `be_at_step`
+**Why use skip_when instead of conditional edges?**
-Asserts that `wizard.current_step_name` equals the expected step.
+Without explicit skipping, a conditional edge on step A must decide not only whether to go to B, but also where to go after B if B is not needed, leading to logic like: "if X then go to B else go to C, but if Y also skip straight to D", which quickly gets messy as you add "next next next" possibilities.
-```ruby
-expect(wizard).to be_at_step(:nationality)
-```
+With skipping, conditional edges remain local: A still points to B, B still points to C, and only B knows when it should be removed from the path. This keeps branching logic simpler, more testable, and easier to extend over time.
-## Path collection matchers
+**Be careful with this feature, use it wisely!**
-### `have_flow_path`
+### Before Callbacks
-Asserts that `wizard.flow_path` equals the expected sequence of steps.
+Execute logic before navigation:
```ruby
-expect(wizard).to have_flow_path(
- [:name_and_date_of_birth, :nationality, :review]
-)
+graph.before_next_step(:handle_special_case)
+graph.before_previous_step(:handle_back_navigation)
+
+# In wizard - return step symbol to override, nil to continue
+def handle_special_case
+ return :review if returning_to_review?
+ nil # Continue normal navigation
+end
```
-### `have_saved_path`
+---
-Asserts that `wizard.saved_path` equals the expected sequence of steps with saved data.
+## In Depth: Route Strategies
-```ruby
-expect(wizard).to have_saved_path(
- [:name_and_date_of_birth, :nationality]
-)
-```
+Route strategies translate step IDs to URLs.
-### `have_valid_path`
+### NamedRoutes Strategy
-Asserts that `wizard.valid_path` equals the expected sequence of valid steps up to the stopping point.
+Uses Rails named routes. Simplest option.
```ruby
-expect(wizard).to have_valid_path(
- [:name_and_date_of_birth, :nationality, :right_to_work_or_study]
-)
+def route_strategy
+ DfE::Wizard::RouteStrategy::NamedRoutes.new(
+ wizard: self,
+ namespace: 'registration'
+ )
+end
```
-## Step object collection matchers
+**Required routes:**
-### `have_flow_steps`
+```ruby
+# config/routes.rb
+get 'registration/:step', to: 'registration#show', as: :registration
+patch 'registration/:step', to: 'registration#update'
+```
-Asserts that `wizard.flow_steps` (hydrated step objects) match the expected list of step instances, in order.
+**URL generation:**
```ruby
-expect(wizard).to have_flow_steps([
- Steps::NameAndDateOfBirth.new,
- Steps::Nationality.new,
- Steps::Review.new
-])
+wizard.current_step_path # => '/registration/name'
+wizard.next_step_path # => '/registration/email'
+wizard.next_step_path(return_to_review: :name) # => '/registration/email?return_to_review=name'
```
-### `have_saved_steps`
+### ConfigurableRoutes Strategy
-Asserts that `wizard.saved_steps` (steps with data) match the expected list of step instances, in order.
+For complex URL patterns or nested resources:
```ruby
-expect(wizard).to have_saved_steps([
- Steps::NameAndDateOfBirth.new,
- Steps::Nationality.new
-])
+def route_strategy
+ DfE::Wizard::RouteStrategy::ConfigurableRoutes.new(
+ wizard: self,
+ namespace: 'applications'
+ ) do |config|
+ # Default arguments for all paths
+ config.default_path_arguments = {
+ application_id: state_store.application_id
+ }
+
+ # Custom path for specific steps
+ config.map_step :review, to: ->(wizard, opts, helpers) {
+ helpers.application_review_path(
+ application_id: wizard.state_store.application_id,
+ **opts
+ )
+ }
+
+ config.map_step :submit, to: ->(wizard, opts, helpers) {
+ helpers.submit_application_path(
+ application_id: wizard.state_store.application_id
+ )
+ }
+ end
+end
```
-### `have_valid_steps`
+### DynamicRoutes Strategy
-Asserts that `wizard.valid_steps` (steps with valid data) match the expected list of step instances, in order.
+For multi-instance wizards where URLs need to include a unique identifier (like `state_key`). This is the recommended strategy when using `state_key` for multiple wizard instances.
-```ruby
-expect(wizard).to have_valid_steps([
- Steps::NameAndDateOfBirth.new,
- Steps::Nationality.new,
- Steps::RightToWorkOrStudy.new
-])
-```
+**Why use DynamicRoutes?**
-## Validation matchers
+- URLs include the instance identifier: `/applications/abc-123/name`
+- Works seamlessly with `state_key` repository pattern
+- The `path_builder` lambda gives you full control over URL generation
-### `be_valid_to`
+**The path_builder receives:**
-Asserts that all steps leading to the target step form a valid path (`wizard.valid_path_to?(target_step)` is true).
+| Argument | Description |
+|----------|-------------|
+| `step_id` | The step symbol (`:name`, `:email`) |
+| `state_store` | Your state store instance (access `state_key` via repository) |
+| `helpers` | Rails URL helpers |
+| `opts` | Additional options passed to path methods |
-```ruby
-expect(wizard).to be_valid_to(:review)
-```
+**Complete example with state_key:**
-### `be_valid_step`
+```ruby
+# Wizard
+class ApplicationWizard
+ include DfE::Wizard
-Asserts that a given step ID is valid in the context of a wizard (`wizard.valid?(step_id)`), using subject-first syntax.
+ def route_strategy
+ DfE::Wizard::RouteStrategy::DynamicRoutes.new(
+ state_store: state_store,
+ path_builder: ->(step_id, state_store, helpers, opts) {
+ helpers.application_step_path(
+ state_key: state_store.repository.state_key,
+ step: step_id,
+ **opts
+ )
+ }
+ )
+ end
+end
+```
```ruby
-expect(:nationality).to be_valid_step.in(wizard)
+# Routes
+get 'applications/:state_key/:step', to: 'applications#show', as: :application_step
+patch 'applications/:state_key/:step', to: 'applications#update'
```
-## State store / operator matchers
+```ruby
+# Controller
+def build_wizard
+ repository = DfE::Wizard::Repository::Session.new(
+ session: session,
+ key: :applications,
+ state_key: params[:state_key]
+ )
+
+ state_store = StateStores::ApplicationStore.new(repository: repository)
-### `have_step_attribute`
+ ApplicationWizard.new(
+ current_step: params[:step].to_sym,
+ current_step_params: params,
+ state_store: state_store
+ )
+end
+```
-Asserts that the state store has a given attribute key, optionally with a specific value.
+Now `@wizard.next_step_path` automatically includes the `state_key`:
```ruby
-# Just presence
-expect(state_store).to have_step_attribute(:first_name)
+@wizard.next_step_path
+# => "/applications/abc-123-def/email"
-# Presence and value
-expect(state_store).to have_step_attribute(:nationality).with_value("british")
+@wizard.next_step_path(return_to_review: true)
+# => "/applications/abc-123-def/email?return_to_review=true"
```
-***
+---
+
+## Advanced: Custom Implementations
-### `have_step_operations`
+### Custom Repository
-Asserts that the wizard’s `steps_operator` has the expected operation pipeline per step.
+Create repositories for other storage backends:
```ruby
-expect(wizard).to have_step_operations(
- payment: [Validate, ProcessPayment, Persist],
- review: []
-)
-```
----
+class DfE::Wizard::Repository::DynamoDB
+ def initialize(table_name:, key:)
+ @client = Aws::DynamoDB::Client.new
+ @table_name = table_name
+ @key = key
+ end
-## Auto generated documentation
+ def read
+ response = @client.get_item(
+ table_name: @table_name,
+ key: { 'id' => @key }
+ )
+ response.item&.dig('data') || {}
+ end
-The gem can **generate documentation automatically** in Mermaid, GraphViz and Markdown for any
-wizard.
+ def write(data)
+ current = read
+ merged = current.merge(data.stringify_keys)
-### What gets generated
+ @client.put_item(
+ table_name: @table_name,
+ item: { 'id' => @key, 'data' => merged }
+ )
+ end
-For each wizard, the documentation generator produces:
+ def clear
+ @client.delete_item(
+ table_name: @table_name,
+ key: { 'id' => @key }
+ )
+ end
+end
+```
-- **Mermaid** diagrams (suitable for Markdown / GitHub / docs sites)
-- **GraphViz** (DOT files)
-- **Markdown** summaries of steps, edges and branching metadata
+### Custom Step Operator
-All of this is driven from the steps processor metadata (`graph.metadata`), so docs stay in sync
-with the actual flow structure.
+Create operators for special processing:
-### How to generate docs
+```ruby
+class SendNotification
+ def initialize(repository:, step:, callable:)
+ @repository = repository
+ @step = step
+ @callable = callable # Your state store
+ end
-Define a Rake task that loads your wizard classes and calls `wizard.documentation.generate_all(output_dir)` for each one:
+ def execute
+ NotificationMailer.step_completed(
+ email: @callable.email,
+ step: @step.class.name
+ ).deliver_later
-```ruby
-# lib/tasks/wizard_docs.rake
-namespace :wizard do
- namespace :docs do
- # Generate documentation for all wizards
- #
- # Generates documentation (Mermaid, GraphViz, Markdown) for all wizard
- # classes found in app/wizards, in all supported themes.
- #
- # @example Generate all wizard documentation
- # rake wizard:docs:generate
- #
- # @example Generate specific wizard
- # WIZARD=PersonalInformationWizard rake wizard:docs:generate
- desc 'Generate documentation for all wizards'
- task generate: :environment do
- # assuming your wizards live on app/wizards
- #
- Dir['app/wizards/**/*.rb'].each { |f| require File.expand_path(f) }
+ { success: true }
+ end
+end
- output_dir = 'docs/wizards'
+# Usage
+def steps_operator
+ DfE::Wizard::StepsOperator::Builder.draw(wizard: self, callable: state_store) do |b|
+ b.on_step(:final_step, add: [SendNotification])
+ end
+end
+```
- # you can hardcoded or make a way to discover all wizards.
- # Here we hardcoded PersonalInformationWizard
- [
- PersonalInformationWizard,
- ].each do |wizard_class|
- wizard = wizard_class.new(state_store: OpenStruct.new)
+### Custom Repository Transform
- # Using #generate_all but you can also generate individually.
-
- # Generate Markdown
- # docs.generate(:markdown, 'docs/wizard.md')
- #
- # Generate Mermaid flowchart
- # docs.generate(:mermaid, 'docs/wizard.mmd')
- #
- # Generate Graphviz diagram
- # docs.generate(:graphviz, 'docs/wizard.dot')
- #
- wizard.documentation.generate_all(output_dir)
+Override `transform_for_read` and `transform_for_write` on your repository to control how data flows between the wizard and your data store. This is particularly useful for:
- puts "Generated docs for #{wizard_class.name}"
- end
+- Mapping step attributes to different column names
+- Working around the step attribute uniqueness constraint
+- Adapting to existing database schemas
+
+**Example: Mapping prefixed attributes to a flat model**
+
+If you have two steps that both conceptually have an "email" field, you must use unique attribute names (`contact_email`, `billing_email`). But your model might just have `email` and `billing_email`:
+
+```ruby
+class MyRepository < DfE::Wizard::Repository::Model
+ # Transform data when reading FROM data store (data store → wizard)
+ def transform_for_read(data)
+ data.merge(
+ 'contact_email' => data['email'] # Map model's 'email' to step's 'contact_email'
+ )
+ end
- puts "All wizard docs written to #{File.expand_path(output_dir)}/"
+ # Transform data when writing TO data store (wizard → data store)
+ def transform_for_write(data)
+ transformed = data.dup
+ if transformed.key?('contact_email')
+ transformed['email'] = transformed.delete('contact_email') # Map back
end
+ transformed
end
end
```
-In this example:
-
-- Each wizard is instantiated (here with a trivial `OpenStruct` state store) and asked to generate documentation into `docs/wizards`.
-- `generate_all` will emit the different formats (Mermaid, GraphViz, Markdown) using the internal metadata of the steps processor (steps, edges, branching, counts).
+This lets your steps use descriptive, unique attribute names while your database uses its existing schema.
---
-## Wizard examples
+## Examples
-* [Personal Information wizard](spec/rails-dummy/app/wizards/personal_information_wizard.rb) - (from Apply teacher training)
-* [Assign mentor](spec/rails-dummy/app/wizards/assign_mentor_wizard.rb) - (from Register early carreers teachers)
-* [A level wizard](spec/rails-dummy/app/wizards/a_levels_requirements_wizard.rb) - (from Publish teacher training)
-* [Register ECT wizard](spec/rails-dummy/app/wizards/register_ect_wizard.rb) - (from Register early carreers teachers)
+Working examples in this repository:
+- [Personal Information Wizard](spec/rails-dummy/app/wizards/personal_information_wizard.rb) - Conditional nationality flow
+- [Register ECT Wizard](spec/rails-dummy/app/wizards/register_ect_wizard.rb) - Complex branching with multiple conditions
+- [A-Levels Wizard](spec/rails-dummy/app/wizards/a_levels_requirements_wizard.rb) - Dynamic root, looping
-Auto generated documentation examples of these wizard can be seem [here](spec/rails-dummy/docs/wizards)
+Generated documentation: [spec/rails-dummy/docs/wizards](spec/rails-dummy/docs/wizards)
---
@@ -1847,112 +2112,102 @@ Auto generated documentation examples of these wizard can be seem [here](spec/ra
### Wizard Methods
-#### Navigation
-- `current_step_name` - Current step symbol
-- `root_step` - First step
-- `next_step` - Next step symbol
-- `previous_step` - Previous step symbol
-- `current_step_path(options)` - URL path
-- `next_step_path(options)` - URL path
-- `previous_step_path(fallback:)` - URL path
-- `flow_path(target)` - Array of steps to target
-- `in_flow?(step_id)` - Check if step is reachable
-
-#### Step Management
-- `current_step` - Instantiated step object
-- `step(step_id)` - Get hydrated step
-- `find_step(step_name)` - Get step class
-- `flow_steps` - All steps in flow as objects
-- `saved_steps` - Steps with data as objects
-- `valid_steps` - Valid steps as objects
-- `attribute_names` - All attributes from all steps
-
-#### State Management
-- `data` - Filtered state (reachable steps only)
-- `raw_data` - Unfiltered state (all steps)
-- `step_data(step_id)` - Step data if reachable
-- `raw_step_data(step_id)` - Step data unfiltered
-- `write_state(updates)` - Merge data
-- `clear_state` - Delete all data
-- `mark_completed` - Set completion flag
-- `completed?` - Check if completed
-- `completed_at` - Get completion timestamp
-- `saved_path` - Steps with data
-- `saved?(step_id)` - Check if step has data
-- `orphaned_steps_data` - Data from unreachable steps
-
-#### Validation
-- `current_step_valid?` - Check current step
-- `valid?(step_id)` - Check specific step
-- `valid_path(target)` - Safe path to target
-- `valid_path_to?(target)` - Can reach target?
-- `valid_path_to_current_step?` - Can reach current?
-
-#### Check Your Answers
-- `handle_return_to_check_your_answers(target)` - Return to review
-- `handle_back_in_check_your_answers(target, origin)` - Navigate back
+**Navigation:**
+- `current_step_name` → Symbol
+- `current_step` → Step object
+- `next_step` → Symbol or nil
+- `previous_step` → Symbol or nil
+- `root_step` → Symbol
+- `current_step_path(options = {})` → String
+- `next_step_path(options = {})` → String or nil
+- `previous_step_path(fallback: nil)` → String or nil
+
+**Flow Analysis:**
+- `flow_path(target = nil)` → Array of Symbols
+- `saved_path(target = nil)` → Array of Symbols
+- `valid_path(target = nil)` → Array of Symbols
+- `in_flow?(step_id)` → Boolean
+- `saved?(step_id)` → Boolean
+- `valid_path_to?(step_id)` → Boolean
+
+**Step Hydration:**
+- `step(step_id)` → Step object
+- `flow_steps` → Array of Step objects
+- `saved_steps` → Array of Step objects
+- `valid_steps` → Array of Step objects
+
+**State:**
+- `save_current_step` → Boolean
+- `current_step_valid?` → Boolean
+- `state_store` → StateStore instance
### Step Methods
-- `valid?` - Check validation
-- `errors` - ActiveModel errors
-- `serializable_data` - Data for storage
-- `model_name` - Rails form helper support
-- `==(other)` - Value equality
-- `inspect` - Debug representation
+- `valid?` → Boolean
+- `errors` → ActiveModel::Errors
+- `serializable_data` → Hash
+- `self.permitted_params` → Array of Symbols
+
+### State Store Methods
+
+- `read` → Hash
+- `write(hash)` → void
+- `clear` → void
+- `[](key)` → value
### Repository Methods
-- `read` - Get all state (decrypted if encrypted)
-- `write(hash)` - Merge into state
-- `save(hash)` - Replace state
-- `clear` - Delete all
-- `execute_operation` - Run operation class
-- `encrypted?` - Check encryption status
+- `read` → Hash
+- `write(hash)` → void
+- `clear` → void
---
## Troubleshooting
-### Attribute Not Accessible
-
-```ruby
-state_store.undefined_attr # => NoMethodError
+### "Predicate method :xxx not found"
-# Solution: Ensure attribute is in attribute_names
-state_store.attribute_names.include?(:undefined_attr) # => false
+Your branching predicate isn't defined on the state store:
-# Add to step class:
-attribute :undefined_attr, :string
+```ruby
+# In state store
+def needs_visa?
+ nationality != 'british'
+end
```
-### Data Not Persisting
+### Step attributes not accessible in state store
-```ruby
-state_store.write({ name: "John" })
-state_store.read[:name] # => nil
+The wizard must be initialised before accessing attributes:
-# Solution: Check repository is configured correctly
-wizard.state_store.repository.class # => InMemory (temporary!)
+```ruby
+# Wrong - wizard not created yet
+state_store.first_name # => NoMethodError
-# Use persistent repository:
-repository = DfE::Wizard::Repository::WizardState.new(model: model)
-repository = DfE::Wizard::Repository::Model.new(model: model)
-repository = DfE::Wizard::Repository::Redis.new(model: model)
+# Right - after wizard initialisation
+wizard = MyWizard.new(state_store: state_store, ...)
+state_store.first_name # => "Sarah"
```
-### Validation Failing Unexpectedly
+### Data not persisting
+
+Check your repository:
```ruby
-wizard.current_step.valid? # => false
-wizard.current_step.errors # => { email: ["invalid format"] }
+wizard.state_store.repository.class
+# => DfE::Wizard::Repository::InMemory # This won't persist!
+
+# Use Session or WizardState for persistence
+```
-# Solution: Check permitted_params includes all attributes
-Steps::Email.permitted_params # => [:email, :confirmed]
-wizard.current_step_params # => { } # checks what the current step params returns
+### Validation failing unexpectedly
-# Check attributes have values
-wizard.current_step.email # => nil
+Check permitted_params includes all fields:
+
+```ruby
+def self.permitted_params
+ %i[first_name last_name] # Must include all validated fields
+end
```
---
@@ -1963,12 +2218,4 @@ wizard.current_step.email # => nil
## License
-MIT License — See LICENSE file for details
-
----
-
-## Contact
-
-Contact the Find & Publish teacher training team in cross gov UK slack for any questions,
-considerations, etc.
-
+MIT License - See LICENSE file