Custom items

Add your own item types by subclassing Katalyst::Content::Item and registering the type in an initializer:

# config/initializers/katalyst_content.rb
Katalyst::Content.configure do |config|
  config.items = %w[
    Katalyst::Content::Section
    Katalyst::Content::Content
    Content::Spacer
  ]
end

Provide the two partials for the new type — frontend and editor form:

<%# app/views/content/spacers/_spacer.html.erb (frontend) %>
<%= content_item_tag(spacer) do %>
  ...
<% end %>
<%# app/views/content/spacers/_spacer.html+form.erb (editor form) %>
<%= form.content_heading_fieldset %>
<%= form.content_theme_field %>

The editor’s form builder provides fields for the shared item attributes — content_heading_fieldset, content_theme_field, content_url_field, and friends — and includes the GOV.UK form builder, so govuk_text_field and the other govuk_* helpers are available for your own attributes.

What goes in the model depends on how much data the item needs.

Simple items

Most item types need nothing beyond the shared STI columns — heading, heading_style, theme, visible. Type-specific presentation settings can be added without migrations using style_attributes, which maps ActiveModel attributes into the shared style JSON column, complete with accessors and validation:

module Content
  class Spacer < Katalyst::Content::Item
    HEIGHTS = %w[small medium large].freeze

    style_attributes do
      attribute :height, :string
    end

    validates :height, presence: true, inclusion: { in: HEIGHTS }

    def self.permitted_params
      super + %i[height]
    end
  end
end

Media items

Items can carry files and formatted text using standard Rails features — Active Storage attachments and Action Text rich text:

module Content
  class ImageBanner < Katalyst::Content::Item
    has_one_attached :image
    has_rich_text :subtitle

    validates :image,
              presence:     true,
              content_type: config.image_mime_types,
              size:         { less_than: config.max_image_size.megabytes }

    default_scope { with_attached_image }

    def self.permitted_params
      super + %i[image subtitle]
    end
  end
end

The gem provides everything these need. In particular, editing published content is copy-on-write — draft changes duplicate items rather than editing them in place — and attachments and rich text are duplicated automatically, including changes an editor has submitted but not yet published: pending uploads, cleared attachment fields, and attachments marked for destruction via nested attributes.

Complex items

When an item needs structured data that doesn’t fit the shared table or the style JSON — typed columns, foreign keys, its own validations — store it in a separate table accessed via a has_one association:

module Content
  class DonationForm < Katalyst::Content::Item
    has_one :donation_form_detail, as: :donation_formable, autosave: true, dependent: :destroy
    accepts_nested_attributes_for :donation_form_detail, update_only: true
    duplicates_association :donation_form_detail

    def self.permitted_params
      super + [DonationFormDetail.permitted_params]
    end
  end
end

Duplication

Complex items take on a responsibility that simple and media items get for free. Copy-on-write editing duplicates items with dup, and the gem cannot know about your detail table — without help, the duplicate would silently lose its detail record the next time an editor changes the item. The duplicates_association declaration closes that gap: declared records are copied with dup, carrying unsaved nested-attribute edits and dropping records marked for destruction. has_many associations are supported the same way.

Items do not need to define initialize_dup unless they copy state that is not an attachment, rich text, or a declared association. Only dup is supported; clone is not.

The Koi/DuplicatesAssociation cop in rubocop-katalyst detects owned associations (dependent: :destroy plus autosave or nested attributes) that are missing a duplicates_association declaration.

Testing

Attachment and rich-text duplication are library features with their own test coverage — you don’t need to re-test them per model. Detail records are project code, so give each an integration-level #dup spec, and consider a whole-of-dup test that duplicates a fully populated item and saves the copy — a broken dup silently loses content on the user’s next edit.

describe "#dup" do
  subject(:form) { create(:content_donation_form, container: page) }

  it "copies the detail record" do
    expect(form.dup.donation_form_detail).to be_new_record
      .and have_attributes(form.donation_form_detail.attributes.except("id", "created_at", "updated_at"))
  end

  it "produces a complete, saveable copy" do
    copy = form.dup
    copy.save!
    expect(copy.donation_form_detail).to be_persisted
  end
end

Duplication outside items

Any model that duplicates copy-on-write can reuse the gem’s duplication behaviour, either by including the concerns (Katalyst::Content::DuplicatesAttachments, DuplicatesRichText, DuplicatesAssociations) or by calling the helper classes directly:

def initialize_dup(source)
  super

  Katalyst::Content::DuplicatesAttachments::DupOne.new(:image).apply(source, self)
  Katalyst::Content::DuplicatesRichText::DupRichText.new(:description).apply(source, self)
end

This site uses Just the Docs, a documentation theme for Jekyll.