+
+ <% if !schema.embeddable_as_block? %>
+ <% nested_blocks.each do |args| %>
+ <%= render "govuk_publishing_components/components/summary_card", **args %>
+ <% end %>
+ <% else %>
+
+ <%= render "govuk_publishing_components/components/details", {
+ title: "All #{object_name} attributes",
+ } do %>
+ <% capture do %>
+
+ These are all the <%= object_name %> attributes that make up the <%= object_name %>. You can use the embed code for each attribute separately in your content if required.
+
diff --git a/app/components/content_block_manager/shared/schedule_publishing_component.rb b/app/components/content_block_manager/shared/schedule_publishing_component.rb
new file mode 100644
index 000000000..64824e320
--- /dev/null
+++ b/app/components/content_block_manager/shared/schedule_publishing_component.rb
@@ -0,0 +1,34 @@
+class ContentBlockManager::Shared::SchedulePublishingComponent < ViewComponent::Base
+ def initialize(content_block_edition:, params:, context:, back_link:, form_url:, is_rescheduling:)
+ @content_block_edition = content_block_edition
+ @params = params
+ @context = context
+ @back_link = back_link
+ @form_url = form_url
+ @is_rescheduling = is_rescheduling
+ end
+
+private
+
+ attr_reader :is_rescheduling, :content_block_edition, :params, :context, :back_link, :form_url
+
+ def year_param
+ content_block_edition.scheduled_publication&.year || params.dig("scheduled_at", "scheduled_publication(1i)")
+ end
+
+ def month_param
+ content_block_edition.scheduled_publication&.month || params.dig("scheduled_at", "scheduled_publication(2i)")
+ end
+
+ def day_param
+ content_block_edition.scheduled_publication&.day || params.dig("scheduled_at", "scheduled_publication(3i)")
+ end
+
+ def hour_param
+ content_block_edition.scheduled_publication&.hour || params.dig("scheduled_at", "scheduled_publication(4i)")
+ end
+
+ def minute_param
+ content_block_edition.scheduled_publication&.min || params.dig("scheduled_at", "scheduled_publication(5i)")
+ end
+end
diff --git a/app/components/content_block_manager/signon_user/show/summary_list_component.html.erb b/app/components/content_block_manager/signon_user/show/summary_list_component.html.erb
new file mode 100644
index 000000000..fea81f350
--- /dev/null
+++ b/app/components/content_block_manager/signon_user/show/summary_list_component.html.erb
@@ -0,0 +1,3 @@
+<%= render "govuk_publishing_components/components/summary_list", {
+ items:,
+ } %>
diff --git a/app/components/content_block_manager/signon_user/show/summary_list_component.rb b/app/components/content_block_manager/signon_user/show/summary_list_component.rb
new file mode 100644
index 000000000..f046e2210
--- /dev/null
+++ b/app/components/content_block_manager/signon_user/show/summary_list_component.rb
@@ -0,0 +1,36 @@
+class ContentBlockManager::SignonUser::Show::SummaryListComponent < ViewComponent::Base
+ def initialize(user:)
+ @user = user
+ end
+
+private
+
+ def items
+ [
+ name_item,
+ email_item,
+ organisation_item,
+ ].compact
+ end
+
+ def name_item
+ {
+ field: "Name",
+ value: @user.name,
+ }
+ end
+
+ def email_item
+ {
+ field: "Email",
+ value: @user.email,
+ }
+ end
+
+ def organisation_item
+ {
+ field: "Organisation",
+ value: @user.organisation.name,
+ }
+ end
+end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 000000000..23fdc170f
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,66 @@
+class Admin::BaseController < ApplicationController
+ # include Admin::EditionRoutesHelper
+ # include PermissionsCheckerConcern
+
+ layout "design_system"
+ prepend_before_action :authenticate_user!
+
+ def limit_edition_access!
+ enforce_permission!(:see, @edition)
+ end
+
+ def require_fatality_handling_permission!
+ forbidden! unless current_user.can_handle_fatalities?
+ end
+
+ def enforce_permission!(action, subject)
+ unless can?(action, subject)
+ raise Whitehall::Authority::Errors::PermissionDenied.new(action, subject)
+ end
+ end
+
+ rescue_from Whitehall::Authority::Errors::PermissionDenied do |exception|
+ logger.warn "Attempt to perform '#{exception.action}' on #{exception.subject} prevented."
+ forbidden!
+ end
+
+ rescue_from Whitehall::Authority::Errors::InvalidAction do |exception|
+ logger.warn "Attempt to perform unknown action '#{exception.action}' prevented."
+ forbidden!
+ end
+
+ def prevent_modification_of_unmodifiable_edition
+ if @edition.unmodifiable?
+ alert = "You cannot modify a #{@edition.state} #{@edition.type.titleize}"
+ redirect_to admin_edition_path(@edition), alert:
+ end
+ end
+
+ def product_name
+ Whitehall.product_name
+ end
+ helper_method :product_name
+
+private
+
+ def forbidden!
+ prepend_view_path Rails.root.join("lib/engines/content_block_manager/app/views") if request.path.start_with?(ContentBlockManager.router_prefix)
+ render "admin/errors/forbidden", status: :forbidden
+ end
+
+ def typecast_for_attachable_routing(attachable)
+ case attachable
+ when Edition then attachable.becomes(Edition)
+ when ConsultationResponse then attachable.becomes(ConsultationResponse)
+ when CallForEvidenceResponse then attachable.becomes(CallForEvidenceResponse)
+ else attachable
+ end
+ end
+ helper_method :typecast_for_attachable_routing
+
+ # Override the default Rails behaviour to raise an exception when receiving
+ # unverified requests instead of nullifying the session
+ def handle_unverified_request
+ raise ActionController::InvalidAuthenticityToken
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 0d95db22b..fbbd579a7 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,20 @@
class ApplicationController < ActionController::Base
- # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
- allow_browser versions: :modern
+ include GDS::SSO::ControllerMethods
+
+ protect_from_forgery
+
+ before_action :set_authenticated_user_header
+
+private
+
+ def set_authenticated_user_header
+ if current_user && GdsApi::GovukHeaders.headers[:x_govuk_authenticated_user].nil?
+ GdsApi::GovukHeaders.set_header(:x_govuk_authenticated_user, current_user.uid)
+ end
+ end
+
+ def product_name
+ "Content Block Manager"
+ end
+ helper_method :product_name
end
diff --git a/app/controllers/concerns/can_schedule_or_publish.rb b/app/controllers/concerns/can_schedule_or_publish.rb
new file mode 100644
index 000000000..27ed5b577
--- /dev/null
+++ b/app/controllers/concerns/can_schedule_or_publish.rb
@@ -0,0 +1,80 @@
+module CanScheduleOrPublish
+ extend ActiveSupport::Concern
+
+ def self.included(base)
+ base.helper_method :is_scheduling?
+ end
+
+ def schedule_or_publish
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type)
+
+ if is_scheduling?
+ ContentBlockManager::ScheduleEditionService.new(@schema).call(@content_block_edition)
+ else
+ publish and return
+ end
+
+ redirect_to content_block_manager.content_block_manager_content_block_workflow_path(id: @content_block_edition.id,
+ step: :confirmation,
+ is_scheduled: true)
+ end
+
+ def publish
+ new_edition = ContentBlockManager::PublishEditionService.new.call(@content_block_edition)
+ redirect_to content_block_manager.content_block_manager_content_block_workflow_path(id: new_edition.id, step: :confirmation)
+ end
+
+ def validate_scheduled_edition
+ case params[:schedule_publishing]
+ when "schedule"
+ validate_scheduled_publication_params
+
+ @content_block_edition.update!(scheduled_publication_params)
+ if @content_block_edition.valid?(:scheduling)
+ @content_block_edition.save!
+ else
+ raise ActiveRecord::RecordInvalid, @content_block_edition
+ end
+ when "now"
+ @content_block_edition.update!(scheduled_publication: nil, state: "draft")
+ ContentBlockManager::SchedulePublishingWorker.dequeue(@content_block_edition)
+ else
+ @content_block_edition.errors.add(:schedule_publishing, t("activerecord.errors.models.content_block_manager/content_block/edition.attributes.schedule_publishing.blank"))
+ raise ActiveRecord::RecordInvalid, @content_block_edition
+ end
+ end
+
+ def validate_scheduled_publication_params
+ error_base = "activerecord.errors.models.content_block_manager/content_block/edition.attributes.scheduled_publication"
+ if scheduled_publication_params.values.all?(&:blank?)
+ @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.blank"))
+ elsif scheduled_publication_time_params.all?(&:blank?)
+ @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.time.blank"))
+ elsif scheduled_publication_date_params.all?(&:blank?)
+ @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.date.blank"))
+ elsif scheduled_publication_params.values.any?(&:blank?)
+ @content_block_edition.errors.add(:scheduled_publication, t("#{error_base}.invalid_date"))
+ end
+
+ raise ActiveRecord::RecordInvalid, @content_block_edition if @content_block_edition.errors.any?
+ end
+
+ def scheduled_publication_time_params
+ [
+ scheduled_publication_params["scheduled_publication(4i)"],
+ scheduled_publication_params["scheduled_publication(5i)"],
+ ]
+ end
+
+ def scheduled_publication_date_params
+ [
+ scheduled_publication_params["scheduled_publication(1i)"],
+ scheduled_publication_params["scheduled_publication(2i)"],
+ scheduled_publication_params["scheduled_publication(3i)"],
+ ]
+ end
+
+ def is_scheduling?
+ @content_block_edition.scheduled_publication.present?
+ end
+end
diff --git a/app/controllers/concerns/embedded_objects.rb b/app/controllers/concerns/embedded_objects.rb
new file mode 100644
index 000000000..dd7ec834c
--- /dev/null
+++ b/app/controllers/concerns/embedded_objects.rb
@@ -0,0 +1,27 @@
+module EmbeddedObjects
+ extend ActiveSupport::Concern
+ include ParamsPreprocessor
+
+ def get_schema_and_subschema(block_type, object_type)
+ schema = get_schema(block_type)
+ subschema = get_subschema(schema, object_type)
+
+ [schema, subschema]
+ end
+
+ def get_schema(block_type)
+ ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type)
+ end
+
+ def get_subschema(schema, object_type)
+ schema.subschema(object_type) or raise(ActionController::RoutingError, "Subschema for #{object_type} not found")
+ end
+
+ def object_params(subschema)
+ processed_params.require("content_block/edition").permit(
+ details: {
+ subschema.block_type.to_s => subschema.permitted_params,
+ },
+ )
+ end
+end
diff --git a/app/controllers/concerns/params_preprocessor.rb b/app/controllers/concerns/params_preprocessor.rb
new file mode 100644
index 000000000..d272fd27c
--- /dev/null
+++ b/app/controllers/concerns/params_preprocessor.rb
@@ -0,0 +1,18 @@
+module ParamsPreprocessor
+ extend ActiveSupport::Concern
+
+ PREPROCESSORS = {
+ "telephones" => ParamsPreprocessors::TelephonePreprocessor,
+ }.freeze
+
+ def processed_params
+ @processed_params ||= begin
+ preprocessor = PREPROCESSORS[params[:object_type]]
+ if preprocessor
+ preprocessor.new(params).processed_params
+ else
+ params
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/params_preprocessors/telephone_preprocessor.rb b/app/controllers/concerns/params_preprocessors/telephone_preprocessor.rb
new file mode 100644
index 000000000..7fb36dee5
--- /dev/null
+++ b/app/controllers/concerns/params_preprocessors/telephone_preprocessor.rb
@@ -0,0 +1,86 @@
+class ParamsPreprocessors::TelephonePreprocessor
+ def initialize(params)
+ @params = params
+ end
+
+ def processed_params
+ process!
+ params
+ end
+
+ def process!
+ params["content_block/edition"]["details"]["telephones"]["opening_hours"] = format_opening_hours
+ params["content_block/edition"]["details"]["telephones"]["call_charges"] = format_call_charges
+ params["content_block/edition"]["details"]["telephones"]["bsl_guidance"] = format_bsl_guidance
+ params["content_block/edition"]["details"]["telephones"]["video_relay_service"] = video_relay_service
+ end
+
+private
+
+ attr_accessor :params
+
+ def format_call_charges
+ call_charges = params["content_block/edition"]["details"]["telephones"]["call_charges"]
+ if call_charges
+ call_charges["show_call_charges_info_url"] = ActiveRecord::Type::Boolean.new.cast(call_charges["show_call_charges_info_url"]) || false
+
+ if call_charges["show_call_charges_info_url"] == false
+ call_charges = {}
+ end
+
+ call_charges
+ end
+ end
+
+ def format_bsl_guidance
+ bsl_guidance = params["content_block/edition"]["details"]["telephones"]["bsl_guidance"]
+ if bsl_guidance
+ bsl_guidance["show"] = ActiveRecord::Type::Boolean.new.cast(bsl_guidance["show"]) || false
+
+ if bsl_guidance["show"] == false
+ bsl_guidance = {}
+ end
+
+ bsl_guidance
+ end
+ end
+
+ def video_relay_service
+ obj = params["content_block/edition"]["details"]["telephones"]["video_relay_service"]
+ if obj
+ obj["show"] = ActiveRecord::Type::Boolean.new
+ .cast(obj["show"]) || false
+
+ if obj["show"] == false
+ obj = {}
+ end
+
+ obj
+ end
+ end
+
+ def format_opening_hours
+ obj = params["content_block/edition"]["details"]["telephones"]["opening_hours"]
+ if obj
+ obj["show_opening_hours"] = ActiveRecord::Type::Boolean.new
+ .cast(obj["show_opening_hours"]) || false
+
+ if obj["show_opening_hours"] == false
+ obj = {}
+ end
+
+ obj
+ end
+ end
+
+ def strip_opening_hours
+ params["content_block/edition"]["details"]["telephones"]["opening_hours"] = []
+ end
+
+ def format_time(hours, prefix)
+ h = hours["#{prefix}(h)"]
+ m = hours["#{prefix}(m)"]
+ meridian = hours["#{prefix}(meridian)"]
+ "#{h}:#{m}#{meridian}"
+ end
+end
diff --git a/app/controllers/concerns/permissions_checker_concern.rb b/app/controllers/concerns/permissions_checker_concern.rb
new file mode 100644
index 000000000..37c521bb6
--- /dev/null
+++ b/app/controllers/concerns/permissions_checker_concern.rb
@@ -0,0 +1,22 @@
+module PermissionsCheckerConcern
+ extend ActiveSupport::Concern
+
+ def can?(action, subject)
+ enforcer_for(subject).can?(action)
+ end
+
+ def can_preview?(subject)
+ can?(:see, subject)
+ end
+
+ included do
+ helper_method :can?
+ end
+
+private
+
+ def enforcer_for(subject)
+ actor = current_user || User.new
+ Whitehall::Authority::Enforcer.new(actor, subject)
+ end
+end
diff --git a/app/controllers/concerns/workflow.rb b/app/controllers/concerns/workflow.rb
new file mode 100644
index 000000000..b7cf81e2e
--- /dev/null
+++ b/app/controllers/concerns/workflow.rb
@@ -0,0 +1,20 @@
+module Workflow
+ class Step < Data.define(:name, :show_action, :update_action, :included_in_create_journey)
+ SUBSCHEMA_PREFIX = "embedded_".freeze
+ GROUP_PREFIX = "group_".freeze
+
+ ALL = [
+ Step.new(:edit_draft, :edit_draft, :update_draft, true),
+ Step.new(:review_links, :review_links, :redirect_to_next_step, false),
+ Step.new(:internal_note, :internal_note, :update_internal_note, false),
+ Step.new(:change_note, :change_note, :update_change_note, false),
+ Step.new(:schedule_publishing, :schedule_publishing, :validate_schedule, false),
+ Step.new(:review, :review, :validate_review_page, true),
+ Step.new(:confirmation, :confirmation, nil, true),
+ ].freeze
+
+ def is_subschema?
+ name.to_s.start_with?(SUBSCHEMA_PREFIX)
+ end
+ end
+end
diff --git a/app/controllers/concerns/workflow/show_methods.rb b/app/controllers/concerns/workflow/show_methods.rb
new file mode 100644
index 000000000..04a2e4d10
--- /dev/null
+++ b/app/controllers/concerns/workflow/show_methods.rb
@@ -0,0 +1,136 @@
+module Workflow::ShowMethods
+ extend ActiveSupport::Concern
+
+ def edit_draft
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type)
+ @form = ContentBlockManager::ContentBlock::EditionForm::Edit.new(content_block_edition: @content_block_edition, schema: @schema)
+
+ @title = @content_block_edition.document.is_new_block? ? "Create #{@form.schema.name}" : "Change #{@form.schema.name}"
+ @back_path = @content_block_edition.document.is_new_block? ? content_block_manager.new_content_block_manager_content_block_document_path : @form.back_path
+
+ render :edit_draft
+ end
+
+ # This handles the optional embedded objects and groups in the flow, delegating to `embedded_objects`
+ # or `embedded_group_objects` as appropriate
+ def method_missing(method_name, *arguments, &block)
+ if method_name.to_s =~ /#{Workflow::Step::SUBSCHEMA_PREFIX}(.*)/
+ embedded_objects(::Regexp.last_match(1))
+ elsif method_name.to_s =~ /#{Workflow::Step::GROUP_PREFIX}(.*)/
+ group_objects(::Regexp.last_match(1))
+ else
+ super
+ end
+ end
+
+ def respond_to_missing?(method_name, include_private = false)
+ method_name.to_s.start_with?(Workflow::Step::SUBSCHEMA_PREFIX) || super
+ end
+
+ def review_links
+ @content_block_document = @content_block_edition.document
+ @order = params[:order]
+ @page = params[:page]
+
+ @host_content_items = ContentBlockManager::HostContentItem.for_document(
+ @content_block_document,
+ order: @order,
+ page: @page,
+ )
+
+ if @host_content_items.empty?
+ referred_from_next_step = request.referer && URI.parse(request.referer).path&.end_with?(next_step.name.to_s)
+
+ redirect_to content_block_manager.content_block_manager_content_block_workflow_path(
+ id: @content_block_edition.id,
+ step: referred_from_next_step ? previous_step.name : next_step.name,
+ )
+ else
+ render :review_links
+ end
+ end
+
+ def schedule_publishing
+ @content_block_document = @content_block_edition.document
+
+ render :schedule_publishing
+ end
+
+ def internal_note
+ @content_block_document = @content_block_edition.document
+
+ render :internal_note
+ end
+
+ def change_note
+ @content_block_document = @content_block_edition.document
+
+ render :change_note
+ end
+
+ def review
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+
+ render :review
+ end
+
+ def confirmation
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+
+ @confirmation_copy = ContentBlockManager::ConfirmationCopyPresenter.new(@content_block_edition)
+
+ render :confirmation
+ end
+
+ def back_path
+ content_block_manager.content_block_manager_content_block_workflow_path(
+ @content_block_edition,
+ step: previous_step.name,
+ )
+ end
+ included do
+ helper_method :back_path
+ end
+
+private
+
+ def embedded_objects(subschema_name)
+ @subschema = @schema.subschema(subschema_name)
+ @step_name = current_step.name
+ @action = @content_block_edition.document.is_new_block? ? "Add" : "Edit"
+ @add_button_text = has_embedded_objects ? "Add another #{subschema_name.humanize.singularize.downcase}" : "Add #{helpers.add_indefinite_article @subschema.name.humanize.singularize.downcase}"
+
+ if @subschema
+ render :embedded_objects
+ else
+ raise ActionController::RoutingError, "Subschema #{subschema_name} does not exist"
+ end
+ end
+
+ def group_objects(group_name)
+ @group_name = group_name
+ @subschemas = @schema.subschemas_for_group(group_name)
+ @step_name = current_step.name
+ @action = @content_block_edition.document.is_new_block? ? "Add" : "Edit"
+
+ if @subschemas.any?
+ if @subschemas.none? { |subschema| has_embedded_objects(subschema) }
+ @group = group_name
+ @back_link = back_path
+ @redirect_path = content_block_manager.new_embedded_objects_options_redirect_content_block_manager_content_block_edition_path(@content_block_edition)
+ @context = @content_block_edition.title
+
+ render "content_block_manager/content_block/shared/embedded_objects/select_subschema"
+ else
+ render :group_objects
+ end
+ else
+ raise ActionController::RoutingError, "Subschema group #{group_name} does not exist"
+ end
+ end
+
+ def has_embedded_objects(subschema = @subschema)
+ @content_block_edition.details[subschema.block_type].present?
+ end
+end
diff --git a/app/controllers/concerns/workflow/steps.rb b/app/controllers/concerns/workflow/steps.rb
new file mode 100644
index 000000000..1c8e40c84
--- /dev/null
+++ b/app/controllers/concerns/workflow/steps.rb
@@ -0,0 +1,91 @@
+module Workflow::Steps
+ extend ActiveSupport::Concern
+ include ContentBlockManager::ContentBlock::SchemaHelper
+
+ included do
+ before_action :initialize_edition_and_schema
+ end
+
+ def steps
+ @steps ||= [
+ *all_steps[0],
+ *group_steps,
+ *subschema_steps,
+ *all_steps[1..],
+ ].compact
+ end
+
+ def current_step
+ steps.find { |step| step.name == params[:step].to_sym }
+ end
+
+ def previous_step
+ steps[index - 1]
+ end
+
+ def next_step
+ steps[index + 1]
+ end
+
+private
+
+ def all_steps
+ if @content_block_edition.document.is_new_block?
+ Workflow::Step::ALL.select { |s| s.included_in_create_journey == true }
+ else
+ Workflow::Step::ALL
+ end
+ end
+
+ def initialize_edition_and_schema
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type)
+ end
+
+ def index
+ steps.find_index { |step| step.name == params[:step]&.to_sym } || 0
+ end
+
+ def skip_subschema?(subschema)
+ !@content_block_edition.document.is_new_block? &&
+ !@content_block_edition.has_entries_for_subschema_id?(subschema.id)
+ end
+
+ def skip_group?(subschemas)
+ subschemas.all? { |subschema| skip_subschema?(subschema) }
+ end
+
+ def subschemas
+ @subschemas ||= ungrouped_subschemas(@schema)
+ end
+
+ def groups
+ @groups ||= grouped_subschemas(@schema)
+ end
+
+ def subschema_steps
+ subschemas.map do |subschema|
+ next if skip_subschema?(subschema)
+
+ Workflow::Step.new(
+ "#{Workflow::Step::SUBSCHEMA_PREFIX}#{subschema.id}".to_sym,
+ "#{Workflow::Step::SUBSCHEMA_PREFIX}#{subschema.id}".to_sym,
+ :redirect_to_next_step,
+ true,
+ )
+ end
+ end
+
+ def group_steps
+ groups.keys.map do |group|
+ next if skip_group?(groups[group])
+
+ Workflow::Step.new(
+ "#{Workflow::Step::GROUP_PREFIX}#{group}".to_sym,
+ "#{Workflow::Step::GROUP_PREFIX}#{group}".to_sym,
+ :redirect_to_next_step,
+ true,
+ )
+ end
+ end
+end
diff --git a/app/controllers/concerns/workflow/update_methods.rb b/app/controllers/concerns/workflow/update_methods.rb
new file mode 100644
index 000000000..aeb20123d
--- /dev/null
+++ b/app/controllers/concerns/workflow/update_methods.rb
@@ -0,0 +1,68 @@
+module Workflow::UpdateMethods
+ extend ActiveSupport::Concern
+
+ REVIEW_ERROR = Data.define(:attribute, :full_message)
+
+ def update_draft
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+
+ @content_block_edition.assign_attributes(
+ title: edition_params[:title],
+ organisation_id: edition_params[:organisation_id],
+ instructions_to_publishers: edition_params[:instructions_to_publishers],
+ details: @content_block_edition.details.merge(edition_params[:details]),
+ )
+ @content_block_edition.save!
+
+ redirect_to_next_step
+ rescue ActiveRecord::RecordInvalid
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_edition.document.block_type)
+ @form = ContentBlockManager::ContentBlock::EditionForm::Edit.new(content_block_edition: @content_block_edition, schema: @schema)
+
+ render :edit_draft
+ end
+
+ def validate_schedule
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+
+ validate_scheduled_edition
+
+ redirect_to_next_step
+ rescue ActiveRecord::RecordInvalid
+ render "content_block_manager/content_block/editions/workflow/schedule_publishing"
+ end
+
+ def update_internal_note
+ @content_block_edition.update!(internal_change_note: edition_params[:internal_change_note])
+
+ redirect_to_next_step
+ end
+
+ def update_change_note
+ @content_block_edition.assign_attributes(change_note: edition_params[:change_note], major_change: edition_params[:major_change])
+ @content_block_edition.save!(context: :change_note)
+
+ redirect_to_next_step
+ rescue ActiveRecord::RecordInvalid
+ render :change_note
+ end
+
+ def validate_review_page
+ if params[:is_confirmed].blank?
+ @confirm_error_copy = I18n.t("content_block_edition.review_page.errors.confirm")
+ @error_summary_errors = [{ text: @confirm_error_copy, href: "#is_confirmed-0" }]
+ render :review
+ else
+ schedule_or_publish
+ end
+ end
+
+private
+
+ def redirect_to_next_step
+ redirect_to content_block_manager.content_block_manager_content_block_workflow_path(
+ id: @content_block_edition.id,
+ step: next_step&.name,
+ )
+ end
+end
diff --git a/app/controllers/content_block_manager/base_controller.rb b/app/controllers/content_block_manager/base_controller.rb
new file mode 100644
index 000000000..525d39c14
--- /dev/null
+++ b/app/controllers/content_block_manager/base_controller.rb
@@ -0,0 +1,47 @@
+class ContentBlockManager::BaseController < Admin::BaseController
+ before_action :check_block_manager_permissions, :set_sentry_tags
+
+ def check_block_manager_permissions
+ forbidden! unless current_user.gds_admin?
+ end
+
+ def scheduled_publication_params
+ params.require(:scheduled_at).permit("scheduled_publication(1i)",
+ "scheduled_publication(2i)",
+ "scheduled_publication(3i)",
+ "scheduled_publication(4i)",
+ "scheduled_publication(5i)")
+ end
+
+ def edition_params
+ params.require("content_block/edition")
+ .permit(
+ :organisation_id,
+ :creator,
+ :instructions_to_publishers,
+ "scheduled_publication(1i)",
+ "scheduled_publication(2i)",
+ "scheduled_publication(3i)",
+ "scheduled_publication(4i)",
+ "scheduled_publication(5i)",
+ :title,
+ :internal_change_note,
+ :change_note,
+ :major_change,
+ document_attributes: %w[block_type],
+ details: @schema.permitted_params,
+ )
+ .merge!(creator: current_user)
+ end
+
+ def set_sentry_tags
+ Sentry.set_tags(engine: "content_block_manager")
+ end
+
+ def product_name
+ "Content Block Manager"
+ end
+
+ delegate :support_url, to: :ContentBlockManager
+ helper_method :support_url
+end
diff --git a/app/controllers/content_block_manager/content_block/documents/schedule_controller.rb b/app/controllers/content_block_manager/content_block/documents/schedule_controller.rb
new file mode 100644
index 000000000..2e427f0b9
--- /dev/null
+++ b/app/controllers/content_block_manager/content_block/documents/schedule_controller.rb
@@ -0,0 +1,19 @@
+class ContentBlockManager::ContentBlock::Documents::ScheduleController < ContentBlockManager::BaseController
+ include CanScheduleOrPublish
+
+ def edit
+ document = ContentBlockManager::ContentBlock::Document.find(params[:document_id])
+ @content_block_edition = document.latest_edition
+ end
+
+ def update
+ document = ContentBlockManager::ContentBlock::Document.find(params[:document_id])
+ @content_block_edition = document.latest_edition.clone_edition(creator: current_user)
+
+ validate_scheduled_edition
+
+ redirect_to content_block_manager.content_block_manager_content_block_workflow_path(@content_block_edition, step: :review)
+ rescue ActiveRecord::RecordInvalid
+ render "content_block_manager/content_block/documents/schedule/edit"
+ end
+end
diff --git a/app/controllers/content_block_manager/content_block/documents_controller.rb b/app/controllers/content_block_manager/content_block/documents_controller.rb
new file mode 100644
index 000000000..d867ba23b
--- /dev/null
+++ b/app/controllers/content_block_manager/content_block/documents_controller.rb
@@ -0,0 +1,64 @@
+class ContentBlockManager::ContentBlock::DocumentsController < ContentBlockManager::BaseController
+ def index
+ if params_filters.any?
+ @filters = params_filters
+ filter_result = ContentBlockManager::ContentBlock::Document::DocumentFilter.new(@filters)
+ @content_block_documents = filter_result.paginated_documents
+ unless filter_result.valid?
+ @errors = filter_result.errors
+ @error_summary_errors = @errors.map { |error| { text: error.full_message, href: "##{error.attribute}_3i" } }
+ end
+ render :index
+ else
+ redirect_to content_block_manager_root_path(default_filters)
+ end
+ end
+
+ def show
+ @content_block_document = ContentBlockManager::ContentBlock::Document.find(params[:id])
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_document.block_type)
+ @content_block_versions = @content_block_document.versions
+ @order = params[:order]
+ @page = params[:page]
+
+ @host_content_items = ContentBlockManager::HostContentItem.for_document(
+ @content_block_document,
+ order: @order,
+ page: @page,
+ )
+ end
+
+ def content_id
+ content_block_document = ContentBlockManager::ContentBlock::Document.where(content_id: params[:content_id]).first
+
+ if content_block_document.present?
+ redirect_to content_block_manager_content_block_document_path(content_block_document)
+ else
+ raise ActiveRecord::RecordNotFound, "Could not find Content Block with Content ID #{params[:content_id]}"
+ end
+ end
+
+ def new
+ @schemas = ContentBlockManager::ContentBlock::Schema.all
+ end
+
+ def new_document_options_redirect
+ if params[:block_type].present?
+ redirect_to new_content_block_manager_content_block_edition_path(block_type: params.require(:block_type))
+ else
+ redirect_to new_content_block_manager_content_block_document_path, flash: { error: I18n.t("activerecord.errors.models.content_block_manager/content_block/document.attributes.block_type.blank") }
+ end
+ end
+
+private
+
+ def params_filters
+ params.slice(:keyword, :block_type, :lead_organisation, :page, :last_updated_to, :last_updated_from)
+ .permit!
+ .to_h
+ end
+
+ def default_filters
+ { lead_organisation: "" }
+ end
+end
diff --git a/app/controllers/content_block_manager/content_block/editions/embedded_objects_controller.rb b/app/controllers/content_block_manager/content_block/editions/embedded_objects_controller.rb
new file mode 100644
index 000000000..6e2df37d3
--- /dev/null
+++ b/app/controllers/content_block_manager/content_block/editions/embedded_objects_controller.rb
@@ -0,0 +1,137 @@
+class ContentBlockManager::ContentBlock::Editions::EmbeddedObjectsController < ContentBlockManager::BaseController
+ include EmbeddedObjects
+
+ before_action :initialize_edition
+
+ def new
+ @schema = get_schema(@content_block_edition.document.block_type)
+
+ if params[:object_type]
+ @subschema = get_subschema(@schema, params[:object_type])
+ @back_link = embedded_objects_path
+
+ render :new
+ else
+ @group = params[:group]
+ @subschemas = @schema.subschemas_for_group(@group)
+ @back_link = content_block_manager.content_block_manager_content_block_workflow_path(
+ @content_block_edition,
+ step: "#{Workflow::Step::GROUP_PREFIX}#{@group}",
+ )
+ @redirect_path = content_block_manager.new_embedded_objects_options_redirect_content_block_manager_content_block_edition_path(@content_block_edition)
+ @context = @content_block_edition.title
+
+ if @subschemas.blank?
+ render "admin/errors/not_found", status: :not_found
+ else
+ render "content_block_manager/content_block/shared/embedded_objects/select_subschema"
+ end
+ end
+ end
+
+ def create
+ @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type])
+ @object = object_params(@subschema).dig(:details, @subschema.block_type)
+ @content_block_edition.add_object_to_details(@subschema.block_type, @object)
+ @content_block_edition.save!
+
+ object_or_group = @subschema.group ? @subschema.group.humanize.singularize : @subschema.name.singularize
+
+ flash[:notice] = I18n.t(
+ "content_block_edition.create.embedded_objects.added_confirmation",
+ object_name: @subschema.name.singularize,
+ object_or_group: object_or_group.downcase,
+ schema_name: @schema.name.singularize.downcase,
+ )
+ redirect_to embedded_objects_path
+ rescue ActiveRecord::RecordInvalid
+ @back_link = embedded_objects_path
+ render :new
+ end
+
+ def edit
+ @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type])
+ @redirect_url = params[:redirect_url]
+ @object_title = params[:object_title]
+ @object = @content_block_edition.details.dig(params[:object_type], params[:object_title])
+
+ render "admin/errors/not_found", status: :not_found unless @object
+ end
+
+ def update
+ @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type])
+ @object = object_params(@subschema).dig(:details, @subschema.block_type)
+ @content_block_edition.update_object_with_details(params[:object_type], params[:object_title], @object)
+ @content_block_edition.save!
+
+ if params[:redirect_url].present?
+ object_or_group = @subschema.group ? @subschema.group.humanize.singularize : @subschema.name.singularize
+
+ flash[:notice] = I18n.t(
+ "content_block_edition.create.embedded_objects.edited_confirmation",
+ object_name: @subschema.name.singularize,
+ object_or_group: object_or_group.downcase,
+ schema_name: @schema.name.singularize.downcase,
+ )
+ redirect_to params[:redirect_url], allow_other_host: false
+ else
+ redirect_to content_block_manager.review_embedded_object_content_block_manager_content_block_edition_path(
+ @content_block_edition,
+ object_type: @subschema.block_type,
+ object_title: params[:object_title],
+ )
+ end
+ rescue ActiveRecord::RecordInvalid
+ @redirect_url = params[:redirect_url]
+ @object_title = params[:object_title]
+ render :edit
+ end
+
+ def review
+ @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type])
+ @object_title = params[:object_title]
+ end
+
+ def publish
+ @schema, @subschema = get_schema_and_subschema(@content_block_edition.document.block_type, params[:object_type])
+ if params[:is_confirmed].blank?
+ flash[:error] = I18n.t("content_block_edition.review_page.errors.confirm")
+ redirect_path = content_block_manager.review_embedded_object_content_block_manager_content_block_edition_path(
+ @content_block_edition,
+ object_type: @subschema.block_type,
+ object_title: params[:object_title],
+ )
+ else
+ @content_block_edition.updated_embedded_object_type = @subschema.block_type
+ @content_block_edition.updated_embedded_object_title = params[:object_title]
+ ContentBlockManager::PublishEditionService.new.call(@content_block_edition)
+ flash[:notice] = "#{@subschema.name.singularize} created"
+ redirect_path = content_block_manager.content_block_manager_content_block_document_path(@content_block_edition.document)
+ end
+
+ redirect_to redirect_path
+ end
+
+ def new_embedded_objects_options_redirect
+ if params[:object_type].present?
+ flash[:back_link] = content_block_manager.new_embedded_objects_options_redirect_content_block_manager_content_block_edition_path(
+ @content_block_edition,
+ group: params.require(:group),
+ )
+ redirect_to content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path(@content_block_edition, object_type: params.require(:object_type))
+ else
+ redirect_to content_block_manager.new_embedded_object_content_block_manager_content_block_edition_path(@content_block_edition, group: params.require(:group)), flash: { error: I18n.t("activerecord.errors.models.content_block_manager/content_block/document.attributes.block_type.blank") }
+ end
+ end
+
+private
+
+ def initialize_edition
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+ end
+
+ def embedded_objects_path
+ step = @subschema.group ? "#{Workflow::Step::GROUP_PREFIX}#{@subschema.group}" : "#{Workflow::Step::SUBSCHEMA_PREFIX}#{@subschema.id}"
+ content_block_manager.content_block_manager_content_block_workflow_path(@content_block_edition, step:)
+ end
+end
diff --git a/app/controllers/content_block_manager/content_block/editions/host_content_controller.rb b/app/controllers/content_block_manager/content_block/editions/host_content_controller.rb
new file mode 100644
index 000000000..28ec3f953
--- /dev/null
+++ b/app/controllers/content_block_manager/content_block/editions/host_content_controller.rb
@@ -0,0 +1,12 @@
+class ContentBlockManager::ContentBlock::Editions::HostContentController < ContentBlockManager::BaseController
+ def preview
+ host_content_id = params[:host_content_id]
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+ @preview_content = ContentBlockManager::PreviewContent.for_content_id(
+ content_id: host_content_id,
+ content_block_edition: @content_block_edition,
+ base_path: params[:base_path],
+ locale: params[:locale],
+ )
+ end
+end
diff --git a/app/controllers/content_block_manager/content_block/editions/workflow_controller.rb b/app/controllers/content_block_manager/content_block/editions/workflow_controller.rb
new file mode 100644
index 000000000..5e27669e4
--- /dev/null
+++ b/app/controllers/content_block_manager/content_block/editions/workflow_controller.rb
@@ -0,0 +1,45 @@
+class ContentBlockManager::ContentBlock::Editions::WorkflowController < ContentBlockManager::BaseController
+ include CanScheduleOrPublish
+
+ include Workflow::Steps
+ include Workflow::ShowMethods
+ include Workflow::UpdateMethods
+
+ def show
+ action = current_step&.show_action
+
+ if action
+ send(action)
+ else
+ raise ActionController::RoutingError, "Step #{params[:step]} does not exist"
+ end
+ end
+
+ def cancel
+ @content_block_edition = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+ end
+
+ def update
+ action = current_step&.update_action
+
+ if action
+ send(action)
+ else
+ raise ActionController::RoutingError, "Step #{params[:step]} does not exist"
+ end
+ end
+
+ def context
+ @content_block_edition.title
+ end
+ helper_method :context
+
+private
+
+ def review_url
+ content_block_manager.content_block_manager_content_block_workflow_path(
+ @content_block_edition,
+ step: :review,
+ )
+ end
+end
diff --git a/app/controllers/content_block_manager/content_block/editions_controller.rb b/app/controllers/content_block_manager/content_block/editions_controller.rb
new file mode 100644
index 000000000..23eadeb4b
--- /dev/null
+++ b/app/controllers/content_block_manager/content_block/editions_controller.rb
@@ -0,0 +1,44 @@
+class ContentBlockManager::ContentBlock::EditionsController < ContentBlockManager::BaseController
+ include Workflow::Steps
+
+ skip_before_action :initialize_edition_and_schema
+
+ def new
+ if params[:document_id]
+ @content_block_document = ContentBlockManager::ContentBlock::Document.find(params[:document_id])
+ @title = @content_block_document.title
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(@content_block_document.block_type)
+ content_block_edition = @content_block_document.latest_edition
+ else
+ @title = "Create content block"
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(params[:block_type].underscore)
+ content_block_edition = ContentBlockManager::ContentBlock::Edition.new
+ end
+ @form = ContentBlockManager::ContentBlock::EditionForm.for(
+ content_block_edition:,
+ schema: @schema,
+ )
+ end
+
+ def create
+ @schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type_param)
+ @content_block_edition = ContentBlockManager::CreateEditionService.new(@schema).call(edition_params, document_id: params[:document_id])
+ redirect_to content_block_manager.content_block_manager_content_block_workflow_path(id: @content_block_edition.id, step: next_step.name)
+ rescue ActiveRecord::RecordInvalid => e
+ @title = params[:document_id] ? e.record.document.title : "Create content block"
+ @form = ContentBlockManager::ContentBlock::EditionForm.for(content_block_edition: e.record, schema: @schema)
+ render "content_block_manager/content_block/editions/new"
+ end
+
+ def destroy
+ edition_to_delete = ContentBlockManager::ContentBlock::Edition.find(params[:id])
+ ContentBlockManager::DeleteEditionService.new.call(edition_to_delete)
+ redirect_to params[:redirect_path] || content_block_manager.content_block_manager_root_path
+ end
+
+private
+
+ def block_type_param
+ params.require("content_block/edition").require("document_attributes").require(:block_type)
+ end
+end
diff --git a/app/controllers/content_block_manager/users_controller.rb b/app/controllers/content_block_manager/users_controller.rb
new file mode 100644
index 000000000..d186713d9
--- /dev/null
+++ b/app/controllers/content_block_manager/users_controller.rb
@@ -0,0 +1,7 @@
+class ContentBlockManager::UsersController < ContentBlockManager::BaseController
+ def show
+ @user = ContentBlockManager::SignonUser.with_uuids([params[:id]]).first
+
+ raise ActiveRecord::RecordNotFound, "Could not find User with ID #{params[:id]}" if @user.blank?
+ end
+end
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
new file mode 100644
index 000000000..e230c5dba
--- /dev/null
+++ b/app/controllers/pages_controller.rb
@@ -0,0 +1,5 @@
+class PagesController < ApplicationController
+ layout "design_system"
+
+ def show; end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be7945..196bef423 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,13 @@
+require "record_tag_helper/helper"
+
module ApplicationHelper
+ include ActionView::Helpers::RecordTagHelper
+
+ def get_content_id(edition)
+ return if edition.nil?
+
+ return unless edition.respond_to?("content_id")
+
+ edition.content_id
+ end
end
diff --git a/app/helpers/content_block_manager/content_block/edition_helper.rb b/app/helpers/content_block_manager/content_block/edition_helper.rb
new file mode 100644
index 000000000..f53b3056c
--- /dev/null
+++ b/app/helpers/content_block_manager/content_block/edition_helper.rb
@@ -0,0 +1,31 @@
+module ContentBlockManager::ContentBlock::EditionHelper
+ def published_date(content_block_edition)
+ tag.time(
+ content_block_edition.updated_at.to_fs(:long_ordinal_with_at),
+ class: "date",
+ datetime: content_block_edition.updated_at.iso8601,
+ lang: "en",
+ )
+ end
+
+ def scheduled_date(content_block_edition)
+ tag.time(
+ content_block_edition.scheduled_publication.to_fs(:long_ordinal_with_at),
+ class: "date",
+ datetime: content_block_edition.scheduled_publication.iso8601,
+ lang: "en",
+ )
+ end
+
+ def formatted_instructions_to_publishers(content_block_edition)
+ if content_block_edition.instructions_to_publishers.present?
+ simple_format(
+ auto_link(content_block_edition.instructions_to_publishers, html: { class: "govuk-link", target: "_blank", rel: "noopener" }),
+ { class: "govuk-!-margin-top-0" },
+ { sanitize_options: { attributes: %w[href class target rel] } },
+ )
+ else
+ "None"
+ end
+ end
+end
diff --git a/app/helpers/content_block_manager/content_block/embed_code_helper.rb b/app/helpers/content_block_manager/content_block/embed_code_helper.rb
new file mode 100644
index 000000000..6bb2c4388
--- /dev/null
+++ b/app/helpers/content_block_manager/content_block/embed_code_helper.rb
@@ -0,0 +1,20 @@
+module ContentBlockManager::ContentBlock::EmbedCodeHelper
+ def copy_embed_code_data_attributes(key, content_block_document)
+ {
+ module: "copy-embed-code",
+ "embed-code": content_block_document.embed_code_for_field(key),
+ }
+ end
+
+ # This generates a row containing the embed code for the field above it -
+ # it will be deleted if javascript is enabled by copy-embed-code.js.
+ def embed_code_row(key, content_block_document)
+ {
+ key: "Embed code",
+ value: content_block_document.embed_code_for_field(key),
+ data: {
+ "embed-code-row": "true",
+ },
+ }
+ end
+end
diff --git a/app/helpers/content_block_manager/content_block/govspeak_helper.rb b/app/helpers/content_block_manager/content_block/govspeak_helper.rb
new file mode 100644
index 000000000..c3c5ece56
--- /dev/null
+++ b/app/helpers/content_block_manager/content_block/govspeak_helper.rb
@@ -0,0 +1,13 @@
+module ContentBlockManager::ContentBlock::GovspeakHelper
+ include ContentBlockTools::Govspeak
+
+ def render_govspeak_if_enabled_for_field(object_key:, field_name:, value:)
+ return value unless field_enabled_for_govspeak?(object_key, field_name)
+
+ render_govspeak(value)
+ end
+
+ def field_enabled_for_govspeak?(object_key, field_name)
+ subschema.govspeak_enabled?(nested_object_key: object_key, field_name: field_name)
+ end
+end
diff --git a/app/helpers/content_block_manager/content_block/schema_helper.rb b/app/helpers/content_block_manager/content_block/schema_helper.rb
new file mode 100644
index 000000000..f43879896
--- /dev/null
+++ b/app/helpers/content_block_manager/content_block/schema_helper.rb
@@ -0,0 +1,16 @@
+module ContentBlockManager::ContentBlock::SchemaHelper
+ def grouped_subschemas(schema)
+ schema.subschemas
+ .select { |subschema| subschema.group.present? }
+ .group_by(&:group)
+ end
+
+ def ungrouped_subschemas(schema)
+ schema.subschemas.select { |subschema| subschema.group.blank? }
+ end
+
+ def redirect_url_for_subschema(subschema, content_block_edition)
+ step = subschema.group.present? ? "#{Workflow::Step::GROUP_PREFIX}#{subschema.group}" : "#{Workflow::Step::SUBSCHEMA_PREFIX}#{subschema.id}"
+ content_block_manager.content_block_manager_content_block_workflow_path(content_block_edition, step:)
+ end
+end
diff --git a/app/helpers/content_block_manager/content_block/summary_list_helper.rb b/app/helpers/content_block_manager/content_block/summary_list_helper.rb
new file mode 100644
index 000000000..c37adf564
--- /dev/null
+++ b/app/helpers/content_block_manager/content_block/summary_list_helper.rb
@@ -0,0 +1,34 @@
+module ContentBlockManager::ContentBlock::SummaryListHelper
+ include ContentBlockManager::ContentBlock::TranslationHelper
+ def first_class_items(input)
+ result = {}
+
+ input.each do |key, value|
+ case value
+ when String
+ result[key] = value
+ when Array
+ value.each_with_index do |item, index|
+ result["#{key}/#{index}"] = item if item.is_a?(String)
+ end
+ end
+ end
+
+ result
+ end
+
+ def nested_items(input)
+ input.select do |_key, value|
+ value.is_a?(Hash) || value.is_a?(Array) && value.all? { |item| item.is_a?(Hash) }
+ end
+ end
+
+ def key_to_title(key, object_type = nil)
+ subject, count = key.split("/")
+ if count
+ humanized_label(relative_key: "#{subject.singularize} #{count.to_i + 1}", root_object: object_type)
+ else
+ humanized_label(relative_key: subject, root_object: object_type)
+ end
+ end
+end
diff --git a/app/helpers/content_block_manager/content_block/translation_helper.rb b/app/helpers/content_block_manager/content_block/translation_helper.rb
new file mode 100644
index 000000000..ff077e5ef
--- /dev/null
+++ b/app/helpers/content_block_manager/content_block/translation_helper.rb
@@ -0,0 +1,17 @@
+module ContentBlockManager::ContentBlock::TranslationHelper
+ def humanized_label(relative_key:, root_object: nil)
+ translation_path = root_object ? "#{root_object}.#{relative_key}" : relative_key
+
+ I18n.t(
+ "content_block_edition.details.labels.#{translation_path}",
+ default: relative_key.humanize.gsub("-", " "),
+ )
+ end
+
+ def translated_value(key, value)
+ default_path = "content_block_edition.details.values.#{value}"
+ translation_path = "content_block_edition.details.values.#{key}.#{value}"
+
+ I18n.t(translation_path, default: [default_path.to_sym, value])
+ end
+end
diff --git a/app/helpers/errors_helper.rb b/app/helpers/errors_helper.rb
new file mode 100644
index 000000000..f838406f3
--- /dev/null
+++ b/app/helpers/errors_helper.rb
@@ -0,0 +1,38 @@
+module ErrorsHelper
+ def errors_for_input(errors, attribute)
+ return nil if errors.blank?
+
+ errors.filter_map { |error|
+ if error.attribute == attribute
+ error.full_message
+ end
+ }
+ .join(tag.br)
+ .html_safe
+ .presence
+ end
+
+ def errors_for(errors, attribute)
+ return nil if errors.blank?
+
+ errors.filter_map { |error|
+ if error.attribute == attribute
+ {
+ text: error.full_message,
+ }
+ end
+ }
+ .presence
+ end
+
+ def errors_from_flash(flash)
+ return nil if flash.blank?
+
+ flash.map do |array|
+ {
+ href: "##{array.first}",
+ text: array.last,
+ }
+ end
+ end
+end
diff --git a/app/helpers/header_helper.rb b/app/helpers/header_helper.rb
new file mode 100644
index 000000000..ae98165c4
--- /dev/null
+++ b/app/helpers/header_helper.rb
@@ -0,0 +1,17 @@
+module HeaderHelper
+ def sub_nav_item(name, path)
+ {
+ label: name,
+ href: path,
+ current: request.path.start_with?(path),
+ }
+ end
+
+ def main_nav_item(name, path)
+ {
+ text: name,
+ href: path,
+ active: request.path.end_with?(path),
+ }
+ end
+end
diff --git a/app/models/concerns/date_validation.rb b/app/models/concerns/date_validation.rb
new file mode 100644
index 000000000..abc1cdf84
--- /dev/null
+++ b/app/models/concerns/date_validation.rb
@@ -0,0 +1,63 @@
+module DateValidation
+ extend ActiveSupport::Concern
+
+ included do
+ attr_reader :invalid_date_attributes
+
+ validates_with DateValidator
+
+ after_validation :rationalise_date_errors
+
+ private
+
+ # In cases when a date attribute is invalid, it will be set to nil by pre_validate_date_attribute and will therefore
+ # fail the presence validation. We therefore need to remove the presence error from each invalid date attribute to
+ # avoid a confusing user experience where both the invalid date and presence errors show simultaneously
+ def rationalise_date_errors
+ @invalid_date_attributes&.each do |invalid_date_attribute|
+ if errors.of_kind?(invalid_date_attribute, :blank)
+ errors.delete(invalid_date_attribute, :blank)
+ end
+ end
+ end
+ end
+
+ def pre_validate_date_attribute(attribute, date)
+ @invalid_date_attributes = Set.new if @invalid_date_attributes.nil?
+ if date.is_a?(Hash)
+ begin
+ # Rails will cast the year part of the date to 0 if the year input parameter is a non-numeric string
+ # This only seems to happen to the year part, other parts remain as strings
+ raise TypeError if date[1].zero?
+
+ # Rails does not accept negative month values, but the Date constructor does
+ raise TypeError if date[2].negative?
+
+ Date.new(date[1], date[2], date[3])
+ @invalid_date_attributes.delete(attribute)
+ rescue ArgumentError, TypeError, NoMethodError
+ @invalid_date_attributes.add(attribute)
+ date = nil
+ end
+ end
+ date
+ end
+
+ class_methods do
+ def date_attributes(*attributes)
+ attributes.each do |attribute|
+ define_method("#{attribute}=") do |value|
+ super(pre_validate_date_attribute(attribute, value))
+ end
+ end
+ end
+ end
+
+ class DateValidator < ActiveModel::Validator
+ def validate(record)
+ record.invalid_date_attributes&.each do |date_attribute|
+ record.errors.add date_attribute, :invalid_date
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/edition/workflow.rb b/app/models/concerns/edition/workflow.rb
new file mode 100644
index 000000000..b75b4f3a5
--- /dev/null
+++ b/app/models/concerns/edition/workflow.rb
@@ -0,0 +1,128 @@
+module Edition::Workflow
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def active
+ where(arel_table[:state].not_eq("superseded"))
+ end
+
+ def in_state(state)
+ valid_state?(state) && public_send(state)
+ end
+
+ def valid_state?(state)
+ %w[active draft submitted rejected published scheduled force_published withdrawn not_published unpublished].include?(state)
+ end
+ end
+
+ included do
+ include ActiveRecord::Transitions
+
+ default_scope -> { where(arel_table[:state].not_eq("deleted")) }
+
+ state_machine auto_scopes: true do
+ state :draft
+ state :submitted
+ state :rejected
+ state :scheduled
+ state :published
+ state :superseded
+ state :deleted
+ state :withdrawn
+ state :unpublished
+
+ event :delete do
+ transitions from: %i[draft submitted rejected], to: :deleted
+ end
+
+ event :submit do
+ transitions from: %i[draft rejected], to: :submitted
+ end
+
+ event :reject do
+ transitions from: :submitted, to: :rejected
+ end
+
+ event :schedule do
+ transitions from: :submitted, to: :scheduled
+ end
+
+ event :force_schedule do
+ transitions from: %i[draft submitted], to: :scheduled
+ end
+
+ event :unschedule do
+ transitions from: :scheduled, to: :submitted
+ end
+
+ event :publish do
+ transitions from: %i[submitted scheduled], to: :published
+ end
+
+ event :force_publish do
+ transitions from: %i[draft submitted], to: :published
+ end
+
+ event :unpublish do
+ transitions from: %i[published unpublished], to: :unpublished
+ end
+
+ event :supersede, success: :destroy_associations_with_edition_dependencies_and_dependants do
+ transitions from: %i[published unpublished], to: :superseded
+ end
+
+ event :withdraw do
+ transitions from: %i[published withdrawn], to: :withdrawn
+ end
+
+ event :unwithdraw do
+ transitions from: :withdrawn, to: :superseded
+ end
+ end
+
+ validate :edition_has_no_unpublished_editions, on: :create
+
+ scope :in_pre_publication_state, -> { where(state: Edition::PRE_PUBLICATION_STATES) }
+ scope :force_published, -> { where(state: "published", force_published: true) }
+ scope :not_published, -> { where(state: %w[draft submitted rejected]) }
+ scope :without_not_published, -> { where.not(state: %w[draft submitted rejected]) }
+ scope :publicly_visible, -> { where(state: Edition::PUBLICLY_VISIBLE_STATES) }
+ scope :scheduled, -> { where(state: "scheduled") }
+
+ scope :future_scheduled_editions, -> { scheduled.where(Edition.arel_table[:scheduled_publication].gteq(Time.zone.now)) }
+ scope :due_for_publication, lambda { |within_time = 0|
+ cutoff = Time.zone.now + within_time
+ scheduled.where(arel_table[:scheduled_publication].lteq(cutoff))
+ }
+ end
+
+ def pre_publication?
+ Edition::PRE_PUBLICATION_STATES.include?(state.to_s)
+ end
+
+ def save_as(user)
+ if save
+ edition_authors.create!(user:)
+ recent_edition_openings.where(editor_id: user).delete_all
+ end
+ end
+
+ def edition_has_no_unpublished_editions
+ return unless document
+
+ if (existing_edition = document.non_published_edition)
+ errors.add(:base, "There is already an active #{existing_edition.state} edition for this document")
+ end
+ end
+
+ def has_workflow?
+ true
+ end
+
+private
+
+ def destroy_associations_with_edition_dependencies_and_dependants
+ edition_dependencies.destroy_all
+ records_of_dependent_editions.destroy_all
+ end
+end
diff --git a/app/models/concerns/simple_workflow.rb b/app/models/concerns/simple_workflow.rb
new file mode 100644
index 000000000..cb77ee092
--- /dev/null
+++ b/app/models/concerns/simple_workflow.rb
@@ -0,0 +1,24 @@
+# Expects Searchable to be included and destroyable? defined.
+module SimpleWorkflow
+ extend ActiveSupport::Concern
+
+ included do
+ include ActiveRecord::Transitions
+
+ default_scope -> { where(arel_table[:state].not_eq("deleted")) }
+
+ state_machine auto_scopes: true, initial: :current do
+ state :current
+ state :deleted
+
+ event :delete, success: ->(document) { document.remove_from_search_index if document.respond_to?(:remove_from_search_index) } do
+ transitions from: [:current], to: :deleted, guard: :destroyable?
+ end
+ end
+
+ # Overwrite this
+ def destroyable?
+ true
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block.rb b/app/models/content_block_manager/content_block.rb
new file mode 100644
index 000000000..e60a39888
--- /dev/null
+++ b/app/models/content_block_manager/content_block.rb
@@ -0,0 +1,7 @@
+module ContentBlockManager
+ module ContentBlock
+ def self.table_name_prefix
+ "content_block_"
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/diff_item.rb b/app/models/content_block_manager/content_block/diff_item.rb
new file mode 100644
index 000000000..17c336089
--- /dev/null
+++ b/app/models/content_block_manager/content_block/diff_item.rb
@@ -0,0 +1,13 @@
+module ContentBlockManager
+ class ContentBlock::DiffItem < Data.define(:previous_value, :new_value)
+ def self.from_hash(hash)
+ hash.with_indifferent_access.map { |key, value|
+ if value.key?("new_value") && value.key?("previous_value")
+ [key, ContentBlock::DiffItem.new(previous_value: value["previous_value"].presence, new_value: value["new_value"].presence)]
+ else
+ [key, ContentBlock::DiffItem.from_hash(value)]
+ end
+ }.to_h
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/document.rb b/app/models/content_block_manager/content_block/document.rb
new file mode 100644
index 000000000..a5f6feec1
--- /dev/null
+++ b/app/models/content_block_manager/content_block/document.rb
@@ -0,0 +1,66 @@
+module ContentBlockManager
+ module ContentBlock
+ class Document < ApplicationRecord
+ include Scopes::SearchableByKeyword
+ include Scopes::SearchableByLeadOrganisation
+ include Scopes::SearchableByUpdatedDate
+
+ include SoftDeletable
+
+ extend FriendlyId
+ friendly_id :sluggable_string, use: :slugged, slug_column: :content_id_alias, routes: :default
+
+ has_many :editions,
+ -> { order(created_at: :asc, id: :asc) },
+ inverse_of: :document
+
+ enum :block_type, ContentBlockManager::ContentBlock::Schema.valid_schemas.index_with(&:to_s)
+ attr_readonly :block_type
+
+ validates :block_type, :sluggable_string, presence: true
+
+ has_one :latest_edition,
+ -> { joins(:document).where("content_block_documents.latest_edition_id = content_block_editions.id") },
+ class_name: "ContentBlockManager::ContentBlock::Edition",
+ inverse_of: :document
+
+ has_many :versions, through: :editions, source: :versions
+
+ scope :live, -> { where.not(latest_edition_id: nil) }
+
+ def embed_code(use_friendly_id: Flipflop.use_friendly_embed_codes?)
+ "#{embed_code_prefix(use_friendly_id)}}}"
+ end
+
+ def embed_code_for_field(field_path, use_friendly_id: Flipflop.use_friendly_embed_codes?)
+ "#{embed_code_prefix(use_friendly_id)}/#{field_path}}}"
+ end
+
+ def title
+ @title ||= latest_edition&.title
+ end
+
+ def is_new_block?
+ editions.count == 1
+ end
+
+ def has_newer_draft?
+ latest_edition_id != editions.select(:id, :created_at).order(created_at: :asc).last.id
+ end
+
+ def latest_draft
+ editions.where(state: :draft).order(created_at: :asc).last
+ end
+
+ def schema
+ @schema ||= ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type)
+ end
+
+ private
+
+ def embed_code_prefix(use_friendly_id)
+ "{{embed:content_block_#{block_type}:#{use_friendly_id ? content_id_alias : content_id}"
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/document/document_filter.rb b/app/models/content_block_manager/content_block/document/document_filter.rb
new file mode 100644
index 000000000..e561f0b92
--- /dev/null
+++ b/app/models/content_block_manager/content_block/document/document_filter.rb
@@ -0,0 +1,91 @@
+module ContentBlockManager
+ class ContentBlock::Document::DocumentFilter
+ FILTER_ERROR = Data.define(:attribute, :full_message)
+
+ def initialize(filters = {})
+ @filters = filters
+ end
+
+ def paginated_documents
+ unpaginated_documents.page(page).per(default_page_size)
+ end
+
+ def errors
+ @errors ||= begin
+ @errors = []
+ from = validate_date(:last_updated_from)
+ to = validate_date(:last_updated_to)
+
+ if @errors.empty? && to.present? && from.present? && from.after?(to)
+ @errors << FILTER_ERROR.new(attribute: "last_updated_from", full_message: I18n.t("content_block_document.index.errors.date.range.invalid"))
+ end
+
+ @errors
+ end
+ end
+
+ def valid?
+ errors.empty?
+ end
+
+ private
+
+ def validate_date(key)
+ return unless is_date_present?(key)
+
+ date = date_from_filters(key)
+ Time.zone.local(date[:year], date[:month], date[:day])
+ rescue ArgumentError, TypeError, NoMethodError, RangeError
+ @errors << FILTER_ERROR.new(attribute: key.to_s, full_message: I18n.t("content_block_document.index.errors.date.invalid", attribute: key.to_s.humanize))
+ nil
+ end
+
+ def page
+ @filters[:page].presence || 1
+ end
+
+ def default_page_size
+ 10
+ end
+
+ def is_date_present?(date_key)
+ @filters[date_key].present? && @filters[date_key].any? { |_, value| value.present? }
+ end
+
+ def date_from_filters(date_key)
+ filter = @filters[date_key]
+ year = filter["1i"].to_i
+ month = filter["2i"].to_i
+ day = filter["3i"].to_i
+ { year:, month:, day: }
+ end
+
+ def from_date
+ @from_date ||= if is_date_present?(:last_updated_from)
+ date = date_from_filters(:last_updated_from)
+ Time.zone.local(date[:year], date[:month], date[:day])
+ end
+ end
+
+ def to_date
+ @to_date ||= if is_date_present?(:last_updated_to)
+ date = date_from_filters(:last_updated_to)
+ Time.zone.local(date[:year], date[:month], date[:day]).end_of_day
+ end
+ end
+
+ def unpaginated_documents
+ documents = ContentBlock::Document
+ documents = documents.where(block_type: ContentBlock::Schema.valid_schemas)
+ documents = documents.live
+ documents = documents.joins(:latest_edition)
+ documents = documents.with_keyword(@filters[:keyword]) if @filters[:keyword].present?
+ documents = documents.where(block_type: @filters[:block_type]) if @filters[:block_type].present?
+ documents = documents.with_lead_organisation(@filters[:lead_organisation]) if @filters[:lead_organisation].present?
+ documents = documents.from_date(from_date) if valid? && from_date
+ documents = documents.to_date(to_date) if valid? && to_date
+ documents.order("content_block_editions.updated_at DESC")
+ documents.distinct
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/document/scopes/searchable_by_keyword.rb b/app/models/content_block_manager/content_block/document/scopes/searchable_by_keyword.rb
new file mode 100644
index 000000000..186d088ff
--- /dev/null
+++ b/app/models/content_block_manager/content_block/document/scopes/searchable_by_keyword.rb
@@ -0,0 +1,26 @@
+module ContentBlockManager
+ module ContentBlock::Document::Scopes::SearchableByKeyword
+ extend ActiveSupport::Concern
+
+ SQL = <<-SQL.freeze
+ MATCH(
+ content_block_editions.title,#{' '}
+ content_block_editions.details_for_indexing,#{' '}
+ content_block_editions.instructions_to_publishers
+ ) AGAINST (:pattern IN BOOLEAN MODE)
+ SQL
+
+ included do
+ scope :with_keyword,
+ lambda { |keywords|
+ split_keywords = keywords.split
+ pattern = split_keywords.map { |k|
+ escaped_word = Regexp.escape(k)
+ "+#{escaped_word}"
+ }.join(" ")
+ joins(:latest_edition)
+ .where(SQL, pattern:)
+ }
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/document/scopes/searchable_by_lead_organisation.rb b/app/models/content_block_manager/content_block/document/scopes/searchable_by_lead_organisation.rb
new file mode 100644
index 000000000..6bc1cb189
--- /dev/null
+++ b/app/models/content_block_manager/content_block/document/scopes/searchable_by_lead_organisation.rb
@@ -0,0 +1,12 @@
+module ContentBlockManager
+ module ContentBlock::Document::Scopes::SearchableByLeadOrganisation
+ extend ActiveSupport::Concern
+
+ included do
+ scope :with_lead_organisation,
+ lambda { |id|
+ joins(latest_edition: :edition_organisation).where("content_block_edition_organisations.organisation_id = :id", id:)
+ }
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/document/scopes/searchable_by_updated_date.rb b/app/models/content_block_manager/content_block/document/scopes/searchable_by_updated_date.rb
new file mode 100644
index 000000000..55bcbe1ad
--- /dev/null
+++ b/app/models/content_block_manager/content_block/document/scopes/searchable_by_updated_date.rb
@@ -0,0 +1,11 @@
+module ContentBlockManager
+ module ContentBlock::Document::Scopes::SearchableByUpdatedDate
+ extend ActiveSupport::Concern
+
+ included do
+ scope :latest_edition, -> { joins(:editions).where("content_block_documents.latest_edition_id = content_block_editions.id") }
+ scope :from_date, ->(date) { latest_edition.where("content_block_editions.updated_at >= ?", date) }
+ scope :to_date, ->(date) { latest_edition.where("content_block_editions.updated_at <= ?", date) }
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/document/soft_deletable.rb b/app/models/content_block_manager/content_block/document/soft_deletable.rb
new file mode 100644
index 000000000..3f8d2598b
--- /dev/null
+++ b/app/models/content_block_manager/content_block/document/soft_deletable.rb
@@ -0,0 +1,17 @@
+module ContentBlockManager
+ module ContentBlock::Document::SoftDeletable
+ extend ActiveSupport::Concern
+
+ included do
+ default_scope { where(deleted_at: nil) }
+ end
+
+ def soft_delete
+ update_column :deleted_at, Time.zone.now
+ end
+
+ def soft_deleted?
+ deleted_at.present?
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition.rb b/app/models/content_block_manager/content_block/edition.rb
new file mode 100644
index 000000000..94be987ff
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition.rb
@@ -0,0 +1,94 @@
+module ContentBlockManager
+ module ContentBlock
+ class Edition < ApplicationRecord
+ validates :title, presence: true
+ validates :change_note, presence: true, if: :major_change?, on: :change_note
+ validates :major_change, inclusion: [true, false], on: :change_note
+
+ include Documentable
+ include HasAuditTrail
+ include HasAuthors
+ include ValidatesDetails
+ include HasLeadOrganisation
+ include Workflow
+
+ scope :current_versions, lambda {
+ joins(
+ "LEFT JOIN content_block_documents document ON document.latest_edition_id = content_block_editions.id",
+ ).where(state: "published")
+ }
+
+ def update_document_reference_to_latest_edition!
+ document.update!(latest_edition_id: id)
+ end
+
+ def render(embed_code)
+ ContentBlockTools::ContentBlock.new(
+ document_type: "content_block_#{block_type}",
+ content_id: document.content_id,
+ title:,
+ details:,
+ embed_code:,
+ ).render
+ rescue TypeError
+ # TODO: Remove this when we've updated Content Block Tools to support an array of telephones
+ nil
+ end
+
+ def clone_edition(creator:)
+ new_edition = dup
+ new_edition.assign_attributes(
+ state: "draft",
+ organisation: lead_organisation,
+ creator: creator,
+ change_note: nil,
+ internal_change_note: nil,
+ )
+ new_edition
+ end
+
+ def add_object_to_details(object_type, body)
+ key = key_for_object(object_type, body["title"])
+
+ details[object_type] ||= {}
+ details[object_type][key] = remove_destroyed body.to_h
+ end
+
+ def update_object_with_details(object_type, object_title, body)
+ details[object_type][object_title] = remove_destroyed body.to_h
+ end
+
+ def key_for_object(object_type, title)
+ base_key = (title.presence || object_type).parameterize
+ key = base_key
+ counter = 1
+
+ while details.dig(object_type, key).present?
+ key = "#{base_key}-#{counter}"
+ counter += 1
+ end
+
+ key
+ end
+
+ def has_entries_for_subschema_id?(subschema_id)
+ details[subschema_id].present?
+ end
+
+ private
+
+ def remove_destroyed(item)
+ item.transform_values { |value|
+ case value
+ when Hash
+ remove_destroyed(value)
+ when Array
+ value.select { |i| !i.is_a?(Hash) || i.delete("_destroy") != "1" }
+ else
+ value
+ end
+ }.to_h
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/diffable.rb b/app/models/content_block_manager/content_block/edition/diffable.rb
new file mode 100644
index 000000000..2a1856414
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/diffable.rb
@@ -0,0 +1,51 @@
+module ContentBlockManager
+ module ContentBlock::Edition::Diffable
+ extend ActiveSupport::Concern
+
+ def generate_diff
+ diff = {}
+ unless document.is_new_block?
+ diff["title"] = ContentBlock::DiffItem.new(previous_value: previous_edition.title, new_value: title) if previous_edition.title != title
+ diff["details"] = details_diff if details_diff.any?
+ diff["lead_organisation"] = ContentBlock::DiffItem.new(previous_value: previous_org.name, new_value: lead_organisation.name) if lead_organisation != previous_org
+ diff["instructions_to_publishers"] = ContentBlock::DiffItem.new(previous_value: previous_edition.instructions_to_publishers, new_value: instructions_to_publishers) if previous_edition.instructions_to_publishers != instructions_to_publishers
+ end
+ diff
+ end
+
+ # This is a temporary solution to allow us to specifically set the previous edition when backfilling
+ # the diffs. This can be deleted once the rake task has been run
+ def previous_edition=(edition)
+ @previous_edition = edition
+ end
+
+ def previous_edition
+ @previous_edition ||= document.editions.includes(:edition_organisation, :organisation)[-2]
+ end
+
+ def previous_org
+ previous_edition.lead_organisation
+ end
+
+ def details_diff
+ @details_diff ||= generate_details_diff
+ end
+
+ def generate_details_diff(previous_details = previous_edition.details, current_details = details)
+ diff = {}
+ keys = [*previous_details&.keys, *current_details&.keys].uniq
+ keys.each do |key|
+ previous_value = previous_details&.fetch(key, nil)
+ new_value = current_details&.fetch(key, nil)
+ if previous_value.is_a?(String) || new_value.is_a?(String)
+ next unless previous_value != new_value
+
+ diff[key] = ContentBlock::DiffItem.new(previous_value:, new_value:)
+ elsif previous_value.is_a?(Hash) || new_value.is_a?(Hash)
+ diff[key] = generate_details_diff(previous_value, new_value)
+ end
+ end
+ diff
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/documentable.rb b/app/models/content_block_manager/content_block/edition/documentable.rb
new file mode 100644
index 000000000..c34506714
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/documentable.rb
@@ -0,0 +1,29 @@
+module ContentBlockManager
+ module ContentBlock::Edition::Documentable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :document, touch: true
+ validates :document, presence: true
+
+ before_validation :ensure_presence_of_document, on: :create
+
+ accepts_nested_attributes_for :document
+ end
+
+ def block_type
+ @block_type ||= document&.block_type
+ end
+
+ def ensure_presence_of_document
+ if document.new_record?
+ document.content_id = create_random_id if document.content_id.blank?
+ document.sluggable_string = title if document.sluggable_string.blank?
+ end
+ end
+
+ def create_random_id
+ SecureRandom.uuid
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/has_audit_trail.rb b/app/models/content_block_manager/content_block/edition/has_audit_trail.rb
new file mode 100644
index 000000000..77b0927aa
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/has_audit_trail.rb
@@ -0,0 +1,45 @@
+module ContentBlockManager
+ module ContentBlock::Edition::HasAuditTrail
+ extend ActiveSupport::Concern
+
+ def self.acting_as(actor)
+ original_actor = Current.user
+ Current.user = actor
+ yield
+ ensure
+ Current.user = original_actor
+ end
+
+ included do
+ include ContentBlock::Edition::Diffable
+
+ has_many :versions, -> { order(created_at: :desc, id: :desc) }, as: :item
+
+ after_create :record_create
+ after_update :record_update
+ end
+
+ attr_accessor :updated_embedded_object_type, :updated_embedded_object_title
+
+ private
+
+ def record_create
+ user = Current.user
+ versions.create!(event: "created", user:)
+ end
+
+ def record_update
+ unless draft?
+ user = Current.user
+ state = try(:state)
+ versions.create!(
+ event: "updated",
+ user:, state:,
+ field_diffs: generate_diff,
+ updated_embedded_object_type:,
+ updated_embedded_object_title:
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/has_authors.rb b/app/models/content_block_manager/content_block/edition/has_authors.rb
new file mode 100644
index 000000000..fda1835f4
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/has_authors.rb
@@ -0,0 +1,10 @@
+module ContentBlockManager
+ module ContentBlock::Edition::HasAuthors
+ extend ActiveSupport::Concern
+ include ContentBlock::Edition::HasCreator
+
+ included do
+ has_many :edition_authors, dependent: :destroy, class_name: "ContentBlockManager::ContentBlock::EditionAuthor"
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/has_creator.rb b/app/models/content_block_manager/content_block/edition/has_creator.rb
new file mode 100644
index 000000000..248b3de17
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/has_creator.rb
@@ -0,0 +1,22 @@
+module ContentBlockManager
+ module ContentBlock::Edition::HasCreator
+ extend ActiveSupport::Concern
+
+ included do
+ validates :creator, presence: true
+ end
+
+ def creator
+ edition_authors.first&.user
+ end
+
+ def creator=(user)
+ if new_record?
+ edition_author = edition_authors.first || edition_authors.build
+ edition_author.user = user
+ else
+ raise "author can only be set on new records"
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/has_lead_organisation.rb b/app/models/content_block_manager/content_block/edition/has_lead_organisation.rb
new file mode 100644
index 000000000..5b59b7ea5
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/has_lead_organisation.rb
@@ -0,0 +1,27 @@
+module ContentBlockManager
+ module ContentBlock::Edition::HasLeadOrganisation
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :edition_organisation, foreign_key: :content_block_edition_id,
+ dependent: :destroy,
+ class_name: "ContentBlockManager::ContentBlock::EditionOrganisation"
+ has_one :organisation, through: :edition_organisation
+
+ validates_with ContentBlockManager::OrganisationValidator
+ end
+
+ def organisation_id=(organisation_id)
+ if organisation_id.blank?
+ self.edition_organisation = nil
+ else
+ edition_organisation = build_edition_organisation
+ edition_organisation.organisation_id = organisation_id
+ end
+ end
+
+ def lead_organisation
+ organisation
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/validates_details.rb b/app/models/content_block_manager/content_block/edition/validates_details.rb
new file mode 100644
index 000000000..8a23d271e
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/validates_details.rb
@@ -0,0 +1,41 @@
+module ContentBlockManager
+ module ContentBlock::Edition::ValidatesDetails
+ extend ActiveSupport::Concern
+
+ DETAILS_PREFIX = "details_".freeze
+
+ included do
+ validates_with ContentBlockManager::DetailsValidator
+
+ # Only used in tests, so we can easily add a schema to an edition, without
+ # having to resort to mocks, which are difficult to setup/clean between tests
+ attr_writer :schema
+
+ def self.human_attribute_name(attr, options = {})
+ if attr.starts_with?(DETAILS_PREFIX)
+ key = attr.to_s.delete_prefix(DETAILS_PREFIX)
+ key.humanize
+ else
+ super attr, options
+ end
+ end
+ end
+
+ def schema
+ @schema ||= ContentBlockManager::ContentBlock::Schema.find_by_block_type(block_type)
+ end
+
+ # When an error is raised about a field within the details hash
+ # we have to prefix it. This overrides the default `read_attribute_for_validation`
+ # method, and reads it from the details hash if the attribute name
+ # is prefixes
+ def read_attribute_for_validation(attr)
+ if attr.starts_with?(DETAILS_PREFIX)
+ key = attr.to_s.delete_prefix(DETAILS_PREFIX)
+ details&.fetch(key, nil)
+ else
+ super(attr)
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition/workflow.rb b/app/models/content_block_manager/content_block/edition/workflow.rb
new file mode 100644
index 000000000..c03084fb1
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition/workflow.rb
@@ -0,0 +1,37 @@
+module ContentBlockManager
+ module ContentBlock::Edition::Workflow
+ extend ActiveSupport::Concern
+ include DateValidation
+
+ module ClassMethods
+ def valid_state?(state)
+ %w[draft published scheduled superseded].include?(state)
+ end
+ end
+
+ included do
+ include ActiveRecord::Transitions
+
+ date_attributes :scheduled_publication
+
+ validates_with ContentBlockManager::ScheduledPublicationValidator, if: -> { validation_context == :scheduling || state == "scheduled" }
+
+ state_machine auto_scopes: true do
+ state :draft
+ state :published
+ state :scheduled
+ state :superseded
+
+ event :publish do
+ transitions from: %i[draft scheduled], to: :published
+ end
+ event :schedule do
+ transitions from: %i[draft], to: :scheduled
+ end
+ event :supersede do
+ transitions from: %i[scheduled], to: :superseded
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition_author.rb b/app/models/content_block_manager/content_block/edition_author.rb
new file mode 100644
index 000000000..46725a95e
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition_author.rb
@@ -0,0 +1,8 @@
+module ContentBlockManager
+ module ContentBlock
+ class EditionAuthor < ApplicationRecord
+ belongs_to :edition
+ belongs_to :user
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/edition_organisation.rb b/app/models/content_block_manager/content_block/edition_organisation.rb
new file mode 100644
index 000000000..afdf8a0fd
--- /dev/null
+++ b/app/models/content_block_manager/content_block/edition_organisation.rb
@@ -0,0 +1,8 @@
+module ContentBlockManager
+ module ContentBlock
+ class EditionOrganisation < ApplicationRecord
+ belongs_to :edition
+ belongs_to :organisation
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/schema.rb b/app/models/content_block_manager/content_block/schema.rb
new file mode 100644
index 000000000..54c208464
--- /dev/null
+++ b/app/models/content_block_manager/content_block/schema.rb
@@ -0,0 +1,121 @@
+module ContentBlockManager
+ module ContentBlock
+ class Schema
+ SCHEMA_PREFIX = "content_block".freeze
+
+ VALID_SCHEMAS = %w[pension contact].freeze
+ private_constant :VALID_SCHEMAS
+
+ CONFIG_PATH = Rails.root.join("config/content_block_manager.yml").to_s
+
+ class << self
+ def valid_schemas
+ VALID_SCHEMAS
+ end
+
+ def all
+ @all ||= Services.publishing_api.get_schemas.select { |k, _v|
+ is_valid_schema?(k)
+ }.map { |id, full_schema|
+ full_schema.dig("definitions", "details")&.yield_self { |schema| new(id, schema) }
+ }.compact
+ end
+
+ def find_by_block_type(block_type)
+ all.find { |schema| schema.block_type == block_type } || raise(ArgumentError, "Cannot find schema for #{block_type}")
+ end
+
+ def is_valid_schema?(key)
+ key.start_with?(SCHEMA_PREFIX) && key.end_with?(*valid_schemas)
+ end
+
+ def schema_settings
+ @schema_settings ||= YAML.load_file(CONFIG_PATH)
+ end
+ end
+
+ attr_reader :id, :body
+
+ def initialize(id, body)
+ @id = id
+ @body = body
+ end
+
+ def name
+ block_type.humanize
+ end
+
+ def parameter
+ block_type.dasherize
+ end
+
+ def fields
+ field_names.map { |field_name| Field.new(field_name, self) }
+ end
+
+ def subschema(name)
+ subschemas.find { |s| s.id == name }
+ end
+
+ def subschemas
+ @subschemas ||= embedded_objects.map { |object| EmbeddedSchema.new(*object, @id) }
+ end
+
+ def subschemas_for_group(group)
+ subschemas.select { |s| s.group == group }
+ end
+
+ def permitted_params
+ field_names
+ end
+
+ def block_type
+ @block_type ||= id.delete_prefix("#{SCHEMA_PREFIX}_")
+ end
+
+ def embeddable_fields
+ config["embeddable_fields"] || []
+ end
+
+ def embeddable_as_block?
+ config["embeddable_as_block"].present?
+ end
+
+ def config
+ @config ||= self.class.schema_settings.dig("schemas", @id) || {}
+ end
+
+ def field_ordering_rule(field)
+ if field_order
+ # If a field order is found in the config, order by the index. If a field is not found, put it to the end
+ field_order.index(field) || 99
+ else
+ # By default, order with title first
+ field == "title" ? 0 : 1
+ end
+ end
+
+ def required_fields
+ @body["required"] || []
+ end
+
+ private
+
+ def field_names
+ sort_fields (@body["properties"].to_a - embedded_objects.to_a).to_h.keys
+ end
+
+ def sort_fields(fields)
+ fields.sort_by { |field| field_ordering_rule(field) }
+ end
+
+ def field_order
+ @field_order ||= config["field_order"]
+ end
+
+ def embedded_objects
+ @body["properties"].select { |_k, v| v["type"] == "object" }
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/schema/embedded_schema.rb b/app/models/content_block_manager/content_block/schema/embedded_schema.rb
new file mode 100644
index 000000000..e4764e202
--- /dev/null
+++ b/app/models/content_block_manager/content_block/schema/embedded_schema.rb
@@ -0,0 +1,67 @@
+module ContentBlockManager
+ module ContentBlock
+ class Schema
+ class EmbeddedSchema < ContentBlockManager::ContentBlock::Schema
+ GOVSPEAK_ENABLED_PROPERTY_KEY = "x-govspeak_enabled".freeze
+
+ def initialize(id, body, parent_schema_id)
+ @parent_schema_id = parent_schema_id
+ body = body["patternProperties"]&.values&.first || raise(ArgumentError, "Subschema `#{id}` is invalid")
+ super(id, body)
+ end
+
+ def block_type
+ @id
+ end
+
+ def embeddable_as_block?
+ @embeddable_as_block ||= config["embeddable_as_block"].present?
+ end
+
+ def config
+ @config ||= self.class.schema_settings.dig("schemas", @parent_schema_id, "subschemas", @id) || {}
+ end
+
+ def group
+ @group ||= config["group"]
+ end
+
+ def group_order
+ @group_order ||= config["group_order"]&.to_i || Float::INFINITY
+ end
+
+ def permitted_params
+ fields.map do |field|
+ if field.nested_fields.present?
+ { field.name => field.nested_fields.map(&:name) }
+ elsif field.format == "array"
+ { field.name => [*field.array_items["properties"]&.keys, "_destroy"] || [] }
+ else
+ field.name
+ end
+ end
+ end
+
+ def govspeak_enabled?(field_name:, nested_object_key: nil)
+ return top_level_govspeak_enabled_fields.include?(field_name) unless nested_object_key
+
+ govspeak_enabled_fields_for_nested_object(nested_object_key).include?(field_name)
+ end
+
+ def govspeak_enabled_fields_for_nested_object(object_key)
+ body.dig("properties", object_key, GOVSPEAK_ENABLED_PROPERTY_KEY) || []
+ end
+
+ def top_level_govspeak_enabled_fields
+ body.dig("properties", GOVSPEAK_ENABLED_PROPERTY_KEY) || []
+ end
+
+ private
+
+ def field_names
+ sort_fields @body["properties"].keys
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/schema/field.rb b/app/models/content_block_manager/content_block/schema/field.rb
new file mode 100644
index 000000000..805833fa6
--- /dev/null
+++ b/app/models/content_block_manager/content_block/schema/field.rb
@@ -0,0 +1,101 @@
+module ContentBlockManager
+ module ContentBlock
+ class Schema
+ class Field
+ attr_reader :name, :schema
+
+ NestedField = Data.define(:name, :format, :enum_values, :default_value) do
+ def initialize(name:, format:, enum_values:, default_value: nil)
+ super(name:, format:, enum_values:, default_value:)
+ end
+ end
+
+ def initialize(name, schema)
+ @name = name
+ @schema = schema
+ end
+
+ def to_s
+ name
+ end
+
+ def component_name
+ if custom_component
+ custom_component
+ elsif enum_values
+ "enum"
+ else
+ format
+ end
+ end
+
+ def format
+ @format ||= properties["type"]
+ end
+
+ def enum_values
+ @enum_values ||= properties["enum"]
+ end
+
+ def default_value
+ @default_value ||= properties["default"]
+ end
+
+ def nested_fields
+ if format == "object"
+ properties.fetch("properties", {}).map do |key, value|
+ NestedField.new(
+ name: key,
+ format: value["type"],
+ enum_values: value["enum"],
+ default_value: value["default"],
+ )
+ end
+ end
+ end
+
+ def nested_field(name)
+ raise(ArgumentError, "Provide the name of a nested field") if name.blank?
+
+ nested_fields.find { |field| field.name == name }
+ end
+
+ def array_items
+ properties.fetch("items", nil)&.tap do |array_items|
+ if array_items["type"] == "object"
+ array_items["properties"] = array_items["properties"].sort_by { |k, _v|
+ field_ordering_rule.find_index(k) || Float::INFINITY
+ }.to_h
+ end
+ end
+ end
+
+ def is_required?
+ schema.required_fields.include?(name)
+ end
+
+ def data_attributes
+ @data_attributes ||= config["data_attributes"] || {}
+ end
+
+ private
+
+ def custom_component
+ @custom_component ||= config["component"]
+ end
+
+ def properties
+ @properties ||= schema.body.dig("properties", name) || {}
+ end
+
+ def config
+ @config ||= schema.config.dig("fields", name) || {}
+ end
+
+ def field_ordering_rule
+ @field_ordering_rule ||= config["field_order"] || []
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/content_block/version.rb b/app/models/content_block_manager/content_block/version.rb
new file mode 100644
index 000000000..f8927fd7c
--- /dev/null
+++ b/app/models/content_block_manager/content_block/version.rb
@@ -0,0 +1,19 @@
+module ContentBlockManager
+ module ContentBlock
+ class Version < ApplicationRecord
+ enum :event, { created: 0, updated: 1 }
+
+ belongs_to :item, polymorphic: true
+ validates :event, presence: true
+ belongs_to :user, foreign_key: "whodunnit"
+
+ def field_diffs
+ self[:field_diffs] ? ContentBlock::DiffItem.from_hash(self[:field_diffs]) : {}
+ end
+
+ def is_embedded_update?
+ updated_embedded_object_type && updated_embedded_object_title
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/embedded_object_immutability_check.rb b/app/models/content_block_manager/embedded_object_immutability_check.rb
new file mode 100644
index 000000000..17c1f297b
--- /dev/null
+++ b/app/models/content_block_manager/embedded_object_immutability_check.rb
@@ -0,0 +1,18 @@
+class ContentBlockManager::EmbeddedObjectImmutabilityCheck
+ def initialize(edition:, field_reference:)
+ @edition = edition
+ @field_reference = field_reference
+ end
+
+ def can_be_deleted?(index)
+ live_fields[index].blank?
+ end
+
+private
+
+ def live_fields
+ @live_fields ||= edition&.details&.dig(*field_reference) || []
+ end
+
+ attr_reader :edition, :field_reference
+end
diff --git a/app/models/content_block_manager/host_content_item.rb b/app/models/content_block_manager/host_content_item.rb
new file mode 100644
index 000000000..5f16fa273
--- /dev/null
+++ b/app/models/content_block_manager/host_content_item.rb
@@ -0,0 +1,75 @@
+module ContentBlockManager
+ class HostContentItem < Data.define(
+ :title,
+ :base_path,
+ :document_type,
+ :publishing_organisation,
+ :publishing_app,
+ :last_edited_by_editor,
+ :last_edited_at,
+ :unique_pageviews,
+ :instances,
+ :host_content_id,
+ :host_locale,
+ )
+
+ DEFAULT_ORDER = "-unique_pageviews".freeze
+
+ class << self
+ def for_document(content_block_document, page: nil, order: nil)
+ api_response = Services.publishing_api.get_host_content_for_content_id(
+ content_block_document.content_id,
+ {
+ page:,
+ order: order || DEFAULT_ORDER,
+ }.compact,
+ ).parsed_content
+
+ editor_uuids = api_response["results"].map { |c| c["last_edited_by_editor_id"] }.compact.uniq
+ editors = editor_uuids.present? ? ContentBlockManager::SignonUser.with_uuids(editor_uuids) : []
+
+ items = api_response["results"].map do |record|
+ from_api_record(record, editors)
+ end
+
+ ContentBlockManager::HostContentItem::Items.new(
+ items:,
+ total: api_response["total"],
+ total_pages: api_response["total_pages"],
+ rollup: rollup(api_response),
+ )
+ end
+
+ private
+
+ def rollup(api_response)
+ ContentBlockManager::HostContentItem::Items::Rollup.new(
+ views: api_response["rollup"]["views"],
+ locations: api_response["rollup"]["locations"],
+ instances: api_response["rollup"]["instances"],
+ organisations: api_response["rollup"]["organisations"],
+ )
+ end
+
+ def from_api_record(record, editors)
+ new(
+ title: record["title"],
+ base_path: record["base_path"],
+ document_type: record["document_type"],
+ publishing_organisation: record["primary_publishing_organisation"],
+ publishing_app: record["publishing_app"],
+ last_edited_by_editor: editors.find { |editor| editor.uid == record["last_edited_by_editor_id"] },
+ last_edited_at: record["last_edited_at"],
+ unique_pageviews: record["unique_pageviews"],
+ instances: record["instances"],
+ host_content_id: record["host_content_id"],
+ host_locale: record["host_locale"],
+ )
+ end
+ end
+
+ def last_edited_at
+ Time.zone.parse(super)
+ end
+ end
+end
diff --git a/app/models/content_block_manager/host_content_item/items.rb b/app/models/content_block_manager/host_content_item/items.rb
new file mode 100644
index 000000000..3799e1698
--- /dev/null
+++ b/app/models/content_block_manager/host_content_item/items.rb
@@ -0,0 +1,12 @@
+module ContentBlockManager
+ class HostContentItem
+ class Items < Data.define(:items, :total, :total_pages, :rollup)
+ extend Forwardable
+
+ ARRAY_METHODS = ([].methods - Object.methods)
+ Rollup = Data.define(:views, :locations, :instances, :organisations)
+
+ def_delegators :items, *ARRAY_METHODS
+ end
+ end
+end
diff --git a/app/models/content_block_manager/host_content_items.rb b/app/models/content_block_manager/host_content_items.rb
new file mode 100644
index 000000000..d0002202a
--- /dev/null
+++ b/app/models/content_block_manager/host_content_items.rb
@@ -0,0 +1,11 @@
+module ContentBlockManager
+ class HostContentItems < Data.define(:items, :total, :total_pages, :rollup)
+ extend Forwardable
+
+ ARRAY_METHODS = ([].methods - Object.methods)
+
+ def_delegators :items, *ARRAY_METHODS
+
+ class Rollup < Data.define(:views, :locations, :instances, :organisations); end
+ end
+end
diff --git a/app/models/content_block_manager/preview_content.rb b/app/models/content_block_manager/preview_content.rb
new file mode 100644
index 000000000..811a08169
--- /dev/null
+++ b/app/models/content_block_manager/preview_content.rb
@@ -0,0 +1,26 @@
+module ContentBlockManager
+ class PreviewContent < Data.define(:title, :html, :instances_count)
+ class << self
+ def for_content_id(content_id:, content_block_edition:, base_path: nil, locale: "en")
+ content_item = Services.publishing_api.get_content(content_id, { locale: }).parsed_content
+ metadata = Services.publishing_api.get_host_content_item_for_content_id(
+ content_block_edition.document.content_id,
+ content_id,
+ { locale: },
+ ).parsed_content
+ html = ContentBlockManager::GeneratePreviewHtml.new(
+ content_id:,
+ content_block_edition:,
+ base_path: base_path || content_item["base_path"],
+ locale:,
+ ).call
+
+ ContentBlockManager::PreviewContent.new(
+ title: content_item["title"],
+ html:,
+ instances_count: metadata["instances"],
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/signon_user.rb b/app/models/content_block_manager/signon_user.rb
new file mode 100644
index 000000000..b1c280a50
--- /dev/null
+++ b/app/models/content_block_manager/signon_user.rb
@@ -0,0 +1,14 @@
+module ContentBlockManager
+ class SignonUser < Data.define(:uid, :name, :email, :organisation)
+ def self.with_uuids(uuids)
+ Services.signon_api_client.get_users(uuids:).map do |user|
+ new(
+ uid: user["uid"],
+ name: user["name"],
+ email: user["email"],
+ organisation: ContentBlockManager::SignonUser::Organisation.from_user_hash(user),
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/content_block_manager/signon_user/organisation.rb b/app/models/content_block_manager/signon_user/organisation.rb
new file mode 100644
index 000000000..2edca6994
--- /dev/null
+++ b/app/models/content_block_manager/signon_user/organisation.rb
@@ -0,0 +1,15 @@
+module ContentBlockManager
+ class SignonUser
+ class Organisation < Data.define(:content_id, :name, :slug)
+ def self.from_user_hash(user)
+ if user["organisation"].present?
+ new(
+ content_id: user["organisation"]["content_id"],
+ name: user["organisation"]["name"],
+ slug: user["organisation"]["slug"],
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
new file mode 100644
index 000000000..73a9744b3
--- /dev/null
+++ b/app/models/current.rb
@@ -0,0 +1,3 @@
+class Current < ActiveSupport::CurrentAttributes
+ attribute :user
+end
diff --git a/app/models/organisation.rb b/app/models/organisation.rb
new file mode 100644
index 000000000..bbf1e2c4f
--- /dev/null
+++ b/app/models/organisation.rb
@@ -0,0 +1,107 @@
+class Organisation < ApplicationRecord
+ has_many :editions, through: :edition_organisations
+
+ has_many :users, foreign_key: :organisation_slug, primary_key: :slug, dependent: :nullify
+
+ validates :slug, presence: true, uniqueness: { case_sensitive: false }
+ # validates_with SafeHtmlValidator
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
+ validates :govuk_status, presence: true, inclusion: { in: %w[live joining exempt transitioning closed] }
+ validates :govuk_closed_status, inclusion: { in: %w[no_longer_exists replaced split merged changed_name left_gov devolved] }, presence: true, if: :closed?
+
+ delegate :ministerial_department?, to: :type
+ delegate :devolved_administration?, to: :type
+
+ scope :closed, -> { where(govuk_status: "closed") }
+
+ def self.with_published_editions
+ where("exists (
+ SELECT 1 FROM `editions`
+ INNER JOIN `edition_organisations` ON `edition_organisations`.`edition_id` = `editions`.`id`
+ WHERE `editions`.`state` = 'published'
+ AND (edition_organisations.organisation_id = organisations.id)
+ )")
+ end
+
+ def live?
+ govuk_status == "live"
+ end
+
+ def closed?
+ govuk_status == "closed"
+ end
+
+ def exempt?
+ govuk_status == "exempt"
+ end
+
+ def replaced?
+ govuk_closed_status == "replaced"
+ end
+
+ def split?
+ govuk_closed_status == "split"
+ end
+
+ def merged?
+ govuk_closed_status == "merged"
+ end
+
+ def changed_name?
+ govuk_closed_status == "changed_name"
+ end
+
+ def devolved?
+ govuk_closed_status == "devolved"
+ end
+
+ def name_without_prefix
+ name.gsub(/^The/, "").strip
+ end
+
+ def display_name
+ [acronym, name].detect(&:present?)
+ end
+
+ def select_name
+ [name, ("(#{acronym})" if acronym.present?), ("[Closed]" if closed?)].compact.join(" ")
+ end
+
+ def published_editions
+ editions.published
+ end
+
+ def to_s
+ name
+ end
+
+ def news_priority_homepage?
+ homepage_type == "news"
+ end
+
+ def base_path
+ "/government/organisations/#{slug}"
+ end
+
+ def public_path(options = {})
+ append_url_options(base_path, options)
+ end
+
+ def link_to_section_on_organisation_list_page
+ append_url_options("/government/organisations", anchor: slug)
+ end
+
+ def public_url(options = {})
+ website_root = if options[:draft]
+ Plek.external_url_for("draft-origin")
+ else
+ Plek.website_root
+ end
+
+ website_root + public_path(options)
+ end
+
+ def publishing_api_presenter
+ PublishingApi::OrganisationPresenter
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 000000000..080fc1fa2
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,85 @@
+class User < ApplicationRecord
+ include GDS::SSO::User
+
+ belongs_to :organisation, foreign_key: :organisation_slug, primary_key: :slug,
+ optional: true
+
+ serialize :permissions, coder: YAML, type: Array
+
+ validates :name, presence: true
+
+ scope :enabled, -> { where(disabled: false) }
+
+ module Permissions
+ SIGNIN = "signin".freeze
+ DEPARTMENTAL_EDITOR = "Editor".freeze
+ MANAGING_EDITOR = "Managing Editor".freeze
+ GDS_EDITOR = "GDS Editor".freeze
+ VIP_EDITOR = "VIP Editor".freeze
+ PUBLISH_SCHEDULED_EDITIONS = "Publish scheduled editions".freeze
+ GDS_ADMIN = "GDS Admin".freeze
+ SIDEKIQ_ADMIN = "Sidekiq Admin".freeze
+ VISUAL_EDITOR_PRIVATE_BETA = "Visual editor private beta".freeze
+ end
+
+ def role
+ if gds_editor? then "GDS Editor"
+ elsif departmental_editor? then "Departmental Editor"
+ elsif managing_editor? then "Managing Editor"
+ else
+ "Writer"
+ end
+ end
+
+ def departmental_editor?
+ has_permission?(Permissions::DEPARTMENTAL_EDITOR)
+ end
+
+ def managing_editor?
+ has_permission?(Permissions::MANAGING_EDITOR)
+ end
+
+ def gds_editor?
+ has_permission?(Permissions::GDS_EDITOR)
+ end
+
+ def vip_editor?
+ has_permission?(Permissions::VIP_EDITOR)
+ end
+
+ def gds_admin?
+ has_permission?(Permissions::GDS_ADMIN)
+ end
+
+ def can_see_visual_editor_private_beta?
+ has_permission?(Permissions::VISUAL_EDITOR_PRIVATE_BETA)
+ end
+
+ def organisation_name
+ organisation ? organisation.name : nil
+ end
+
+ def has_email?
+ email.present?
+ end
+
+ def editable_by?(user)
+ user.gds_editor?
+ end
+
+ def can_handle_fatalities?
+ gds_editor? || (organisation && organisation.handles_fatalities?)
+ end
+
+ def fuzzy_last_name
+ name.split(/ +/, 2).last
+ end
+
+ def organisation_content_id
+ return organisation.content_id if organisation
+
+ @organisation_content_id || ""
+ end
+
+ attr_writer :organisation_content_id
+end
diff --git a/app/presenters/content_block_manager/confirmation_copy_presenter.rb b/app/presenters/content_block_manager/confirmation_copy_presenter.rb
new file mode 100644
index 000000000..8ac8a2808
--- /dev/null
+++ b/app/presenters/content_block_manager/confirmation_copy_presenter.rb
@@ -0,0 +1,37 @@
+module ContentBlockManager
+ class ConfirmationCopyPresenter
+ def initialize(content_block_edition)
+ @content_block_edition = content_block_edition
+ end
+
+ def for_panel
+ I18n.t("content_block_edition.confirmation_page.#{state}.banner", block_type:, date:)
+ end
+
+ def for_paragraph
+ I18n.t("content_block_edition.confirmation_page.#{state}.detail")
+ end
+
+ def state
+ if content_block_edition.scheduled?
+ :scheduled
+ elsif content_block_edition.document.editions.count > 1
+ :updated
+ else
+ :created
+ end
+ end
+
+ private
+
+ attr_reader :content_block_edition
+
+ def date
+ I18n.l(content_block_edition.scheduled_publication, format: :long_ordinal) if content_block_edition.scheduled_publication
+ end
+
+ def block_type
+ content_block_edition.block_type.humanize
+ end
+ end
+end
diff --git a/app/services/content_block_manager/concerns/dequeueable.rb b/app/services/content_block_manager/concerns/dequeueable.rb
new file mode 100644
index 000000000..5bcf54a31
--- /dev/null
+++ b/app/services/content_block_manager/concerns/dequeueable.rb
@@ -0,0 +1,16 @@
+module ContentBlockManager
+ module Concerns
+ module Dequeueable
+ extend ActiveSupport::Concern
+
+ def dequeue_all_previously_queued_editions(content_block_edition)
+ content_block_edition.document.editions.where(state: :scheduled).find_each do |edition|
+ next if content_block_edition.id == edition.id
+
+ ContentBlockManager::SchedulePublishingWorker.dequeue(edition)
+ edition.supersede!
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/content_block_manager/create_edition_service.rb b/app/services/content_block_manager/create_edition_service.rb
new file mode 100644
index 000000000..b6946f9bc
--- /dev/null
+++ b/app/services/content_block_manager/create_edition_service.rb
@@ -0,0 +1,43 @@
+module ContentBlockManager
+ class CreateEditionService
+ def initialize(schema)
+ @schema = schema
+ end
+
+ def call(edition_params, document_id: nil)
+ @new_edition = build_edition(edition_params, document_id)
+ params = build_params(edition_params, document_id)
+ @new_edition.assign_attributes(params)
+ @new_edition.save!
+ @new_edition
+ end
+
+ private
+
+ def build_edition(edition_params, document_id)
+ if document_id.nil?
+ ContentBlockManager::ContentBlock::Edition.new(edition_params)
+ else
+ document = ContentBlockManager::ContentBlock::Document.find(document_id)
+ new_edition = document.latest_edition.dup
+ ContentBlockManager::ContentBlock::Edition.new(
+ document_id:,
+ title: edition_params[:title],
+ details: new_edition.details,
+ document_attributes: edition_params.delete(:document_attributes)
+ .except(:block_type)
+ .merge({ id: document_id }),
+ )
+ end
+ end
+
+ def build_params(edition_params, document_id)
+ unless document_id.nil?
+ document = ContentBlockManager::ContentBlock::Document.find(document_id)
+ latest_edition = document.latest_edition
+ edition_params[:details] = latest_edition.details.merge(edition_params[:details])
+ end
+ edition_params
+ end
+ end
+end
diff --git a/app/services/content_block_manager/delete_edition_service.rb b/app/services/content_block_manager/delete_edition_service.rb
new file mode 100644
index 000000000..5448d579a
--- /dev/null
+++ b/app/services/content_block_manager/delete_edition_service.rb
@@ -0,0 +1,23 @@
+module ContentBlockManager
+ class DeleteEditionService
+ def call(content_block_edition)
+ if content_block_edition.draft?
+ document = content_block_edition.document
+ document.with_lock do
+ content_block_edition.destroy!
+ if document_has_no_more_editions?(document)
+ document.destroy!
+ end
+ end
+ else
+ raise ArgumentError, "Could not delete Content Block Edition #{content_block_edition.id} because it is not in draft"
+ end
+ end
+
+ private
+
+ def document_has_no_more_editions?(document)
+ document.editions.count.zero?
+ end
+ end
+end
diff --git a/app/services/content_block_manager/find_and_replace_embed_codes_service.rb b/app/services/content_block_manager/find_and_replace_embed_codes_service.rb
new file mode 100644
index 000000000..da65fd486
--- /dev/null
+++ b/app/services/content_block_manager/find_and_replace_embed_codes_service.rb
@@ -0,0 +1,44 @@
+module ContentBlockManager
+ class FindAndReplaceEmbedCodesService
+ def self.call(html)
+ new(html).call
+ end
+
+ def call
+ embed_content_references.uniq.each do |reference|
+ content_block = content_blocks.find do |c|
+ c.document.content_id == reference.identifier || c.document.content_id_alias == reference.identifier
+ end
+ next if content_block.nil?
+
+ html.gsub!(reference.embed_code, content_block.render(reference.embed_code))
+ end
+
+ html
+ end
+
+ private
+
+ attr_reader :html
+
+ def initialize(html)
+ @html = html
+ end
+
+ def embed_content_references
+ @embed_content_references ||= ContentBlockTools::ContentBlockReference.find_all_in_document(html)
+ end
+
+ def identifiers
+ embed_content_references.map(&:identifier)
+ end
+
+ def content_blocks
+ @content_blocks ||= begin
+ scope = ContentBlockManager::ContentBlock::Edition.current_versions
+ scope.where(document: { content_id: identifiers })
+ .or(scope.where(document: { content_id_alias: identifiers }))
+ end
+ end
+ end
+end
diff --git a/app/services/content_block_manager/generate_preview_html.rb b/app/services/content_block_manager/generate_preview_html.rb
new file mode 100644
index 000000000..0b61ae511
--- /dev/null
+++ b/app/services/content_block_manager/generate_preview_html.rb
@@ -0,0 +1,91 @@
+require "net/http"
+require "json"
+require "uri"
+
+module ContentBlockManager
+ class GeneratePreviewHtml
+ include ContentBlockManager::Engine.routes.url_helpers
+
+ def initialize(content_id:, content_block_edition:, base_path:, locale:)
+ @content_id = content_id
+ @content_block_edition = content_block_edition
+ @base_path = base_path
+ @locale = locale
+ end
+
+ def call
+ uri = URI(frontend_path)
+ nokogiri_html = html_snapshot_from_frontend(uri)
+ update_local_link_paths(nokogiri_html)
+ add_draft_style(nokogiri_html)
+ replace_existing_content_blocks(nokogiri_html).to_s
+ end
+
+ private
+
+ BLOCK_STYLE = "background-color: yellow;".freeze
+ ERROR_HTML = "
Preview not found
".freeze
+
+ attr_reader :content_block_edition, :content_id, :base_path, :locale
+
+ def frontend_path
+ frontend_base_path + base_path
+ end
+
+ def frontend_base_path
+ Rails.env.development? ? Plek.external_url_for("government-frontend") : Plek.website_root
+ end
+
+ def html_snapshot_from_frontend(uri)
+ begin
+ raw_html = Net::HTTP.get(uri)
+ rescue StandardError
+ raw_html = ERROR_HTML
+ end
+ Nokogiri::HTML.parse(raw_html)
+ end
+
+ def update_local_link_paths(nokogiri_html)
+ url = host_content_preview_content_block_manager_content_block_edition_path(id: content_block_edition.id, host_content_id: content_id, locale:)
+ nokogiri_html.css("a").each do |link|
+ next if link[:href].start_with?("//") || link[:href].start_with?("http")
+
+ link[:href] = "#{url}&base_path=#{link[:href]}"
+ link[:target] = "_parent"
+ end
+
+ nokogiri_html
+ end
+
+ def add_draft_style(nokogiri_html)
+ nokogiri_html.css("body").each do |body|
+ body["class"] ||= ""
+ body["class"] += " draft"
+ end
+ nokogiri_html
+ end
+
+ def replace_existing_content_blocks(nokogiri_html)
+ replace_blocks(nokogiri_html)
+ style_blocks(nokogiri_html)
+ nokogiri_html
+ end
+
+ def replace_blocks(nokogiri_html)
+ content_block_wrappers(nokogiri_html).each do |wrapper|
+ embed_code = wrapper["data-embed-code"]
+ wrapper.replace content_block_edition.render(embed_code)
+ end
+ end
+
+ def style_blocks(nokogiri_html)
+ content_block_wrappers(nokogiri_html).each do |wrapper|
+ wrapper["style"] = BLOCK_STYLE
+ end
+ end
+
+ def content_block_wrappers(nokogiri_html)
+ nokogiri_html.css("[data-content-id=\"#{@content_block_edition.document.content_id}\"]")
+ end
+ end
+end
diff --git a/app/services/content_block_manager/publish_edition_service.rb b/app/services/content_block_manager/publish_edition_service.rb
new file mode 100644
index 000000000..029d0c71d
--- /dev/null
+++ b/app/services/content_block_manager/publish_edition_service.rb
@@ -0,0 +1,78 @@
+module ContentBlockManager
+ class PublishEditionService
+ class PublishingFailureError < StandardError; end
+
+ include Concerns::Dequeueable
+
+ def call(edition)
+ publish_with_rollback(edition)
+ end
+
+ private
+
+ def publish_with_rollback(content_block_edition)
+ document = content_block_edition.document
+ schema = ContentBlockManager::ContentBlock::Schema.find_by_block_type(document.block_type)
+ content_id = document.content_id
+ content_id_alias = document.content_id_alias
+
+ create_publishing_api_edition(
+ content_id:,
+ content_id_alias:,
+ schema_id: schema.id,
+ content_block_edition:,
+ )
+ dequeue_all_previously_queued_editions(content_block_edition)
+ publish_publishing_api_edition(content_id:)
+ update_content_block_document_with_latest_edition(content_block_edition)
+ content_block_edition.public_send(:publish!)
+ content_block_edition
+ rescue PublishingFailureError => e
+ discard_publishing_api_edition(content_id:)
+ raise e
+ end
+
+ def create_publishing_api_edition(content_id:, content_id_alias:, schema_id:, content_block_edition:)
+ Services.publishing_api.put_content(
+ content_id,
+ publishing_api_payload(schema_id, content_id_alias, content_block_edition),
+ )
+ end
+
+ def publishing_api_payload(schema_id, content_id_alias, content_block_edition)
+ {
+ schema_name: schema_id,
+ document_type: schema_id,
+ publishing_app: Whitehall::PublishingApp::WHITEHALL,
+ title: content_block_edition.title,
+ instructions_to_publishers: content_block_edition.instructions_to_publishers,
+ content_id_alias:,
+ details: content_block_edition.details,
+ links: {
+ primary_publishing_organisation: [
+ content_block_edition.lead_organisation.content_id,
+ ],
+ },
+ update_type: content_block_edition.major_change ? "major" : "minor",
+ change_note: content_block_edition.major_change ? content_block_edition.change_note : nil,
+ }
+ end
+
+ def publish_publishing_api_edition(content_id:)
+ Services.publishing_api.publish(content_id)
+ rescue GdsApi::HTTPErrorResponse => e
+ raise PublishingFailureError, "Could not publish #{content_id} because: #{e.message}"
+ end
+
+ def update_content_block_document_with_latest_edition(content_block_edition)
+ content_block_edition.document.update!(
+ latest_edition_id: content_block_edition.id,
+ live_edition_id: content_block_edition.id,
+ )
+ end
+
+ def discard_publishing_api_edition(content_id:)
+ Services.publishing_api.discard_draft(content_id)
+ end
+ end
+end
diff --git a/app/services/content_block_manager/schedule_edition_service.rb b/app/services/content_block_manager/schedule_edition_service.rb
new file mode 100644
index 000000000..0547479be
--- /dev/null
+++ b/app/services/content_block_manager/schedule_edition_service.rb
@@ -0,0 +1,44 @@
+module ContentBlockManager
+ class ScheduleEditionService
+ include Concerns::Dequeueable
+
+ def initialize(schema)
+ @schema = schema
+ end
+
+ def call(edition)
+ schedule_with_rollback do
+ edition.update_document_reference_to_latest_edition!
+ edition
+ end
+ send_publish_intents_for_host_documents(content_block_edition: edition)
+ edition
+ end
+
+ private
+
+ def schedule_with_rollback
+ raise ArgumentError, "Local database changes not given" unless block_given?
+
+ ActiveRecord::Base.transaction do
+ content_block_edition = yield
+
+ content_block_edition.schedule! unless content_block_edition.scheduled?
+
+ dequeue_all_previously_queued_editions(content_block_edition)
+ ContentBlockManager::SchedulePublishingWorker.queue(content_block_edition)
+ end
+ end
+
+ def send_publish_intents_for_host_documents(content_block_edition:)
+ host_content_items = ContentBlockManager::HostContentItem.for_document(content_block_edition.document)
+ host_content_items.each do |host_content_item|
+ ContentBlockManager::PublishIntentWorker.perform_async(
+ host_content_item.base_path,
+ host_content_item.publishing_app,
+ content_block_edition.scheduled_publication.to_s,
+ )
+ end
+ end
+ end
+end
diff --git a/app/validators/content_block_manager/details_validator.rb b/app/validators/content_block_manager/details_validator.rb
new file mode 100644
index 000000000..a32ea01e7
--- /dev/null
+++ b/app/validators/content_block_manager/details_validator.rb
@@ -0,0 +1,77 @@
+class ContentBlockManager::DetailsValidator < ActiveModel::Validator
+ attr_reader :edition
+
+ def validate(edition)
+ @edition = edition
+ errors = validate_with_schema(edition)
+ errors.each do |e|
+ if e["type"] == "required"
+ add_blank_errors(e)
+ elsif %w[format pattern].include?(e["type"])
+ add_format_errors(e)
+ end
+ end
+ end
+
+ def add_blank_errors(error)
+ missing_keys = error.dig("details", "missing_keys") || []
+ missing_keys.each do |k|
+ key = key_with_optional_prefix(error, k)
+ edition.errors.add(
+ "details_#{key}",
+ translate_error("blank", k),
+ )
+ end
+ end
+
+ def add_format_errors(error)
+ data_pointer = error["data_pointer"].delete_prefix("/")
+ field_items = data_pointer.split("/")
+ attribute = field_items.last
+ key = key_with_optional_prefix(error, nil)
+ edition.errors.add(
+ "details_#{key}",
+ translate_error("invalid", attribute),
+ )
+ end
+
+ def validate_with_schema(edition)
+ # Fetch the details and remove any blank fields (JSONSchema classes an empty string as valid,
+ # unless a specific format has been specified)
+ details = compact_nested(edition.details)
+ schemer = JSONSchemer.schema(edition.schema.body)
+ schemer.validate(details)
+ end
+
+ def key_with_optional_prefix(error, key)
+ if error["data_pointer"].present?
+ keys = error["data_pointer"].split("/")
+ [
+ keys[1],
+ *keys[3..],
+ key,
+ ].compact.join("_")
+ else
+ key
+ end
+ end
+
+ def translate_error(type, attribute)
+ default = "activerecord.errors.models.content_block_manager/content_block/edition.#{type}".to_sym
+ I18n.t(
+ "activerecord.errors.models.content_block_manager/content_block/edition.attributes.#{attribute}.#{type}",
+ attribute: attribute.humanize,
+ default: [default],
+ )
+ end
+
+private
+
+ def compact_nested(object)
+ return object unless object.respond_to?(:compact_blank!)
+
+ object.compact_blank!
+ object.each { |o| compact_nested(o) }
+ object
+ end
+end
diff --git a/app/validators/content_block_manager/organisation_validator.rb b/app/validators/content_block_manager/organisation_validator.rb
new file mode 100644
index 000000000..ef71fa48a
--- /dev/null
+++ b/app/validators/content_block_manager/organisation_validator.rb
@@ -0,0 +1,10 @@
+class ContentBlockManager::OrganisationValidator < ActiveModel::Validator
+ attr_reader :edition
+
+ def validate(edition)
+ @edition = edition
+ if edition.edition_organisation.blank?
+ edition.errors.add("lead_organisation", :blank)
+ end
+ end
+end
diff --git a/app/validators/content_block_manager/scheduled_publication_validator.rb b/app/validators/content_block_manager/scheduled_publication_validator.rb
new file mode 100644
index 000000000..9b2b76d83
--- /dev/null
+++ b/app/validators/content_block_manager/scheduled_publication_validator.rb
@@ -0,0 +1,11 @@
+class ContentBlockManager::ScheduledPublicationValidator < ActiveModel::Validator
+ attr_reader :edition
+
+ def validate(edition)
+ if edition.scheduled_publication.blank?
+ edition.errors.add("scheduled_publication", :blank)
+ elsif edition.scheduled_publication < Time.zone.now
+ edition.errors.add("scheduled_publication", :future_date)
+ end
+ end
+end
diff --git a/app/views/admin/errors/bad_request.html.erb b/app/views/admin/errors/bad_request.html.erb
new file mode 100644
index 000000000..6968201cd
--- /dev/null
+++ b/app/views/admin/errors/bad_request.html.erb
@@ -0,0 +1,15 @@
+<% content_for :product_name, ContentBlockManager.product_name %>
+<% content_for :page_title, "Something went wrong" %>
+<% content_for :title, "Something went wrong" %>
+<% content_for :title_margin_bottom, 6 %>
+
+
+
+
Please try again by selecting the browser’s back button or email us at
+ <%= mail_to(
+ ContentBlockManager.support_email_address,
+ { class: "govuk-link" },
+ ) %>
+ to raise a support ticket.
If you need to access this page, please email
+ <%= mail_to(
+ ContentBlockManager.support_email_address,
+ { class: "govuk-link" },
+ ) %>
+ to raise a support ticket.
You can also email us at
+ <%= mail_to(
+ ContentBlockManager.support_email_address,
+ { class: "govuk-link" },
+ ) %> with the following information:
+
+ <%= render "govuk_publishing_components/components/list", {
+ visible_counters: true,
+ items: [
+ "the url of this page",
+ "the time of the error",
+ ],
+ } %>
+
+
diff --git a/app/views/admin/errors/not_found.html.erb b/app/views/admin/errors/not_found.html.erb
new file mode 100644
index 000000000..2274181d4
--- /dev/null
+++ b/app/views/admin/errors/not_found.html.erb
@@ -0,0 +1,11 @@
+<% content_for :product_name, ContentBlockManager.product_name %>
+<% content_for :page_title, "There is a mistake in the URL" %>
+<% content_for :title, "There is a mistake in the URL" %>
+<% content_for :title_margin_bottom, 6 %>
+
+
+
+
If you typed the URL, check it is correct.
+
If you pasted the URL, check you copied the entire address.
Please try again by selecting the browser’s back button or email us at
+ <%= mail_to(
+ ContentBlockManager.support_email_address,
+ { class: "govuk-link" },
+ ) %>
+ to raise a support ticket.
diff --git a/app/views/content_block_manager/content_block/editions/workflow/change_note.html.erb b/app/views/content_block_manager/content_block/editions/workflow/change_note.html.erb
new file mode 100644
index 000000000..285d05a86
--- /dev/null
+++ b/app/views/content_block_manager/content_block/editions/workflow/change_note.html.erb
@@ -0,0 +1,59 @@
+<% content_for :context, context %>
+<% content_for :title, "Do users have to know the content has changed?" %>
+<% content_for :title_margin_bottom, 4 %>
+<% content_for :back_link do %>
+ <%= render "govuk_publishing_components/components/back_link", {
+ href: back_path,
+ } %>
+<% end %>
+
+<% content_for :error_summary, render(Admin::ErrorSummaryComponent.new(object: @content_block_edition)) %>
+
+
+
+ <%= form_with url: content_block_manager.content_block_manager_content_block_workflow_path(
+ @content_block_edition,
+ step: :change_note,
+ ), method: :put, id: "change_note" do %>
+
+ <%= render "govuk_publishing_components/components/radio", {
+ name: "content_block/edition[major_change]",
+ hint: "Some content types show public change notes. GOV.UK users can subscribe to email alerts and RSS feeds to receive public change notes. Telling users when published information has changed is important for transparency.",
+ id: "content_block_manager_content_block_edition_major_change",
+ error_items: errors_for(@content_block_edition.errors, :major_change),
+ items: [
+ {
+ value: "1",
+ checked: @content_block_edition.major_change === true,
+ text: "Yes - information has been added, updated or removed",
+ hint_text: "A change note will be published on every relevant page containing the content block you've changed. Change notes will also be emailed to users subscribed to email alerts for every page affected by this change. The 'last updated' date will change on pages that display it.",
+ bold: true,
+ conditional: render("govuk_publishing_components/components/textarea", {
+ label: {
+ text: "Describe the edit for users",
+ bold: true,
+ },
+ name: "content_block/edition[change_note]",
+ id: "content_block_manager_content_block_edition_change_note",
+ error_items: errors_for(@content_block_edition.errors, :change_note),
+ value: @content_block_edition.change_note,
+ hint: "Tell users what has been edited, where and why. Write in full sentences, leading with the most important words. For example, \"The full basic State Pension rate has changed from £156.20 per week to £169.50 per week.\"",
+ }),
+ },
+ {
+ value: "0",
+ checked: @content_block_edition.major_change === false,
+ text: "No - it's a minor edit that does not change the meaning",
+ hint_text: "This includes fixing a typo or broken link, a style change or similar. Users signed up to email alerts will not get notified and the 'last updated' date will not change.",
+ bold: true,
+ },
+ ],
+ } %>
+ <% end %>
+
+ <%= render ContentBlockManager::Shared::ContinueOrCancelButtonGroup.new(
+ form_id: "change_note",
+ content_block_edition: @content_block_edition,
+ ) %>
+
+
diff --git a/app/views/content_block_manager/content_block/editions/workflow/confirmation.html.erb b/app/views/content_block_manager/content_block/editions/workflow/confirmation.html.erb
new file mode 100644
index 000000000..f5e307ac9
--- /dev/null
+++ b/app/views/content_block_manager/content_block/editions/workflow/confirmation.html.erb
@@ -0,0 +1,27 @@
+<% content_for :page_title, "Your content block is available for use" %>
+
+
+
+
+
+ <%= @confirmation_copy.for_panel %>
+
+
+
+
+
+
+
+
What happens next?
+
<%= @confirmation_copy.for_paragraph %> If you need any support or want to delete a content block you can raise a support request.
+
diff --git a/app/views/shared/_phase_banner.html.erb b/app/views/shared/_phase_banner.html.erb
new file mode 100644
index 000000000..06ae439dc
--- /dev/null
+++ b/app/views/shared/_phase_banner.html.erb
@@ -0,0 +1,13 @@
+<%= render "govuk_publishing_components/components/phase_banner", {
+ phase: "Beta",
+ message: sanitize("For support, to delete a content block or to give feedback, email
+ #{
+ mail_to(
+ "email@example.com",
+ "email@example.com",
+ {
+ class: "govuk-link",
+ },
+ )
+ }.", attributes: %w(class href)),
+ } %>
diff --git a/bin/dev b/bin/dev
index 5f91c2054..b6d1f9b9c 100755
--- a/bin/dev
+++ b/bin/dev
@@ -1,2 +1,6 @@
-#!/usr/bin/env ruby
-exec "./bin/rails", "server", *ARGV
+#!/usr/bin/env sh
+if !(gem list foreman -i --silent); then
+ echo "Installing foreman..."
+ gem install foreman
+fi
+exec foreman start -f Procfile.dev "$@"
diff --git a/config/application.rb b/config/application.rb
index d96b3db0c..9f9e08f96 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -7,12 +7,13 @@
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
-# require "action_mailer/railtie"
+require "action_mailer/railtie"
# require "action_mailbox/engine"
# require "action_text/engine"
require "action_view/railtie"
-require "action_cable/engine"
-# require "rails/test_unit/railtie"
+require "sprockets/railtie"
+# require "action_cable/engine"
+require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
@@ -34,7 +35,10 @@ class Application < Rails::Application
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
- # config.eager_load_paths << Rails.root.join("extras")
+
+ config.eager_load_paths += %W[
+ #{config.root}/lib
+ ]
# Don't generate system test files.
config.generators.system_tests = nil
diff --git a/config/content_block_manager.yml b/config/content_block_manager.yml
new file mode 100644
index 000000000..c09042ce7
--- /dev/null
+++ b/config/content_block_manager.yml
@@ -0,0 +1,131 @@
+schemas:
+ content_block_pension:
+ fields:
+ description:
+ component:
+ textarea
+ subschemas:
+ rates:
+ embeddable_fields:
+ - amount
+ field_order:
+ - title
+ - amount
+ - frequency
+ - description
+ content_block_contact:
+ embeddable_as_block: true
+ field_order:
+ - title
+ - description
+ - contact_type
+ fields:
+ description:
+ component:
+ textarea
+ subschemas:
+ email_addresses:
+ group: contact_methods
+ group_order: 1
+ embeddable_as_block: true
+ embeddable_fields:
+ - email_address
+ field_order:
+ - title
+ - label
+ - email_address
+ - subject
+ - body
+ - description
+ fields:
+ body:
+ component:
+ textarea
+ description:
+ component:
+ textarea
+ telephones:
+ group: contact_methods
+ group_order: 2
+ embeddable_as_block: true
+ embeddable_fields:
+ - telephone_numbers
+ - video_relay_service
+ - opening_hours
+ - call_charges
+ - bsl_guidance
+ field_order:
+ - title
+ - description
+ - telephone_numbers
+ - video_relay_service
+ - bsl_guidance
+ - opening_hours
+ - call_charges
+ fields:
+ description:
+ component:
+ textarea
+ opening_hours:
+ component:
+ opening_hours
+ call_charges:
+ component:
+ call_charges
+ bsl_guidance:
+ component:
+ bsl_guidance
+ video_relay_service:
+ component:
+ video_relay_service
+ telephone_numbers:
+ data_attributes:
+ module: auto-populate-telephone-number-label
+ field_order:
+ - type
+ - label
+ - telephone_number
+ addresses:
+ group: contact_methods
+ group_order: 3
+ field_order:
+ - title
+ - recipient
+ - street_address
+ - town_or_city
+ - state_or_county
+ - postal_code
+ - country
+ - description
+ embeddable_as_block: true
+ embeddable_fields:
+ - title
+ - street_address
+ - town_or_city
+ - state_or_county
+ - postal_code
+ - country
+ - description
+ fields:
+ country:
+ component:
+ country
+ description:
+ component:
+ textarea
+ contact_links:
+ group: contact_methods
+ embeddable_as_block: true
+ group_order: 4
+ fields:
+ description:
+ component:
+ textarea
+ embeddable_fields:
+ - url
+ field_order:
+ - title
+ - label
+ - url
+ - description
+
diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc
deleted file mode 100644
index 22839c68f..000000000
--- a/config/credentials.yml.enc
+++ /dev/null
@@ -1 +0,0 @@
-I4XXtZ0TknEONVEUcmE1t0xa2H4TWlpdVA+2vfLrDy3woJxBwDRlopNpuOwauhelZVyT4sR4Mn1pnPVtqqU/KQO55pkbBgQR+qE4G38648jnqHB8H5OWJKN07knN3+Clo/kGRVNQIzaqo0DYUr5kKYPGXqXnkJFYLJa++hPTn3RYRPBqXVJEjAcGoZByerUOrs/ltGIwfi3lYNqvedxRSa3gBV51ymtrdhUqqfrHQUuAGN+d6GzzVCYHlpt6/R9I33x1GGK+O9LDJcjnydjyl0myIhnUZKtScvwf+LG3ha+EyXmaRLWceHjO1g4kG0UVx17IOlLmHnps+/AaWkncxzFITsCVIefAV5zTOsR93LkdOzzAgqb3W9YOVlFAISJicLbY+rvTrwDYdAHzFhL770Kbo/dYim4TnpVdWxbW6r/GgaQMYo0O6yD33wjrWdScGGO91UABDvTg6D6GjaqhSEi+xmQ2dGOxQP87Kebz8o2DU5NUrxvTUM1c--/cmi/KDn3om6YPgv--oB9LKUiBoqipfTYaX2aC1w==
\ No newline at end of file
diff --git a/config/cucumber.yml b/config/cucumber.yml
new file mode 100644
index 000000000..7fd8059f8
--- /dev/null
+++ b/config/cucumber.yml
@@ -0,0 +1,10 @@
+<%
+rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : ""
+rerun = rerun.strip.gsub /\s/, ' '
+rerun_opts = rerun.empty? ? "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} features" : "--format #{ENV['CUCUMBER_FORMAT'] || 'pretty'} #{rerun}"
+std_opts = "--format #{ENV['CUCUMBER_FORMAT'] || 'progress'} --strict --tags 'not @wip'"
+dirs = ["features"].flatten.join(" ")
+%>
+default: <%= std_opts %> <%= dirs %> --publish-quiet
+wip: --tags @wip:3 --wip <%= dirs %> --publish-quiet
+rerun: <%= rerun_opts %> --format rerun --out rerun.txt --strict --tags 'not @wip' --publish-quiet
diff --git a/config/database.yml b/config/database.yml
index 4bbee365f..747677c2b 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -1,85 +1,20 @@
-# PostgreSQL. Versions 9.3 and up are supported.
-#
-# Install the pg driver:
-# gem install pg
-# On macOS with Homebrew:
-# gem install pg -- --with-pg-config=/usr/local/bin/pg_config
-# On Windows:
-# gem install pg
-# Choose the win32 build.
-# Install PostgreSQL and put its /bin directory on your path.
-#
-# Configure Using Gemfile
-# gem "pg"
-#
default: &default
adapter: postgresql
encoding: unicode
- # For details on connection pooling, see Rails configuration guide
- # https://guides.rubyonrails.org/configuring.html#database-pooling
- pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
-
+ pool: 12
+ template: template0
development:
<<: *default
- database: govuk_content_block_manager_development
-
- # The specified database role being used to connect to PostgreSQL.
- # To create additional roles in PostgreSQL see `$ createuser --help`.
- # When left blank, PostgreSQL will use the default role. This is
- # the same name as the operating system user running Rails.
- #username: govuk_content_block_manager
-
- # The password associated with the PostgreSQL role (username).
- #password:
-
- # Connect on a TCP socket. Omitted by default since the client uses a
- # domain socket that doesn't need configuration. Windows does not have
- # domain sockets, so uncomment these lines.
- #host: localhost
-
- # The TCP port the server listens on. Defaults to 5432.
- # If your server runs on a different port number, change accordingly.
- #port: 5432
-
- # Schema search path. The server defaults to $user,public
- #schema_search_path: myapp,sharedapp,public
-
- # Minimum log levels, in increasing order:
- # debug5, debug4, debug3, debug2, debug1,
- # log, notice, warning, error, fatal, and panic
- # Defaults to warning.
- #min_messages: notice
+ database: govuk-content-block-manager_development
+ url: <%= ENV["DATABASE_URL"]%>
-# Warning: The database defined as "test" will be erased and
-# re-generated from your development database when you run "rake".
-# Do not set this db to the same as development or production.
test:
<<: *default
- database: govuk_content_block_manager_test
+ database: govuk-content-block-manager_test
+ url: <%= ENV["TEST_DATABASE_URL"] %>
-# As with config/credentials.yml, you never want to store sensitive information,
-# like your database password, in your source code. If your source code is
-# ever seen by anyone, they now have access to your database.
-#
-# Instead, provide the password or a full connection URL as an environment
-# variable when you boot the app. For example:
-#
-# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
-#
-# If the connection URL is provided in the special DATABASE_URL environment
-# variable, Rails will automatically merge its configuration values on top of
-# the values provided in this file. Alternatively, you can specify a connection
-# URL environment variable explicitly:
-#
-# production:
-# url: <%= ENV["MY_APP_DATABASE_URL"] %>
-#
-# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
-# for a full overview on how database connection configuration can be specified.
-#
production:
<<: *default
- database: govuk_content_block_manager_production
- username: govuk_content_block_manager
- password: <%= ENV["GOVUK_CONTENT_BLOCK_MANAGER_DATABASE_PASSWORD"] %>
+ database: govuk-content-block-manager_production
+ url: <%= ENV["DATABASE_URL"]%>
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 3665f6f16..a2ad03444 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -20,13 +20,33 @@
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
+
+ config.cache_store = :memory_store
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
else
config.action_controller.perform_caching = false
+
+ config.cache_store = :null_store
end
- # Change to :null_store to avoid any caching.
- config.cache_store = :memory_store
+ # Debug mode disables concatenation and preprocessing of assets.
+ # This option may cause significant delays in view rendering with a large
+ # number of complex assets.
+ config.assets.debug = ENV["DISABLE_ASSETS_DEBUG"].nil?
+
+ # Asset digests allow you to set far-future HTTP expiration dates on all assets,
+ # yet still be able to expire them through the digest params.
+ config.assets.digest = false
+
+ # Suppress logger output for asset requests.
+ config.assets.quiet = false
+
+ # Adds additional error checking when serving assets at runtime.
+ # Checks for improperly declared sprockets dependencies.
+ # Raises helpful error messages.
+ config.assets.raise_runtime_errors = true
+
+ config.assets.cache_store = :null_store
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
@@ -43,6 +63,8 @@
# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
+ config.hosts.clear
+
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
index 487324424..493e0c098 100644
--- a/config/initializers/assets.rb
+++ b/config/initializers/assets.rb
@@ -3,5 +3,5 @@
# Version of your assets, change this if you want to expire all your assets.
Rails.application.config.assets.version = "1.0"
-# Add additional assets to the asset load path.
-# Rails.application.config.assets.paths << Emoji.images_path
+# Add Yarn node_modules folder to the asset load path.
+Rails.application.config.assets.paths << Rails.root.join("node_modules")
diff --git a/config/initializers/better_errors.rb b/config/initializers/better_errors.rb
new file mode 100644
index 000000000..bb3c5ac1d
--- /dev/null
+++ b/config/initializers/better_errors.rb
@@ -0,0 +1,3 @@
+if Rails.env.development?
+ BetterErrors::Middleware.allow_ip! "0.0.0.0/0"
+end
diff --git a/config/initializers/dartsass.rb b/config/initializers/dartsass.rb
new file mode 100644
index 000000000..e39ee0e17
--- /dev/null
+++ b/config/initializers/dartsass.rb
@@ -0,0 +1,8 @@
+APP_STYLESHEETS = {
+ "application.scss" => "application.css",
+}.freeze
+
+all_stylesheets = APP_STYLESHEETS.merge(GovukPublishingComponents::Config.all_stylesheets)
+Rails.application.config.dartsass.builds = all_stylesheets
+
+Rails.application.config.dartsass.build_options << " --quiet-deps"
diff --git a/config/initializers/friendly_id.rb b/config/initializers/friendly_id.rb
new file mode 100644
index 000000000..96f7a4717
--- /dev/null
+++ b/config/initializers/friendly_id.rb
@@ -0,0 +1,6 @@
+FriendlyId.defaults do |config|
+ config.base = :name
+ config.use :slugged, :finders, :sequentially_slugged, FriendlyId::CustomNormalise
+
+ config.sequence_separator = "--"
+end
diff --git a/config/puma.rb b/config/puma.rb
index 787e4ce98..d08b16a62 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -1,38 +1,2 @@
-# This configuration file will be evaluated by Puma. The top-level methods that
-# are invoked here are part of Puma's configuration DSL. For more information
-# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
-#
-# Puma starts a configurable number of processes (workers) and each process
-# serves each request in a thread from an internal thread pool.
-#
-# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
-# should only set this value when you want to run 2 or more workers. The
-# default is already 1.
-#
-# The ideal number of threads per worker depends both on how much time the
-# application spends waiting for IO operations and on how much you wish to
-# prioritize throughput over latency.
-#
-# As a rule of thumb, increasing the number of threads will increase how much
-# traffic a given process can handle (throughput), but due to CRuby's
-# Global VM Lock (GVL) it has diminishing returns and will degrade the
-# response time (latency) of the application.
-#
-# The default is set to 3 threads as it's deemed a decent compromise between
-# throughput and latency for the average Rails application.
-#
-# Any libraries that use a connection pool or another resource pool should
-# be configured to provide at least as many connections as the number of
-# threads. This includes Active Record's `pool` parameter in `database.yml`.
-threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
-threads threads_count, threads_count
-
-# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
-port ENV.fetch("PORT", 3000)
-
-# Allow puma to be restarted by `bin/rails restart` command.
-plugin :tmp_restart
-
-# Specify the PID file. Defaults to tmp/pids/server.pid in development.
-# In other environments, only set the PID file if requested.
-pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
+require "govuk_app_config/govuk_puma"
+GovukPuma.configure_rails(self)
diff --git a/config/routes.rb b/config/routes.rb
index 48254e88e..088b7f46c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,14 +1,42 @@
Rails.application.routes.draw do
- # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
+ get "/healthcheck/live", to: proc { [200, {}, %w[OK]] }
+ get "/healthcheck/ready", to: GovukHealthcheck.rack_response
- # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
- # Can be used by load balancers and uptime monitors to verify that the app is live.
- get "up" => "rails/health#show", as: :rails_health_check
+ namespace :content_block_manager, path: "/" do
+ root to: "content_block/documents#index", via: :get
- # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
- # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
- # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
+ resources :users, only: %i[show]
- # Defines the root path route ("/")
- # root "posts#index"
+ namespace :content_block, path: "content-block" do
+ get "content-id/:content_id", to: "documents#content_id", as: :content_id
+ resources :documents, only: %i[index show new], path_names: { new: "(:block_type)/new" }, path: "" do
+ collection do
+ post :new_document_options_redirect
+ end
+ resources :editions, only: %i[new create]
+ get "schedule/edit", to: "documents/schedule#edit", as: :schedule_edit
+ put "schedule", to: "documents/schedule#update", as: :update_schedule
+ patch "schedule", to: "documents/schedule#update"
+ end
+ resources :editions, only: %i[new create destroy], path_names: { new: ":block_type/new" } do
+ member do
+ resources :workflow, only: %i[show update], controller: "editions/workflow", param: :step do
+ collection do
+ get :cancel, to: "editions/workflow#cancel"
+ end
+ end
+ get "embedded-objects/(:object_type)/new", to: "editions/embedded_objects#new", as: :new_embedded_object
+ post "embedded-objects", to: "editions/embedded_objects#new_embedded_objects_options_redirect", as: :new_embedded_objects_options_redirect
+ post "embedded-objects/:object_type", to: "editions/embedded_objects#create", as: :create_embedded_object
+ get "embedded-objects/:object_type/:object_title/edit", to: "editions/embedded_objects#edit", as: :edit_embedded_object
+ put "embedded-objects/:object_type/:object_title", to: "editions/embedded_objects#update", as: :embedded_object
+ get "embedded-objects/:object_type/:object_title/review", to: "editions/embedded_objects#review", as: :review_embedded_object
+ post "embedded-objects/:object_type/:object_title/publish", to: "editions/embedded_objects#publish", as: :publish_embedded_object
+ get :preview, to: "editions/host_content#preview", path: "host-content/:host_content_id/preview", as: :host_content_preview
+ end
+ end
+ end
+ end
+
+ get "/page" => "pages#show"
end
diff --git a/config/secrets.yml b/config/secrets.yml
new file mode 100644
index 000000000..8240e59a5
--- /dev/null
+++ b/config/secrets.yml
@@ -0,0 +1,8 @@
+development:
+ secret_key_base: secret
+
+test:
+ secret_key_base: secret
+
+production:
+ secret_key_base: <%= ENV['SECRET_KEY_BASE'] %>
diff --git a/db/migrate/20250819132716_create_users.rb b/db/migrate/20250819132716_create_users.rb
new file mode 100644
index 000000000..4aae1398c
--- /dev/null
+++ b/db/migrate/20250819132716_create_users.rb
@@ -0,0 +1,18 @@
+class CreateUsers < ActiveRecord::Migration[8.0]
+ def change
+ create_table :users do |t|
+ t.text :name, null: false
+ t.text :uid, null: false
+ t.text :email, null: false
+ t.boolean :disabled, default: false
+ t.boolean :remotely_signed_out, default: false
+ t.text :organisation_slug
+ t.text :permissions
+
+ t.timestamps
+ end
+ add_index :users, :uid
+ add_index :users, :email
+ add_index :users, :organisation_slug
+ end
+end
diff --git a/db/migrate/20250819142711_create_organisations.rb b/db/migrate/20250819142711_create_organisations.rb
new file mode 100644
index 000000000..0b22252bb
--- /dev/null
+++ b/db/migrate/20250819142711_create_organisations.rb
@@ -0,0 +1,20 @@
+class CreateOrganisations < ActiveRecord::Migration[8.0]
+ def change
+ create_table :organisations do |t|
+ t.text :name, null: false
+ t.text :slug, null: false
+ t.text :url
+ t.text :govuk_status, null: false, default: "live"
+ t.datetime :closed_at
+ t.text :content_id
+ t.boolean :political, null: false, default: false
+ t.text :organisation_type_key, null: false
+ t.text :govuk_closed_status
+
+ t.timestamps
+ end
+ add_index :organisations, :slug
+ add_index :organisations, :organisation_type_key
+ add_index :organisations, :content_id
+ end
+end
diff --git a/db/migrate/20250819165035_create_content_block_documents.rb b/db/migrate/20250819165035_create_content_block_documents.rb
new file mode 100644
index 000000000..c76c067c5
--- /dev/null
+++ b/db/migrate/20250819165035_create_content_block_documents.rb
@@ -0,0 +1,18 @@
+class CreateContentBlockDocuments < ActiveRecord::Migration[8.0]
+ def change
+ create_table :content_block_documents do |t|
+ t.text :content_id
+ t.text :sluggable_string
+ t.text :block_type
+ t.integer :latest_edition_id
+ t.integer :live_edition_id
+ t.string :content_id_alias
+ t.datetime :deleted_at
+
+ t.timestamps
+ end
+ add_index :content_block_documents, :latest_edition_id
+ add_index :content_block_documents, :live_edition_id
+ add_index :content_block_documents, :content_id_alias, unique: true
+ end
+end
diff --git a/db/migrate/20250820105744_create_content_block_editions.rb b/db/migrate/20250820105744_create_content_block_editions.rb
new file mode 100644
index 000000000..512d32478
--- /dev/null
+++ b/db/migrate/20250820105744_create_content_block_editions.rb
@@ -0,0 +1,20 @@
+class CreateContentBlockEditions < ActiveRecord::Migration[8.0]
+ def change
+ create_table :content_block_editions do |t|
+ t.json :details, null: false
+ t.integer :document_id, null: false
+ t.text :state, null: false, default: "draft"
+ t.datetime :scheduled_publication
+ t.text :instructions_to_publishers
+ t.text :title, null: false, default: ""
+ t.text :internal_change_note
+ t.text :change_note
+ t.boolean :major_change
+
+ t.timestamps
+ end
+
+ add_index :content_block_editions, :title
+ add_index :content_block_editions, :document_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 000000000..cd36494d4
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,79 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[8.0].define(version: 2025_08_20_105744) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "pg_catalog.plpgsql"
+
+ create_table "content_block_documents", force: :cascade do |t|
+ t.text "content_id"
+ t.text "sluggable_string"
+ t.text "block_type"
+ t.integer "latest_edition_id"
+ t.integer "live_edition_id"
+ t.string "content_id_alias"
+ t.datetime "deleted_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["content_id_alias"], name: "index_content_block_documents_on_content_id_alias", unique: true
+ t.index ["latest_edition_id"], name: "index_content_block_documents_on_latest_edition_id"
+ t.index ["live_edition_id"], name: "index_content_block_documents_on_live_edition_id"
+ end
+
+ create_table "content_block_editions", force: :cascade do |t|
+ t.json "details", null: false
+ t.integer "document_id", null: false
+ t.text "state", default: "draft", null: false
+ t.datetime "scheduled_publication"
+ t.text "instructions_to_publishers"
+ t.text "title", default: "", null: false
+ t.text "internal_change_note"
+ t.text "change_note"
+ t.boolean "major_change"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["document_id"], name: "index_content_block_editions_on_document_id"
+ t.index ["title"], name: "index_content_block_editions_on_title"
+ end
+
+ create_table "organisations", force: :cascade do |t|
+ t.text "name", null: false
+ t.text "slug", null: false
+ t.text "url"
+ t.text "govuk_status", default: "live", null: false
+ t.datetime "closed_at"
+ t.text "content_id"
+ t.boolean "political", default: false, null: false
+ t.text "organisation_type_key", null: false
+ t.text "govuk_closed_status"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["content_id"], name: "index_organisations_on_content_id"
+ t.index ["organisation_type_key"], name: "index_organisations_on_organisation_type_key"
+ t.index ["slug"], name: "index_organisations_on_slug"
+ end
+
+ create_table "users", force: :cascade do |t|
+ t.text "name", null: false
+ t.text "uid", null: false
+ t.text "email", null: false
+ t.boolean "disabled", default: false
+ t.boolean "remotely_signed_out", default: false
+ t.text "organisation_slug"
+ t.text "permissions"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["email"], name: "index_users_on_email"
+ t.index ["organisation_slug"], name: "index_users_on_organisation_slug"
+ t.index ["uid"], name: "index_users_on_uid"
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index 4fbd6ed97..c3b7f44c7 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,9 +1,11 @@
-# This file should ensure the existence of records required to run the application in every environment (production,
-# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
-# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
-#
-# Example:
-#
-# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
-# MovieGenre.find_or_create_by!(name: genre_name)
-# end
+return if Rails.env.test?
+
+gds_organisation_id = "af07d5a5-df63-4ddc-9383-6a666845ebe9"
+User.create!(
+ name: "Test user",
+ uid: "test-user-1",
+ email: "test@gds.example.com",
+ permissions: ["signin", "GDS Admin", "GDS Editor", "Managing Editor", "Sidekiq Admin"],
+ organisation_content_id: gds_organisation_id,
+ organisation_slug: "government-digital-service",
+)
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 000000000..124a3faa2
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,27 @@
+const { defineConfig, globalIgnores } = require('eslint/config')
+
+const globals = require('globals')
+const js = require('@eslint/js')
+
+const { FlatCompat } = require('@eslint/eslintrc')
+
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+ recommendedConfig: js.configs.recommended,
+ allConfig: js.configs.all
+})
+
+module.exports = defineConfig([
+ {
+ extends: compat.extends('standard', 'prettier'),
+
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.jasmine,
+ GOVUK: 'readonly'
+ }
+ }
+ },
+ globalIgnores(['app/assets/javascripts/vendor/'])
+])
diff --git a/features/create_contact_object.feature b/features/create_contact_object.feature
new file mode 100644
index 000000000..681dd7d68
--- /dev/null
+++ b/features/create_contact_object.feature
@@ -0,0 +1,264 @@
+Feature: Create a contact object
+
+ Background:
+ Given I am a GDS admin
+ And the organisation "Ministry of Example" exists
+ And a schema "contact" exists:
+ """
+ {
+ "type":"object",
+ "required":[
+ "description"
+ ],
+ "additionalProperties":false,
+ "properties":{
+ "description": {
+ "type": "string"
+ }
+ }
+ }
+ """
+ And the schema has a subschema "email_addresses":
+ """
+ {
+ "type":"object",
+ "required": ["title", "email_address"],
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "email_address": {
+ "type": "string"
+ },
+ "subject": {
+ "type": "string"
+ },
+ "body": {
+ "type": "string"
+ }
+ }
+ }
+ """
+ And the schema has a subschema "telephones":
+ """
+ {
+ "type":"object",
+ "required": [
+ "title",
+ "telephone_numbers"
+ ],
+ "properties": {
+ "telephone_numbers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "type",
+ "label",
+ "telephone_number"
+ ],
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "telephone_number": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "",
+ "telephone",
+ "textphone"
+ ]
+ }
+ }
+ }
+ },
+ "title": {
+ "type": "string"
+ },
+ "video_relay_service": {
+ "type": "object",
+ "properties": {
+ "show": {
+ "type": "boolean",
+ "default": false
+ },
+ "prefix": {
+ "type": "string",
+ "default": "**Default** prefix: 18000 then"
+ },
+ "telephone_number": {
+ "type": "string",
+ "default": "0800 123 4567"
+ }
+ },
+ "x-govspeak_enabled": ["prefix"]
+ },
+ "call_charges": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string",
+ "default": "Find out about call charges"
+ },
+ "call_charges_info_url": {
+ "type": "string",
+ "default": "https://gov.uk/call-charges"
+ },
+ "show_call_charges_info_url": {
+ "type": "boolean",
+ "default": false
+ }
+ }
+ },
+ "bsl_guidance": {
+ "type": "object",
+ "properties": {
+ "show": {
+ "type": "boolean",
+ "default": false
+ },
+ "value": {
+ "type": "string",
+ "default": "British Sign Language (BSL) [video relay service](https://connect.interpreterslive.co.uk/vrs?ilc=DWP)> if you’re on a computer - find out how to [use the service on mobile or tablet](https://www.youtube.com/watch?v=oELNMfAvDxw)"
+ }
+ }
+ },
+ "opening_hours": {
+ "type": "object",
+ "properties": {
+ "opening_hours": {
+ "type": "string"
+ },
+ "show_opening_hours": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "if": {
+ "properties": {
+ "show_opening_hours": {
+ "const": true
+ }
+ }
+ },
+ "then": {
+ "required": [
+ "opening_hours"
+ ]
+ },
+ "else": {
+ "required": []
+ }
+ }
+ }
+ }
+ """
+ And the schema has a subschema "contact_links":
+ """
+ {
+ "type":"object",
+ "required": ["url"],
+ "properties": {
+ "title": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ }
+ }
+ }
+ """
+ And the schema "contact" has a group "contact_methods" with the following subschemas:
+ | email_addresses | telephones | contact_links |
+ And I visit the Content Block Manager home page
+ And I click to create an object
+ And I click on the "contact" schema
+ And I complete the form with the following fields:
+ | title | description | organisation | instructions_to_publishers |
+ | my basic contact | this is basic | Ministry of Example | this is important |
+
+ @javascript
+ Scenario: GDS editor creates a Contact with an email address and a telephone
+ And I click on the "email_addresses" subschema
+ And I complete the "email_address" form with the following fields:
+ | title | label | email_address | subject | body |
+ | Email us | Send an email | foo@example.com | Your ref | Name and address |
+ And I click to add another "contact_method"
+ And I click on the "telephones" subschema
+ And I fill in the "telephone" form with the following fields:
+ | title |
+ | New phone number |
+ And I add the following "telephone_numbers" to the form:
+ | label | telephone_number | type |
+ | Telephone 1 | 12345 | Telephone |
+ | Telephone 2 | 6789 | Textphone |
+ And I indicate that the video relay service info should be displayed
+ And I provide custom video relay service info where available
+ And I indicate that the call charges info URL should be shown
+ And I change the call charges info URL from its default value
+ And I change the call charges info label from its default value
+ And I indicate that BSL guidance should be shown
+ And I change the BSL guidance label from its default value
+ And I indicate that the opening hours should be shown
+ And I input the opening hours
+ And I save and continue
+ And I click to add another "contact_method"
+ And I click on the "contact_links" subschema
+ And I fill in the "contact_link" form with the following fields:
+ | title | label | url | description |
+ | Contact Form | Contact Us | http://example.com | Description |
+ When I save and continue
+ Then I should be on the "add_group_contact_methods" step
+ When I save and continue
+ And I review and confirm my answers are correct
+ Then I should be taken to the confirmation page for a new "contact"
+ When I click to view the content block
+ And I should see the created embedded object of type "email_address"
+ And I should see the created embedded object of type "telephone"
+ And I should see the created embedded object of type "contact_link"
+ When I view all the telephone attributes
+ Then I should see that the call charges fields have been changed
+ And I should see that the video relay service info is to be shown
+ And I should see that the custom video relay info has been recorded
+ And I should see that the BSL guidance fields have been changed
+
+ @javascript
+ Scenario: GDS editor sees errors for invalid telephone objects
+ When I save and continue
+ And I click on the "telephones" subschema
+ When I save and continue
+ Then I should see errors for the required nested "telephone_number" fields
+
+ @javascript
+ Scenario: Telephone number label is automatically populated
+ When I click on the "telephones" subschema
+ And I choose "Textphone" from the type dropdown
+ Then the label should be set to "Textphone"
+
+ Scenario: GDS editor edits answers during creation of an object
+ And I click on the "email_addresses" subschema
+ And I complete the "email_address" form with the following fields:
+ | title | email_address |
+ | New email | foo@example.com |
+ And I save and continue
+ When I click the first edit link
+ And I complete the form with the following fields:
+ | title |
+ | New email 2 |
+ And I save and continue
+ Then I am asked to review my answers
+ And I confirm my answers are correct
+ And I review and confirm my answers are correct
+ And I should be taken to the confirmation page for a new "contact"
diff --git a/features/step_definitions/content_block_manager_steps.rb b/features/step_definitions/content_block_manager_steps.rb
new file mode 100644
index 000000000..42cfe5173
--- /dev/null
+++ b/features/step_definitions/content_block_manager_steps.rb
@@ -0,0 +1,442 @@
+require_relative "../support/stubs"
+require_relative "../support/helpers"
+
+# Suppress noisy Sidekiq logging in the test output
+# Sidekiq.configure_client do |cfg|
+# cfg.logger.level = ::Logger::WARN
+# end
+
+Given("I am in the staging or integration environment") do
+ Whitehall.stubs(:integration_or_staging?).returns(true)
+end
+
+When("I click to create an object") do
+ click_link "Create content block"
+end
+
+When("I click cancel") do
+ click_button "Cancel"
+end
+
+When("I choose to delete the in-progress draft") do
+ click_button "Delete draft"
+end
+
+When("I click to save and come back later") do
+ click_link "Save for later"
+end
+
+When("I click the cancel link") do
+ click_link "Cancel"
+end
+
+Then(/^I click on page ([^"]*)$/) do |page_number|
+ click_link page_number
+end
+
+When("I click to view results") do
+ click_button "View results"
+end
+
+When("I complete the form with the following fields:") do |table|
+ fields = table.hashes.first
+ @title = fields.delete("title")
+ @organisation = fields.delete("organisation")
+ @instructions_to_publishers = fields.delete("instructions_to_publishers")
+ @details = fields
+
+ fill_in "Title", with: @title if @title.present?
+
+ select @organisation, from: "content_block_manager_content_block_edition_lead_organisation" if @organisation.present?
+
+ fill_in "Instructions to publishers", with: @instructions_to_publishers if @instructions_to_publishers.present?
+
+ fields.keys.each do |k|
+ fill_in "content_block_manager_content_block_edition_details_#{k}", with: @details[k]
+ end
+
+ click_save_and_continue
+end
+
+Then("the edition should have been created successfully") do
+ edition = ContentBlockManager::ContentBlock::Edition.all.last
+
+ assert_not_nil edition
+ assert_not_nil edition.document
+
+ assert_equal @title, edition.title if @title.present?
+ assert_equal @instructions_to_publishers, edition.instructions_to_publishers if @instructions_to_publishers.present?
+
+ @details.keys.each do |k|
+ assert_equal edition.details[k], @details[k]
+ end
+end
+
+And("I should be taken to the confirmation page for a published block") do
+ content_block_edition = ContentBlockManager::ContentBlock::Edition.last
+
+ assert_text I18n.t("content_block_edition.confirmation_page.updated.banner", block_type: content_block_edition.document.block_type.humanize)
+ assert_text I18n.t("content_block_edition.confirmation_page.updated.detail")
+
+ expect(page).to have_link(
+ "View content block",
+ href: content_block_manager.content_block_manager_content_block_document_path(
+ content_block_edition.document,
+ ),
+ )
+end
+
+And("I should be taken to the confirmation page for a new {string}") do |block_type|
+ content_block = ContentBlockManager::ContentBlock::Edition.last
+
+ assert_text I18n.t("content_block_edition.confirmation_page.created.banner", block_type: block_type.titlecase)
+ assert_text I18n.t("content_block_edition.confirmation_page.created.detail")
+
+ expect(page).to have_link(
+ "View content block",
+ href: content_block_manager.content_block_manager_content_block_document_path(
+ content_block.document,
+ ),
+ )
+end
+
+When("I click to view the content block") do
+ click_link href: content_block_manager.content_block_manager_content_block_document_path(
+ ContentBlockManager::ContentBlock::Edition.last.document,
+ )
+end
+
+When("I should be taken to the scheduled confirmation page") do
+ content_block_edition = ContentBlockManager::ContentBlock::Edition.last
+
+ assert_text I18n.t(
+ "content_block_edition.confirmation_page.scheduled.banner",
+ block_type: "Pension",
+ date: I18n.l(content_block_edition.scheduled_publication, format: :long_ordinal),
+ ).squish
+ assert_text I18n.t("content_block_edition.confirmation_page.scheduled.detail")
+
+ expect(page).to have_link(
+ "View content block",
+ href: content_block_manager.content_block_manager_content_block_document_path(
+ content_block_edition.document,
+ ),
+ )
+end
+
+Then("I should be taken back to the document page") do
+ expect(page.current_url).to match(content_block_manager.content_block_manager_content_block_document_path(
+ ContentBlockManager::ContentBlock::Edition.last.document,
+ ))
+end
+
+Given("a pension content block has been created") do
+ @content_blocks ||= []
+ organisation = create(:organisation)
+ @content_block = create(
+ :content_block_edition,
+ :pension,
+ details: { description: "Some text" },
+ creator: @user,
+ organisation:,
+ title: "My pension",
+ )
+ ContentBlockManager::ContentBlock::Edition::HasAuditTrail.acting_as(@user) do
+ @content_block.publish!
+ end
+ @content_blocks.push(@content_block)
+end
+
+Given("a contact content block has been created") do
+ @content_blocks ||= []
+ organisation = create(:organisation)
+ @content_block = create(
+ :content_block_edition,
+ :contact,
+ details: { description: "Some text" },
+ creator: @user,
+ organisation:,
+ title: "My contact",
+ )
+ ContentBlockManager::ContentBlock::Edition::HasAuditTrail.acting_as(@user) do
+ @content_block.publish!
+ end
+ @content_blocks.push(@content_block)
+end
+
+Given(/^([^"]*) content blocks of type ([^"]*) have been created with the fields:$/) do |count, block_type, table|
+ fields = table.rows_hash
+ organisation_name = fields.delete("organisation")
+ organisation = Organisation.where(name: organisation_name).first
+ title = fields.delete("title") || "title"
+ instructions_to_publishers = fields.delete("instructions_to_publishers")
+
+ (1..count.to_i).each do |_i|
+ document = create(:content_block_document, block_type.to_sym, sluggable_string: title.parameterize(separator: "_"))
+
+ editions = create_list(
+ :content_block_edition,
+ 3,
+ block_type.to_sym,
+ document:,
+ organisation:,
+ details: fields,
+ creator: @user,
+ instructions_to_publishers:,
+ title:,
+ )
+
+ document.latest_edition = editions.last
+ document.save!
+ end
+end
+
+Then("I am taken back to Content Block Manager home page") do
+ assert_equal current_path, content_block_manager.content_block_manager_root_path
+end
+
+And("no draft Content Block Edition has been created") do
+ assert_equal 0, ContentBlockManager::ContentBlock::Edition.where(state: "draft").count
+end
+
+And("no draft Content Block Document has been created") do
+ assert_equal 0, ContentBlockManager::ContentBlock::Document.count
+end
+
+Then("I should see the details for all documents") do
+ assert_text "Content Block Manager"
+
+ ContentBlockManager::ContentBlock::Document.find_each do |document|
+ should_show_summary_title_for_generic_content_block(
+ document.title,
+ )
+ end
+end
+
+Then("I should see the details for all documents from my organisation") do
+ ContentBlockManager::ContentBlock::Document.with_lead_organisation(@user.organisation.id).each do |document|
+ should_show_summary_title_for_generic_content_block(
+ document.title,
+ )
+ end
+end
+
+Then("I should see the content block with title {string} returned") do |title|
+ expect(page).to have_selector(".govuk-summary-card__title", text: title)
+end
+
+Then("{string} content blocks are returned in total") do |count|
+ assert_text "#{count} #{'result'.pluralize(count.to_i)}"
+end
+
+When("I click to view the document") do
+ @schema = @schemas[@content_block.document.block_type]
+ click_link href: content_block_manager.content_block_manager_content_block_document_path(@content_block.document)
+end
+
+When("I click to view the document with title {string}") do |title|
+ content_block = ContentBlockManager::ContentBlock::Edition.where(title:).first
+
+ click_link href: content_block_manager.content_block_manager_content_block_document_path(content_block.document)
+end
+
+Then("I should see the details for the contact content block") do
+ expect(page).to have_selector("h1", text: @content_block.document.title)
+ should_show_generic_content_block_details(@content_block.document.title, @organisation)
+end
+
+When("I click the first edit link") do
+ click_link "Edit", match: :first
+end
+
+When("I click to edit the {string}") do |block_type|
+ click_link "Edit #{block_type}", match: :first
+end
+
+When("I fill out the form") do
+ change_details(object_type: @content_block.document.block_type)
+end
+
+When("I set all fields to blank") do
+ fill_in "Title", with: ""
+ fill_in "Description", with: ""
+ select "", from: "content_block/edition[organisation_id]"
+ click_save_and_continue
+end
+
+Then("the edition should have been updated successfully") do
+ block_type = @content_block.document.block_type
+
+ case block_type
+ when "pension"
+ should_show_summary_card_for_pension_content_block(
+ "Changed title",
+ "New description",
+ "Ministry of Example",
+ "new context information",
+ )
+ else
+ should_show_summary_card_for_contact_content_block(
+ "Changed title",
+ "changed@example.com",
+ "Ministry of Example",
+ "new context information",
+ )
+ end
+
+ # TODO: this can be removed once the summary list is referring to the Edition's title, not the Document title
+ edition = ContentBlockManager::ContentBlock::Edition.all.last
+ assert_equal "Changed title", edition.title
+end
+
+Then("I am asked to review my answers") do
+ assert_text "Review contact"
+end
+
+Then("I am asked to review my answers for a {string}") do |block_type|
+ assert_text "Review #{block_type}"
+end
+
+Then("I confirm my answers are correct") do
+ check "is_confirmed"
+end
+
+When("I review and confirm my answers are correct") do
+ review_and_confirm
+end
+
+When("I click publish without confirming my details") do
+ click_on "Publish"
+end
+
+When(/^I save and continue$/) do
+ click_save_and_continue
+end
+
+Then(/^I choose to publish the change now$/) do
+ @is_scheduled = false
+ publish_now
+end
+
+Then("I check the block type {string}") do |checkbox_name|
+ check checkbox_name
+end
+
+Then("I select the lead organisation {string}") do |organisation|
+ select organisation, from: "lead_organisation"
+end
+
+When("I make the changes") do
+ change_details
+ click_save_and_continue
+end
+
+When("I am updating a content block") do
+ update_content_block
+ add_internal_note
+ add_change_note
+end
+
+When("one of the content blocks was updated 2 days ago") do
+ content_block_document = ContentBlockManager::ContentBlock::Document.all.last
+ content_block_document.latest_edition.updated_at = 2.days.before(Time.zone.now)
+ content_block_document.latest_edition.save!
+end
+
+Then("the published state of the object should be shown") do
+ visit content_block_manager.content_block_manager_content_block_document_path(@content_block.document)
+ expect(page).to have_selector(".govuk-summary-list__key", text: "Status")
+ expect(page).to have_selector(".govuk-summary-list__value", text: "Published")
+end
+
+Then("I should see the scheduled date on the object") do
+ expect(page).to have_selector(".govuk-summary-list__key", text: "Status")
+ expect(page).to have_selector(".govuk-summary-list__value", text: @future_date.to_fs(:long_ordinal_with_at).squish)
+end
+
+When("I continue after reviewing the links") do
+ click_save_and_continue
+end
+
+When(/^I add a change note$/) do
+ add_change_note
+end
+
+Then(/^I should see the object store's title in the header$/) do
+ expect(page).to have_selector(".govuk-header__product-name", text: "Content Block Manager")
+end
+
+Then(/^I should see the object store's home page title$/) do
+ expect(page).to have_title "Home - GOV.UK Content Block Manager"
+end
+
+And(/^I should see the object store's navigation$/) do
+ expect(page).to have_selector("a.govuk-service-navigation__link[href='#{content_block_manager.content_block_manager_root_path}']", text: "Blocks")
+end
+
+And("I should see the object store's phase banner") do
+ expect(page).to have_selector(".govuk-tag", text: "Beta")
+ expect(page).to have_link("feedback-content-modelling@digital.cabinet-office.gov.uk", href: "mailto:feedback-content-modelling@digital.cabinet-office.gov.uk")
+end
+
+Then(/^I should still see the live edition on the homepage$/) do
+ within(".govuk-summary-card", text: @content_block.document.title) do
+ @content_block.details.keys.each do |key|
+ expect(page).to have_content(@content_block.details[key])
+ end
+ end
+end
+
+Then(/^I should not see the draft document$/) do
+ expect(page).not_to have_content(@title)
+end
+
+Then("I should see the content block manager home page") do
+ expect(page).to have_content("Content Block Manager")
+end
+
+When(/^I add an internal note$/) do
+ add_internal_note
+end
+
+Then(/^I should see a notification that a draft is in progress$/) do
+ expect(page).to have_content("There’s a saved draft of this content block")
+end
+
+Then(/^I should not see a notification that a draft is in progress$/) do
+ expect(page).to_not have_content("There’s a saved draft of this content block")
+end
+
+Then("there should be no draft editions remaining") do
+ expect(@content_block.document.reload.editions.select { |e| e.state == "draft" }.count).to eq(0)
+end
+
+When(/^I click on the link to continue editing$/) do
+ click_on "Continue editing"
+end
+
+And(/^I update the content block and publish$/) do
+ change_details
+ click_save_and_continue
+ add_internal_note
+ add_change_note
+ publish_now
+ review_and_confirm
+end
+
+And(/^I click the back link$/) do
+ click_on "Back"
+end
+
+Given(/^my pension content block has no rates$/) do
+ @content_block.details["rates"] = {}
+ @content_block.save!
+end
+
+When("I choose {string} from the type dropdown") do |type|
+ select type, from: "content_block_manager_content_block_edition_details_telephones_telephone_numbers_0_type"
+end
+
+Then("the label should be set to {string}") do |label|
+ expect(find("#content_block_manager_content_block_edition_details_telephones_telephone_numbers_0_label").value).to eq(label)
+end
diff --git a/features/step_definitions/dependent_content_steps.rb b/features/step_definitions/dependent_content_steps.rb
new file mode 100644
index 000000000..ab1b6c619
--- /dev/null
+++ b/features/step_definitions/dependent_content_steps.rb
@@ -0,0 +1,56 @@
+require_relative "../support/dependent_content"
+require_relative "../support/helpers"
+
+Then(/^I should see the dependent content listed$/) do
+ assert_text "List of locations"
+
+ @dependent_content.each do |item|
+ assert_text item["title"]
+ break if item == @dependent_content.last
+ end
+
+ expect(page).to have_link(@host_content_editor.name, href: content_block_manager.content_block_manager_user_path(@host_content_editor.uid))
+end
+
+Then(/^I (should )?see the rollup data for the dependent content$/) do |_should|
+ should_show_rollup_data
+end
+
+When(/^dependent content exists for a content block$/) do
+ host_editor_id = SecureRandom.uuid
+ @dependent_content = 10.times.map do |i|
+ {
+ "title" => "Content #{i}",
+ "document_type" => "document",
+ "base_path" => "/host-content-path-#{i}",
+ "content_id" => SecureRandom.uuid,
+ "last_edited_by_editor_id" => host_editor_id,
+ "last_edited_at" => 2.days.ago.to_s,
+ "host_content_id" => "abc12345",
+ "host_locale" => "en",
+ "instances" => 1,
+ "primary_publishing_organisation" => {
+ "content_id" => SecureRandom.uuid,
+ "title" => "Organisation #{i}",
+ "base_path" => "/organisation/#{i}",
+ },
+ }
+ end
+
+ @rollup = build(:rollup).to_h
+
+ stub_publishing_api_has_embedded_content_for_any_content_id(
+ results: @dependent_content,
+ total: @dependent_content.length,
+ order: ContentBlockManager::HostContentItem::DEFAULT_ORDER,
+ rollup: @rollup,
+ )
+
+ stub_publishing_api_has_embedded_content_details(@dependent_content.first)
+
+ @host_content_editor = build(:signon_user, uid: host_editor_id)
+
+ stub_request(:get, "#{Plek.find('signon', external: true)}/api/users")
+ .with(query: { uuids: [host_editor_id] })
+ .to_return(body: [@host_content_editor].to_json)
+end
diff --git a/features/step_definitions/embedded_object_steps.rb b/features/step_definitions/embedded_object_steps.rb
new file mode 100644
index 000000000..1afbfaed8
--- /dev/null
+++ b/features/step_definitions/embedded_object_steps.rb
@@ -0,0 +1,192 @@
+require_relative "../support/form_step_helpers"
+require_relative "./video_relay_service_steps"
+
+When("I complete the {string} form with the following fields:") do |object_type, table|
+ fill_in_embedded_object_form(object_type, table)
+
+ click_save_and_continue
+end
+
+And("I fill in the {string} form with the following fields:") do |object_type, table|
+ @object_type = object_type
+ fill_in_embedded_object_form(object_type, table)
+end
+
+And("I add the following {string} to the form:") do |item_type, table|
+ fields = table.hashes
+
+ if item_type == "opening_hours"
+ fields.map! do |item|
+ time_from = item.delete("time_from")
+ time_to = item.delete("time_to")
+
+ item["time_from(h)"] = time_from.split(":")[0]
+ item["time_from(m)"] = time_from.split(":")[1][0..1]
+ item["time_from(meridian)"] = time_from[-2..]
+
+ item["time_to(h)"] = time_to.split(":")[0]
+ item["time_to(m)"] = time_to.split(":")[1][0..1]
+ item["time_to(meridian)"] = time_to[-2..]
+
+ item
+ end
+
+ check "Show opening hours"
+ end
+
+ fields.each do |row|
+ field_prefix = "content_block/edition[details][#{@object_type.pluralize}][#{item_type}][]"
+
+ row.each do |key, value|
+ within all(".js-add-another__fieldset").last do
+ field = page.all(:css, "[name='#{field_prefix}[#{key}]']").last
+ if field.tag_name == "select"
+ select value, from: field[:id]
+ else
+ fill_in field[:id], with: value
+ end
+ end
+ end
+
+ page.driver.with_playwright_page do |page|
+ page.get_by_text("Add another #{item_type.humanize.singularize}").click unless row == fields.last
+ end
+ end
+end
+
+Given("I indicate that the call charges info URL should be shown") do
+ check "Show hyperlink to 'Find out about call charges'"
+end
+
+Given("I change the call charges info URL from its default value") do
+ fill_in("URL to find out about call charges", with: "https://custom.example.com")
+end
+
+Given("I change the call charges info label from its default value") do
+ within(".app-c-content-block-manager-call-charges-component") do
+ fill_in("Label", with: "Learn about the cost of calls (custom label)")
+ end
+end
+
+When("I view all the telephone attributes") do
+ # navigate to "telephones" tab
+ find("#tab_telephones").click
+ # expand the list of telphone details
+ find("span[data-ga4-expandable='']", text: "All telephone attributes").click
+end
+
+Then("I should see that the call charges fields have been changed") do
+ within(".gem-c-summary-card[title='Call Charges']") do
+ expect(page).to have_css("dt", text: "Show call charges info url")
+ expect(page).to have_css("dt", text: "on")
+
+ expect(page).not_to have_content("https://gov.uk/call-charges")
+ expect(page).to have_content("https://custom.example.com")
+
+ expect(page).not_to have_content("Find out about call charges")
+ expect(page).to have_content("Learn about the cost of calls (custom label)")
+ end
+end
+
+When("I indicate that BSL guidance should be shown") do
+ check I18n.t("content_block_edition.details.labels.telephones.bsl_guidance.show")
+end
+
+When("I change the BSL guidance label from its default value") do
+ fill_in(I18n.t("content_block_edition.details.labels.telephones.bsl_guidance.value"), with: "More about BSL")
+end
+
+When("I indicate that the opening hours should be shown") do
+ check I18n.t("content_block_edition.details.labels.telephones.opening_hours.show_opening_hours")
+end
+
+When("I input the opening hours") do
+ fill_in(I18n.t("content_block_edition.details.labels.telephones.opening_hours.opening_hours"), with: "Monday - Friday: 9am-5pm")
+end
+
+Then("I should see that the BSL guidance fields have been changed") do
+ within(".gem-c-summary-card[title='BSL Guidance']") do
+ expect(page).to have_css("dt", text: "Show")
+ expect(page).to have_css("dt", text: "true")
+
+ expect(page).to have_css("dt", text: "Value")
+ expect(page).to have_css("dt", text: "More about BSL")
+ end
+end
+
+Then("I should see errors for the required {string} fields") do |object_type|
+ schema = @schemas.values.first.subschema(object_type.pluralize)
+ required_fields = schema.body["required"]
+ required_fields.each do |required_field|
+ assert_text "#{ContentBlockManager::ContentBlock::Edition.human_attribute_name("details_#{required_field}")} cannot be blank", minimum: 2
+ end
+end
+
+Then("I should see errors for the required nested {string} fields") do |nested_object_name|
+ subschema = @subschemas[@object_type.pluralize]
+ required_fields = subschema.dig("properties", nested_object_name.pluralize, "items", "required")
+ required_fields.each do |required_field|
+ assert_text "#{ContentBlockManager::ContentBlock::Edition.human_attribute_name("details_#{required_field}")} cannot be blank", minimum: 2
+ end
+end
+
+And("I should see details of my {string}") do |object_type|
+ within "div[data-testid='#{object_type.pluralize}_listing']" do
+ @details.keys.each do |k|
+ assert_text @details[k]
+ end
+ end
+end
+
+And("I click to add a new {string}") do |object_type|
+ @object_type = object_type
+ click_on "Add #{add_indefinite_article object_type.humanize.downcase}"
+end
+
+And("I click to add another {string}") do |object_type|
+ @object_type = object_type
+ click_on "Add another #{object_type.humanize.downcase}"
+end
+
+And(/^that pension has a rate with the following fields:$/) do |table|
+ rate = table.hashes.first
+ @content_block.details["rates"] = {
+ rate[:title].parameterize.to_s => {
+ "title" => rate[:title],
+ "amount" => rate[:amount],
+ "frequency" => rate[:frequency],
+ },
+ }
+ @content_block.save!
+end
+
+And(/^I should see the rates for that block$/) do
+ @content_block.details["rates"].keys.each do |k|
+ within "div[data-test-id=embedded_#{k}]" do
+ @content_block.details["rates"][k].each do |_k, value|
+ assert_text value
+ end
+ end
+ end
+end
+
+When(/^I click to edit the first rate$/) do
+ key = @content_block.details["rates"].keys.first
+ within "div[data-test-id=embedded_#{key}]" do
+ click_on "Edit"
+ end
+end
+
+And(/^I should see the updated rates for that block$/) do
+ @details.keys.each do |k|
+ assert_text @details[k]
+ end
+end
+
+And("I should not see a button to add a new {string}") do |object_type|
+ assert_no_text "Add #{add_indefinite_article object_type}"
+end
+
+Then("I should see the created embedded object of type {string}") do |object_type|
+ assert_text "#{object_type.humanize.pluralize} (1)"
+end
diff --git a/features/step_definitions/form_step_steps.rb b/features/step_definitions/form_step_steps.rb
new file mode 100644
index 000000000..fd972dba3
--- /dev/null
+++ b/features/step_definitions/form_step_steps.rb
@@ -0,0 +1,22 @@
+require_relative "../support/form_step_helpers"
+
+Then(/^I should be on the "([^"]*)" step$/) do |step|
+ case step
+ when "edit"
+ should_show_edit_form(object_type: @content_block.document.block_type)
+ when "review_links"
+ should_show_dependent_content(object_type: @content_block.document.block_type)
+ when "schedule_publishing"
+ should_show_publish_form
+ when "review"
+ should_be_on_review_step(object_type: @content_block.document.block_type)
+ when "change_note"
+ should_be_on_change_note_step
+ when /add_#{Workflow::Step::SUBSCHEMA_PREFIX}(.*)/
+ should_be_on_subschema_step(::Regexp.last_match(1), "Add")
+ when /edit_#{Workflow::Step::SUBSCHEMA_PREFIX}(.*)/
+ should_be_on_subschema_step(::Regexp.last_match(1), "Edit")
+ when /add_#{Workflow::Step::GROUP_PREFIX}(.*)/
+ assert_text "Add #{::Regexp.last_match(1).humanize(capitalize: false)}"
+ end
+end
diff --git a/features/step_definitions/navigation_steps.rb b/features/step_definitions/navigation_steps.rb
new file mode 100644
index 000000000..12faa86c5
--- /dev/null
+++ b/features/step_definitions/navigation_steps.rb
@@ -0,0 +1,17 @@
+When("I visit the Content Block Manager home page") do
+ visit content_block_manager.content_block_manager_root_path
+end
+
+When("I visit a block's content ID endpoint") do
+ block = ContentBlockManager::ContentBlock::Document.last
+ visit content_block_manager.content_block_manager_content_block_content_id_path(block.content_id)
+end
+
+When("I revisit the edit page") do
+ @content_block = @content_block.document.latest_edition
+ visit_edit_page
+end
+
+def content_block_manager
+ Rails.application.routes.url_helpers
+end
diff --git a/features/step_definitions/organisation_steps.rb b/features/step_definitions/organisation_steps.rb
new file mode 100644
index 000000000..ce4ec82d5
--- /dev/null
+++ b/features/step_definitions/organisation_steps.rb
@@ -0,0 +1,3 @@
+Given(/^the organisation "([^"]*)" exists$/) do |name|
+ create_org_and_stub_content_store(:ministerial_department, name:)
+end
diff --git a/features/step_definitions/schema_steps.rb b/features/step_definitions/schema_steps.rb
new file mode 100644
index 000000000..558687483
--- /dev/null
+++ b/features/step_definitions/schema_steps.rb
@@ -0,0 +1,56 @@
+When("I click on the {string} schema") do |schema_id|
+ @schema = @schemas[schema_id]
+ ContentBlockManager::ContentBlock::Schema.expects(:find_by_block_type).with(schema_id).at_least_once.returns(@schema)
+ choose @schema.name
+ click_save_and_continue
+end
+
+When("I click on the {string} subschema") do |schema_id|
+ schema = @schemas.values.last
+ subschema = schema.subschema(schema_id)
+ choose subschema.name.singularize
+ @object_type = schema_id
+ click_save_and_continue
+end
+
+Then("I should see a form for the schema") do
+ expect(page).to have_text("Create #{@schema.name.downcase}")
+end
+
+Then("I should see all the schemas listed") do
+ @schemas.values.each do |schema|
+ expect(page).to have_content(schema.name)
+ end
+end
+
+And("the schema {string} has a group {string} with the following subschemas:") do |block_type, group, table|
+ subschemas = table.raw.first
+ schema = @schemas[block_type]
+
+ subschemas.each do |subschema_id|
+ subschema = schema.subschema(subschema_id)
+ subschema.stubs(:group).returns(group)
+ end
+end
+
+And("a schema {string} exists:") do |block_type, json|
+ @schemas ||= {}
+ body = JSON.parse(json)
+ @schema = build(:content_block_schema, block_type:, body:)
+ @schemas[block_type] = @schema
+ ContentBlockManager::ContentBlock::Schema.stubs(:all).returns(@schemas.values)
+end
+
+And("the schema has a subschema {string}:") do |subschema_name, json|
+ @subschemas ||= {}
+ @subschemas[subschema_name] = JSON.parse(json)
+ @schema.body["properties"][subschema_name] = {
+ "type" => "object",
+ "patternProperties" => {
+ "^[a-z0-9]+(?:-[a-z0-9]+)*$" => @subschemas[subschema_name],
+ },
+ }
+ @schema = build(:content_block_schema, block_type: @schema.block_type, body: @schema.body)
+ @schemas[@schema.block_type] = @schema
+ ContentBlockManager::ContentBlock::Schema.stubs(:all).returns(@schemas.values)
+end
diff --git a/features/step_definitions/session_steps.rb b/features/step_definitions/session_steps.rb
new file mode 100644
index 000000000..5195de801
--- /dev/null
+++ b/features/step_definitions/session_steps.rb
@@ -0,0 +1,48 @@
+Given(/^I am (?:a|an) (writer|editor|admin|GDS editor|GDS admin|importer|managing editor)(?: called "([^"]*)")?$/) do |role, name|
+ binding.pry
+ @user = case role
+ when "writer"
+ create(:writer, name: name || "Wally Writer")
+ when "editor"
+ create(:departmental_editor, name: name || "Eddie Depteditor")
+ when "admin"
+ create(:user)
+ when "GDS editor"
+ create(:gds_editor)
+ when "GDS admin"
+ create(:gds_admin)
+ when "importer"
+ create(:importer)
+ when "managing editor"
+ create(:managing_editor)
+ end
+ @user.save!
+ login_as @user
+end
+
+Given(/^I am (?:an?) (admin|writer|editor|GDS editor) in the organisation "([^"]*)"$/) do |role, organisation_name|
+ organisation = Organisation.find_by(name: organisation_name) || create_org_and_stub_content_store(:ministerial_department, name: organisation_name)
+ @user = case role
+ when "admin"
+ create(:user, organisation:)
+ when "writer"
+ create(:writer, name: "Wally Writer", organisation:)
+ when "editor"
+ create(:departmental_editor, name: "Eddie Depteditor", organisation:)
+ when "GDS editor"
+ create(:gds_editor, organisation:)
+ end
+ login_as @user
+end
+
+Given(/^I have the "(.*?)" permission$/) do |perm|
+ @user.permissions << perm
+ @user.save!
+end
+
+Around("@use_real_sso") do |_scenario, block|
+ current_sso_env = ENV["GDS_SSO_MOCK_INVALID"]
+ ENV["GDS_SSO_MOCK_INVALID"] = "1"
+ block.call
+ ENV["GDS_SSO_MOCK_INVALID"] = current_sso_env
+end
diff --git a/features/step_definitions/user_steps.rb b/features/step_definitions/user_steps.rb
new file mode 100644
index 000000000..a95776a64
--- /dev/null
+++ b/features/step_definitions/user_steps.rb
@@ -0,0 +1,24 @@
+Given("A user exists with uuid {string}") do |uuid|
+ @user_from_signon = build(
+ :signon_user,
+ uid: uuid,
+ name: "John Doe",
+ email: "john@doe.com",
+ organisation: build(:signon_user_organisation, content_id: "456", name: "User's Org", slug: "users-org"),
+ )
+
+ stub_request(:get, "#{Plek.find('signon', external: true)}/api/users")
+ .with(query: { uuids: [uuid] })
+ .to_return(body: [@user_from_signon].to_json)
+end
+
+When("I visit the user page for uuid {string}") do |uuid|
+ visit content_block_manager_user_path(uuid)
+end
+
+Then("I should see the details for that user") do
+ expect(page).to have_selector("h1", text: @user_from_signon.name)
+ expect(page).to have_selector(".govuk-summary-list__value", text: @user_from_signon.name)
+ expect(page).to have_selector(".govuk-summary-list__value", text: @user_from_signon.email)
+ expect(page).to have_selector(".govuk-summary-list__value", text: @user_from_signon.organisation.name)
+end
diff --git a/features/step_definitions/video_relay_service_steps.rb b/features/step_definitions/video_relay_service_steps.rb
new file mode 100644
index 000000000..2c35963ec
--- /dev/null
+++ b/features/step_definitions/video_relay_service_steps.rb
@@ -0,0 +1,39 @@
+Given("I indicate that the video relay service info should be displayed") do
+ check label_for("show")
+end
+
+Given("I provide custom video relay service info where available") do
+ within(".app-c-content-block-manager-video-relay-service-component") do
+ fill_in(label_for("prefix"), with: "**Custom** prefix: 12345 then")
+ should_be_able_to_preview_the_govspeak_enabled_field
+ fill_in(label_for("telephone_number"), with: "01777 123 1234")
+ end
+end
+
+When("I should see that the video relay service info is to be shown") do
+ within(".gem-c-summary-card[title='Video Relay Service']") do
+ expect(page).to have_css("dt", text: "Show")
+ expect(page).to have_css("dt", text: "true")
+ end
+end
+
+When("I should see that the custom video relay info has been recorded") do
+ within(".gem-c-summary-card[title='Video Relay Service']") do
+ expect(page).to have_content("01777 123 1234")
+ expect(page).to have_content("**Custom** prefix: 12345 then")
+ end
+end
+
+def label_for(field_name)
+ I18n.t("content_block_edition.details.labels.telephones.video_relay_service.#{field_name}")
+end
+
+def should_be_able_to_preview_the_govspeak_enabled_field
+ click_button("Preview")
+ preliminary_preview_text = page.find(".app-c-govspeak-editor__preview p").text
+
+ assert_equal(
+ "Generating preview, please wait.",
+ preliminary_preview_text,
+ )
+end
diff --git a/features/support/capybara.rb b/features/support/capybara.rb
new file mode 100644
index 000000000..ece8f9c42
--- /dev/null
+++ b/features/support/capybara.rb
@@ -0,0 +1,36 @@
+# On the CI box we have seen intermittent failures.
+# We think this may be due to timeouts (the default is 2 secs),
+# so we've increased the default timeout.
+Capybara.default_max_wait_time = 5
+
+# Allow Capybara to click a