Skip to content

Commit e79ea54

Browse files
authored
Merge pull request #2080 from alphagov/ldeb-sc-add-form-state-machine
Add form state machine logic
2 parents f732ddf + af4acea commit e79ea54

19 files changed

Lines changed: 1106 additions & 46 deletions

Gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ gem "csv"
8484
# Used for sorting/ordering of pages object
8585
gem "acts_as_list"
8686

87+
# Add state machine for forms
88+
gem "aasm", "~> 5.5"
89+
# Used by AASM to autocommit state changes when even method is used with bang eg. make_live!
90+
gem "after_commit_everywhere", "~> 1.6"
91+
8792
group :development, :test do
8893
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
8994
gem "debug", platforms: %i[mri mingw x64_mingw]

Gemfile.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ GEM
2424
MailchimpMarketing (3.0.80)
2525
excon (>= 0.76.0, < 1)
2626
json (~> 2.1, >= 2.1.0)
27+
aasm (5.5.1)
28+
concurrent-ruby (~> 1.0)
2729
actioncable (8.0.2)
2830
actionpack (= 8.0.2)
2931
activesupport (= 8.0.2)
@@ -108,6 +110,9 @@ GEM
108110
activesupport (>= 6.1)
109111
addressable (2.8.7)
110112
public_suffix (>= 2.0.2, < 7.0)
113+
after_commit_everywhere (1.6.0)
114+
activerecord (>= 4.2)
115+
activesupport
111116
ast (2.4.3)
112117
aws-eventstream (1.4.0)
113118
aws-partitions (1.1114.0)
@@ -568,8 +573,10 @@ PLATFORMS
568573

569574
DEPENDENCIES
570575
MailchimpMarketing (~> 3.0)
576+
aasm (~> 5.5)
571577
activeresource (~> 6.1)
572578
acts_as_list
579+
after_commit_everywhere (~> 1.6)
573580
aws-sdk-cloudwatch (~> 1.116)
574581
aws-sdk-codepipeline (~> 1.101)
575582
axe-core-rspec

app/models/condition.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ class Condition < ApplicationRecord
77

88
before_destroy :destroy_postconditions
99

10+
def save_and_update_form
11+
save!
12+
# TODO: https://trello.com/c/dg9CFPgp/1503-user-triggers-state-change-from-live-to-livewithdraft
13+
# Will not be needed when users can trigger this event themselves through the UI
14+
form.create_draft_from_live_form if form.live?
15+
form.update!(question_section_completed: false)
16+
end
17+
18+
def destroy_and_update_form!
19+
destroy! && form.update!(question_section_completed: false)
20+
end
21+
1022
private
1123

1224
def destroy_postconditions

app/models/form.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
class Form < ApplicationRecord
2+
include FormStateMachine
3+
24
has_many :pages, -> { order(position: :asc) }, dependent: :destroy
35

