Skip to content

Validate each document tab form separately [WHIT-3214]#11436

Open
eYinka wants to merge 8 commits into
mainfrom
validate-doc-tab-separately
Open

Validate each document tab form separately [WHIT-3214]#11436
eYinka wants to merge 8 commits into
mainfrom
validate-doc-tab-separately

Conversation

@eYinka
Copy link
Copy Markdown
Contributor

@eYinka eYinka commented May 8, 2026

What

Move the edition validation off of the edition model on to the configurable form object, and validate each form independently. Make sure that all forms are validated before the edition can be published.

In the scope of this implementation:

  • Errors for the form are rendered to the user at the top of the page and next to the invalid form field, with a link from the top of the page to the invalid form
  • Any edition tab’s form can be saved regardless of the validity of the other tabs
  • All edition forms are validated before the “Publish” button is displayed to the user, and any errors are displayed to the user on the summary page.
  • Only a globally valid edition is updated in Publishing API
  • Users can see a hint text letting them know draft previews may be out of date if an edition has invalid tabs

Why

  • Accelerates future development of configurable document types with a more appropriate abstraction
  • Decouples form validation and attributes from the Edition model

Visual Changes

image

Testing

Experiencing the full benefit of this work is tricky, because you will almost never have a saved multi-tab edition in bad state unless you manually manipulate the data.
Currently, the only way to access dynamic tabs is by saving the document tab first, which means the draft is fully valid before any subsequent updates can be made. Therefore, it's not inherently clear that we are scoping validations per tab.

For anyone that wants to give it a go locally, an easy way is to configure a document type (like topical event) with "send_change_history": true, then:

  • Redraft a published topical event - this should surface the change history form on the document tab.
  • Without filling the change history form, try to save another tab (i.e Social media links) with invalid data. You should only see errors related to social media tab and nothing about the invalid change history should be displayed. Also you will be able to see a summary of the invalid tabs on the edition summary page.
  • Alternatively, you can grab a draft edition and manipulate the data by doing the following:
# Grab the edition
edition = Edition.find(INSERT_EDITION_ID)

# Manipulate the block content by setting the body to nil and storing an invalid social media link
new_content = edition.translation[:block_content].merge("body" => nil, "social_media_links" => [{ "url" => nil, "social_media_service_name" => "Facebook" }])

# Persist the data
edition.translation.update_column(:block_content, new_content)

Future Improvements:

  • When all document types have been migrated to using config-driven tab forms, we should be able to remove validate_block_content and scoped_block_content from has_block_content.rb.
    TabForm will then be the single point for handling all block_content validation. For now, it's fine to keep the validation in both places because not all tabs are currently fully config-driven, and edition.valid? is called in several places that don't go through TabForm.
  • Add title and summary to the documents schema of all config driven document types
  • In the spirit of maintaining consistency, enforce adding a label to each form in document type configurations.
  • Maintain a single way to access the documents tab. Currently, it is accessible via /standard-editions/:id/edit and /standard-editions/:id/edit?current_tab=documents. The latter is necessary for validating only the documents tab while the former will assume full edition validation.

JIRA


⚠️ This repo is Continuously Deployed: make sure you follow the guidance ⚠️

This application is owned by the Whitehall Experience team. Please let us know in #govuk-whitehall-experience-tech when you raise any PRs.

Follow these steps if you are doing a Rails upgrade.

@eYinka eYinka force-pushed the validate-doc-tab-separately branch 13 times, most recently from 8644b69 to afd0dfb Compare May 14, 2026 15:15
eYinka added 3 commits May 14, 2026 17:39
In Standard Edition model, we have these association checks:
- `organisation_association_enabled`
- `worldwide_organisation_association_required?`
- `world_location_association_required?`

And they are responsible for enabling validations on their associated fields each time an edition is updated.
They now return false if the field in question doesn't belong to the current tab context (i.e If you're on a different tab that doesn't include the associated fields).

I've added `current_tab_context_includes_field?` to centralise that check.
Introducing a form object that validates a single tab's fields in isolation. It separates block_content fields (which are validated via a scoped BlockContent) from edition attribute fields.

Edition attribute fields in this context are fields that are meant to be validated on the edition. Rather than duplicating code to represent them specially for standard edition, we handle their validation  by running `edition.valid?` under a temporary tab context, then cherry-picking only that tab's errors for displaying to the user later.

This is the core of the per-tab validation feature and should be hopefully sufficient for what we need.

