Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
## What needs to be done

### State store inspection

Would be good to inspect the state store all step attributes and return what is on repository in
a very explanable manner (step attribute and repository value):

```ruby
state_store.inspect
```

### README

The readme is a mess.

We need to explain many concepts first:

* Repository
* State Store
* Step
* Wizard
* Route Strategy
* Steps Processors
* Steps operator
* Inspect
* Logger

MOST IMPORTANT: Explain data flow:

* The data flow - devs are confusing
* Explain in ASCII diagram
* Make simple examples

Explain Navigation flow:

* DSL Definitions on Wizard#steps_processor
* Method definitions in wizard (although state store is recommended)
* Explain flow_path, saved_path, valid_path
* Explain current_step, next_step, previous_step
* Explain current_step_path, next_step_path, previous_step_path
* Previous path uses flow_path therefore makes sure there are no circular flow

Explain steps instantiation (if needed):
* flow_steps, saved_steps, valid_steps

Explain implementing check your answers page:
* Document the CheckAnswersPresenter module
* Show how to create custom presenter classes with row grouping
* Explain format_value override for custom formatting

We need to enforce the decisions developers needs to make when using the gem and implementing a
wizard.

Things you need to defined (where to store data? Create one repo for all steps - maybe one - maybe many).
8 changes: 6 additions & 2 deletions lib/dfe/wizard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ module DfE
# include DfE::Wizard
#
# def steps_processor
# DfE::Wizard::StepsProcessor::Graph.draw(self) do |graph|
# DfE::Wizard::StepsProcessor::Graph.draw(self, predicate_caller: state_store) do |graph|
# graph.add_node :name, NameStep
# graph.add_node :email, EmailStep
# graph.add_node :review, ReviewStep
Expand Down Expand Up @@ -109,6 +109,10 @@ module Core
end
# @!endgroup

# Module for building Check Your Answers pages
# @api public
autoload :CheckAnswersPresenter, 'dfe/wizard/check_answers_presenter'

# @!group Auto generate Documentation for any wizard
module Documentation
autoload :Generator, 'dfe/wizard/documentation/generator'
Expand Down Expand Up @@ -325,7 +329,7 @@ def define_step_attributes_in_state_store
#
# @example
# def steps_processor
# DfE::Wizard::StepsProcessor::Graph.draw(self) do |graph|
# DfE::Wizard::StepsProcessor::Graph.draw(self, predicate_caller: state_store) do |graph|
# graph.add_node :step1, Step1
# graph.root :step1
# end
Expand Down
8 changes: 4 additions & 4 deletions lib/dfe/wizard/behaviours/step_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ def step_definitions
# Return all attribute names from all step classes
#
# Flattens the step definitions to extract every attribute defined across
# all steps in the wizard. Used by StateStore's method_missing to determine
# which method calls should access repository data.
# all steps in the wizard. Used by StateStore to generate accessor methods
# for each attribute.
#
# Attributes are collected from each step class via its `attribute_names`
# class method. Steps without attributes contribute nothing to this list.
Expand All @@ -311,11 +311,11 @@ def step_definitions
# wizard.attribute_names.include?("undefined") # => false
#
# @note Attribute names are collected lazily during wizard initialization.
# This allows StateStore to build method_missing handlers only for
# This allows StateStore to generate accessor methods only for
# attributes actually defined in step classes.
#
# @see #step_definitions For step class objects themselves
# @see StateStore#method_missing Which uses this list to route method calls
# @see StateStore#define_attribute_accessors Which generates methods from this list
# @api public
def attribute_names
step_definitions.flat_map do |_step_id, step_class|
Expand Down
278 changes: 278 additions & 0 deletions lib/dfe/wizard/check_answers_presenter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
module DfE
module Wizard
# Module for building Check Your Answers pages.
#
# Include this module in your own presenter class to get helper methods
# for building check your answers pages. You control the structure, grouping,
# and formatting - the module provides the building blocks.
#
# @example Create your own check answers presenter
# class RegisterECTCheckAnswers
# include DfE::Wizard::CheckAnswersPresenter
#
# def teacher_details
# [
# row_for(:review_ect_details, :correct_full_name),
# row_for(:email_address, :email),
# row_for(:start_date, :start_date),
# ]
# end
#
# def programme_details
# [
# row_for(:programme_type, :training_programme),
# row_for(:lead_provider, :lead_provider_id),
# ]
# end
#
# # Override to customize formatting
# def format_value(attribute, value)
# case attribute
# when :start_date
# value&.to_fs(:govuk_date)
# else
# value
# end
# end
# end
#
# @example In controller
# @check_answers = RegisterECTCheckAnswers.new(@wizard)
#
# @example In view
# <% @check_answers.teacher_details.each do |row| %>
# <dt><%= row.label %></dt>
# <dd><%= row.formatted_value %></dd>
# <dd><%= link_to "Change", row.change_path %></dd>
# <% end %>
#
module CheckAnswersPresenter
# Represents a single row in a check your answers page.
#
# Provides access to raw and formatted values, labels from I18n,
# and change paths for editing.
class Row
# @return [Symbol] the step identifier
attr_reader :step_id

# @return [Symbol] the attribute name
attr_reader :attribute

# @return [DfE::Wizard::Step, nil] the step instance
attr_reader :step

# @return [String] the change path with return_to_review parameter
attr_reader :change_path

