Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dd235b8
Add ADR-0011: Experiment with traditional ActiveRecord models
edavey Mar 11, 2026
025a0f8
Add Cucumber feature for new-style Block::TimePeriod
edavey Mar 25, 2026
57bbab1
Create block_documents table
edavey Mar 25, 2026
e8bf135
Create block_editions table with STI support
edavey Mar 11, 2026
f828d83
Create block_time_period_date_ranges table
edavey Mar 11, 2026
e14e0f2
Add Block::Document model
edavey Mar 11, 2026
a1cedee
Add Block::Edition STI base model
edavey Mar 12, 2026
7ef343c
Add Block::TimePeriodEdition STI subclass
edavey Mar 12, 2026
c5d114e
Add Block::TimePeriodDateRange model
edavey Mar 12, 2026
e643c67
Add #date_range assoc & #details to TimePeriodEdition
edavey Mar 12, 2026
34ad801
Add type-specific edition associations to Block::Document
edavey Mar 25, 2026
77ad71e
Add TimePeriodEditionsController
edavey Mar 25, 2026
4ffa2fc
Add validations and callbacks to Block::Edition
edavey Mar 30, 2026
51e57ed
Implement TimePeriodEditionsController
edavey Mar 30, 2026
50d8e42
Fix lead_organisation_id to use UUID column type
edavey Mar 25, 2026
7745048
Add TimePeriodDateRangesController
edavey Mar 25, 2026
97e9663
Implement TimePeriodDateRangesController
edavey Mar 25, 2026
eb60845
Wire up two-step workflow: redirect to date range form
edavey Mar 25, 2026
76e958c
Complete the create block journey
edavey Mar 25, 2026
479efb8
Introduce 'schemaless_experience' flag to reveal experimental nav/ctas
edavey Mar 18, 2026
addcb68
Add nav elements for 'schemaless experiment'
edavey Mar 18, 2026
8af4cd2
Add date validation for TimePeriodDateRange start/end fields
edavey Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/controllers/block/documents_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Block
class DocumentsController < ApplicationController
def index
@editions = Block::Document.all.map { |document| document.editions.last }
end
end
end
34 changes: 34 additions & 0 deletions app/controllers/block/time_period_date_ranges_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Block
class TimePeriodDateRangesController < ApplicationController
before_action :set_document
before_action :set_edition

def show; end

def edit; end

def update
if @edition.update(edition_params)
redirect_to block_time_period_edition_path(@edition)
else
render :edit, status: :unprocessable_entity
end
end

private

def set_document
@document = Block::Document.find(params[:document_id])
end

def set_edition
@edition = @document.time_period_editions.find(params[:id])
end

def edition_params
params.require(:edition).permit(
date_range_attributes: %i[id start end],
)
end
end
end
51 changes: 51 additions & 0 deletions app/controllers/block/time_period_editions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module Block
class TimePeriodEditionsController < ApplicationController
before_action :set_edition, only: %i[show edit update]
before_action :set_organisations, only: %i[new create]

def new
@edition = Block::TimePeriodEdition.new
@edition.build_document(block_type: "time_period")
end

def create
@edition = Block::TimePeriodEdition.new(edition_params)
@edition.build_document(block_type: "time_period") unless @edition.document

if @edition.save
redirect_to edit_block_document_time_period_date_range_path(
@edition.document,
@edition,
)
else
render :new, status: :unprocessable_entity
end
end

def show; end

def edit; end

def update; end

private

def set_edition
@edition = Block::TimePeriodEdition.find(params[:id])
end

def set_organisations
@organisations = [{ text: "", value: "" }] +
Organisation.all.map { |org| { text: org.name, value: org.id } }
end

def edition_params
params.require(:edition).permit(
:title,
:description,
:instructions_to_publishers,
:lead_organisation_id,
)
end
end
end
47 changes: 47 additions & 0 deletions app/models/block/document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Block
class Document < ApplicationRecord
self.table_name = "block_documents"

extend FriendlyId
friendly_id :sluggable_string, use: :slugged, slug_column: :content_id_alias, routes: :default

has_many :editions,
class_name: "Block::Edition",
foreign_key: :block_document_id,
dependent: :destroy,
inverse_of: :document

has_many :time_period_editions,
-> { where(type: "Block::TimePeriodEdition") },
class_name: "Block::TimePeriodEdition",
foreign_key: :block_document_id,
dependent: :destroy,
inverse_of: :document

before_validation :generate_content_id, on: :create
after_validation :set_content_id_alias_and_embed_code, on: :create

def title
editions.order(created_at: :desc).first&.title
end

def built_embed_code
"{{embed:content_block_#{block_type}:#{content_id_alias}}}"
end

def embed_code_for_field(field_path)
"{{embed:content_block_#{block_type}:#{content_id_alias}/#{field_path}}}"
end

private

def generate_content_id
self.content_id ||= SecureRandom.uuid
end

