Skip to content

Conversation

thomasiles
Copy link
Contributor

@thomasiles thomasiles commented Oct 2, 2025

Add a new service to revert the changes in a draft form to the live version

Trello card: https://trello.com/c/tP5Fd96d/2610-add-the-ability-to-delete-a-draft-of-a-live-form-with-a-confirmation-page

Motivation

Form creators with a live form can make changes without altering the live form. We call this form the draft form and the live form is not affected. Form creators can make a series of updates to the form, check it and then make the new version live.

Sometimes a form creator makes changes but then doesn't want to make them live. Instead they want to start the editing again from the current live version. This is thought of as deleting the draft version. Again the live version isn't changed.

How are live and draft forms implemented?

See ADR17 and ADR25 for more context.

We have one model which represents the form. It holds the current edited state of the form. It has an association to pages, which themselves can contain conditions.

A state machine is used to model a Form's current publishing lifecycle status.

Form's start off in the draft state. When they are ready to be made live they transition to :live and a FormDocument is created. The FormDocument contains a snapshot of the forms data to be used by the runner to display the form to form fillers.

When a form creator views a form in the admin interface they are redirected to different views based on the form's state. There is a live view and view for editing drafts (the tasklist).

When a form creator wants to make changes to live form, they follow a link to the tasklist view. If they make any changes to a form in the :live state, it's transitioned into the :live_with_draft state.

Deleting drafts

Because :draft and :live_with_draft are just states on the form there is not technically anything to delete. Instead we must reset everything in the Form to match the snapshot taken when the form was made live.

Because the only information about what the contained is in the live FormDocument, we need to pick through it and find the data we need there.

This PR adds a new service which takes this approach. It contains a whitelist of attributes to copy from the form and then goes through the pages of the form, known as steps in the FormDocument, making sure they match.

I don't think there is a definite check to say that the form matches the live version. In the tests we rely on the idea that if we were to make the draft version live again it should match the live FormDocument.

Things to consider when reviewing

  • Is this a maintainable solution for future changes? Is it likely adding new features will catch us out?
  • is there a better place to put this code? Should it be tied more closely to the code which makes FormDocuments from Forms?
  • Are there any alternatives to this approach? Could we easily change things to make all changes FormDocuments so that we would have something to delete?

class RevertDraftFormService
attr_reader :form

# A list of attributes on the Form model that should be reverted
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be easier, and shorter, to maintain a list of exclusions?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah I think that's a fair question. Thats how we started out yesterday with the except code.

I think a list of things to include is more explicit. The real difference is how we want it in the future when we add attributes to form. Do we want to include them by default or not? I'm not sure either way 🤷

Copy link
Contributor

Choose a reason for hiding this comment

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

Then can we make that list a module that we mix-in and only update once?

Copy link
Contributor

Choose a reason for hiding this comment

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

The as_form_document method uses a list of exceptions - I think it could potentially be confusing if the attributes are implicit when creating a form document from the current state, but explicit when doing the reverse

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've switched this to use a list of exceptions rather than explicitly stating what should be copied.

I've pulled out the list of exceptions in Form into a constant and use that combined with another list of attributes which shouldn't be reverted.

Overall, I think we'd be better off making it more explicit about what goes in a form document. I think that's a bigger conversation though so this brings it in line with how we are creating the form_document in Form.

@thomasiles thomasiles marked this pull request as ready for review October 2, 2025 14:24
declaration_text
external_id
form_slug
language
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure about including language and available_languages in this list... I don't understand yet how the Welsh language feature is going to work, but it seems surprising behaviour that deleting the draft for one language could delete the draft for another, which is what this implies to me...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I'm not sure how this will work for welsh either.

I think if you create a draft form and add welsh to it, then delete the draft, you would expect the welsh version to be deleted as well.

In that case language should be included.

This will need to be updated when we add translations because we'll need to take some measures to use the right set of formDocuments when we update the form. I think.

