Skip to content
Open
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
43 changes: 31 additions & 12 deletions app/models/strata/flows/application_form_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ def flow_record
raise NotImplementedError, "#{self.class.name} must define #flow_record"
end

# Resolves the flow record id from request params, handling both top-level
# routes (where params[:id] is the flow record) and loop-nested routes
# (where params[:id] is the loop child and params["<flow_class>_id"] is
# the flow record).
def flow_record_id
parent_key = "#{controller_name.singularize}_id"
params[parent_key] || params[:id]
Comment on lines +28 to +32
end

class_methods do
def flow(flow_class)
before_action :set_flow
Expand All @@ -37,7 +46,11 @@ def flow(flow_class)

# Set a @flow_task instance that can provide completion methods and routing helpers.
define_method(:set_flow_task) do
@flow_page, @flow_task = flow_class.find_page_and_task_by_action(flow_record, request.path_parameters[:action])
@flow_page, @flow_task = flow_class.find_page_and_task_by_action(
flow_record,
request.path_parameters[:action],
request.path_parameters[:id]
)
end

# Redirect to start_path if the current page's task has unmet dependencies.
Expand All @@ -49,27 +62,33 @@ def flow(flow_class)
end
end

# For each question page, define the edit and update paths.
flow_class.pages.each_with_index do |page, page_idx|
# /{record_class}/:id/edit_{question_page_name}
# For each question page (including pages inside loops), define the edit
# and update actions. Action names are namespaced by loop name for loop
# pages (e.g. update_prior_employer_business_name).
flow_class.all_pages.each do |page|
# /{record_class}/:id/edit_{question_page_name} (or nested under loop child)
define_method(page.edit_pathname) do
end

# /{record_class}/:id/update_{question_page_name}
# /{record_class}/:id/update_{question_page_name} (or nested under loop child)
define_method(page.update_pathname) do
# Permit attributes based on the fields defined on the question page
record_class_name = flow_record.class.name.underscore.to_sym
form_params = params.require(record_class_name).permit(*(page.attributes(flow_record.class)))
flow_record.assign_attributes(form_params)
# For loop pages, reuse the loop_record already resolved by set_flow_task
# so that assign_attributes/errors land on the same instance the view
# renders, preserving submitted values across a re-render.
target_record = page.in_loop? ? @flow_task.loop_record : flow_record

record_class_name = target_record.class.name.underscore.to_sym
form_params = params.require(record_class_name).permit(*(page.attributes(target_record.class)))
target_record.assign_attributes(form_params)

if flow_record.valid? && flow_record.save(context: page.name)
if target_record.valid? && target_record.save(context: page.name)
redirect_to @flow_task.next_path || (@flow.tasks.length == 1 ? @flow.end_path : @flow.start_path)
else
# Allow custom error-handling behaviors by defining :on_flow_update_invalid
if respond_to?(:on_flow_update_invalid)
on_flow_update_invalid
on_flow_update_invalid(target_record)
else
flash.now[:errors] = flow_record.errors.full_messages
flash.now[:errors] = target_record.errors.full_messages
end

render page.edit_pathname, status: :unprocessable_content
Expand Down
83 changes: 72 additions & 11 deletions app/models/strata/flows/application_form_flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ module Strata::Flows
# question_page :leave_dates
# question_page :supporting_documents, if: ->(app) { app.leave_type_medical? }
# end
# task :prior_employment do
# loop :prior_employer, association: :prior_employers do
# question_page :business_name
# question_page :role
# end
# end
# end
module ApplicationFormFlow
extend ActiveSupport::Concern
Expand All @@ -38,10 +44,20 @@ def pages
tasks.flat_map(&:pages)
end

# Returns the flat sequence of QuestionPages across the flow, with each
# Loop expanded in place into its enclosed pages.
def all_pages
tasks.flat_map do |task|
task.pages.flat_map do |item|
item.is_a?(Loop) ? item.pages : [ item ]
end
end
end