**Note:** The inclusion of `ADDITIONAL_DEFAULT_TAB_FIELDS` brings a question for future work.
We should be looking at including `title` and `summary` in the document's tab schema of each configurable document type.
We needed a way to validate only a part of an edition, in the case of config-driven form tabs i.e when a user is saving data on a particular tab, they should only see errors related to that tab.

Consequently, we will need to skip the full edition validation when patching up that chunk of the edition. Rails allows us to specify options like `validate: false` to do that.
This commit prepares the save handler to accept such options.
@eYinka eYinka force-pushed the validate-doc-tab-separately branch 2 times, most recently from 7fec067 to e20c4bf Compare May 14, 2026 16:50
@eYinka eYinka marked this pull request as ready for review May 14, 2026 16:51
eYinka added 5 commits May 15, 2026 14:57
When a tab context is present (i.e. user is saving a non-document tab), validate only that tab's fields via TabForm form object instead of running full edition validation. On success, we call save with `validate: false`. On failure, we simply copy tab errors onto `@edition.errors` so that the existing error summary component displays them correctly.

For the non-tab pages, fall back to the parent controller's handler and run full validation.
This will be useful in future commit to iterate over a configurable document's form tabs
Now, we compute which tabs are invalid and surface them through to the sidebar component.
The sidebar will list every invalid tab as links rather than a flat list of errors for StandardEditions.
I've also added a hint text to the preview edition component to let publishers know that the preview of an edition "may be out of date" if any tab is invalid; as we will not send an edition in bad state to publishing API.
The Document tab nav item was only highlighted when current_tab was nil. Passing `?current_tab=documents` (e.g. from an error link) left it non-highlighted. I've added `on_default_tab` which is true when `current_tab` is either nil (implicitly defaults to documents tab at the moment) or equals `edition.default_tab`, so both forms of "on the default tab" are handled.
We have agreed to only send a globally valid edition to Publishing API. In that sense, the Edition Publisher and Edition Scheduler services now collect each tab's failure reasons (e.g. "Social media accounts tab is invalid") so publishers see useful messages rather than a generic one.

Consequently, the Draft Edition Updater service skips pushing to Publishing API if any tab is invalid.
@eYinka eYinka force-pushed the validate-doc-tab-separately branch from e20c4bf to eb62998 Compare May 15, 2026 13:58
Copy link
Copy Markdown
Contributor

@ChrisBAshton ChrisBAshton left a comment

Choose a reason for hiding this comment

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

Well done for working through such a tricky problem ⭐

Whilst what you have works, it does spread the tab-awareness throughout some quite key Whitehall publishing workflows. I'd hope that given we have a dedicated StandardEditionsController, we could be a bit more precise about keeping tab awareness 'contained' and not having to reference low-level StandardEdition concepts from high level Edition Services etc.

It may be easier said than done , but I think it's worth spending a few days considering how self-contained we can make the tab validation, and also how self-contained we can make the 'associations' validation (rather than changing the meaning of the associations helper methods in the first commit). 🙏 Give me a shout if you'd like to talk any of this through.

def organisation_association_enabled?
field_paths.include?(ConfigurableContentBlocks::Path.new("lead_organisation_ids"))
current_tab_context_includes_field?("lead_organisation_ids") &&
field_paths.include?(ConfigurableContentBlocks::Path.new("lead_organisation_ids"))
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.

I'm a bit concerned this is in the wrong place in the stack. We want to be able to ask "for this type of StandardEdition, are organisations enabled?". That's a business logic / model decision. Whether or not to enforce validation on that association is more of a controller-level one, I'd say.

Making the changes here risks introducing bugs in, say, the Edition Access Limited controller's changed? method, or risks us not calling the build_default_organisation method on the Edition Controller, or there may be other places too where we rely on this method call being a true reflection of what is/isn't enabled on a document type.

I think really this sort of branching should move to:

validate :at_least_one_lead_organisation, if: :organisation_association_enabled?
validate :no_duplication_of_organisations, if: :organisation_association_enabled?

Something like:

    validate :at_least_one_lead_organisation, if: -> { organisation_association_enabled? && current_tab_context_includes_field?("lead_organisation_ids") }

attr_reader :edition, :tab_key

validate :validate_block_content
validate :validate_edition_fields, if: -> { edition_attribute_keys.any? }
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.

I'd move the conditional inside validate_edition_fields as an early return, instead of here.

attr_path.first unless attr_path.first == "block_content"
end

tab_key == edition.default_tab ? keys + ADDITIONAL_DEFAULT_TAB_FIELDS : keys # Need to move these fields into document form configuration
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.