Add a new service which will take a live form with a draft and revert
the changes to match the live form.
# Returns true on success, false on failure
def revert_draft_to_live
# Return early if there's no draft to discard
return true unless form.live_with_draft?
Copy link
Contributor

Choose a reason for hiding this comment

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

We also have an archived_with_draft status, I'm not sure whether restoring from an archived version is something we should cover as part of this card or make a new one for it, but it feels like a valid thing to do

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've updated the service to make it more generic so we can add deleting drafts from archived forms in the future.

page = form.pages.find_or_initialize_by(id: step_data["id"])

assign_page_attributes(page, step_data)
synchronize_routing_conditions_for_page(page, step_data["routing_conditions"] || [])
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'm not fully understanding the difference between RoutingConditions and the other Conditions...? How come we only copy over

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Condition is the ActiveRecord model. "routing_conditions" is how they are stored in form_document. Take a look at https://github.com/alphagov/forms/blob/main/ADR/ADR034-api-v2-for-forms.md#form-document-response to see an example.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why do check_conditions and goto_conditions not go into the FormDocument? Also asking in relation to copying a Form, and whether to also ignore them or if they need to be copied as I'm not clear what they're for

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All conditions for a form go into the document. Conditions in a form document are stored once under each ["step"]["routing_condition"].

Here is a section from an example in the above ADR:

{
     "step": {
          id: 18
      /* ..other steps attributes... */
      "routing_conditions": [
        {
          "id": 3,
          "check_step_id": 18,
          "routing_step_id": 18,
          "goto_step_id": null,
          "answer_value": "Yes",
          "created_at": "2024-08-02T08:54:07.479Z",
          "updated_at": "2024-08-02T08:54:07.479Z",
          "skip_to_end": true,
          "validation_errors": []
        }
      ]
   }
}

The step.id, is the id of the step (which is a Page in active record but stored as step in the form document).

step.routing_conditions contains an array of Conditions linked to the step through routing_step_id. check_step_id and goto_step_id are references to other steps in the form. These are just references, not extra conditions. All Conditions have a routing_step_id which links them to a Step.

In this PR we are extracting values from the json form document, so we go through each "step" and create them, setting the references as we go.

In your PR, you will go through each Condition in Page.routing_conditions setting the values directly from the Condition. This will ensure you have all of Conditions.

# to the state of the form_document with the given tag
# Returns true on success, false on failure
def revert_draft_to_live
def revert_draft_to_form_document(tag)
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if revert_draft_from_form_document(tag) is a little better, as to mean it would read as revert draft from form document (that's live or archived)

# effectively discarding all draft changes
class RevertDraftFormService
attr_reader :form

Copy link
Contributor

Choose a reason for hiding this comment

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

Please can we reword the commit message from blacklist to blocklist 🙏

To allow reverting to an archived form document as well as the live form
document we pass the tag of the form document to revert to as a
parameter.

We also rename the method and change any references to live to match the
new functionality.
Move the list of attributes excluded from Form#as_form_document to a
constant so it can be referenced elsewhere.
Previously, the list of attributes to copy from a from_document when
reverting a draft form was explicitly defined.

This commit changes the list to be dynamically generated from the
Form model minus some attributes we know we don't want to copy.
Previously conditions were recreated as part of the page creation.

This meant that conditions could be created with validation errors if the page
hadn't been created yet.

This commit fixes that by reverting conditions after pages are created.
Previously, drafts could only be discarded from :live_with_draft.

This commit adds the ability to discard drafts from :live too.

This has been added because it's possible to delete all a forms pages
without creating a new draft.
Copy link

Copy link

🎉 A review copy of this PR has been deployed! You can reach it at: https://pr-2258.admin.review.forms.service.gov.uk/

It may take 5 minutes or so for the application to be fully deployed and working. If it still isn't ready
after 5 minutes, there may be something wrong with the ECS task. You will need to go to the integration AWS account
to debug, or otherwise ask an infrastructure person.

For the sign in details and more information, see the review apps wiki page.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants