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
3 changes: 2 additions & 1 deletion app/models/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Form < ApplicationRecord

after_create :set_external_id
after_update :update_draft_form_document
ATTRIBUTES_NOT_IN_FORM_DOCUMENT = %i[state external_id pages question_section_completed declaration_section_completed share_preview_completed].freeze

def save_draft!
save!
Expand Down Expand Up @@ -148,7 +149,7 @@ def file_upload_question_count

def as_form_document(live_at: nil)
content = as_json(
except: %i[state external_id pages question_section_completed declaration_section_completed share_preview_completed],
except: ATTRIBUTES_NOT_IN_FORM_DOCUMENT,
methods: %i[start_page steps],
)
content["form_id"] = content.delete("id").to_s
Expand Down
112 changes: 112 additions & 0 deletions app/services/revert_draft_form_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# This service reverts a draft form to a form_document with a given tag,
# effectively discarding all draft changes
class RevertDraftFormService
attr_reader :form

# A list of attributes on the Form model that should be not be reverted
FORM_ATTRIBUTES_TO_PRESERVE = %i[id created_at updated_at creator_id].freeze
ATTRIBUTES_TO_EXCLUDE = Form::ATTRIBUTES_NOT_IN_FORM_DOCUMENT + FORM_ATTRIBUTES_TO_PRESERVE

def initialize(form)
@form = form
end

# Discards draft changes by reverting the form and its associations
# to the state of the form_document with the given tag
# Returns true on success, false on failure
def revert_draft_from_form_document(tag)
# Return early if there's no draft to discard
form_document = FormDocument.find_by(form_id: form.id, tag:, language: "en")
return false if form_document.blank?

form_document_content = form_document.content

ActiveRecord::Base.transaction do
revert_form_attributes(form_document_content)
revert_pages_and_nested_associations(form_document_content["steps"])

form.delete_draft_from_live_form
form.save!
end

true
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("Failed to discard draft for form #{form.id}: #{e.message}")
false
end

private

# revert the top-level attributes of the Form object
def revert_form_attributes(form_document_content)
attributes_to_update = Form.attribute_names - ATTRIBUTES_TO_EXCLUDE.map(&:to_s)

form.assign_attributes(form_document_content.slice(*attributes_to_update))
end

def revert_pages_and_nested_associations(steps_data)
form.pages.reload

revert_pages(steps_data)

# revert conditions after pages are created to make sure conditions don't
# have validation errors if the page hasn't been created yet
revert_routing_conditions(steps_data)
end

def revert_pages(steps_data)
form_document_step_ids = steps_data.pluck("id")

# delete any pages on the form that are not present in the form_document version
form.pages.where.not(id: form_document_step_ids).destroy_all

# iterate through the form_document steps data to create or update pages using the original ids
steps_data.each do |step_data|
page = form.pages.find_or_initialize_by(id: step_data["id"])

assign_page_attributes(page, step_data)

page.save!
end
end

# steps in a form_document store the page attributes under "data"
def assign_page_attributes(page, step_data)
page_data = step_data["data"]
page.assign_attributes(
position: step_data["position"],
question_text: page_data["question_text"],
hint_text: page_data["hint_text"],
answer_type: page_data["answer_type"],
is_optional: page_data["is_optional"],
answer_settings: page_data["answer_settings"],
page_heading: page_data["page_heading"],
guidance_markdown: page_data["guidance_markdown"],
is_repeatable: page_data["is_repeatable"],
)
end

def revert_routing_conditions(steps_data)
all_conditions_data = steps_data.flat_map { |step| step["routing_conditions"] || [] }
form_document_condition_ids = all_conditions_data.pluck("id")

# remove any conditions which have been added to the draft but are not in the form_document data
Condition.where(routing_page_id: form.pages.select(:id)).where.not(id: form_document_condition_ids).destroy_all

all_conditions_data.each do |condition_data|
condition = Condition.find_or_initialize_by(id: condition_data["id"])

assign_condition_attributes(condition, condition_data)
condition.save!
end
end

def assign_condition_attributes(condition, condition_data)
condition.assign_attributes(
answer_value: condition_data["answer_value"],
routing_page_id: condition_data["routing_page_id"],
check_page_id: condition_data["check_page_id"],
goto_page_id: condition_data["goto_page_id"],
)
end
end
4 changes: 4 additions & 0 deletions app/state_machines/form_state_machine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ module FormStateMachine
transitions from: :live, to: :archived
transitions from: :live_with_draft, to: :archived_with_draft
end

event :delete_draft_from_live_form do
transitions from: %i[live_with_draft live], to: :live
end
end
end
end
147 changes: 147 additions & 0 deletions spec/services/revert_draft_form_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
require "rails_helper"

describe RevertDraftFormService do
subject(:revert_draft_form_service) { described_class.new(form) }

let(:form) { create(:form, :live_with_draft) }
let(:tag) { :live }

RSpec::Matchers.define :be_reverted_to_state do |expected_state|
match do |form|
# reload the form to get the latest state from the database
reloaded_form = form.reload

# the form should be live
# is_live = reloaded_form.live?
state_matches = reloaded_form.state == expected_state.to_s

# We convert the form to a form document and compare the content
# to the live form document content and check they match baring the live_at times
# this is the closest we can get to saying there is no changes to the form
form_document = FormDocument.find_by(form_id: form.id, tag:, language: "en")
document_matches = reloaded_form.as_form_document.except("live_at") == form_document.content.except("live_at")

state_matches && document_matches
end
end

def revert_draft_to_live
revert_draft_form_service.revert_draft_from_form_document(tag)
end

# we use `freeze_time` to freeze the timestamps of the form and its pages
# reverting a draft will not keep the timestamps from the live version
around { |example| freeze_time { example.run } }

context "when the draft has no changes" do
it "reverts the form to its live state" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "when a form attribute is changed in the draft" do
before do
form.update!(name: "A new draft name")
end

it "reverts the attribute change" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "when a page attribute is changed in the draft" do
before do
form.pages.first.update!(question_text: "A new draft question text")
end

it "reverts the page change" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "when a page is added to the draft" do
before do
form.pages.create!(answer_type: "text", question_text: "A new page added to the draft", is_optional: false)
end

it "removes the added page" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "when a page is removed from the draft" do
before do
form.pages.last.destroy!
end

it "re-adds the removed page" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "with routing conditions" do
let(:form) { create(:form, :ready_for_live, pages_count: 2) }

before do
# live version with a routing condition
form.pages.first.routing_conditions.create!(
answer_value: "Yes",
goto_page_id: form.pages.last.id,
routing_page_id: form.pages.first.id,
)
FormDocument.create!(form:, tag: "live", content: form.as_form_document(live_at: form.updated_at))
form.update!(state: :live_with_draft)
end

context "when a routing condition is added to the draft" do
before do
form.pages.first.routing_conditions.create!(
answer_value: "No",
goto_page_id: form.pages.last.id,
routing_page_id: form.pages.first.id,
)
end

it "removes the added routing condition" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "when a routing condition is removed from the draft" do
before do
form.pages.first.routing_conditions.first.destroy!
end

it "re-adds the removed routing condition" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end

context "when a routing condition is changed in the draft" do
before do
form.pages.first.routing_conditions.first.update!(answer_value: "Maybe")
end

it "reverts the changed routing condition" do
revert_draft_to_live
expect(form).to be_reverted_to_state(:live)
end
end
end

context "when reverting to an archived form_document" do
let(:form) { create(:form, :archived) }
let(:tag) { :archived }

it "reverts the form to its live state" do
expect(form).to be_reverted_to_state(:archived)
end
end
end