# @return [String, nil] custom label override
attr_reader :custom_label

# @return [Object, nil] pre-computed formatted value
attr_reader :formatted_value

def initialize(step_id:, attribute:, step:, change_path:, formatted_value: nil, custom_label: nil)
@step_id = step_id
@attribute = attribute
@step = step
@change_path = change_path
@formatted_value = formatted_value
@custom_label = custom_label
end

# The raw attribute value from the step.
#
# @return [Object] the unformatted value
def value
step&.public_send(attribute)
end

# The human-readable label for this attribute.
#
# Uses custom label if provided, otherwise falls back to
# the step's human_attribute_name (which uses I18n).
#
# @return [String] the label text
#
# @example With I18n
# # config/locales/en.yml
# # en:
# # activemodel:
# # attributes:
# # steps/email_step:
# # email: "Email address"
#
# row.label # => "Email address"
def label
return custom_label if custom_label

if step&.class.respond_to?(:human_attribute_name)
step.class.human_attribute_name(attribute)
else
attribute.to_s.humanize
end
end

# Check if the row has a value.
#
# @return [Boolean] true if value is present
def value?
value.present?
end

# Convert to hash for compatibility.
#
# @return [Hash] row data as hash
def to_h
{
step_id: step_id,
attribute: attribute,
label: label,
value: value,
formatted_value: formatted_value,
change_path: change_path,
}
end
end

# @return [DfE::Wizard] the wizard instance
attr_reader :wizard

# Initialize the presenter with a wizard.
#
# @param wizard [DfE::Wizard] the wizard instance
def initialize(wizard)
@wizard = wizard
end

# Steps available for review. Override to customize.
#
# @return [Array<DfE::Wizard::Step>] steps to include in review
def reviewable_steps
wizard.flow_steps
end

# Find a step by ID from reviewable steps.
#
# @param step_id [Symbol] the step identifier
# @return [DfE::Wizard::Step, nil] the step or nil if not found
def find_reviewable_step(step_id)
reviewable_steps.find { |step| step.step_id == step_id }
end

# Build a row for a step attribute.
#
# Returns a Row object with access to the value, formatted value,
# label, and change path. Returns nil if the step is not in
# reviewable_steps or if the attribute doesn't exist on the step.
#
# @param step_id [Symbol] the step identifier
# @param attribute [Symbol] the attribute name
# @param label [String, nil] custom label (defaults to step's human_attribute_name)
# @param change_step [Symbol, nil] override which step the change link goes to
#
# @return [Row, nil] row object or nil if step/attribute not available
#
# @example Basic row
# row = row_for(:email_address, :email)
# row.value # => "john@example.com"
# row.formatted_value # => "john@example.com" (or custom via format_value override)
# row.label # => "Email" (from I18n or humanized)
# row.change_path # => "/email?return_to_review=email_address"
#
# @example With custom label
# row_for(:start_date, :start_date, label: "ECT start date")
def row_for(step_id, attribute, label: nil, change_step: nil)
step = find_reviewable_step(step_id)
return nil unless step
return nil unless step.respond_to?(attribute)

raw_value = step.public_send(attribute)

Row.new(
step_id: step_id,
attribute: attribute,
step: step,
change_path: change_path_for(change_step || step_id),
formatted_value: format_value(attribute, raw_value),
custom_label: label,
)
end

# Format a value for display.
#
# Override this method in your presenter to customize how values
# are displayed. By default, returns the raw value unchanged.
#
# @param attribute [Symbol] the attribute name
# @param value [Object] the raw value from the step
# @return [Object] the formatted value for display
#
# @example Custom formatting
# def format_value(attribute, value)
# case attribute
# when :start_date then value&.to_fs(:govuk_date)
# when :working_pattern then value&.humanize
# else value
# end
# end
def format_value(_attribute, value)
value
end

# Build multiple rows from a step's attributes.
#
# Returns an empty array if the step is not in reviewable_steps.
# Filters out any nil rows (e.g., for non-existent attributes).
#
# @param step_id [Symbol] the step identifier
# @param attributes [Array<Symbol, Hash>] attribute names or hashes with options
#
# @return [Array<Row>] array of Row objects
#
# @example
# rows_for(:personal_details, [:first_name, :last_name, :email])
# rows_for(:personal_details, [:first_name, { attribute: :dob, label: "Date of birth" }])
def rows_for(step_id, attributes)
step = find_reviewable_step(step_id)
return [] unless step

attributes.filter_map do |attr|
if attr.is_a?(Hash)
row_for(step_id, attr[:attribute], **attr.except(:attribute))
else
row_for(step_id, attr)
end
end
end

# Generate the change path for a step.
#
# @param step_id [Symbol] the step to link to
# @return [String] the path with return_to_review parameter
def change_path_for(step_id)
wizard.resolve_step_path(step_id, return_to_review: step_id)
end

# Get all valid steps that have been completed.
#
# @return [Array<DfE::Wizard::Step>] array of valid step instances
def completed_steps
wizard.valid_steps
end

# Get the flow steps (all steps in current path).
#
# @return [Array<DfE::Wizard::Step>] array of step instances in flow order
def flow_steps
wizard.flow_steps
end

# Access the state store for custom data.
#
# @return [DfE::Wizard::StateStore] the state store
def state_store
wizard.state_store
end
end
end
end
Loading