# Returns all routes that can be generated when used in
# combination with ApplicationFormController
def generated_routes
tasks.flat_map(&:pages).flat_map do |page|
all_pages.flat_map do |page|
[ page.edit_pathname, page.update_pathname ]
end
end
Expand Down Expand Up @@ -71,9 +87,25 @@ def task(task_name, depends_on: nil, &block)
# If no fields are provided, we assume that the page
# has one field which matches the name of the page.
def question_page(page_name, if: nil, fields: nil)
page = QuestionPage.new(page_name, if:, fields:)
@current_task.pages.push(page)
page = QuestionPage.new(page_name, if:, fields:, loop: @current_loop)
if @current_loop.present?
@current_loop.pages.push(page)
else
@current_task.pages.push(page)
end
contexts.push(page_name)
validate_unique_action_names!
end

# Defines a loop over a has_many association on the flow record.
# When association: is omitted, it defaults to the loop name.
# `scope:` optionally narrows the association — accepts a Symbol naming
# a scope on the relation, or a Proc that receives and returns a relation.
def loop(loop_name, association: nil, scope: nil, &block)
@current_loop = Loop.new(loop_name, association: association, scope: scope)
@current_task.pages.push(@current_loop)
block.call
@current_loop = nil
end

# A start page to return to when exiting out of a
Expand All @@ -96,12 +128,23 @@ def end_page(path)
@end_pathname = path
end

