Validate each document tab form separately [WHIT-3214]#11436
Conversation
8644b69 to
afd0dfb
Compare
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.
7fec067 to
e20c4bf
Compare
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.
e20c4bf to
eb62998
Compare
ChrisBAshton
left a comment
There was a problem hiding this comment.
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")) |
There was a problem hiding this comment.
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:
whitehall/app/models/concerns/edition/organisations.rb
Lines 16 to 17 in 5521aee
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? } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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:
| 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) |
There was a problem hiding this comment.
I'd expect a corresponding unit test here
|
|
||
| def update | ||
| @edition.current_tab_context = @current_tab_context | ||
| super |
There was a problem hiding this comment.
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?
whitehall/app/controllers/admin/editions_controller.rb
Lines 124 to 148 in 52e5953
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.
There was a problem hiding this comment.
(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) %> |
There was a problem hiding this comment.
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| |
There was a problem hiding this comment.
| [{ 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? |
There was a problem hiding this comment.
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| |
There was a problem hiding this comment.
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:
| 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) |
There was a problem hiding this comment.
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)
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:
Why
Visual Changes
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:Future Improvements:
validate_block_contentandscoped_block_contentfrom has_block_content.rb.TabForm will then be the single point for handling all
block_contentvalidation. For now, it's fine to keep the validation in both places because not all tabs are currently fully config-driven, andedition.valid?is called in several places that don't go through TabForm.titleandsummaryto the documents schema of all config driven document typeslabelto each form in document type configurations./standard-editions/:id/editand/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 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.