govuk-wizardry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +107 -0
- data/Rakefile +27 -0
- data/app/helpers/wizardry_helper.rb +121 -0
- data/lib/tasks/wizardry_tasks.rake +4 -0
- data/lib/wizardry/engine.rb +7 -0
- data/lib/wizardry/framework.rb +56 -0
- data/lib/wizardry/graph.rb +55 -0
- data/lib/wizardry/instance.rb +74 -0
- data/lib/wizardry/pages/check_your_answers_page.rb +22 -0
- data/lib/wizardry/pages/completion_page.rb +21 -0
- data/lib/wizardry/pages/page.rb +51 -0
- data/lib/wizardry/pages/question_page.rb +22 -0
- data/lib/wizardry/questions/answer.rb +19 -0
- data/lib/wizardry/questions/date.rb +15 -0
- data/lib/wizardry/questions/email_address.rb +15 -0
- data/lib/wizardry/questions/hidden.rb +22 -0
- data/lib/wizardry/questions/long_answer.rb +15 -0
- data/lib/wizardry/questions/radios.rb +36 -0
- data/lib/wizardry/questions/short_answer.rb +15 -0
- data/lib/wizardry/questions/telephone_number.rb +15 -0
- data/lib/wizardry/railtie.rb +4 -0
- data/lib/wizardry/routing/next_page.rb +13 -0
- data/lib/wizardry/version.rb +3 -0
- data/lib/wizardry.rb +115 -0
- metadata +251 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: de9d24e16cac679c4ee2c36dd8a8556dbe051ffabbfc8771c1dcddd5a00964c3
|
4
|
+
data.tar.gz: f7bf947d3cbb3e95b03c12d5f527805c1d005d09a6e12738ae7ec77fc4a644df
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 47ac4127a0b74792a27812c48c5911cea6341a7f215714a6b95003443e8237dc70a9ac85129a8b7e88b9e91e98b276cc18fb5a532ef4fcf5e25ec2419967ffa4
|
7
|
+
data.tar.gz: 98515e090121ce5703f1058190a98ff2d487fa361703bfaefd8578d0c2463621a060ac47b81ba6af566fce3412eac07d4c6ccc62ec0fcc2a03e7f771cef61c07
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2020 Graphia Ltd.
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# Wizardry 🧙
|
2
|
+
|
3
|
+
This gem for Rails allows application writers to define a GOV.UK wizard
|
4
|
+
(multi-page form) in one place.
|
5
|
+
|
6
|
+
**Note this library is at the very early stages of development. It's not
|
7
|
+
properly tested and is very likely to change**.
|
8
|
+
|
9
|
+
## Things the library does:
|
10
|
+
|
11
|
+
* renders the wizard pages based on the specification ✔
|
12
|
+
* correctly annotates the pages using Rails' I18n functionality ✔
|
13
|
+
* deals with form submissions ✔
|
14
|
+
* applies contextual validation on a per-page basis and displaying error messages ✔
|
15
|
+
* let developers override pages and questions with their own partials ✔
|
16
|
+
* allow for extra customisation over the size of labels, legends and captions ✔
|
17
|
+
* allow conditional logic to customise the path through the journey ✔
|
18
|
+
* provide a callbacks upon step and wizard completion ✔
|
19
|
+
|
20
|
+
## How does it work?
|
21
|
+
|
22
|
+
The wizard relies on the application having a single model that will hold the
|
23
|
+
collected data. Each question in the wizard maps to a field and every page
|
24
|
+
maps to a [validation context](https://guides.rubyonrails.org/active_record_validations.html#on).
|
25
|
+
|
26
|
+
Throughout the process the last completed step is recorded in the model. It's
|
27
|
+
used to track progress and hold the state of the response. It is automatically
|
28
|
+
updated when a successful submission is made.
|
29
|
+
|
30
|
+
### Example
|
31
|
+
|
32
|
+
Just include the `Wizardry` module in your controller and use the `wizard` macro
|
33
|
+
to define your pages and questions:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
class RatingsController < ApplicationController
|
37
|
+
include Wizardry
|
38
|
+
|
39
|
+
wizard name: 'ratings',
|
40
|
+
class_name: 'Rating',
|
41
|
+
edit_path_helper: :ratings_page_path,
|
42
|
+
update_path_helper: :ratings_path,
|
43
|
+
pages: [
|
44
|
+
Wizardry::Pages::Page.new(
|
45
|
+
:identification,
|
46
|
+
title: 'Who are you?',
|
47
|
+
questions: [
|
48
|
+
Wizardry::Questions::ShortAnswer.new(:full_name),
|
49
|
+
Wizardry::Questions::EmailAddress.new(:email)
|
50
|
+
]
|
51
|
+
),
|
52
|
+
Wizardry::Pages::Page.new(
|
53
|
+
:feedback,
|
54
|
+
title: 'What did you think about our service?',
|
55
|
+
questions: [
|
56
|
+
Wizardry::Questions::ShortAnswer.new(:customer_type),
|
57
|
+
Wizardry::Questions::Date.new(:purchase_date),
|
58
|
+
Wizardry::Questions::LongAnswer.new(:feedback),
|
59
|
+
Wizardry::Questions::Radios.new(:rating, {
|
60
|
+
1 => 'Dire', 2 => 'Alright', 3 => 'Average', 4 => 'Decent', 5 => 'Amazing'
|
61
|
+
})
|
62
|
+
]
|
63
|
+
),
|
64
|
+
Wizardry::Pages::CheckYourAnswersPage.new,
|
65
|
+
]
|
66
|
+
end
|
67
|
+
```
|
68
|
+
|
69
|
+
### Routes
|
70
|
+
|
71
|
+
In the example above, we're providing the `edit_path_helper` and
|
72
|
+
`update_path_helper` values. These are the Rails path helpers that correspond
|
73
|
+
with the path that displays the current page of the wizard and the path that
|
74
|
+
updates the object.
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
Rails.application.routes.draw do
|
78
|
+
get %(rating/:page), to: %(ratings#edit), as: :ratings_page
|
79
|
+
patch %(rating/:page), to: %(ratings#update), as: :ratings
|
80
|
+
end
|
81
|
+
```
|
82
|
+
|
83
|
+
### Rendering
|
84
|
+
|
85
|
+
To add the wizard content to your `edit` template, the gem comes with a
|
86
|
+
`wizardry_content` helper. Additionally, the wizard object is available for the
|
87
|
+
developer to interact with in the `@wizard` instance variable. Here we are
|
88
|
+
using it to set the heading.
|
89
|
+
|
90
|
+
```erb
|
91
|
+
<h1 class="govuk-heading-l"><%= @wizard.current_page.name.capitalize %></h1>
|
92
|
+
|
93
|
+
<%= wizardry_content %>
|
94
|
+
```
|
95
|
+
|
96
|
+
### Special pages
|
97
|
+
|
98
|
+
Most pages in a wizard are ones that ask questions. Many wizards also contain a
|
99
|
+
[check your answers](https://design-system.service.gov.uk/patterns/check-answers/) page and
|
100
|
+
a page that's displayed upon completion. These are both **in scope** for this
|
101
|
+
gem.
|
102
|
+
|
103
|
+
### Preview
|
104
|
+
|
105
|
+
| Page one | Page two |
|
106
|
+
| -----------------| ---------- |
|
107
|
+
|  |  |
|
data/Rakefile
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
begin
|
2
|
+
require 'bundler/setup'
|
3
|
+
rescue LoadError
|
4
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'rdoc/task'
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = 'rdoc'
|
11
|
+
rdoc.title = 'Wizardry'
|
12
|
+
rdoc.options << '--line-numbers'
|
13
|
+
rdoc.rdoc_files.include('README.md')
|
14
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
15
|
+
end
|
16
|
+
|
17
|
+
require 'bundler/gem_tasks'
|
18
|
+
|
19
|
+
require 'rake/testtask'
|
20
|
+
|
21
|
+
Rake::TestTask.new(:test) do |t|
|
22
|
+
t.libs << 'test'
|
23
|
+
t.pattern = 'test/**/*_test.rb'
|
24
|
+
t.verbose = false
|
25
|
+
end
|
26
|
+
|
27
|
+
task default: :test
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module WizardryHelper
|
2
|
+
def wizardry_content(w)
|
3
|
+
case w.current_page
|
4
|
+
when Wizardry::Pages::QuestionPage
|
5
|
+
render_form(w)
|
6
|
+
when Wizardry::Pages::CheckYourAnswersPage
|
7
|
+
safe_join([render_check_your_answers(w), render_form(w)])
|
8
|
+
when Wizardry::Pages::CompletionPage
|
9
|
+
render_completion(w)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def render_form(w)
|
16
|
+
capture do
|
17
|
+
try_partial(w, 'page') do
|
18
|
+
wizard_form(w) do |f|
|
19
|
+
try_partial(w, 'form', locals: { f: f }) do
|
20
|
+
concat(f.govuk_error_summary)
|
21
|
+
|
22
|
+
w.current_page.questions.map do |q|
|
23
|
+
concat(f.send(q.form_method, q.name, *q.extra_args, **q.extra_kwargs))
|
24
|
+
end
|
25
|
+
|
26
|
+
concat(f.govuk_submit)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def render_check_your_answers(w)
|
34
|
+
capture do
|
35
|
+
try_partial(w, 'page') do
|
36
|
+
safe_join(
|
37
|
+
[
|
38
|
+
render(GovukComponent::SummaryListComponent.new) do |summary_list|
|
39
|
+
w.route.each do |page|
|
40
|
+
page.questions.each do |question|
|
41
|
+
summary_list.row do |sl|
|
42
|
+
sl.key(text: check_your_answers_key(w.object.class.name, question.name))
|
43
|
+
sl.value(text: w.object.send(question.name))
|
44
|
+
sl.action(href: send(w.framework.edit_path_helper, page.name))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
]
|
50
|
+
)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def render_completion(w)
|
56
|
+
capture do
|
57
|
+
try_partial(w, 'page') do
|
58
|
+
safe_join(
|
59
|
+
[
|
60
|
+
tag.h1('Completed'),
|
61
|
+
|
62
|
+
tag.p do
|
63
|
+
[
|
64
|
+
'Add a partial called',
|
65
|
+
tag.code(%(_completion.html.erb)),
|
66
|
+
'to override this message'
|
67
|
+
].join(' ').html_safe
|
68
|
+
end
|
69
|
+
]
|
70
|
+
)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def wizard_form(w, turbo_frame_id: 'wizardry-form', &block)
|
76
|
+
turbo_frame_tag(turbo_frame_id) do
|
77
|
+
safe_join(
|
78
|
+
[
|
79
|
+
form_for(
|
80
|
+
w.object,
|
81
|
+
url: send(
|
82
|
+
w.framework.update_path_helper,
|
83
|
+
page: w.current_page.name
|
84
|
+
),
|
85
|
+
method: :patch,
|
86
|
+
builder: GOVUKDesignSystemFormBuilder::FormBuilder,
|
87
|
+
&block
|
88
|
+
)
|
89
|
+
]
|
90
|
+
)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def try_partial(w, section, locals: {}, &block)
|
95
|
+
if lookup_context.template_exists?(partial_file_path(w, section))
|
96
|
+
Rails.logger.debug("🧙 Page partial #{partial_file_path(w, section)} found; rendering entire page")
|
97
|
+
|
98
|
+
concat(render(partial: partial_render_path(w, section), locals: locals))
|
99
|
+
else
|
100
|
+
Rails.logger.debug('🧙 No overriding form partial found; automatically building form')
|
101
|
+
|
102
|
+
block.call
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def check_your_answers_key(class_name, question_name)
|
107
|
+
I18n.t!("helpers.legend.#{class_name.underscore}.#{question_name}")
|
108
|
+
rescue I18n::MissingTranslationData
|
109
|
+
I18n.t("helpers.label.#{class_name.underscore}.#{question_name}")
|
110
|
+
end
|
111
|
+
|
112
|
+
def partial_file_path(w, suffix)
|
113
|
+
"#{w.framework.name}/_#{w.current_page.name}_#{suffix}"
|
114
|
+
end
|
115
|
+
|
116
|
+
def partial_render_path(w, suffix)
|
117
|
+
"#{w.framework.name}/#{w.current_page.name}_#{suffix}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
ActiveSupport.on_load(:action_view) { include WizardryHelper }
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Wizardry
|
2
|
+
# Framework holds data on how the wizard itself is constructed. It'll
|
3
|
+
# be the same for every instance
|
4
|
+
class Framework
|
5
|
+
attr_accessor :name, :pages, :class_name, :edit_path_helper, :update_path_helper, :completion_flag
|
6
|
+
|
7
|
+
def initialize(name:, pages:, class_name:, edit_path_helper:, update_path_helper:, completion_flag: 'complete')
|
8
|
+
@name = name
|
9
|
+
@pages = setup_pages(pages)
|
10
|
+
@class_name = class_name
|
11
|
+
@edit_path_helper = edit_path_helper
|
12
|
+
@update_path_helper = update_path_helper
|
13
|
+
@completion_flag = completion_flag
|
14
|
+
|
15
|
+
page_sense_check
|
16
|
+
end
|
17
|
+
|
18
|
+
def cookie_name
|
19
|
+
%(#{@name}-wizard)
|
20
|
+
end
|
21
|
+
|
22
|
+
def page_names
|
23
|
+
pages.map(&:name)
|
24
|
+
end
|
25
|
+
|
26
|
+
def trunk_pages
|
27
|
+
pages.reject(&:branch?)
|
28
|
+
end
|
29
|
+
|
30
|
+
def branch_pages
|
31
|
+
pages.select(&:branch?)
|
32
|
+
end
|
33
|
+
|
34
|
+
def page(name)
|
35
|
+
pages.detect { |p| p.name == name }
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def page_sense_check
|
41
|
+
# should have no more than one check your answers page
|
42
|
+
pages.count { |page| page.is_a?(Wizardry::Pages::CheckYourAnswersPage) }.tap do |check_your_answers_pages|
|
43
|
+
Rails.logger.warn("🧙 More than one check your answers page detected") if check_your_answers_pages > 1
|
44
|
+
end
|
45
|
+
|
46
|
+
# should have no more than one completion page
|
47
|
+
pages.count { |page| page.is_a?(Wizardry::Pages::CompletionPage) }.tap do |completion_pages|
|
48
|
+
Rails.logger.warn("🧙 More than one completion page detected") if completion_pages > 1
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def setup_pages(pages)
|
53
|
+
pages.map { |page| [page, page.pages.each(&:branch!)].select(&:presence) }.flatten
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Wizardry
|
2
|
+
class Graph
|
3
|
+
attr_reader :graph
|
4
|
+
|
5
|
+
def initialize(framework)
|
6
|
+
@framework = framework
|
7
|
+
@graph = reticulate
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_dot
|
11
|
+
<<~GRAPH
|
12
|
+
digraph name {
|
13
|
+
#{build_edges.flatten.join(';')};
|
14
|
+
}
|
15
|
+
GRAPH
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def reticulate
|
21
|
+
@framework.pages.each.with_index.with_object({}) do |(current, i), g|
|
22
|
+
g[current.name] = specified_next_pages(current).merge(following_page(current, i))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def build_edges
|
27
|
+
@graph.map do |source, targets|
|
28
|
+
targets.map do |target_page, condition|
|
29
|
+
%(#{source} -> #{target_page}).tap do |edge|
|
30
|
+
if condition.present?
|
31
|
+
formatted_condition = %("#{condition}")
|
32
|
+
edge << %( [label=#{formatted_condition}])
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def specified_next_pages(page)
|
40
|
+
page.next_pages.each.with_object({}) do |np, h|
|
41
|
+
h[np.name] = np.label
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def following_page(_page, index)
|
46
|
+
next_non_branch_page = @framework.pages[index.next..].detect { |p| !p.branch? }
|
47
|
+
|
48
|
+
if next_non_branch_page.present?
|
49
|
+
{ next_non_branch_page.name => nil }
|
50
|
+
else
|
51
|
+
{ finish: nil }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Wizardry
|
2
|
+
# Instance holds data specific to this page/response
|
3
|
+
class Instance
|
4
|
+
attr_accessor :current_page, :object, :framework
|
5
|
+
|
6
|
+
def initialize(current_page:, object:, framework:)
|
7
|
+
@object = object
|
8
|
+
@framework = framework
|
9
|
+
@current_page = @framework.pages.detect { |p| p.name == current_page.to_sym }
|
10
|
+
|
11
|
+
raise(ActionController::RoutingError, %(Wizard page #{current_page} not found)) unless @current_page
|
12
|
+
end
|
13
|
+
|
14
|
+
def next_page(page = current_page)
|
15
|
+
next_branch_page(page) || next_trunk_page(page)
|
16
|
+
end
|
17
|
+
|
18
|
+
# find all the pages we've visited on our way to
|
19
|
+
# the current page
|
20
|
+
def route(from = framework.pages.first)
|
21
|
+
@route ||= route!(from)
|
22
|
+
end
|
23
|
+
|
24
|
+
def route!(from = framework.pages.first)
|
25
|
+
page = from
|
26
|
+
|
27
|
+
@route = [].tap do |completed|
|
28
|
+
until page == current_page
|
29
|
+
completed << page
|
30
|
+
|
31
|
+
page = next_page(page)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def valid_so_far?
|
37
|
+
route.all? { |complete_page| object.valid?(complete_page.name) }
|
38
|
+
end
|
39
|
+
|
40
|
+
# check this wizard hasn't already been completed using the
|
41
|
+
# object's :completion_flag
|
42
|
+
def ensure_not_complete
|
43
|
+
raise Wizardry::AlreadyCompletedError if complete?
|
44
|
+
end
|
45
|
+
|
46
|
+
def complete?
|
47
|
+
object.send(framework.completion_flag)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def next_branch_page(page)
|
53
|
+
next_page = page.next_pages.detect do |p|
|
54
|
+
p.condition.blank? || p.condition.call(object)
|
55
|
+
end
|
56
|
+
|
57
|
+
return unless next_page
|
58
|
+
|
59
|
+
framework.page(next_page.name)
|
60
|
+
end
|
61
|
+
|
62
|
+
# if the branch ends continue along the trunk from
|
63
|
+
# where we left off
|
64
|
+
def next_trunk_page(page)
|
65
|
+
page_index = framework.pages.index(page)
|
66
|
+
|
67
|
+
framework.pages.detect.with_index do |p, i|
|
68
|
+
next if p.branch?
|
69
|
+
|
70
|
+
i > page_index
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Pages
|
3
|
+
class CheckYourAnswersPage < Page
|
4
|
+
attr_reader :title, :questions
|
5
|
+
|
6
|
+
def initialize(questions: [], title: 'Check your answers')
|
7
|
+
@title = title
|
8
|
+
@questions = questions
|
9
|
+
|
10
|
+
super(pages: [])
|
11
|
+
end
|
12
|
+
|
13
|
+
def name
|
14
|
+
:check_your_answers
|
15
|
+
end
|
16
|
+
|
17
|
+
def question_names
|
18
|
+
@questions.map(&:name)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Pages
|
3
|
+
class Page
|
4
|
+
attr_reader :pages
|
5
|
+
|
6
|
+
def initialize(pages: [], before_edit: nil, before_update: nil, after_update: nil)
|
7
|
+
@pages = pages
|
8
|
+
@branch = false
|
9
|
+
|
10
|
+
# callbacks
|
11
|
+
@before_edit = before_edit
|
12
|
+
@before_update = before_update
|
13
|
+
@after_update = after_update
|
14
|
+
end
|
15
|
+
|
16
|
+
def branch!
|
17
|
+
@branch = true
|
18
|
+
end
|
19
|
+
|
20
|
+
def branch?
|
21
|
+
@branch
|
22
|
+
end
|
23
|
+
|
24
|
+
def questions
|
25
|
+
[]
|
26
|
+
end
|
27
|
+
|
28
|
+
def next_pages
|
29
|
+
[]
|
30
|
+
end
|
31
|
+
|
32
|
+
def before_edit!(object)
|
33
|
+
return unless @before_edit
|
34
|
+
|
35
|
+
@before_edit.call(object)
|
36
|
+
end
|
37
|
+
|
38
|
+
def before_update!(object)
|
39
|
+
return unless @before_update
|
40
|
+
|
41
|
+
@before_update.call(object)
|
42
|
+
end
|
43
|
+
|
44
|
+
def after_update!(object)
|
45
|
+
return unless @after_update
|
46
|
+
|
47
|
+
@after_update.call(object)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Pages
|
3
|
+
class QuestionPage < Page
|
4
|
+
attr_accessor :name, :questions, :title, :next_pages
|
5
|
+
|
6
|
+
def initialize(name, questions:, next_pages: {}, pages: [], title: nil, before_edit: nil, before_update: nil, after_update: nil)
|
7
|
+
Rails.logger.debug("🧙 Adding page '#{name}' with #{questions&.size || 'no'} questions")
|
8
|
+
|
9
|
+
@name = name
|
10
|
+
@title = title || name.capitalize
|
11
|
+
@questions = questions
|
12
|
+
@next_pages = next_pages
|
13
|
+
|
14
|
+
super(pages: pages, before_edit: before_edit, before_update: before_update, after_update: after_update)
|
15
|
+
end
|
16
|
+
|
17
|
+
def question_names
|
18
|
+
@questions.map(&:name)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
# Ask a date question
|
4
|
+
class Date < Wizardry::Questions::Answer
|
5
|
+
def initialize(name)
|
6
|
+
Rails.logger.debug("🧙 Adding date question '#{name}'")
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
def form_method
|
11
|
+
:govuk_date_field
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
# Ask a respondant for an email address
|
4
|
+
class EmailAddress < Wizardry::Questions::Answer
|
5
|
+
def initialize(name)
|
6
|
+
Rails.logger.debug("🧙 Adding email address question '#{name}'")
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
def form_method
|
11
|
+
:govuk_email_field
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
class Hidden < Answer
|
4
|
+
attr_reader :name
|
5
|
+
|
6
|
+
def initialize(name, value)
|
7
|
+
Rails.logger.debug("🧙 Adding hidden field '#{name}'")
|
8
|
+
@value = value
|
9
|
+
|
10
|
+
super(name)
|
11
|
+
end
|
12
|
+
|
13
|
+
def form_method
|
14
|
+
:hidden_field
|
15
|
+
end
|
16
|
+
|
17
|
+
def extra_kwargs
|
18
|
+
{ value: @value }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
# Ask a question that can be answered with multiple lines of text
|
4
|
+
class LongAnswer < Wizardry::Questions::Answer
|
5
|
+
def initialize(name)
|
6
|
+
Rails.logger.debug("🧙 Adding long question '#{name}'")
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
def form_method
|
11
|
+
:govuk_text_area
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
class Radios < Wizardry::Questions::Answer
|
4
|
+
attr_reader :options
|
5
|
+
|
6
|
+
RadioOption = Struct.new(:value, :label, keyword_init: true)
|
7
|
+
|
8
|
+
def initialize(name, options)
|
9
|
+
Rails.logger.debug("🧙 Adding radios '#{name}' with options #{options}")
|
10
|
+
|
11
|
+
@options = options
|
12
|
+
|
13
|
+
super(name)
|
14
|
+
end
|
15
|
+
|
16
|
+
def form_method
|
17
|
+
:govuk_collection_radio_buttons
|
18
|
+
end
|
19
|
+
|
20
|
+
def extra_args
|
21
|
+
[build_options, :value, :label]
|
22
|
+
end
|
23
|
+
|
24
|
+
def build_options
|
25
|
+
case options
|
26
|
+
when Array
|
27
|
+
options.map { |v| Wizardry::Questions::Radios::RadioOption.new(value: v, label: v) }
|
28
|
+
when Hash
|
29
|
+
options.map { |k, v| Wizardry::Questions::Radios::RadioOption.new(value: k, label: v) }
|
30
|
+
else
|
31
|
+
fail ArgumentError, "Options must be an Hash or Array"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
# Ask a question that can be answered with one line of text
|
4
|
+
class ShortAnswer < Wizardry::Questions::Answer
|
5
|
+
def initialize(name)
|
6
|
+
Rails.logger.debug("🧙 Adding short question '#{name}'")
|
7
|
+
super(name)
|
8
|
+
end
|
9
|
+
|
10
|
+
def form_method
|
11
|
+
:govuk_text_field
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Wizardry
|
2
|
+
module Questions
|
3
|
+
# Ask a respondant for a telephone number
|
4
|
+
class TelephoneNumber < Wizardry::Questions::Answer
|
5
|
+
def initialize(name)
|
6
|
+
Rails.logger.debug("🧙 Adding telephone number question '#{name}'")
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
def form_method
|
11
|
+
:govuk_phone_field
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
data/lib/wizardry.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
require "wizardry/engine"
|
2
|
+
require "wizardry/railtie"
|
3
|
+
require "wizardry/framework"
|
4
|
+
require "wizardry/instance"
|
5
|
+
|
6
|
+
require "wizardry/pages/page"
|
7
|
+
require "wizardry/pages/question_page"
|
8
|
+
require "wizardry/pages/check_your_answers_page"
|
9
|
+
require "wizardry/pages/completion_page"
|
10
|
+
|
11
|
+
require "wizardry/questions/answer"
|
12
|
+
require "wizardry/questions/short_answer"
|
13
|
+
require "wizardry/questions/long_answer"
|
14
|
+
require "wizardry/questions/radios"
|
15
|
+
require "wizardry/questions/telephone_number"
|
16
|
+
require "wizardry/questions/email_address"
|
17
|
+
require "wizardry/questions/date"
|
18
|
+
require "wizardry/questions/hidden"
|
19
|
+
|
20
|
+
require "wizardry/routing/next_page"
|
21
|
+
|
22
|
+
require "govuk_design_system_formbuilder"
|
23
|
+
require "govuk/components"
|
24
|
+
|
25
|
+
module Wizardry
|
26
|
+
extend ActiveSupport::Concern
|
27
|
+
|
28
|
+
class AlreadyCompletedError < StandardError; end
|
29
|
+
|
30
|
+
class_methods do
|
31
|
+
def wizard(...)
|
32
|
+
define_method(:wizard) do
|
33
|
+
@framework ||= Wizardry::Framework.new(...)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
included do
|
39
|
+
before_action :setup_wizard, :check_wizard
|
40
|
+
|
41
|
+
def edit
|
42
|
+
Rails.logger.debug("🧙 Running before_edit callback")
|
43
|
+
@wizard.current_page.before_edit!(@wizard.object)
|
44
|
+
end
|
45
|
+
|
46
|
+
def update
|
47
|
+
Rails.logger.debug("🧙 Object valid, saving and moving on")
|
48
|
+
@wizard.object.assign_attributes(object_params.merge(last_completed_step_params))
|
49
|
+
|
50
|
+
Rails.logger.debug("🧙 Running before_update callback")
|
51
|
+
@wizard.current_page.before_update!(@wizard.object)
|
52
|
+
|
53
|
+
if @wizard.object.valid?(@wizard.current_page.name)
|
54
|
+
@wizard.object.transaction do
|
55
|
+
@wizard.object.save!
|
56
|
+
Rails.logger.debug("🧙 Object saved, trying after_update callback")
|
57
|
+
|
58
|
+
@wizard.current_page.after_update!(@wizard.object)
|
59
|
+
Rails.logger.debug("🧙 Object saved and callbacks run, moving on")
|
60
|
+
|
61
|
+
finalize_object if @wizard.complete?
|
62
|
+
end
|
63
|
+
|
64
|
+
redirect_to send(@wizard.framework.edit_path_helper, @wizard.next_page.name.to_s.dasherize)
|
65
|
+
else
|
66
|
+
Rails.logger.debug("🧙 Object not valid, try again")
|
67
|
+
|
68
|
+
render :edit
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def finalize_object(finalize: :finalize!)
|
75
|
+
Rails.logger.debug("🧙 Wizard complete, finalizing object")
|
76
|
+
|
77
|
+
if @wizard.object.respond_to?(finalize)
|
78
|
+
@wizard.object.send(finalize)
|
79
|
+
Rails.logger.debug("🧙 Wizard object finalized")
|
80
|
+
else
|
81
|
+
Rails.logger.warn("🧙 Wizard object has no #{finalize} method")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def check_wizard
|
86
|
+
@wizard.ensure_not_complete unless @wizard.current_page.is_a?(Wizardry::Pages::CompletionPage)
|
87
|
+
end
|
88
|
+
|
89
|
+
def setup_wizard
|
90
|
+
Rails.logger.debug("🧙 Finding or initialising #{wizard.class_name} with '#{identifier}'")
|
91
|
+
|
92
|
+
object = wizard.class_name.constantize.find_or_initialize_by(identifier: identifier)
|
93
|
+
|
94
|
+
Rails.logger.debug("🧙 Initialising the wizard 🪄")
|
95
|
+
|
96
|
+
@wizard = Wizardry::Instance.new(current_page: params[:page].underscore, object: object, framework: @framework)
|
97
|
+
end
|
98
|
+
|
99
|
+
def object_params
|
100
|
+
param_key = @wizard.framework.class_name.constantize.model_name.param_key
|
101
|
+
|
102
|
+
params.require(param_key).permit(@wizard.current_page.question_names)
|
103
|
+
rescue ActionController::ParameterMissing
|
104
|
+
{ param_key => last_completed_step_params }
|
105
|
+
end
|
106
|
+
|
107
|
+
def last_completed_step_params
|
108
|
+
{ last_completed_step: @wizard.current_page.name }
|
109
|
+
end
|
110
|
+
|
111
|
+
def identifier
|
112
|
+
cookies.fetch(wizard.cookie_name) { cookies[wizard.cookie_name] = SecureRandom.uuid }
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
metadata
ADDED
@@ -0,0 +1,251 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: govuk-wizardry
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Peter Yates
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-10-31 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: govuk-components
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 2.1.3
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 2.1.3
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: govuk_design_system_formbuilder
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.7.5
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 2.7.5
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rails
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 7.0.0a2
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 7.0.0a2
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: capybara
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pry-byebug
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry-rails
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rails-controller-testing
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rspec-expectations
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: rspec-rails
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: rubocop-govuk
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: shoulda-matchers
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ">="
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: sqlite3
|
169
|
+
requirement: !ruby/object:Gem::Requirement
|
170
|
+
requirements:
|
171
|
+
- - ">="
|
172
|
+
- !ruby/object:Gem::Version
|
173
|
+
version: '0'
|
174
|
+
type: :development
|
175
|
+
prerelease: false
|
176
|
+
version_requirements: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: '0'
|
181
|
+
- !ruby/object:Gem::Dependency
|
182
|
+
name: webrick
|
183
|
+
requirement: !ruby/object:Gem::Requirement
|
184
|
+
requirements:
|
185
|
+
- - ">="
|
186
|
+
- !ruby/object:Gem::Version
|
187
|
+
version: '0'
|
188
|
+
type: :development
|
189
|
+
prerelease: false
|
190
|
+
version_requirements: !ruby/object:Gem::Requirement
|
191
|
+
requirements:
|
192
|
+
- - ">="
|
193
|
+
- !ruby/object:Gem::Version
|
194
|
+
version: '0'
|
195
|
+
description: A gem that allows an entire GOV.UK wizard/journey to be easily defined
|
196
|
+
and built
|
197
|
+
email:
|
198
|
+
- peter.yates@graphia.co.uk
|
199
|
+
executables: []
|
200
|
+
extensions: []
|
201
|
+
extra_rdoc_files: []
|
202
|
+
files:
|
203
|
+
- MIT-LICENSE
|
204
|
+
- README.md
|
205
|
+
- Rakefile
|
206
|
+
- app/helpers/wizardry_helper.rb
|
207
|
+
- lib/tasks/wizardry_tasks.rake
|
208
|
+
- lib/wizardry.rb
|
209
|
+
- lib/wizardry/engine.rb
|
210
|
+
- lib/wizardry/framework.rb
|
211
|
+
- lib/wizardry/graph.rb
|
212
|
+
- lib/wizardry/instance.rb
|
213
|
+
- lib/wizardry/pages/check_your_answers_page.rb
|
214
|
+
- lib/wizardry/pages/completion_page.rb
|
215
|
+
- lib/wizardry/pages/page.rb
|
216
|
+
- lib/wizardry/pages/question_page.rb
|
217
|
+
- lib/wizardry/questions/answer.rb
|
218
|
+
- lib/wizardry/questions/date.rb
|
219
|
+
- lib/wizardry/questions/email_address.rb
|
220
|
+
- lib/wizardry/questions/hidden.rb
|
221
|
+
- lib/wizardry/questions/long_answer.rb
|
222
|
+
- lib/wizardry/questions/radios.rb
|
223
|
+
- lib/wizardry/questions/short_answer.rb
|
224
|
+
- lib/wizardry/questions/telephone_number.rb
|
225
|
+
- lib/wizardry/railtie.rb
|
226
|
+
- lib/wizardry/routing/next_page.rb
|
227
|
+
- lib/wizardry/version.rb
|
228
|
+
homepage: https://www.github.com/graphia/wizardry
|
229
|
+
licenses:
|
230
|
+
- MIT
|
231
|
+
metadata: {}
|
232
|
+
post_install_message:
|
233
|
+
rdoc_options: []
|
234
|
+
require_paths:
|
235
|
+
- lib
|
236
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
237
|
+
requirements:
|
238
|
+
- - ">="
|
239
|
+
- !ruby/object:Gem::Version
|
240
|
+
version: '0'
|
241
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
242
|
+
requirements:
|
243
|
+
- - ">="
|
244
|
+
- !ruby/object:Gem::Version
|
245
|
+
version: '0'
|
246
|
+
requirements: []
|
247
|
+
rubygems_version: 3.2.22
|
248
|
+
signing_key:
|
249
|
+
specification_version: 4
|
250
|
+
summary: GOV.UK design system wizard generator
|
251
|
+
test_files: []
|