def find_page_and_task_by_action(flow_record, action)
def find_page_and_task_by_action(flow_record, action, id_param = nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is pretty dense and complex (ends up having a loop inside of a loop inside of a loop). Would you mind cleaning this method a little to make it more readable?

action_sym = action.to_sym

tasks.each do |task|
task.pages.each_with_index do |page, page_idx|
# Search for the current page based on the request action
if [ page.edit_pathname.to_sym, page.update_pathname.to_sym ].include?(action.to_sym)
return page, TaskEvaluator.new(task, flow_record, page_idx)
task.pages.each_with_index do |item, page_idx|
if item.is_a?(Loop)
item.pages.each_with_index do |loop_page, loop_page_idx|
next unless [ loop_page.edit_pathname.to_sym, loop_page.update_pathname.to_sym ].include?(action_sym)

loop_record = resolve_loop_record(item, flow_record, id_param)
return loop_page, TaskEvaluator.new(
task, flow_record, page_idx,
loop_record: loop_record, loop_page_idx: loop_page_idx
)
end
elsif [ item.edit_pathname.to_sym, item.update_pathname.to_sym ].include?(action_sym)
return item, TaskEvaluator.new(task, flow_record, page_idx)
end
end
end
Expand All @@ -114,6 +157,7 @@ def to_mermaid

tasks.each do |task|
task.pages.each do |page|
next if page.is_a?(Loop)
node_name = page.name
fields = page.fields.flat_map do |field|
if field.is_a?(Hash)
Expand All @@ -133,10 +177,11 @@ def to_mermaid
end

diagram += " subgraph t_#{task.name}[Task: #{task.name}]\n"
if task.pages.length < 2
diagram += " #{task.pages.first.name}\n"
rendered_pages = task.pages.reject { |p| p.is_a?(Loop) }
if rendered_pages.length < 2
diagram += " #{rendered_pages.first.name}\n" if rendered_pages.first
else
task.pages.each_cons(2) do |a, b|
rendered_pages.each_cons(2) do |a, b|
diagram += " #{a.name} --> #{b.name}\n"
end
end
Expand All @@ -149,6 +194,22 @@ def to_mermaid

diagram
end

private

def resolve_loop_record(loop_node, flow_record, id_param)
return nil unless id_param

loop_node.records_for(flow_record).find(id_param)
end

def validate_unique_action_names!
pathnames = all_pages.flat_map { |p| [ p.edit_pathname, p.update_pathname ] }
duplicates = pathnames.tally.select { |_, count| count > 1 }.keys
return if duplicates.empty?

raise ArgumentError, "duplicate action name(s) in flow: #{duplicates.join(', ')}"
end
end

# === Instance Methods =====
Expand Down
55 changes: 55 additions & 0 deletions app/models/strata/flows/loop.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true

module Strata::Flows
# Represents a sequence of question pages that iterates over a has_many
# association on the flow record. Each child record is walked through the
# same set of pages before the cursor advances to the next child.
#
# `scope:` optionally narrows the association — accepts a Symbol naming a
# scope on the relation, or a Proc that receives the relation and returns
# a filtered one. The filtered set drives traversal, completion, and child
# lookup, so records outside the scope are skipped and cannot be edited.
class Loop
attr_accessor :name, :association, :scope, :pages

def initialize(name, association: nil, scope: nil, pages: [])
@name = name
@association = association || name
@scope = scope
@pages = pages
end

def records_for(flow_record)
base = flow_record.public_send(@association)
apply_scope(base)
end

def record_class(flow_record)
flow_record.class.reflect_on_association(@association).klass
end

def started?(flow_record)
records_for(flow_record).any? do |child|
@pages.any? { |page| page.completed?(child) }
end
end

def completed?(flow_record)
records_for(flow_record).all? do |child|
@pages.all? { |page| page.completed?(child) }
end
end

private

def apply_scope(records)
return records if @scope.nil?

case @scope
when Symbol then records.public_send(@scope)
when Proc then @scope.call(records)
else raise ArgumentError, "Loop scope must be a Symbol or Proc, got #{@scope.class}"
end
end
end
end
35 changes: 27 additions & 8 deletions app/models/strata/flows/question_page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ module Strata::Flows
# Represents an individual question page with a set of input fields.
class QuestionPage
include Rails.application.routes.url_helpers
attr_accessor :name, :fields
attr_accessor :name, :fields, :loop

def initialize(name, if: nil, fields: nil)
def initialize(name, if: nil, fields: nil, loop: nil)
reserved_attributes = { if: }

@name = name
@if = reserved_attributes[:if]
@fields = fields || [ @name.to_sym ]
@loop = loop
end

def in_loop?
@loop.present?
end

def needed?(record)
Expand All @@ -23,19 +28,33 @@ def completed?(record)
end

def edit_pathname
"edit_#{@name}"
in_loop? ? "edit_#{@loop.name}_#{@name}" : "edit_#{@name}"
end

def edit_path(record)
send("#{edit_pathname}_#{record.class.name.underscore}_path", record)
def edit_path(flow_record, loop_record = nil)
if in_loop?
send(
"#{edit_pathname}_#{flow_record.class.name.underscore}_#{@loop.association.to_s.singularize}_path",
flow_record, loop_record
)
else
send("#{edit_pathname}_#{flow_record.class.name.underscore}_path", flow_record)
end
end

def update_pathname
"update_#{@name}"
in_loop? ? "update_#{@loop.name}_#{@name}" : "update_#{@name}"
end

def update_path(record)
send("#{update_pathname}_#{record.class.name.underscore}_path", record)
def update_path(flow_record, loop_record = nil)
if in_loop?
send(
"#{update_pathname}_#{flow_record.class.name.underscore}_#{@loop.association.to_s.singularize}_path",
flow_record, loop_record
)
else
send("#{update_pathname}_#{flow_record.class.name.underscore}_path", flow_record)
end
end

# Returns the list of permitted parameter keys for this page's fields,
Expand Down
47 changes: 43 additions & 4 deletions app/models/strata/flows/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ def initialize(name, depends_on: nil, pages: [])
end

def started?(record)
@pages.any? { |page| page.completed?(record) }
@pages.any? do |item|
item.is_a?(Loop) ? item.started?(record) : item.completed?(record)
end
end

def completed?(record)
@pages.all? { |page| page.completed?(record) }
@pages.all? do |item|
item.completed?(record)
end
end

def dependencies_met?(flow)
Expand All @@ -28,9 +32,44 @@ def path(record)
return nil if @pages.empty?

if !started?(record) || completed?(record)
@pages.first.edit_path(record)
first_path(record)
else
first_incomplete_path(record)
end
end

private

def first_path(record)
@pages.each do |item|
path = enter_forward(item, record)
return path if path
end
nil
end

def first_incomplete_path(record)
@pages.each do |item|
if item.is_a?(Loop)
item.records_for(record).each do |child|
incomplete = item.pages.find { |p| !p.completed?(child) }
return incomplete.edit_path(record, child) if incomplete
end
elsif !item.completed?(record)
return item.edit_path(record)
end
end
nil
end

def enter_forward(item, record)
if item.is_a?(Loop)
first_child = item.records_for(record).first
return nil unless first_child
first_page = item.pages.find { |p| p.needed?(first_child) }
first_page&.edit_path(record, first_child)
else
@pages.find { |page| !page.completed?(record) }.edit_path(record)
item.needed?(record) ? item.edit_path(record) : nil
end
Comment on lines +51 to 73
end
end
Expand Down
Loading
Loading