I think we can drop the const as it's only used here.
Can also make it a bit more optimised for deletion as below:

Suggested change
tab_key == edition.default_tab ? keys + ADDITIONAL_DEFAULT_TAB_FIELDS : keys # Need to move these fields into document form configuration
# TODO: Move these fields into document form configuration
# so that we don't have to inject them here
if tab_key == edition.default_tab
keys << %w[title summary]
end
keys

def save_as(user)
if save
def save_as(user, options = {})
if save(**options)
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.

I'd expect a corresponding unit test here


def update
@edition.current_tab_context = @current_tab_context
super
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.

We're moving away from calling super on every update, to only calling super when saving a non-tabbed form. Have we explored the downsides of that?

def update
@edition.assign_attributes(edition_params)
if updater.can_perform? && @edition.save_as(current_user)
updater.perform!
if @edition.link_check_report
LinkCheckerApiService.check_links(@edition, admin_link_checker_api_callback_url)
end
redirect_to redirect_param(fallback: show_or_edit_path), saved_confirmation_notice
else
flash.now[:alert] = updater.failure_reason
build_edition_dependencies
fetch_version_and_remark_trails
construct_similar_slug_warning_error
render :edit
end
rescue ActiveRecord::StaleObjectError
flash.now[:alert] = "This document has been saved since you opened it"
@conflicting_edition = Edition.find(params[:id])
@edition.lock_version = @conflicting_edition.lock_version
build_edition_dependencies
render :edit
end

Calling super means calling updater.perform!, rather than @edition.save_as - does the DraftEditionUpdater service do something important we'd lose otherwise?

Also currently super means a fresh LinkCheckReport is generated, which we'd currently be losing by not calling that.

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.

(it may be that we want to call super when updating the Document tab, but otherwise call our own logic if saving any other tab 🤔 )

<%= render partial: "admin/editions/show/main_notices", locals: { edition: @edition } %>

<%= render Admin::Editions::Show::PreviewComponent.new(edition: @edition) %>
<%= render Admin::Editions::Show::PreviewComponent.new(edition: @edition, invalid_tab_forms: @invalid_tab_forms) %>
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.

Hmmm this extra param is being passed, but doesn't appear to be being used anywhere?

on_default_tab = current_tab.nil? || current_tab == edition.default_tab

[{ label: "Document", href: base_url, current: on_dynamic_tab && current_tab.nil? }].tap do |nav_items|
[{ label: "Document", href: base_url, current: on_dynamic_tab && on_default_tab }].tap do |nav_items|
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.

Suggested change
[{ label: "Document", href: base_url, current: on_dynamic_tab && on_default_tab }].tap do |nav_items|
[{ label: "Document", href: base_url, current: on_default_tab }].tap do |nav_items|

def perform!
if can_perform?
update_publishing_api!
update_publishing_api! if can_push_draft?
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.

Is this not already caught by the elsif !edition.valid? check in failure_reason?

If not, failure_reason feels like the right place to capture a StandardEdition specific check - rather than introducing a change here.

But I'm also confused because in my earlier comment, it looks as though we never call the DraftEditionUpdater service with the current changes to the StandardEditionController 🤔
And actually maybe that's a better approach. By the time we get to the DraftEditionUpdater, we perhaps should already be confident that every tab's individual validation issues have been fixed.

def can_push_draft?
return true unless edition.is_a?(StandardEdition)

edition.type_instance.form_keys.all? do |tab_key|
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.

If we do need to expose the tab form validation at a higher level than the StandardEditionController, can we at least wrap it in a simpler helper method so we're not having to reference form keys etc? Something like:

Suggested change
edition.type_instance.form_keys.all? do |tab_key|
edition.valid?(:on_all_tabs)

def invalid_tab_reasons
edition.type_instance.form_keys.filter_map do |tab_key|
tab_form = StandardEdition::TabForm.new(edition, tab_key)
unless tab_form.valid?(:publish)
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.

Yeah I'm not keen that we've overridden the meaning of edition.valid? or edition.valid?(:publish) and now have to update the rest of Whitehall accordingly. I think one of the AC's for this work, in hindsight, should be that calling edition.valid?(:publish) checks for validation errors across all tabs. That way we don't have to update any of these higher level workflows.

And then at the lower StandardEdition level, we just stop calling edition.valid?(:publish) and start being tab-aware instead, e.g.

edition.validation_tab = tab
edition.valid?(:tabbed)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants