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