Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/models/configurable_content_blocks/default_textarea.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module ConfigurableContentBlocks
class DefaultTextarea < BaseBlock
private

def template_name
"default_textarea"
end
end
end
23 changes: 23 additions & 0 deletions app/models/configurable_document_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -86,6 +87,28 @@ 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"] == part_key }.map do |key, field|
{ "key" => key, "part_name" => field["part_name"], "part" => part_key }
end
end
end

def form(key = nil)
return nil if @forms.empty?

Expand Down
42 changes: 41 additions & 1 deletion app/models/configurable_document_types/topical_event.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,36 @@
}
}
},
"about": {

@lauraghiorghisor-tw lauraghiorghisor-tw Jun 2, 2026

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.

but you can imagine how we'd extend this to support all possible Corporate Information Pages, for example.

It appears I cannot, in fact, imagine 😅

How would you extend this for multiple corporate information pages? Add all the types there as new forms, with their own fields, and part "/corporate-thing-blah"? 💀

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, exactly! Or every corporate information page on one tab (like we have for Travel Advice pages):

"corporate_information_pages": {
  "dynamic": true,
  "label": "Corporate information pages",
  "fields": {
    "recruitment_summary": {
      "title": "Recruitment page summary (leave blank if not needed)",
      "block": "govspeak",
      "attribute_path": ["block_content", "recruitment_summary"],
      "part": "/recruitment",
      "part_name": "summary",
      "translatable": true
    },
    "recruitment_body": {
      "title": "Recruitment page body (leave blank if not needed)",
      "block": "govspeak",
      "attribute_path": ["block_content", "recruitment_body"],
      "part": "/recruitment",
      "part_name": "body",
      "translatable": true
    },
    "accessibility_statement_summary": {
      "title": "Accessibility statement summary (leave blank if not needed)",
       ...etc

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.

That's really busy 😵‍💫
The array solution might work better, and then you can just let the user add... another.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Less bad in latest commits (each 'part' would be its own nested default_object, and there are fewer properties needed, e.g. part_name/part). But agree it's still not great.
As we touched on in 1:1 this morning, not entirely convinced this would scale to corporate information pages now I know CIP pages have an Attachments tab requirement too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Though could very well scale to Travel Advice 🤔

"dynamic": true,
"label": "About page",
"fields": {
"about_title": {
"title": "Title",
"block": "default_string",
"attribute_path": ["block_content", "about_title"],
"part": "/about",

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 probably store the plain "about" here and inject the "/" where needed?

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.

Also, I find it odd that we have no mechanism to know where this block sits - like we shouldn't have to even do something like this. The tree traversal should give us this, and we should have the option to map this in the presenters section.

Is it that our interpretation of Path has now veered towards leaf nodes so much? Like, if we had an attribute_path that had the block's title, we could use that.

Something about you having to do this suggests the current implementation is lacking something. I just would not like to add extraordinary rules to what we already have, if possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmmm true - perhaps we can infer this by setting something like "attribute_path": ["block_content", "about", "title"], instead of the nasty namespacing 🤔

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.

There definitely seems to be a pattern/rule? of only using leaves in that attribute_path array now.

We can explore some options when implementing this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in latest commits.

"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
}
}
},
"social_media_accounts": {
"dynamic": true,
"label": "Social media accounts",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions app/models/edition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/models/standard_edition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ def base_path
"#{type_instance.settings['base_path_prefix']}/#{slug}"
end

def additional_routes
type_instance.parts
end

def type_instance
ConfigurableDocumentType.find(configurable_document_type)
end
Expand Down
2 changes: 1 addition & 1 deletion app/presenters/publishing_api/organisation_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def use_prefix_route?
def additional_routes
return [] if court_or_tribunal?

%w[atom]
%w[.atom]
end

def details
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ def social_media_links(attribute)
}
end
end

def parts(_attribute)

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 don't know that I like injecting new flavours of things in the logic of these classes like this. The config driven approach becomes a bit un-hold-in-ones-head-able. If we expect the methods here to be mappers to keys that are attributes, let's try to follow that pattern.

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.

Plus, wouldn't the rest of the presenter logic also loop through the about field and add it to the payload?

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 this is a case for an object block. Not sure whether we said we were doing those or not anymore 😅 It's a blur.

Or alternatively we can add an array of fields with a single option. And then it is truly obvious how that would extend for multiple pages (though add another is not exactly great for this case).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Plus, wouldn't the rest of the presenter logic also loop through the about field and add it to the payload?

...no? By my understanding, everything in the presenter payload has to exist in the presenters.publishing_api.details property. If we drop parts: parts, then the parts aren't included in the payload.

  "presenters": {
    "publishing_api": {
      "details": {
        "body": "govspeak",
        "social_media_links": "social_media_links",
        "parts": "parts"
      },

But to your first point - I agree. It would be much nicer if we could be more explicit in the presenter config, something like this:

  "presenters": {
    "publishing_api": {
      "details": {
        "body": { "_source": "body", "_processor": "govspeak" },
        "social_media_links": { "_source": "social_media_links", "_processor": "social_media_links" },
        "parts": {
          "_sources": {
            "title": "{ "_source": "about_title", "_processor": "raw" },
            "summary": "{ "_source": "about_summary", "_processor": "raw" },
            "body": "{ "_source": "about_body", "_processor": "string" },
          }
        }
      },
      ...
    }
  },

That would mean we could drop the parts presenter method and also drop the part_name thing that was only included to drop the namespacing when presenting.

(Have gone for underscore "_source" notation in case for whatever reason you'd have a page with a details.source, we don't want to conflict with 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.

Ah yes yes "everything in the presenter payload has to exist..." - this is correct 🤦🏻‍♀️

I think what I meant with the "keys that are attributes" was that - most of those payload builder methods have the block_content calling that attribute.
So since our form is already called "about", if we map about: parts in the presenter section in the schema, wouldn't that call our "parts" builder with an "about" attribute, and then we get the "part" as "about" without further work? It depends if the code would actually save this "about" block/form to the block_content correctly. Yeah, I am basically describing a nested, non-root, object. And I don't think that works anymore.

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.

What does this save like now, in the block_content? It doesn't wrap an "about" object over the fields, does it? 🤔 I imagine it would be block_content: { about_title: ..., about_summary etc}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Have pushed up some commits changing the schema to use the default_object with an "about" namespace, rather than several flat "about_x" properties. Meant we can kill the parts method etc :)

item.type_instance.parts.map do |part|
PayloadBuilder::MultipleParts.for(item, part)
end
end
end
end
end
32 changes: 32 additions & 0 deletions app/presenters/publishing_api/payload_builder/multiple_parts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module PublishingApi
module PayloadBuilder
class MultipleParts
attr_reader :item, :part

def self.for(item, part)
new(item, part).call
end

def initialize(item, part)
@item = item
@part = part
end

def call
parts
end

private

def parts
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
end
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/presenters/publishing_api/payload_builder/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/presenters/publishing_api/topical_event_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %>
12 changes: 11 additions & 1 deletion public/configurable-document-type.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"default_date",
"default_object",
"default_string",
"default_textarea",
"govspeak",
"default_select",
"select_with_search_tagging",
Expand All @@ -89,6 +90,14 @@
"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"
},
"options": {
"type": "array",
"description": "List of options for select inputs.",
Expand Down Expand Up @@ -285,7 +294,8 @@
"rfc3339_date",
"image",
"raw",
"social_media_links"
"social_media_links",
"parts"
]
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading