From 3c4b9728f56b1716856227b0b76ec23d58ee7b62 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Tue, 26 May 2026 11:56:54 +0100 Subject: [PATCH 01/13] Make label for default_string component bold for consistency Other fields on the page render like this, so why not default_string? --- .../configurable_content_blocks/default_string.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/views/admin/configurable_content_blocks/default_string.html.erb b/app/views/admin/configurable_content_blocks/default_string.html.erb index 927daf64d5b..0a418d35444 100644 --- a/app/views/admin/configurable_content_blocks/default_string.html.erb +++ b/app/views/admin/configurable_content_blocks/default_string.html.erb @@ -1,6 +1,9 @@ <%= render "govuk_publishing_components/components/input", { id: block.path.form_control_id, - label: { text: block.title + (block.required ? " (required)" : "") }, + label: { + text: block.title + (block.required ? " (required)" : ""), + heading_size: "m", + }, name: block.path.form_control_name, value: block.value, hint: block.hint_text, From 3e789047599963af0421a6588e8f3cad8c2eb85b Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Tue, 26 May 2026 09:52:54 +0100 Subject: [PATCH 02/13] Add DefaultTextarea component Sometimes we want a multi-line input that isn't Govspeak and should not have the 'inline preview' feature. --- .../default_textarea.rb | 9 ++ app/models/configurable_document_type.rb | 1 + .../default_textarea.html.erb | 19 ++++ public/configurable-document-type.schema.json | 1 + .../default_textarea_test.rb | 86 +++++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 app/models/configurable_content_blocks/default_textarea.rb create mode 100644 app/views/admin/configurable_content_blocks/default_textarea.html.erb create mode 100644 test/unit/app/models/configurable_content_blocks/default_textarea_test.rb diff --git a/app/models/configurable_content_blocks/default_textarea.rb b/app/models/configurable_content_blocks/default_textarea.rb new file mode 100644 index 00000000000..620817e7a05 --- /dev/null +++ b/app/models/configurable_content_blocks/default_textarea.rb @@ -0,0 +1,9 @@ +module ConfigurableContentBlocks + class DefaultTextarea < BaseBlock + private + + def template_name + "default_textarea" + end + end +end diff --git a/app/models/configurable_document_type.rb b/app/models/configurable_document_type.rb index fed1817f754..c5a8911a21a 100644 --- a/app/models/configurable_document_type.rb +++ b/app/models/configurable_document_type.rb @@ -3,6 +3,7 @@ class ConfigurableDocumentType CONTENT_BLOCKS = { "default_string" => ConfigurableContentBlocks::DefaultString, + "default_textarea" => ConfigurableContentBlocks::DefaultTextarea, "govspeak" => ConfigurableContentBlocks::Govspeak, "default_date" => ConfigurableContentBlocks::DefaultDate, "default_select" => ConfigurableContentBlocks::DefaultSelect, diff --git a/app/views/admin/configurable_content_blocks/default_textarea.html.erb b/app/views/admin/configurable_content_blocks/default_textarea.html.erb new file mode 100644 index 00000000000..d66e3b4403c --- /dev/null +++ b/app/views/admin/configurable_content_blocks/default_textarea.html.erb @@ -0,0 +1,19 @@ +<%= render "govuk_publishing_components/components/textarea", { + textarea_id: block.path.form_control_id, + label: { + text: block.title + (block.required ? " (required)" : ""), + heading_size: "m", + }, + name: block.path.form_control_name, + value: block.value, + hint: block.hint_text, + right_to_left: block.edition.translation_locale.rtl?, + error_items: errors_for(block.edition.errors, block.path.validation_error_attribute.to_sym), +} %> +<% if block.primary_locale_value.present? %> + <%= render "govuk_publishing_components/components/details", { + title: "Primary locale content for #{block.title}", + } do %> + <%= block.primary_locale_value %> + <% end %> +<% end %> diff --git a/public/configurable-document-type.schema.json b/public/configurable-document-type.schema.json index 5556f65fab7..e3bbbdab28e 100644 --- a/public/configurable-document-type.schema.json +++ b/public/configurable-document-type.schema.json @@ -67,6 +67,7 @@ "default_date", "default_object", "default_string", + "default_textarea", "govspeak", "default_select", "select_with_search_tagging", diff --git a/test/unit/app/models/configurable_content_blocks/default_textarea_test.rb b/test/unit/app/models/configurable_content_blocks/default_textarea_test.rb new file mode 100644 index 00000000000..eaabbbf58bf --- /dev/null +++ b/test/unit/app/models/configurable_content_blocks/default_textarea_test.rb @@ -0,0 +1,86 @@ +require "test_helper" + +class ConfigurableContentBlocks::DefaultTextareaRenderingTest < ActionView::TestCase + include ConfigurableContentBlockSharedTests + + setup do + @field = { + "block" => "default_textarea", + "title" => "Test attribute", + "description" => "A test attribute", + "attribute_path" => %w[block_content test_attribute], + "translatable" => true, + } + @path = Path.new(%w[block_content test_attribute]) + ConfigurableDocumentType.setup_test_types(build_configurable_document_type("test_type", { + "forms" => { + "documents" => { + "fields" => { + "test_attribute" => @field, + }, + }, + }, + "schema" => { + "attributes" => { + "test_attribute" => { + "type" => "string", + }, + }, + }, + })) + @edition = StandardEdition.new( + configurable_document_type: "test_type", + block_content: { "test_attribute" => "foo" }, + ) + @block = ConfigurableContentBlocks::DefaultTextarea.new(@edition, @field, @path) + end + + test "the form label is equal to the attribute title" do + render @block + assert_dom "label", text: @field["title"] + end + + test "it adds a required message to the label when the attribute is required" do + @field["required"] = true + render @block + assert_dom "label", text: "#{@field['title']} (required)" + end + + test "it sets the textarea name correctly" do + render @block + assert_dom "textarea[name=?]", "edition[block_content][test_attribute]" + end + + test "it sets the textarea value based on the content" do + render @block + assert_dom "textarea", text: @edition.block_content["test_attribute"] + end + + test "it sets the hint text based on the description" do + render @block + assert_dom ".govuk-hint", text: @field["description"] + end + + test "it sets the direction on the input to right to left when the current locale is Arabic" do + with_locale(:ar) do + render @block + end + assert_dom "textarea[dir=\"rtl\"]" + end + + test "it renders the primary locale content under the textarea when the current locale is different from the primary locale" do + with_locale(:es) do + render @block + end + + assert_dom ".govuk-details__text", text: @edition.block_content["test_attribute"] + end + + test "it renders any validation errors when they are present" do + messages = %w[foo bar] + messages.each { |m| @edition.errors.add(:test_attribute, m) } + + render @block + assert_dom ".govuk-error-message", "Error: #{messages.map { |m| "Test attribute #{m}" }.join}" + end +end From c26db21aee3ff3db21515889a2cb3dbea0659e65 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 1 Jun 2026 13:42:01 +0100 Subject: [PATCH 03/13] Stop hardcoding period on all 'additional_routes' Until now, 'additional_routes' have only been used for publishing atom and RSS equivalents of pages. The base path prefix and the additional route have therefore been joined by a hardcoded `.`. In the next few commits we're going to publish additional routes for topical event about pages, i.e. the topical event page and a topical event 'about' page that shares its based path prefix but has a "/about" suffix. Without this commit, that would come out as something like "/topical/events/foo./about" - we don't want the '.' --- app/presenters/publishing_api/organisation_presenter.rb | 2 +- app/presenters/publishing_api/payload_builder/routes.rb | 2 +- app/presenters/publishing_api/topical_event_presenter.rb | 2 +- .../presenters/publishing_api/payload_builder/routes_test.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/presenters/publishing_api/organisation_presenter.rb b/app/presenters/publishing_api/organisation_presenter.rb index 1c3f5812f8b..53eb7fc99ff 100644 --- a/app/presenters/publishing_api/organisation_presenter.rb +++ b/app/presenters/publishing_api/organisation_presenter.rb @@ -74,7 +74,7 @@ def use_prefix_route? def additional_routes return [] if court_or_tribunal? - %w[atom] + %w[.atom] end def details diff --git a/app/presenters/publishing_api/payload_builder/routes.rb b/app/presenters/publishing_api/payload_builder/routes.rb index b95c625beeb..8f17c1335ee 100644 --- a/app/presenters/publishing_api/payload_builder/routes.rb +++ b/app/presenters/publishing_api/payload_builder/routes.rb @@ -17,7 +17,7 @@ def call routes = [] routes << { path: base_path, type: } additional_routes.each do |additional_route| - routes << { path: "#{base_path}.#{additional_route}", type: "exact" } + routes << { path: "#{base_path}#{additional_route}", type: "exact" } end { routes: } end diff --git a/app/presenters/publishing_api/topical_event_presenter.rb b/app/presenters/publishing_api/topical_event_presenter.rb index baf8e051f78..44e4c8c0d8e 100644 --- a/app/presenters/publishing_api/topical_event_presenter.rb +++ b/app/presenters/publishing_api/topical_event_presenter.rb @@ -26,7 +26,7 @@ def content rendering_app: Whitehall::RenderingApp::FRONTEND, schema_name: "topical_event", ) - content.merge!(PayloadBuilder::PolymorphicPath.for(item, additional_routes: %w[atom])) + content.merge!(PayloadBuilder::PolymorphicPath.for(item, additional_routes: %w[.atom])) end def links diff --git a/test/unit/app/presenters/publishing_api/payload_builder/routes_test.rb b/test/unit/app/presenters/publishing_api/payload_builder/routes_test.rb index 8d0ed486d78..43b9e88e92d 100644 --- a/test/unit/app/presenters/publishing_api/payload_builder/routes_test.rb +++ b/test/unit/app/presenters/publishing_api/payload_builder/routes_test.rb @@ -17,7 +17,7 @@ class RoutesTest < ActiveSupport::TestCase test "returns a routes payload with additional routes" do base_path = "some/base/path" - additional_routes = %w[atom rss] + additional_routes = %w[.atom .rss] expected_routes = [ { path: base_path, type: "exact" }, { path: "#{base_path}.atom", type: "exact" }, From f505d63a718f3a5ac049e585763fba0a7b871cd9 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 1 Jun 2026 13:55:05 +0100 Subject: [PATCH 04/13] Support passing 'additional_routes' to StandardEdition documents Any StandardEdition document can publish to more than one route, by specifying a `settings.additional_routes` property. This will be an array of paths which are to be combined with the `settings.base_path_prefix` property to determine the full route. --- app/models/edition.rb | 4 ++++ app/models/standard_edition.rb | 4 ++++ .../publishing_api/payload_builder/public_document_path.rb | 7 ++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/models/edition.rb b/app/models/edition.rb index 5b8dc94a07f..252d0d3d32d 100644 --- a/app/models/edition.rb +++ b/app/models/edition.rb @@ -422,6 +422,10 @@ def public_url(options = {}) website_root + public_path(options) end + def additional_routes + [] + end + def force_scheduled? force_published? && state == "scheduled" end diff --git a/app/models/standard_edition.rb b/app/models/standard_edition.rb index 24e702517ae..de379bf507f 100644 --- a/app/models/standard_edition.rb +++ b/app/models/standard_edition.rb @@ -106,6 +106,10 @@ def base_path "#{type_instance.settings['base_path_prefix']}/#{slug}" end + def additional_routes + type_instance.settings["additional_routes"] || [] + end + def type_instance ConfigurableDocumentType.find(configurable_document_type) end diff --git a/app/presenters/publishing_api/payload_builder/public_document_path.rb b/app/presenters/publishing_api/payload_builder/public_document_path.rb index 41206cbb741..8a0b3ebed00 100644 --- a/app/presenters/publishing_api/payload_builder/public_document_path.rb +++ b/app/presenters/publishing_api/payload_builder/public_document_path.rb @@ -12,7 +12,12 @@ def initialize(item) end def call - { base_path: }.merge(PayloadBuilder::Routes.for(base_path)) + routes = if item.additional_routes.any? + PayloadBuilder::Routes.for(base_path, additional_routes: item.additional_routes) + else + PayloadBuilder::Routes.for(base_path) + end + { base_path: }.merge(routes) end private From 72dfa7ab7a4cebf8d9ba07aaccbbf3aeb1dbefa4 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 1 Jun 2026 14:35:38 +0100 Subject: [PATCH 05/13] Define & publish child pages as 'parts' of the parent content item Following the approach of Travel Advice Pages (TAP), we can use a single content item to serve multiple pages/routes. Example: https://www.gov.uk/api/content/foreign-travel-advice/afghanistan What's special about these routes: 1. More than one exact route is included in the payload (https://github.com/alphagov/travel-advice-publisher/blob/c285ea5cd2a364ca986c072baa0b246f94af00ed/app/presenters/edition_presenter.rb#L91-L99) 2. The contents of each child 'page' is included under `details.parts` (https://github.com/alphagov/travel-advice-publisher/blob/c285ea5cd2a364ca986c072baa0b246f94af00ed/app/presenters/edition_presenter.rb#L52) Frontend has some fairly [generic support](https://github.com/alphagov/frontend/blob/0a6b871bd125fd80d34a8f22f649d71ebed28f8c/app/models/concerns/parts.rb#L1) for 'parts' via a concern. In order for this to extend to Topical Events and their About pages, the [FlexiblePage model](https://github.com/alphagov/frontend/blob/46b2be223a029be5c161f572f4bccbaec5c7be1d/app/models/flexible_page.rb#L1) would need to include the Parts concern like the [TravelAdvice model](https://github.com/alphagov/frontend/blob/1f6acf73c6901ee6779b9351714488a39fc99705/app/models/travel_advice.rb#L2) does. On the Whitehall side, we just have to pass additional_routes, and find a way of passing 'parts' to the details hash. Our current abstraction for 'presenters' in the JSON configs is a bit too restrictive at the moment - it doesn't support passing parameters - so currently the generic 'parts' PayloadBuilder method has a hardcoded reference to 'about_page_parts'. To be production ready we'd have to iterate the JSON schema to allow for specifying said parameter in the config itself. --- app/models/configurable_document_type.rb | 10 +++++ .../topical_event.json | 45 ++++++++++++++++++- .../payload_builder/block_content.rb | 6 +++ .../payload_builder/multiple_parts.rb | 30 +++++++++++++ public/configurable-document-type.schema.json | 11 ++++- .../models/configurable_document_type_test.rb | 44 ++++++++++++++++++ 6 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 app/presenters/publishing_api/payload_builder/multiple_parts.rb diff --git a/app/models/configurable_document_type.rb b/app/models/configurable_document_type.rb index c5a8911a21a..d8af361b92c 100644 --- a/app/models/configurable_document_type.rb +++ b/app/models/configurable_document_type.rb @@ -87,6 +87,16 @@ def properties @schema["attributes"] || {} end + def fields_for_part(part_key) + @forms.each_value.flat_map do |form| + next unless form["fields"] + + form["fields"].select { |_, field| field["part_of"] == part_key }.map do |key, field| + { "key" => key, "part_name" => field["part_name"] } + end + end + end + def form(key = nil) return nil if @forms.empty? diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index 9efab21d933..a44aa3a41f5 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -30,6 +30,36 @@ } } }, + "about": { + "dynamic": true, + "label": "About page", + "fields": { + "about_title": { + "title": "Title", + "block": "default_string", + "attribute_path": ["block_content", "about_title"], + "part_of": "about_page_parts", + "part_name": "title", + "translatable": true + }, + "about_summary": { + "title": "Summary", + "block": "default_textarea", + "attribute_path": ["block_content", "about_summary"], + "part_of": "about_page_parts", + "part_name": "summary", + "translatable": true + }, + "about_body": { + "title": "Body", + "block": "govspeak", + "attribute_path": ["block_content", "about_body"], + "part_of": "about_page_parts", + "part_name": "body", + "translatable": true + } + } + }, "social_media_accounts": { "dynamic": true, "label": "Social media accounts", @@ -128,6 +158,15 @@ "body": { "type": "string" }, + "about_title": { + "type": "string" + }, + "about_summary": { + "type": "string" + }, + "about_body": { + "type": "string" + }, "social_media_links": { "type": "array", "attributes": { @@ -175,7 +214,8 @@ "publishing_api": { "details": { "body": "govspeak", - "social_media_links": "social_media_links" + "social_media_links": "social_media_links", + "parts": "parts" }, "links": [ "organisations" @@ -184,6 +224,9 @@ }, "settings": { "base_path_prefix": "/government/topical-events", + "additional_routes": [ + "/about" + ], "publishing_api_schema_name": "topical_event", "publishing_api_document_type": "topical_event", "rendering_app": "frontend", diff --git a/app/presenters/publishing_api/payload_builder/block_content.rb b/app/presenters/publishing_api/payload_builder/block_content.rb index 854278f9429..36f55b37e4f 100644 --- a/app/presenters/publishing_api/payload_builder/block_content.rb +++ b/app/presenters/publishing_api/payload_builder/block_content.rb @@ -55,6 +55,12 @@ def social_media_links(attribute) } end end + + def parts(_attribute) + # TODO: make more abstract by passing a parameter to the 'parts' method + # so that we're not hardcoding this only for 'about page' usage. + PayloadBuilder::MultipleParts.for(item, "about_page_parts") + end end end end diff --git a/app/presenters/publishing_api/payload_builder/multiple_parts.rb b/app/presenters/publishing_api/payload_builder/multiple_parts.rb new file mode 100644 index 00000000000..c6ffa64e091 --- /dev/null +++ b/app/presenters/publishing_api/payload_builder/multiple_parts.rb @@ -0,0 +1,30 @@ +module PublishingApi + module PayloadBuilder + class MultipleParts + attr_reader :item, :part_of + + def self.for(item, part_of) + new(item, part_of).call + end + + def initialize(item, part_of) + @item = item + @part_of = part_of + end + + def call + parts + end + + private + + def parts + item.type_instance.fields_for_part(part_of).map do |field| + { + field["part_name"].to_sym => item.block_content&.public_send(field["key"]), + } + end + end + end + end +end diff --git a/public/configurable-document-type.schema.json b/public/configurable-document-type.schema.json index e3bbbdab28e..8232771d7eb 100644 --- a/public/configurable-document-type.schema.json +++ b/public/configurable-document-type.schema.json @@ -90,6 +90,14 @@ "description": "When true, only renders the field if the configurable_document_types feature flag is enabled", "type": "boolean" }, + "part_of": { + "description": "Identifier for a group of fields that together form a 'part', i.e. a lightweight separate child page.", + "type": "string" + }, + "part_name": { + "description": "What we should call this individual part when sending the child page to Publishing API (otherwise we're forced to retain an unnecessary namespace prefix).", + "type": "string" + }, "options": { "type": "array", "description": "List of options for select inputs.", @@ -286,7 +294,8 @@ "rfc3339_date", "image", "raw", - "social_media_links" + "social_media_links", + "parts" ] } }, diff --git a/test/unit/app/models/configurable_document_type_test.rb b/test/unit/app/models/configurable_document_type_test.rb index b758bad4a03..4a69b202c9d 100644 --- a/test/unit/app/models/configurable_document_type_test.rb +++ b/test/unit/app/models/configurable_document_type_test.rb @@ -154,6 +154,50 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase assert_equal "new_type", types_we_can_convert_to.first.key end + test "#fields_for_part returns the fields for a specific part key" do + configurable_document_type = build_configurable_document_type( + "test_type", + { + "forms" => { + "some_tab_name" => { + "fields" => { + "about_title" => { + "title" => "Title", + "block" => "default_string", + "attribute_path" => %w[block_content about_title], + "part_of" => "about_page_parts", + "part_name" => "title", + "translatable" => true, + }, + "about_summary" => { + "title" => "Summary", + "block" => "default_textarea", + "attribute_path" => %w[block_content about_summary], + "part_of" => "about_page_parts", + "part_name" => "summary", + "translatable" => true, + }, + }, + }, + }, + }, + ) + ConfigurableDocumentType.setup_test_types(configurable_document_type) + document_type = ConfigurableDocumentType.find("test_type") + + expected_fields = [ + { + "key" => "about_title", + "part_name" => "title", + }, + { + "key" => "about_summary", + "part_name" => "summary", + }, + ] + assert_equal expected_fields, document_type.fields_for_part("about_page_parts") + end + test "#form creates a flattened hash of fields if no form key is provided" do configurable_document_type = build_configurable_document_type( "test_type", From bd18a6322f5436ff90212b9fc3bffe07d56ec33e Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 1 Jun 2026 16:50:54 +0100 Subject: [PATCH 06/13] Make routes and parts dynamic We can now define arbitrary routes (and their equivalent arbitrary parts) by simply declaring a different `"part": "/route"` property under the `forms`. In this case we only want one route and part - topical event about pages - but you can imagine how we'd extend this to support all possible Corporate Information Pages, for example. --- app/models/configurable_document_type.rb | 16 +++++++++++++-- .../topical_event.json | 9 +++------ app/models/standard_edition.rb | 2 +- .../payload_builder/block_content.rb | 6 +++--- .../payload_builder/multiple_parts.rb | 20 ++++++++++--------- public/configurable-document-type.schema.json | 4 ++-- .../models/configurable_document_type_test.rb | 8 +++++--- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/app/models/configurable_document_type.rb b/app/models/configurable_document_type.rb index d8af361b92c..59862b3aebb 100644 --- a/app/models/configurable_document_type.rb +++ b/app/models/configurable_document_type.rb @@ -87,12 +87,24 @@ def properties @schema["attributes"] || {} end + def parts + parts = [] + @forms.each_value.flat_map do |form| + next unless form["fields"] + + form["fields"].each do |_, field| + parts << field["part"] if field["part"] + end + end + parts.uniq + end + def fields_for_part(part_key) @forms.each_value.flat_map do |form| next unless form["fields"] - form["fields"].select { |_, field| field["part_of"] == part_key }.map do |key, field| - { "key" => key, "part_name" => field["part_name"] } + form["fields"].select { |_, field| field["part"] == part_key }.map do |key, field| + { "key" => key, "part_name" => field["part_name"], "part" => part_key } end end end diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index a44aa3a41f5..299a1ac79c5 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -38,7 +38,7 @@ "title": "Title", "block": "default_string", "attribute_path": ["block_content", "about_title"], - "part_of": "about_page_parts", + "part": "/about", "part_name": "title", "translatable": true }, @@ -46,7 +46,7 @@ "title": "Summary", "block": "default_textarea", "attribute_path": ["block_content", "about_summary"], - "part_of": "about_page_parts", + "part": "/about", "part_name": "summary", "translatable": true }, @@ -54,7 +54,7 @@ "title": "Body", "block": "govspeak", "attribute_path": ["block_content", "about_body"], - "part_of": "about_page_parts", + "part": "/about", "part_name": "body", "translatable": true } @@ -224,9 +224,6 @@ }, "settings": { "base_path_prefix": "/government/topical-events", - "additional_routes": [ - "/about" - ], "publishing_api_schema_name": "topical_event", "publishing_api_document_type": "topical_event", "rendering_app": "frontend", diff --git a/app/models/standard_edition.rb b/app/models/standard_edition.rb index de379bf507f..d5e12444905 100644 --- a/app/models/standard_edition.rb +++ b/app/models/standard_edition.rb @@ -107,7 +107,7 @@ def base_path end def additional_routes - type_instance.settings["additional_routes"] || [] + type_instance.parts end def type_instance diff --git a/app/presenters/publishing_api/payload_builder/block_content.rb b/app/presenters/publishing_api/payload_builder/block_content.rb index 36f55b37e4f..d39c28b7760 100644 --- a/app/presenters/publishing_api/payload_builder/block_content.rb +++ b/app/presenters/publishing_api/payload_builder/block_content.rb @@ -57,9 +57,9 @@ def social_media_links(attribute) end def parts(_attribute) - # TODO: make more abstract by passing a parameter to the 'parts' method - # so that we're not hardcoding this only for 'about page' usage. - PayloadBuilder::MultipleParts.for(item, "about_page_parts") + item.type_instance.parts.map do |part| + PayloadBuilder::MultipleParts.for(item, part) + end end end end diff --git a/app/presenters/publishing_api/payload_builder/multiple_parts.rb b/app/presenters/publishing_api/payload_builder/multiple_parts.rb index c6ffa64e091..d7a67b08e77 100644 --- a/app/presenters/publishing_api/payload_builder/multiple_parts.rb +++ b/app/presenters/publishing_api/payload_builder/multiple_parts.rb @@ -1,15 +1,15 @@ module PublishingApi module PayloadBuilder class MultipleParts - attr_reader :item, :part_of + attr_reader :item, :part - def self.for(item, part_of) - new(item, part_of).call + def self.for(item, part) + new(item, part).call end - def initialize(item, part_of) + def initialize(item, part) @item = item - @part_of = part_of + @part = part end def call @@ -19,11 +19,13 @@ def call private def parts - item.type_instance.fields_for_part(part_of).map do |field| - { - field["part_name"].to_sym => item.block_content&.public_send(field["key"]), - } + hash = { + slug: part.gsub("/", ""), + } + item.type_instance.fields_for_part(part).each_with_object(hash) do |field, obj| + obj[field["part_name"].to_sym] = item.block_content&.public_send(field["key"]) end + hash end end end diff --git a/public/configurable-document-type.schema.json b/public/configurable-document-type.schema.json index 8232771d7eb..a03773a41b6 100644 --- a/public/configurable-document-type.schema.json +++ b/public/configurable-document-type.schema.json @@ -90,8 +90,8 @@ "description": "When true, only renders the field if the configurable_document_types feature flag is enabled", "type": "boolean" }, - "part_of": { - "description": "Identifier for a group of fields that together form a 'part', i.e. a lightweight separate child page.", + "part": { + "description": "The slug (e.g. '/about') of the part this field belongs to, if any. Used to group fields into parts and subpages, and to determine the part_name value sent to the Publishing API.", "type": "string" }, "part_name": { diff --git a/test/unit/app/models/configurable_document_type_test.rb b/test/unit/app/models/configurable_document_type_test.rb index 4a69b202c9d..66910f5e3b7 100644 --- a/test/unit/app/models/configurable_document_type_test.rb +++ b/test/unit/app/models/configurable_document_type_test.rb @@ -165,7 +165,7 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase "title" => "Title", "block" => "default_string", "attribute_path" => %w[block_content about_title], - "part_of" => "about_page_parts", + "part" => "/about", "part_name" => "title", "translatable" => true, }, @@ -173,7 +173,7 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase "title" => "Summary", "block" => "default_textarea", "attribute_path" => %w[block_content about_summary], - "part_of" => "about_page_parts", + "part" => "/about", "part_name" => "summary", "translatable" => true, }, @@ -189,13 +189,15 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase { "key" => "about_title", "part_name" => "title", + "part" => "/about", }, { "key" => "about_summary", "part_name" => "summary", + "part" => "/about", }, ] - assert_equal expected_fields, document_type.fields_for_part("about_page_parts") + assert_equal expected_fields, document_type.fields_for_part("/about") end test "#form creates a flattened hash of fields if no form key is provided" do From 284149fb38d927820595cbb29b27e66b5a452be1 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Wed, 3 Jun 2026 12:50:54 +0100 Subject: [PATCH 07/13] Use 'default_object' to remove need to namespace fields This _almost_ works - but Publishing API rejects the save, because the parts[n].body is expected to be an array (of string and govspeak) but is in fact only a string. This also underlines the fact that we don't currently support any 'processing' on a given property within the default_object scope - we need to initialise `PublishingApi::PayloadBuilder::BlockContent.new(item)` from the 'MultipleParts' class. See next commit. Also had to drop the `summary` property since this isn't allowed by the schema. It expects only title, body and slug. --- app/models/configurable_document_type.rb | 26 +++++++- .../topical_event.json | 63 ++++++++++--------- .../payload_builder/multiple_parts.rb | 13 ++-- .../models/configurable_document_type_test.rb | 40 ++++++------ 4 files changed, 83 insertions(+), 59 deletions(-) diff --git a/app/models/configurable_document_type.rb b/app/models/configurable_document_type.rb index 59862b3aebb..cd6dc8c41ec 100644 --- a/app/models/configurable_document_type.rb +++ b/app/models/configurable_document_type.rb @@ -94,6 +94,13 @@ def parts form["fields"].each do |_, field| parts << field["part"] if field["part"] + + # TODO: make this recursive rather than just one level deep + if field["fields"] + field["fields"].each do |_, nested_field| + parts << nested_field["part"] if nested_field["part"] + end + end end end parts.uniq @@ -103,9 +110,7 @@ def fields_for_part(part_key) @forms.each_value.flat_map do |form| next unless form["fields"] - form["fields"].select { |_, field| field["part"] == part_key }.map do |key, field| - { "key" => key, "part_name" => field["part_name"], "part" => part_key } - end + fields_for_part_in_fields(form["fields"], part_key) end end @@ -155,6 +160,21 @@ class NotFoundError < StandardError private + def fields_for_part_in_fields(fields, part_key) + fields.flat_map do |key, field| + matches = [] + if field["part"] == part_key + matches << { "key" => key, "part" => part_key } + end + + if field["fields"] + matches.concat(fields_for_part_in_fields(field["fields"], part_key)) + end + + matches + end + end + def find_field_title(config, segments) return config["title"] if segments.empty? return nil unless config["fields"] diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index 299a1ac79c5..4bc08537113 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -34,29 +34,25 @@ "dynamic": true, "label": "About page", "fields": { - "about_title": { - "title": "Title", - "block": "default_string", - "attribute_path": ["block_content", "about_title"], - "part": "/about", - "part_name": "title", - "translatable": true - }, - "about_summary": { - "title": "Summary", - "block": "default_textarea", - "attribute_path": ["block_content", "about_summary"], - "part": "/about", - "part_name": "summary", - "translatable": true - }, - "about_body": { - "title": "Body", - "block": "govspeak", - "attribute_path": ["block_content", "about_body"], - "part": "/about", - "part_name": "body", - "translatable": true + "about": { + "block": "default_object", + "attribute_path": ["block_content", "about"], + "fields": { + "title": { + "title": "Title", + "block": "default_string", + "attribute_path": ["title"], + "part": "/about", + "translatable": true + }, + "body": { + "title": "Body", + "block": "govspeak", + "attribute_path": ["body"], + "part": "/about", + "translatable": true + } + } } } }, @@ -158,14 +154,19 @@ "body": { "type": "string" }, - "about_title": { - "type": "string" - }, - "about_summary": { - "type": "string" - }, - "about_body": { - "type": "string" + "about": { + "type": "object", + "attributes": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "body": { + "type": "govspeak" + } + } }, "social_media_links": { "type": "array", diff --git a/app/presenters/publishing_api/payload_builder/multiple_parts.rb b/app/presenters/publishing_api/payload_builder/multiple_parts.rb index d7a67b08e77..9dda57005ae 100644 --- a/app/presenters/publishing_api/payload_builder/multiple_parts.rb +++ b/app/presenters/publishing_api/payload_builder/multiple_parts.rb @@ -1,15 +1,15 @@ module PublishingApi module PayloadBuilder class MultipleParts - attr_reader :item, :part + attr_reader :item, :part_with_trailing_slash def self.for(item, part) new(item, part).call end - def initialize(item, part) + def initialize(item, part_with_trailing_slash) @item = item - @part = part + @part_with_trailing_slash = part_with_trailing_slash end def call @@ -19,11 +19,12 @@ def call private def parts + part = part_with_trailing_slash.gsub("/", "") hash = { - slug: part.gsub("/", ""), + slug: part, } - item.type_instance.fields_for_part(part).each_with_object(hash) do |field, obj| - obj[field["part_name"].to_sym] = item.block_content&.public_send(field["key"]) + item.type_instance.fields_for_part(part_with_trailing_slash).each_with_object(hash) do |field, obj| + obj[field["key"].to_sym] = item.block_content&.public_send(part)[field["key"]] end hash end diff --git a/test/unit/app/models/configurable_document_type_test.rb b/test/unit/app/models/configurable_document_type_test.rb index 66910f5e3b7..887f1e848c8 100644 --- a/test/unit/app/models/configurable_document_type_test.rb +++ b/test/unit/app/models/configurable_document_type_test.rb @@ -161,21 +161,25 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase "forms" => { "some_tab_name" => { "fields" => { - "about_title" => { - "title" => "Title", - "block" => "default_string", - "attribute_path" => %w[block_content about_title], - "part" => "/about", - "part_name" => "title", - "translatable" => true, - }, - "about_summary" => { - "title" => "Summary", - "block" => "default_textarea", - "attribute_path" => %w[block_content about_summary], - "part" => "/about", - "part_name" => "summary", - "translatable" => true, + "about" => { + "type" => "default_object", + "attribute_path" => %w[block_content about], + "fields" => { + "title" => { + "title" => "Title", + "block" => "default_string", + "attribute_path" => %w[title], + "part" => "/about", + "translatable" => true, + }, + "summary" => { + "title" => "Summary", + "block" => "default_textarea", + "attribute_path" => %w[summary], + "part" => "/about", + "translatable" => true, + }, + }, }, }, }, @@ -187,13 +191,11 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase expected_fields = [ { - "key" => "about_title", - "part_name" => "title", + "key" => "title", "part" => "/about", }, { - "key" => "about_summary", - "part_name" => "summary", + "key" => "summary", "part" => "/about", }, ] From de9919ae71f75984afe0f5ef241a6a7ffcbdd657 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Wed, 3 Jun 2026 13:57:00 +0100 Subject: [PATCH 08/13] Support calling conversion methods on 'nested' fields This commit does a few things: - Makes it possible to call conversion methods (such as `govspeak`) on nested fields (such as `about.title`) - Introduces a way of specifying a hardcoded value in the payload (in this case, a part's "slug") - Kills the `MultipleParts` payload builder as a result - no longer needed - Kills the 'fields_for_part' logic - Simplifies the 'parts' logic - Makes the `presenters.publishing_api.details` config more expressive - Introduces new `compiled_and_raw_govspeak` converter, as some schemas expect Whitehall to send both (in this case, when sending a 'body' for a 'part'). - Renames `govspeak` to `compiled_govspeak` to disambiguate - it otherwise read as though we were sending the raw GovSpeak. --- app/models/configurable_document_type.rb | 40 +-------------- .../topical_event.json | 14 ++++-- .../payload_builder/block_content.rb | 50 ++++++++++++++----- .../payload_builder/multiple_parts.rb | 33 ------------ .../models/configurable_document_type_test.rb | 48 ------------------ 5 files changed, 47 insertions(+), 138 deletions(-) delete mode 100644 app/presenters/publishing_api/payload_builder/multiple_parts.rb diff --git a/app/models/configurable_document_type.rb b/app/models/configurable_document_type.rb index cd6dc8c41ec..7d248269b5d 100644 --- a/app/models/configurable_document_type.rb +++ b/app/models/configurable_document_type.rb @@ -88,30 +88,7 @@ def properties end def parts - parts = [] - @forms.each_value.flat_map do |form| - next unless form["fields"] - - form["fields"].each do |_, field| - parts << field["part"] if field["part"] - - # TODO: make this recursive rather than just one level deep - if field["fields"] - field["fields"].each do |_, nested_field| - parts << nested_field["part"] if nested_field["part"] - end - end - end - end - parts.uniq - end - - def fields_for_part(part_key) - @forms.each_value.flat_map do |form| - next unless form["fields"] - - fields_for_part_in_fields(form["fields"], part_key) - end + (presenter("publishing_api")["details"]["parts"] || []).map { |part| part["slug"]["hardcoded_value"] } end def form(key = nil) @@ -160,21 +137,6 @@ class NotFoundError < StandardError private - def fields_for_part_in_fields(fields, part_key) - fields.flat_map do |key, field| - matches = [] - if field["part"] == part_key - matches << { "key" => key, "part" => part_key } - end - - if field["fields"] - matches.concat(fields_for_part_in_fields(field["fields"], part_key)) - end - - matches - end - end - def find_field_title(config, segments) return config["title"] if segments.empty? return nil unless config["fields"] diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index 4bc08537113..16b80e2f954 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -42,14 +42,12 @@ "title": "Title", "block": "default_string", "attribute_path": ["title"], - "part": "/about", "translatable": true }, "body": { "title": "Body", "block": "govspeak", "attribute_path": ["body"], - "part": "/about", "translatable": true } } @@ -214,9 +212,15 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak", - "social_media_links": "social_media_links", - "parts": "parts" + "body": { "field": "body", "type": "compiled_govspeak" }, + "social_media_links": { "field": "social_media_links", "type": "social_media_links" }, + "parts": [ + { + "title": { "field": "about.title", "type": "raw" }, + "body": { "field": "about.body", "type": "compiled_and_raw_govspeak" }, + "slug": { "hardcoded_value": "/about" } + } + ] }, "links": [ "organisations" diff --git a/app/presenters/publishing_api/payload_builder/block_content.rb b/app/presenters/publishing_api/payload_builder/block_content.rb index d39c28b7760..48c0361901f 100644 --- a/app/presenters/publishing_api/payload_builder/block_content.rb +++ b/app/presenters/publishing_api/payload_builder/block_content.rb @@ -16,7 +16,24 @@ def call return {} unless mapping mapping.each_with_object({}) { |(attribute, builder), details| - details[attribute.to_sym] = send(builder, attribute) + if builder.is_a?(Array) + details[attribute.to_sym] = builder.map { |part_builder| + part_builder.each_with_object({}) { |(part_attribute, part_builder_type), part_details| + if part_builder_type["hardcoded_value"] + part_details[part_attribute.to_sym] = part_builder_type["hardcoded_value"] + elsif part_builder_type["field"].include?(".") + # TODO: again, support recursion for infinite depths + namespace = part_builder_type["field"].split(".").first + field = part_builder_type["field"].split(".").last + part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(namespace)[field]) + else + part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(part_builder_type["field"])) + end + } + } + else + details[attribute.to_sym] = send(builder["type"], item.block_content&.public_send(attribute)) + end }.compact end @@ -25,22 +42,35 @@ def call attr_reader :item def raw(attribute) - item.block_content&.public_send(attribute) + attribute end - def govspeak(attribute) - content = item.block_content&.public_send(attribute) + def compiled_govspeak(content) return nil if content.nil? govspeak_to_html(content, images: item.images, attachments: item.attachments) end + def compiled_and_raw_govspeak(content) + return nil if content.nil? + + [ + { + content_type: "text/html", + content: compiled_govspeak(content), + }, + { + content_type: "text/govspeak", + content: content, + } + ] + end + def rfc3339_date(attribute) - item.block_content&.public_send(attribute)&.rfc3339 + attribute&.rfc3339 end - def social_media_links(attribute) - content = item.block_content&.public_send(attribute) + def social_media_links(content) return [] if content.blank? content.map do |item| @@ -55,12 +85,6 @@ def social_media_links(attribute) } end end - - def parts(_attribute) - item.type_instance.parts.map do |part| - PayloadBuilder::MultipleParts.for(item, part) - end - end end end end diff --git a/app/presenters/publishing_api/payload_builder/multiple_parts.rb b/app/presenters/publishing_api/payload_builder/multiple_parts.rb deleted file mode 100644 index 9dda57005ae..00000000000 --- a/app/presenters/publishing_api/payload_builder/multiple_parts.rb +++ /dev/null @@ -1,33 +0,0 @@ -module PublishingApi - module PayloadBuilder - class MultipleParts - attr_reader :item, :part_with_trailing_slash - - def self.for(item, part) - new(item, part).call - end - - def initialize(item, part_with_trailing_slash) - @item = item - @part_with_trailing_slash = part_with_trailing_slash - end - - def call - parts - end - - private - - def parts - part = part_with_trailing_slash.gsub("/", "") - hash = { - slug: part, - } - item.type_instance.fields_for_part(part_with_trailing_slash).each_with_object(hash) do |field, obj| - obj[field["key"].to_sym] = item.block_content&.public_send(part)[field["key"]] - end - hash - end - end - end -end diff --git a/test/unit/app/models/configurable_document_type_test.rb b/test/unit/app/models/configurable_document_type_test.rb index 887f1e848c8..b758bad4a03 100644 --- a/test/unit/app/models/configurable_document_type_test.rb +++ b/test/unit/app/models/configurable_document_type_test.rb @@ -154,54 +154,6 @@ class ConfigurableDocumentTypeTest < ActiveSupport::TestCase assert_equal "new_type", types_we_can_convert_to.first.key end - test "#fields_for_part returns the fields for a specific part key" do - configurable_document_type = build_configurable_document_type( - "test_type", - { - "forms" => { - "some_tab_name" => { - "fields" => { - "about" => { - "type" => "default_object", - "attribute_path" => %w[block_content about], - "fields" => { - "title" => { - "title" => "Title", - "block" => "default_string", - "attribute_path" => %w[title], - "part" => "/about", - "translatable" => true, - }, - "summary" => { - "title" => "Summary", - "block" => "default_textarea", - "attribute_path" => %w[summary], - "part" => "/about", - "translatable" => true, - }, - }, - }, - }, - }, - }, - }, - ) - ConfigurableDocumentType.setup_test_types(configurable_document_type) - document_type = ConfigurableDocumentType.find("test_type") - - expected_fields = [ - { - "key" => "title", - "part" => "/about", - }, - { - "key" => "summary", - "part" => "/about", - }, - ] - assert_equal expected_fields, document_type.fields_for_part("/about") - end - test "#form creates a flattened hash of fields if no form key is provided" do configurable_document_type = build_configurable_document_type( "test_type", From 02ec9c019b98d4e50d5a50ce0d1ae4298f7fd4b5 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Wed, 3 Jun 2026 14:38:34 +0100 Subject: [PATCH 09/13] Fixup tests --- .../case_study.json | 2 +- .../government_response.json | 2 +- .../history_page.json | 4 +- .../news_story.json | 2 +- .../press_release.json | 2 +- .../topical_event.json | 5 +- .../world_news_story.json | 2 +- .../payload_builder/block_content.rb | 38 ++-- .../test_configurable_document_type.json | 6 +- ...test_configurable_document_type_group.json | 4 +- public/configurable-document-type.schema.json | 201 ++++++++++-------- test/fixtures/test_schema.json | 2 +- ...rd_edition_translations_controller_test.rb | 2 +- .../configurable_document_type_helper.rb | 2 +- .../payload_builder/block_content_test.rb | 46 ++-- .../standard_edition_presenter_test.rb | 18 +- 16 files changed, 179 insertions(+), 159 deletions(-) diff --git a/app/models/configurable_document_types/case_study.json b/app/models/configurable_document_types/case_study.json index 71896345250..21c92892d49 100644 --- a/app/models/configurable_document_types/case_study.json +++ b/app/models/configurable_document_types/case_study.json @@ -75,7 +75,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [ "world_locations", diff --git a/app/models/configurable_document_types/government_response.json b/app/models/configurable_document_types/government_response.json index 76a8682639f..5a766227589 100644 --- a/app/models/configurable_document_types/government_response.json +++ b/app/models/configurable_document_types/government_response.json @@ -89,7 +89,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [ "ministerial_role_appointments", diff --git a/app/models/configurable_document_types/history_page.json b/app/models/configurable_document_types/history_page.json index 1c2ae114e59..a14a93b6936 100644 --- a/app/models/configurable_document_types/history_page.json +++ b/app/models/configurable_document_types/history_page.json @@ -56,8 +56,8 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak", - "lead_paragraph": "raw" + "body": { "field": "body", "type": "compiled_govspeak" }, + "lead_paragraph": { "field": "lead_paragraph", "type": "raw" } }, "links": [] } diff --git a/app/models/configurable_document_types/news_story.json b/app/models/configurable_document_types/news_story.json index 73efb9c396a..cc9d6e9aaf4 100644 --- a/app/models/configurable_document_types/news_story.json +++ b/app/models/configurable_document_types/news_story.json @@ -89,7 +89,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [ "ministerial_role_appointments", diff --git a/app/models/configurable_document_types/press_release.json b/app/models/configurable_document_types/press_release.json index 45d4ba72299..86bcdc18e32 100644 --- a/app/models/configurable_document_types/press_release.json +++ b/app/models/configurable_document_types/press_release.json @@ -89,7 +89,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [ "ministerial_role_appointments", diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index 16b80e2f954..c38009f26c2 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -158,11 +158,8 @@ "title": { "type": "string" }, - "summary": { - "type": "string" - }, "body": { - "type": "govspeak" + "type": "string" } } }, diff --git a/app/models/configurable_document_types/world_news_story.json b/app/models/configurable_document_types/world_news_story.json index 22b58cb4977..3aaaa84c7ab 100644 --- a/app/models/configurable_document_types/world_news_story.json +++ b/app/models/configurable_document_types/world_news_story.json @@ -75,7 +75,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [ "topical_events", diff --git a/app/presenters/publishing_api/payload_builder/block_content.rb b/app/presenters/publishing_api/payload_builder/block_content.rb index 48c0361901f..5e8cf5b96af 100644 --- a/app/presenters/publishing_api/payload_builder/block_content.rb +++ b/app/presenters/publishing_api/payload_builder/block_content.rb @@ -16,24 +16,24 @@ def call return {} unless mapping mapping.each_with_object({}) { |(attribute, builder), details| - if builder.is_a?(Array) - details[attribute.to_sym] = builder.map { |part_builder| - part_builder.each_with_object({}) { |(part_attribute, part_builder_type), part_details| - if part_builder_type["hardcoded_value"] - part_details[part_attribute.to_sym] = part_builder_type["hardcoded_value"] - elsif part_builder_type["field"].include?(".") - # TODO: again, support recursion for infinite depths - namespace = part_builder_type["field"].split(".").first - field = part_builder_type["field"].split(".").last - part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(namespace)[field]) - else - part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(part_builder_type["field"])) - end - } - } - else - details[attribute.to_sym] = send(builder["type"], item.block_content&.public_send(attribute)) - end + details[attribute.to_sym] = if builder.is_a?(Array) + builder.map do |part_builder| + part_builder.each_with_object({}) do |(part_attribute, part_builder_type), part_details| + if part_builder_type["hardcoded_value"] + part_details[part_attribute.to_sym] = part_builder_type["hardcoded_value"] + elsif part_builder_type["field"].include?(".") + # TODO: again, support recursion for infinite depths + namespace = part_builder_type["field"].split(".").first + field = part_builder_type["field"].split(".").last + part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(namespace)&.[](field)) + else + part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(part_builder_type["field"])) + end + end + end + else + send(builder["type"], item.block_content&.public_send(attribute)) + end }.compact end @@ -62,7 +62,7 @@ def compiled_and_raw_govspeak(content) { content_type: "text/govspeak", content: content, - } + }, ] end diff --git a/features/fixtures/test_configurable_document_type.json b/features/fixtures/test_configurable_document_type.json index e734820a309..61f10603875 100644 --- a/features/fixtures/test_configurable_document_type.json +++ b/features/fixtures/test_configurable_document_type.json @@ -153,9 +153,9 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak", - "date_field": "rfc3339_date", - "list_of_foods": "raw" + "body": { "field": "body", "type": "compiled_govspeak" }, + "date_field": { "field": "date_field", "type": "rfc3339_date" }, + "list_of_foods": { "field": "list_of_foods", "type": "raw" } }, "links": [] } diff --git a/features/fixtures/test_configurable_document_type_group.json b/features/fixtures/test_configurable_document_type_group.json index 33056a2c402..0bc2ac5f0e0 100644 --- a/features/fixtures/test_configurable_document_type_group.json +++ b/features/fixtures/test_configurable_document_type_group.json @@ -26,7 +26,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [] } @@ -83,7 +83,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": [] } diff --git a/public/configurable-document-type.schema.json b/public/configurable-document-type.schema.json index a03773a41b6..1d5de594ba6 100644 --- a/public/configurable-document-type.schema.json +++ b/public/configurable-document-type.schema.json @@ -41,27 +41,20 @@ "type": "object", "properties": { "title": { - "type": "string", - "description": "Display name for the content block." + "type": "string" }, "description": { - "type": "string", - "description": "Description of the content block's purpose. Usually used for hint text." + "type": "string" }, "required": { - "type": "boolean", - "description": "Whether or not the form value is required" + "type": "boolean" }, "attribute_path": { "type": "array", - "description": "The path of the active record attribute this field controls. For JSON attributes this may have multiple items", - "items": { - "type": "string" - } + "items": { "type": "string" } }, "block": { "type": "string", - "description": "Identifier for a content block. Maps to a specific block class.", "enum": [ "default_array", "default_date", @@ -76,75 +69,35 @@ }, "fields": { "type": "object", + "description": "Nested fields for object-type blocks (recursive form structure).", "patternProperties": { ".*": { "$ref": "#/$defs/form_field" } - } - }, - "translatable": { - "description": "Determines whether or not the field's value can be translated for different locales", - "type": "boolean" - }, - "experimental": { - "description": "When true, only renders the field if the configurable_document_types feature flag is enabled", - "type": "boolean" - }, - "part": { - "description": "The slug (e.g. '/about') of the part this field belongs to, if any. Used to group fields into parts and subpages, and to determine the part_name value sent to the Publishing API.", - "type": "string" - }, - "part_name": { - "description": "What we should call this individual part when sending the child page to Publishing API (otherwise we're forced to retain an unnecessary namespace prefix).", - "type": "string" + }, + "additionalProperties": false }, + "translatable": { "type": "boolean" }, + "experimental": { "type": "boolean" }, + "part": { "type": "string" }, + "part_name": { "type": "string" }, "options": { "type": "array", - "description": "List of options for select inputs.", "items": { "type": "object", + "required": ["label", "value"], "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": ["label", "value"] + "label": { "type": "string" }, + "value": { "type": "string" } + } } }, - "blank_option_label": { - "type": "string", - "description": "Text to display for the blank/default option in a select input." - }, - "container": { - "description": "The option container to be used in the select_with_search_tagging_block", - "type": "string", - "enum": [ - "alternative_format_providers", - "detailed_guides", - "ministerial_role_appointments", - "organisations", - "role_appointments", - "roles", - "statistical_data_sets", - "topical_event_documents", - "topical_events", - "world_locations", - "worldwide_organisations" - ] - }, - "size": { - "type": "number", - "description": "The number of items in an array field" - }, - "add_another_button_text": { - "type": "string", - "description": "Text to display for the 'Add another' button in default_array block. If empty, it defaults to 'Add another'." - } + "blank_option_label": { "type": "string" }, + "container": { "type": "string" }, + "size": { "type": "number" }, + "add_another_button_text": { "type": "string" } }, - "required": ["title", "block", "attribute_path", "translatable"], + "required": ["block", "attribute_path"], "additionalProperties": false }, "block_attributes": { @@ -157,15 +110,13 @@ }, "block_attribute": { "type": "object", - "description": "Minimal schema information for a single content block attribute.", "properties": { "type": { "type": "string", - "description": "Data type for the content block. Should map to an active record type, with the exception of the object type.", - "enum": ["string", "integer", "date", "array"] + "enum": ["string", "integer", "date", "array", "object"] }, "attributes": { - "description": "For array types, defines the schema of each array item using the same minimal attribute format.", + "description": "For array OR object types, defines nested schema.", "$ref": "#/$defs/block_attributes" } }, @@ -174,7 +125,7 @@ "allOf": [ { "if": { - "properties": { "type": { "const": "array" } }, + "properties": { "type": { "enum": ["array", "object"] } }, "required": ["type"] }, "then": { @@ -284,32 +235,105 @@ "properties": { "details": { "type": "object", - "description": "Mapping of schema attributes to their respective publishing API details formats.", + "description": "Mapping of schema attributes to Publishing API formats and processors.", + "properties": { + "parts": { + "type": "array", + "description": "Structured multi-part content mapping.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "oneOf": [ + { + "type": "object", + "properties": { + "field": { "type": "string" }, + "type": { "type": "string", "enum": ["raw"] } + }, + "required": ["field", "type"], + "additionalProperties": false + } + ] + }, + "body": { + "oneOf": [ + { + "type": "object", + "properties": { + "field": { "type": "string" }, + "type": { + "type": "string", + "enum": ["compiled_govspeak", "compiled_and_raw_govspeak"] + } + }, + "required": ["field", "type"], + "additionalProperties": false + } + ] + }, + "slug": { + "oneOf": [ + { + "type": "object", + "properties": { + "hardcoded_value": { "type": "string" } + }, + "required": ["hardcoded_value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "field": { "type": "string" }, + "type": { "type": "string", "enum": ["raw"] } + }, + "required": ["field", "type"], + "additionalProperties": false + } + ] + } + } + } + } + }, "patternProperties": { - ".*": { - "type": "string", - "description": "The format to use for the content block when presenting to the Publishing API.", - "enum": [ - "govspeak", - "rfc3339_date", - "image", - "raw", - "social_media_links", - "parts" - ] + "^(?!parts$).+": { + "type": "object", + "description": "Standard field mapping to Publishing API formats.", + "properties": { + "field": { + "type": "string", + "description": "Name of the schema field to use." + }, + "type": { + "type": "string", + "description": "Processor to apply to the field.", + "enum": [ + "compiled_govspeak", + "compiled_and_raw_govspeak", + "rfc3339_date", + "image", + "raw", + "social_media_links" + ] + } + }, + "required": ["field", "type"], + "additionalProperties": false } }, + "additionalProperties": false, "propertyNames": { "type": "string", - "description": "Attribute to be included in the presenter payload. Must match an existing attribute defined in the schema." + "description": "Attribute to be included in the presenter payload." } }, "links": { - "description": "The builder methods to use for the links object when presenting to the Publishing API.", "type": "array", "items": { "type": "string", - "description": "The builder methods to use for populating the links object when presenting to the Publishing API.", "enum": [ "ministerial_role_appointments", "topical_events", @@ -325,8 +349,7 @@ "required": ["details", "links"] } }, - "additionalProperties": false, - "required": ["publishing_api"] + "additionalProperties": false }, "settings": { "type": "object", diff --git a/test/fixtures/test_schema.json b/test/fixtures/test_schema.json index 222adc1c704..3835381243a 100644 --- a/test/fixtures/test_schema.json +++ b/test/fixtures/test_schema.json @@ -25,7 +25,7 @@ "presenters": { "publishing_api": { "details": { - "body": "govspeak" + "body": { "field": "body", "type": "compiled_govspeak" } }, "links": ["government"] } diff --git a/test/functional/admin/standard_edition_translations_controller_test.rb b/test/functional/admin/standard_edition_translations_controller_test.rb index c1a57532e95..ad0375491c7 100644 --- a/test/functional/admin/standard_edition_translations_controller_test.rb +++ b/test/functional/admin/standard_edition_translations_controller_test.rb @@ -27,7 +27,7 @@ class Admin::StandardEditionTranslationsControllerTest < ActionController::TestC "presenters" => { "publishing_api" => { "details" => { - "body" => "govspeak", + "body" => { "field" => "body", "type" => "compiled_govspeak" }, }, }, }, diff --git a/test/support/configurable_document_type_helper.rb b/test/support/configurable_document_type_helper.rb index ab0469c6107..fa06f27ec1c 100644 --- a/test/support/configurable_document_type_helper.rb +++ b/test/support/configurable_document_type_helper.rb @@ -26,7 +26,7 @@ def build_configurable_document_type(type, attributes = {}) "presenters" => { "publishing_api" => { "details" => { - "field_attribute" => "raw", + "field_attribute" => { "field" => "field_attribute", "type" => "raw" }, }, "links" => [], }, diff --git a/test/unit/app/presenters/publishing_api/payload_builder/block_content_test.rb b/test/unit/app/presenters/publishing_api/payload_builder/block_content_test.rb index 71807a33b4a..7efe9bbefb9 100644 --- a/test/unit/app/presenters/publishing_api/payload_builder/block_content_test.rb +++ b/test/unit/app/presenters/publishing_api/payload_builder/block_content_test.rb @@ -15,8 +15,8 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase @item.stubs(:type_instance).returns(type_instance) type_instance.stubs(:presenter).with("publishing_api").returns({ "details" => { - "body" => :govspeak, - "published_on" => :rfc3339_date, + "body" => { "field" => "body", "type" => "compiled_govspeak" }, + "published_on" => { "field" => "published_on", "type" => "rfc3339_date" }, }, }) @@ -39,9 +39,9 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase type_instance.stubs(:presenter).with("publishing_api").returns({ "details" => { - "body_attribute" => :govspeak, - "date_attribute" => :rfc3339_date, - "string_attribute" => :raw, + "body_attribute" => { "field" => "body_attribute", "type" => "compiled_govspeak" }, + "date_attribute" => { "field" => "date_attribute", "type" => "rfc3339_date" }, + "string_attribute" => { "field" => "string_attribute", "type" => "raw" }, }, }) @block_content.stubs(:body_attribute).returns(nil) @@ -62,7 +62,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase @block_content.stubs(:string_chunk).returns(string_content) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - result = builder.send(:raw, :string_chunk) + result = builder.send(:raw, @item.block_content.public_send(:string_chunk)) assert_equal string_content, result end @@ -72,34 +72,34 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase @block_content.stubs(:array_chunk).returns(array_content) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - result = builder.send(:raw, :array_chunk) + result = builder.send(:raw, @item.block_content.public_send(:array_chunk)) assert_equal array_content, result end end - context "govspeak payload builder" do - test "govspeak returns nil when content is nil" do + context "compiled_govspeak payload builder" do + test "compiled_govspeak returns nil when content is nil" do @item.stubs(:block_content).returns(nil) @item.stubs(:images).returns([]) @item.stubs(:attachments).returns([]) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - assert_nil builder.send(:govspeak, :body_attribute) + assert_nil builder.send(:compiled_govspeak, @item.block_content&.public_send(:body_attribute)) end - test "govspeak returns nil when content for attribute is nil" do + test "compiled_govspeak returns nil when content for attribute is nil" do @block_content.stubs(:body).returns(nil) @item.stubs(:images).returns([]) @item.stubs(:attachments).returns([]) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - assert_nil builder.send(:govspeak, :body) + assert_nil builder.send(:compiled_govspeak, @item.block_content.public_send(:body)) end - test "govspeak converts content to HTML with images and attachments" do + test "compiled_govspeak converts content to HTML with images and attachments" do image = mock("image") @block_content.stubs(:body).returns("## Heading\n\nParagraph") @item.stubs(:images).returns([image]) @@ -112,7 +112,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase attachments: [], ).returns("

Heading

Paragraph

") - result = builder.send(:govspeak, :body) + result = builder.send(:compiled_govspeak, @item.block_content.public_send(:body)) assert_equal "

Heading

Paragraph

", result end end @@ -123,7 +123,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase @block_content.stubs(:published_on).returns(date) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - result = builder.send(:rfc3339_date, :published_on) + result = builder.send(:rfc3339_date, @item.block_content.public_send(:published_on)) assert_equal date.to_time.rfc3339, result end @@ -132,14 +132,14 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase @item.stubs(:block_content).returns(nil) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - assert_nil builder.send(:rfc3339_date, :date_attribute) + assert_nil builder.send(:rfc3339_date, @item.block_content&.public_send(:date_attribute)) end test "rfc3339_date returns nil if content for attribute is nil" do @block_content.stubs(:published_on).returns(nil) builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - assert_nil builder.send(:rfc3339_date, :published_on) + assert_nil builder.send(:rfc3339_date, @item.block_content&.public_send(:published_on)) end end @@ -149,7 +149,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - assert_equal [], builder.send(:social_media_links, :some_attribute) + assert_equal [], builder.send(:social_media_links, @item.block_content&.public_send(:some_attribute)) end test "social_media_links returns empty array when no links have been provided" do @@ -157,7 +157,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase builder = PublishingApi::PayloadBuilder::BlockContent.new(@item) - assert_equal [], builder.send(:social_media_links, :some_attribute) + assert_equal [], builder.send(:social_media_links, @item.block_content&.public_send(:some_attribute)) end test "social_media_links returns array of social media links" do @@ -187,7 +187,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase href: "https://personal.com", }, ] - assert_equal expected_payload, builder.send(:social_media_links, :some_attribute) + assert_equal expected_payload, builder.send(:social_media_links, @item.block_content&.public_send(:some_attribute)) end test "social_media_links defaults title to service name when no title provided" do @@ -203,7 +203,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase href: "https://example.com", }, ] - assert_equal expected_payload, builder.send(:social_media_links, :some_attribute) + assert_equal expected_payload, builder.send(:social_media_links, @item.block_content&.public_send(:some_attribute)) end test "social_media_links uses custom title when provided" do @@ -219,7 +219,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase href: "https://twitter.com/govuk", }, ] - assert_equal expected_payload, builder.send(:social_media_links, :some_attribute) + assert_equal expected_payload, builder.send(:social_media_links, @item.block_content&.public_send(:some_attribute)) end test "social_media_links falls back to service name when title is blank" do @@ -235,7 +235,7 @@ class PublishingApi::PayloadBuilder::BlockContentTest < ActiveSupport::TestCase href: "https://facebook.com/govuk", }, ] - assert_equal expected_payload, builder.send(:social_media_links, :some_attribute) + assert_equal expected_payload, builder.send(:social_media_links, @item.block_content&.public_send(:some_attribute)) end end end diff --git a/test/unit/app/presenters/publishing_api/standard_edition_presenter_test.rb b/test/unit/app/presenters/publishing_api/standard_edition_presenter_test.rb index e83bb5904a9..60069188380 100644 --- a/test/unit/app/presenters/publishing_api/standard_edition_presenter_test.rb +++ b/test/unit/app/presenters/publishing_api/standard_edition_presenter_test.rb @@ -43,8 +43,8 @@ class PublishingApi::StandardEditionPresenterTest < ActiveSupport::TestCase "presenters" => { "publishing_api" => { "details" => { - "attribute_one" => "raw", - "attribute_two" => "raw", + "attribute_one" => { "field" => "attribute_one", "type" => "raw" }, + "attribute_two" => { "field" => "attribute_two", "type" => "raw" }, }, }, }, @@ -92,9 +92,9 @@ class PublishingApi::StandardEditionPresenterTest < ActiveSupport::TestCase "presenters" => { "publishing_api" => { "details" => { - "string_chunk_of_content" => "raw", - "chunk_of_content_one" => "govspeak", - "chunk_of_content_two" => "govspeak", + "string_chunk_of_content" => { "field" => "string_chunk_of_content", "type" => "raw" }, + "chunk_of_content_one" => { "field" => "chunk_of_content_one", "type" => "compiled_govspeak" }, + "chunk_of_content_two" => { "field" => "chunk_of_content_two", "type" => "compiled_govspeak" }, }, }, }, @@ -145,8 +145,8 @@ class PublishingApi::StandardEditionPresenterTest < ActiveSupport::TestCase "presenters" => { "publishing_api" => { "details" => { - "chunk_of_content_one" => "govspeak", - "chunk_of_content_two" => "govspeak", + "chunk_of_content_one" => { "field" => "chunk_of_content_one", "type" => "compiled_govspeak" }, + "chunk_of_content_two" => { "field" => "chunk_of_content_two", "type" => "compiled_govspeak" }, }, }, }, @@ -181,8 +181,8 @@ class PublishingApi::StandardEditionPresenterTest < ActiveSupport::TestCase "presenters" => { "publishing_api" => { "details" => { - "chunk_of_content_one" => "govspeak", - "chunk_of_content_two" => "govspeak", + "chunk_of_content_one" => { "field" => "chunk_of_content_one", "type" => "compiled_govspeak" }, + "chunk_of_content_two" => { "field" => "chunk_of_content_two", "type" => "compiled_govspeak" }, }, }, }, From f93db31415b8b2f297331dbc4151a0b4ea942369 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 8 Jun 2026 15:15:30 +0100 Subject: [PATCH 10/13] Re-add summary field This is needed for About pages - it's rendered on the page. We're going to move away from 'parts' in the next commit - so are no longer constrained by trying to conform to that schema. --- .../configurable_document_types/topical_event.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index c38009f26c2..fdb6da9144c 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -44,6 +44,12 @@ "attribute_path": ["title"], "translatable": true }, + "summary": { + "title": "Summary", + "block": "default_textarea", + "attribute_path": ["summary"], + "translatable": true + }, "body": { "title": "Body", "block": "govspeak", @@ -158,6 +164,9 @@ "title": { "type": "string" }, + "summary": { + "type": "string" + }, "body": { "type": "string" } From 06fe7047bd58e20b964d153d30bcf0699914e757 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 8 Jun 2026 15:17:16 +0100 Subject: [PATCH 11/13] ITERATION: `about` hash instead of `parts` [Deduced through conversation](https://gds.slack.com/archives/C08B3CJGD3N/p1780926290028789?thread_ts=1780397116.381579&cid=C08B3CJGD3N) that Travel-Advice-like 'parts' won't work for Topical Events because Travel Advice expects every page to be in the 'parts', whereas the implementation we've worked with for Topical Events expects only the about page in the 'parts' array. We could look at moving the main Topical Event page into the parts array but that has it diverging from all other StandardEdition formats in a way that is more difficult to roll back later. The alternative is to keep separate content items for Topical Event page and About page, using the fully editionable workflow (2x StandardEdition instances) we're planning to use for landing pages. The middle ground is to include the About page details in the details hash of the Topical Event page, but not try to abstract it through the 'pages' property - just keep a top-level 'about' property instead. That's what we're implementing here. --- .../configurable_document_types/topical_event.json | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index fdb6da9144c..0dd6ccaf42c 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -220,13 +220,12 @@ "details": { "body": { "field": "body", "type": "compiled_govspeak" }, "social_media_links": { "field": "social_media_links", "type": "social_media_links" }, - "parts": [ - { - "title": { "field": "about.title", "type": "raw" }, - "body": { "field": "about.body", "type": "compiled_and_raw_govspeak" }, - "slug": { "hardcoded_value": "/about" } - } - ] + "about": { + "title": { "field": "about.title", "type": "raw" }, + "summary": { "field": "about.summary", "type": "raw" }, + "body": { "field": "about.body", "type": "compiled_and_raw_govspeak" }, + "slug": { "hardcoded_value": "/about" } + } }, "links": [ "organisations" From c57b768c6a5ac89d7630e9b25ae936618f0ed8cc Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 8 Jun 2026 16:53:06 +0100 Subject: [PATCH 12/13] Add support for 'headings' --- .../topical_event.json | 4 +- .../payload_builder/block_content.rb | 45 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index 0dd6ccaf42c..20ed2b3b8b1 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -223,8 +223,8 @@ "about": { "title": { "field": "about.title", "type": "raw" }, "summary": { "field": "about.summary", "type": "raw" }, - "body": { "field": "about.body", "type": "compiled_and_raw_govspeak" }, - "slug": { "hardcoded_value": "/about" } + "body": { "field": "about.body", "type": "compiled_govspeak" }, + "headers": { "field": "about.body", "type": "headings_from" } } }, "links": [ diff --git a/app/presenters/publishing_api/payload_builder/block_content.rb b/app/presenters/publishing_api/payload_builder/block_content.rb index 5e8cf5b96af..862f3f33640 100644 --- a/app/presenters/publishing_api/payload_builder/block_content.rb +++ b/app/presenters/publishing_api/payload_builder/block_content.rb @@ -2,6 +2,7 @@ module PublishingApi module PayloadBuilder class BlockContent include GovspeakHelper + include Presenters::PublishingApi::PayloadHeadingsHelper def self.for(item) new(item).call @@ -16,24 +17,26 @@ def call return {} unless mapping mapping.each_with_object({}) { |(attribute, builder), details| - details[attribute.to_sym] = if builder.is_a?(Array) - builder.map do |part_builder| - part_builder.each_with_object({}) do |(part_attribute, part_builder_type), part_details| - if part_builder_type["hardcoded_value"] - part_details[part_attribute.to_sym] = part_builder_type["hardcoded_value"] - elsif part_builder_type["field"].include?(".") - # TODO: again, support recursion for infinite depths - namespace = part_builder_type["field"].split(".").first - field = part_builder_type["field"].split(".").last - part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(namespace)&.[](field)) - else - part_details[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(part_builder_type["field"])) - end - end - end - else - send(builder["type"], item.block_content&.public_send(attribute)) - end + if builder["type"] + details[attribute.to_sym] = send(builder["type"], item.block_content&.public_send(attribute)) + else # this is a nested hash + # TODO: again, we want to support recursion for infinite depths + # And probably handle possible namespace clash of field/type/hardcoded_value keys better + child_hash = {} + builder.keys.each do |part_attribute| + part_builder_type = builder[part_attribute] + if part_builder_type["hardcoded_value"] + child_hash[part_attribute.to_sym] = part_builder_type["hardcoded_value"] + elsif part_builder_type["field"].include?(".") + namespace = part_builder_type["field"].split(".").first + field = part_builder_type["field"].split(".").last + child_hash[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(namespace)&.[](field)) + else + child_hash[part_attribute.to_sym] = send(part_builder_type["type"], item.block_content&.public_send(part_builder_type["field"])) + end + end + details[attribute.to_sym] = child_hash + end }.compact end @@ -85,6 +88,12 @@ def social_media_links(content) } end end + + def headings_from(attribute) + return nil if attribute.nil? + + extract_headings(attribute)[:headers] + end end end end From cfcca7a3c0b14f388057cbd964163834acd33220 Mon Sep 17 00:00:00 2001 From: ChrisBAshton Date: Mon, 8 Jun 2026 16:54:31 +0100 Subject: [PATCH 13/13] Hard-code additional routes in settings We no longer dynamically derive this from the 'parts'. --- app/models/configurable_document_types/topical_event.json | 1 + app/models/standard_edition.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/configurable_document_types/topical_event.json b/app/models/configurable_document_types/topical_event.json index 20ed2b3b8b1..841ce6e0b98 100644 --- a/app/models/configurable_document_types/topical_event.json +++ b/app/models/configurable_document_types/topical_event.json @@ -234,6 +234,7 @@ }, "settings": { "base_path_prefix": "/government/topical-events", + "additional_routes": ["/about"], "publishing_api_schema_name": "topical_event", "publishing_api_document_type": "topical_event", "rendering_app": "frontend", diff --git a/app/models/standard_edition.rb b/app/models/standard_edition.rb index d5e12444905..de379bf507f 100644 --- a/app/models/standard_edition.rb +++ b/app/models/standard_edition.rb @@ -107,7 +107,7 @@ def base_path end def additional_routes - type_instance.parts + type_instance.settings["additional_routes"] || [] end def type_instance