46
enum :submission_type, {
@@ -9,9 +11,29 @@ class Form < ApplicationRecord
911

1012
after_create :set_external_id
1113

14+
def has_draft_version
15+
draft? || live_with_draft? || archived_with_draft?
16+
end
17+
18+
def has_live_version
19+
live? || live_with_draft?
20+
end
21+
22+
def has_been_archived
23+
archived? || archived_with_draft?
24+
end
25+
26+
def ready_for_live
27+
task_status_service.mandatory_tasks_completed?
28+
end
29+
1230
private
1331

1432
def set_external_id
1533
update(external_id: id)
1634
end
35+
36+
def task_status_service
37+
@task_status_service ||= TaskStatusService.new(form: self)
38+
end
1739
end

app/models/page.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ class Page < ApplicationRecord
1313

1414
ANSWER_TYPES_WITH_SETTINGS = %w[selection text date address name].freeze
1515

16+
def destroy_and_update_form!
17+
form = self.form
18+
destroy! && form.update!(question_section_completed: false)
19+
end
20+
21+
def save_and_update_form
22+
return true unless has_changes_to_save?
23+
24+
save!
25+
# TODO: https://trello.com/c/dg9CFPgp/1503-user-triggers-state-change-from-live-to-livewithdraft
26+
# Will not be needed when users can trigger this event themselves through the UI
27+
form.create_draft_from_live_form! if form.live?
28+
form.create_draft_from_archived_form! if form.archived?
29+
30+
form.update!(question_section_completed: false)
31+
check_conditions.destroy_all if answer_type_changed_from_selection
32+
check_conditions.destroy_all if answer_settings_changed_from_only_one_option
33+
34+
true
35+
end
36+
1637
private
1738

1839
def destroy_secondary_skip_conditions
@@ -28,4 +49,15 @@ def destroy_secondary_skip_conditions
2849
.filter { |condition| condition.check_page_id != condition.routing_page_id }
2950
.each(&:destroy!)
3051
end
52+
53+
def answer_type_changed_from_selection
54+
answer_type_previously_was&.to_sym == :selection && answer_type&.to_sym != :selection
55+
end
56+
57+
def answer_settings_changed_from_only_one_option
58+
from_only_one_option = ActiveModel::Type::Boolean.new.cast(answer_settings_previously_was.try(:[], "only_one_option"))
59+
to_multiple_options = !ActiveModel::Type::Boolean.new.cast(answer_settings.try(:[], "only_one_option"))
60+
61+
from_only_one_option && to_multiple_options
62+
end
3163
end

app/services/condition_repository.rb

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def create!(form_id:,
2020
exit_page_heading:,
2121
exit_page_markdown:,
2222
)
23-
save_to_database!(condition)
23+
update_and_save_to_database!(condition)
2424
condition
2525
end
2626

@@ -34,7 +34,7 @@ def save!(record)
3434
condition = Api::V1::ConditionResource.new(record.attributes, true)
3535
condition.prefix_options = record.prefix_options
3636
condition.save!
37-
save_to_database!(condition)
37+
update_and_save_to_database!(condition)
3838
condition
3939
end
4040

@@ -44,7 +44,7 @@ def destroy(record)
4444

4545
begin
4646
condition.destroy # rubocop:disable Rails/SaveBang
47-
Condition.destroy(record.id)
47+
Condition.find(record.id).destroy_and_update_form!
4848
rescue ActiveResource::ResourceNotFound, ActiveRecord::RecordNotFound
4949
# ActiveRecord::Persistence#destroy doesn't raise an error
5050
# if record has already been destroyed, let's emulate that
@@ -58,5 +58,11 @@ def destroy(record)
5858
def save_to_database!(record)
5959
Condition.upsert(record.database_attributes)
6060
end
61+
62+
def update_and_save_to_database!(record)
63+
condition = Condition.find_or_initialize_by(id: record.id)
64+
condition.assign_attributes(record.database_attributes)
65+
condition.save_and_update_form
66+
end
6167
end
6268
end

app/services/form_repository.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,31 @@ def save!(record)
2828
form = Api::V1::FormResource.new(record.attributes, true)
2929
form.save!
3030
save_to_database!(form)
31+
db_form = Form.find(record.id)
32+
db_form.create_draft_from_live_form! if db_form.live?
33+
db_form.create_draft_from_archived_form! if db_form.archived?
3134
form
3235
end
3336

3437
def make_live!(record)
3538
form = Api::V1::FormResource.new(record.attributes, true)
36-
39+
save_to_database!(form)
40+
save_pages_to_database!(form, form.pages) if Form.find(record.id).pages.empty?
3741
response = form.make_live!
3842
form.from_json(response.body)
39-
40-
save_to_database!(form)
43+
Form.find(record.id).make_live!
4144

4245
form
4346
end
4447

4548
def archive!(record)
4649
form = Api::V1::FormResource.new(record.attributes, true)
4750

51+
save_to_database!(form)
4852
response = form.archive!
4953
form.from_json(response.body)
54+
Form.find(record.id).archive_live_form!
5055

51-
save_to_database!(form)
5256
form
5357
end
5458

app/services/page_repository.rb

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ def create!(form_id:,
2626
guidance_markdown:,
2727
answer_type:,
2828
)
29-
save_to_database!(page)
29+
update_and_save_to_database!(page)
3030
page
3131
end
3232

3333
def save!(record)
3434
page = Api::V1::PageResource.new(record.attributes, true)
3535
page.prefix_options = record.prefix_options
3636
page.save!
37-
save_to_database!(page)
37+
update_and_save_to_database!(page)
3838
page
3939
end
4040

@@ -44,7 +44,7 @@ def destroy(record)
4444

4545
begin
4646
page.destroy # rubocop:disable Rails/SaveBang
47-
Page.destroy(record.id)
47+
Page.find(record.id).destroy_and_update_form!
4848
rescue ActiveResource::ResourceNotFound, ActiveRecord::RecordNotFound
4949
# ActiveRecord::Persistence#destroy doesn't raise an error
5050
# if record has already been destroyed, let's emulate that
@@ -58,7 +58,7 @@ def move_page(record, direction)
5858
page.prefix_options = record.prefix_options
5959

6060
page.move_page(direction)
61-
save_to_database!(page)
61+
update_and_save_to_database!(page)
6262

6363
page
6464
end
@@ -101,5 +101,27 @@ def save_to_database!(record)
101101

102102
save_routing_conditions_to_database!(record)
103103
end
104+
105+
def update_and_save_to_database!(record)
106+
attributes = record.database_attributes
107+
108+
begin
109+
# transaction is required to be able to retry after catching exception
110+
ActiveRecord::Base.transaction do
111+
page = Page.find_or_initialize_by(id: record.id)
112+
page.assign_attributes(**attributes)
113+
page.save_and_update_form
114+
end
115+
# we get an exception if the form does not already exist in the database
116+
rescue ActiveRecord::RecordInvalid => e
117+
raise unless e.message.include? "Form must exist"
118+
119+
find_form_and_save_to_database!(record)
120+
121+
retry
122+
end
123+
124+
save_routing_conditions_to_database!(record)
125+
end
104126
end
105127
end
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
class TaskStatusService
2+
def initialize(form:)
3+
@form = form
4+
end
5+
6+
def mandatory_tasks_completed?
7+
incomplete_tasks.empty?
8+
end
9+
10+
def incomplete_tasks
11+
{ missing_pages: pages_status,
12+
missing_what_happens_next: what_happens_next_status,
13+
missing_privacy_policy_url: privacy_policy_status,
14+
missing_contact_details: support_contact_details_status,
15+
share_preview_not_completed: share_preview_status }.reject { |_k, v| v == :completed }.map { |k, _v| k }
16+
end
17+
18+
def task_statuses
19+
{
20+
name_status:,
21+
pages_status:,
22+
declaration_status:,
23+
what_happens_next_status:,
24+
payment_link_status:,
25+
privacy_policy_status:,
26+
support_contact_details_status:,
27+
receive_csv_status:,
28+
share_preview_status:,
29+
make_live_status:,
30+
}
31+
end
32+
33+
private
34+
35+
def name_status
36+
:completed
37+
end
38+
39+
def pages_status
40+
return :completed if @form.question_section_completed && @form.pages.any?
41+
return :in_progress if @form.pages.any?
42+
43+
:not_started
44+
end
45+
46+
def declaration_status
47+
return :completed if @form.declaration_section_completed
48+
return :in_progress if @form.declaration_text.present?
49+
50+
:not_started
51+
end
52+
53+
def what_happens_next_status
54+
return :completed if @form.what_happens_next_markdown.present?
55+
56+
:not_started
57+
end
58+
59+
def payment_link_status
60+
return :completed if @form.payment_url.present?
61+
62+
:optional
63+
end
64+
65+
def privacy_policy_status
66+
return :completed if @form.privacy_policy_url.present?
67+
68+
:not_started
69+
end
70+
71+
def support_contact_details_status
72+
return :completed if @form.support_email.present? || @form.support_phone.present? || (@form.support_url_text.present? && @form.support_url)
73+
74+
:not_started
75+
end
76+
77+
def receive_csv_status
78+
return :completed if @form.email_with_csv?
79+
80+
:optional
81+
end
82+
83+
def share_preview_status
84+
return :cannot_start unless @form.pages.any?
85+
return :completed if @form.share_preview_completed?
86+
87+
:not_started
88+
end
89+
90+
def make_live_status
91+
return make_live_status_for_draft if @form.has_draft_version
92+
return :not_started if @form.has_been_archived
93+
94+
:completed if @form.has_live_version
95+
end
96+
97+
def make_live_status_for_draft
98+
mandatory_tasks_completed? ? :not_started : :cannot_start
99+
end
100+
end

0 commit comments

Comments
 (0)