def set_content_id_alias_and_embed_code
self.content_id_alias = friendly_id
self.embed_code = built_embed_code
end
end
end
42 changes: 42 additions & 0 deletions app/models/block/edition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
module Block
class Edition < ApplicationRecord
self.table_name = "block_editions"

include ::Edition::HasLeadOrganisation

belongs_to :document, class_name: "Block::Document", foreign_key: :block_document_id, inverse_of: :editions

validates :title, presence: true
validate :title_contains_alphanumeric_chars

before_validation :set_document_sluggable_string, on: :create

# Abstract method to be implemented by subclasses
# Returns a hash representation of the edition's details
def details
raise NotImplementedError, "Subclasses must implement #details method"
end

def state
:draft
end

def creator
User.first
end

private

def set_document_sluggable_string
return unless document.present? && document.new_record?

document.sluggable_string = title if document.sluggable_string.blank?
end

def title_contains_alphanumeric_chars
if title.present? && title !~ /[a-z0-9]+/i
errors.add(:title, :alphanumeric)
end
end
end
end
8 changes: 8 additions & 0 deletions app/models/block/other_edition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Block
# Stub edition class for testing STI scoping behavior
class OtherEdition < Edition
def details
{ type: "other" }
end
end
end
37 changes: 37 additions & 0 deletions app/models/block/time_period_date_range.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module Block
class TimePeriodDateRange < ApplicationRecord
self.table_name = "block_time_period_date_ranges"

include DateValidation
date_attributes :start, :end

belongs_to :edition, class_name: "Block::TimePeriodEdition"

validates :start, presence: true
validates :end, presence: true
validate :end_date_after_start_date

def to_details
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder if we should be consistent with other publishing apps and use presenters to present information we send to the Publishing API - example here https://github.com/alphagov/whitehall/blob/main/app/presenters/publishing_api/case_study_presenter.rb

{
"start" => {
"date" => start.strftime("%Y-%m-%d"),
"time" => start.strftime("%H:%M"),
},
"end" => {
"date" => self.end.strftime("%Y-%m-%d"),
"time" => self.end.strftime("%H:%M"),
},
}
end

private

def end_date_after_start_date
return if self.end.blank? || start.blank?

if self.end <= start
errors.add(:end, "must be after start date")
end
end
end
end
14 changes: 14 additions & 0 deletions app/models/block/time_period_edition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Block
class TimePeriodEdition < Edition
has_one :date_range, class_name: "Block::TimePeriodDateRange", foreign_key: :edition_id, dependent: :destroy

accepts_nested_attributes_for :date_range

def details
{
"description" => description,
"date_range" => date_range&.to_details,
}.compact
end
end
end
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def role
module Permissions
SIGNIN = "signin".freeze
PRE_RELEASE_FEATURES_PERMISSION = "pre_release_features".freeze
SCHEMALESS_EXPERIMENT_PERMISSION = "schemaless_experiment".freeze
SHOW_ALL_CONTENT_BLOCK_TYPES = "show_all_content_block_types".freeze
end

Expand Down
7 changes: 7 additions & 0 deletions app/public/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ def pre_release_features?
false
end

def schemaless_experiment_enabled?
return true if current_user&.has_permission?(User::Permissions::SCHEMALESS_EXPERIMENT_PERMISSION)
return true if params[:schemaless_experiment] == "true"

false
end

def get_content_id(edition)
return if edition.nil?

Expand Down
8 changes: 7 additions & 1 deletion app/public/helpers/header_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ def main_nav_item(name, path)
}
end

def navigation_items(current_user)
def navigation_items(current_user, schemaless_experiment_enabled: false)
return [] if current_user.nil?

if schemaless_experiment_enabled
return [
main_nav_item("New-style blocks", block_documents_path),
]
end

[
main_nav_item("Blocks", root_path),
{
Expand Down
42 changes: 42 additions & 0 deletions app/views/block/documents/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<% content_for :page_title, "New-style blocks" %>

<div class="govuk-grid-row content-block-manager-header">
<div class="govuk-grid-column-one-half">
<h1 class="govuk-heading-xl content-block-manager-header--heading">
New-style blocks
</h1>
</div>
</div>

<div class="govuk-grid-row">
<div class="govuk-grid-column-two-thirds">
<% @editions.each_with_index do |edition, index| %>
<div data-testid="homepage-item-<%= index %>">
<%= render "govuk_publishing_components/components/summary_card", {
title: edition.title,
rows: [
{
key: "Time period name",
value: edition.title,
},
{
key: "Lead organisation",
value: edition.lead_organisation.name,
},
{
key: "Status",
value: render(Shared::DocumentFullStatusComponent.new(edition: edition)),
},

],
summary_card_actions: [
{
label: "View",
href: block_time_period_edition_path(edition),
},
],
} %>
</div>
<% end %>
</div>
</div>
Loading
Loading