diff --git a/app/assets/stylesheets/koi/base/_tables.scss b/app/assets/stylesheets/koi/base/_tables.scss
index 87289f31f..527d3460e 100644
--- a/app/assets/stylesheets/koi/base/_tables.scss
+++ b/app/assets/stylesheets/koi/base/_tables.scss
@@ -1 +1,15 @@
@use "katalyst/tables";
+
+:where(th.type-enum, td.type-enum) {
+ width: var(--width-small);
+}
+
+:where(td.type-enum span) {
+ --background-color: var(--site-primary-light);
+ --color: var(--site-on-primary);
+ background: var(--background-color);
+ color: var(--color);
+ border-radius: 0.25rem;
+ font-size: var(--paragraph--small);
+ padding: 0.25rem 0.5rem;
+}
diff --git a/app/components/koi/tables/cells/enum_component.rb b/app/components/koi/tables/cells/enum_component.rb
new file mode 100644
index 000000000..07fcdc0c9
--- /dev/null
+++ b/app/components/koi/tables/cells/enum_component.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Koi
+ module Tables
+ module Cells
+ # Displays an enum value using data inferred from the model.
+ class EnumComponent < Katalyst::Tables::CellComponent
+ def rendered_value
+ if (value = self.value).present?
+ label = t(i18n_enum_label_key(value), default: value)
+ tag.span(label, data: { enum: column, value: })
+ end
+ end
+
+ private
+
+ def default_html_attributes
+ { class: "type-enum" }
+ end
+
+ def i18n_enum_label_key(value)
+ "active_record.attributes.#{collection.model_name.i18n_key}/#{column}.#{value}"
+ end
+ end
+ end
+ end
+end
diff --git a/app/components/koi/tables/table_component.rb b/app/components/koi/tables/table_component.rb
index 2338f841e..e34f0c066 100644
--- a/app/components/koi/tables/table_component.rb
+++ b/app/components/koi/tables/table_component.rb
@@ -31,6 +31,34 @@ def link(column, label: nil, heading: false, url: [:admin, record], link: {}, **
), &)
end
+ # Generates a column from an enum value rendered as a tag.
+ # The target attribute must be defined as an `enum` in the model.
+ #
+ # @param column [Symbol] the column's name, called as a method on the record.
+ # @param label [String|nil] the label to use for the column header
+ # @param heading [boolean] if true, data cells will use `th` tags
+ # @param ** [Hash] HTML attributes to be added to column cells
+ # @param & [Proc] optional block to wrap the cell content
+ #
+ # When rendering an enum value, the component will check for translations
+ # using the key `active_record.attributes.[model]/[column].[value]`,
+ # e.g. `active_record.attributes.banner/status.published`.
+ #
+ # If a block is provided, it will be called with the cell component as an argument.
+ # @yieldparam cell [Katalyst::Tables::CellComponent] the cell component
+ #
+ # @return [void]
+ #
+ # @example Render a generic text column for any value that supports `to_s`
+ # <% row.enum :status %>
+ # <%# label =>
Status | %>
+ # <%# data => Published | %>
+ def enum(column, label: nil, heading: false, **, &)
+ with_cell(Tables::Cells::EnumComponent.new(
+ collection:, row:, column:, record:, label:, heading:, **,
+ ), &)
+ end
+
# Generates a column that renders an ActiveStorage attachment as a downloadable link.
#
# @param column [Symbol] the column's name, called as a method on the record
diff --git a/lib/tasks/dummy.thor b/lib/tasks/dummy.thor
index 15453de61..a2bf10b3b 100644
--- a/lib/tasks/dummy.thor
+++ b/lib/tasks/dummy.thor
@@ -93,18 +93,12 @@ class Dummy < Thor
inside("spec/dummy") do
run <<~SH
rails g koi:admin Post name:string title:string content:rich_text active:boolean published_on:date
- rails g koi:admin Banner name:string image:attachment ordinal:integer
+ rails g koi:admin Banner name:string image:attachment ordinal:integer status:integer
SH
run "rails db:migrate"
end
- gsub_file("app/models/banner.rb", "has_one_attached :image\n", <<~RUBY)
- has_one_attached :image do |image|
- image.variant :thumb, resize_to_fill: [100, 100]
- end
- RUBY
-
gsub_file("config/routes/admin.rb", "resources :banners\n", <<~RUBY)
resources :banners do
patch :order, on: :collection
diff --git a/spec/components/koi/tables/cells/enum_component_spec.rb b/spec/components/koi/tables/cells/enum_component_spec.rb
new file mode 100644
index 000000000..b436de788
--- /dev/null
+++ b/spec/components/koi/tables/cells/enum_component_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Koi::Tables::Cells::EnumComponent do
+ let(:table) { Koi::Tables::TableComponent.new(collection:) }
+ let(:collection) { create_list(:banner, 1, status: "published") }
+ let(:rendered) { render_inline(table) { |row, _post| row.enum(:status) } }
+ let(:label) { rendered.at_css("thead th") }
+ let(:data) { rendered.at_css("tbody td") }
+
+ it "renders column header" do
+ expect(label).to match_html(<<~HTML)
+ Status |
+ HTML
+ end
+
+ it "renders column data" do
+ expect(data).to match_html(<<~HTML)
+ Published |
+ HTML
+ end
+
+ context "with html_options" do
+ let(:rendered) { render_inline(table) { |row| row.enum(:status, **Test::HTML_ATTRIBUTES) } }
+
+ it "renders header with html_options" do
+ expect(label).to match_html(<<~HTML)
+ Status |
+ HTML
+ end
+
+ it "renders data with html_options" do
+ expect(data).to match_html(<<~HTML)
+ Published |
+ HTML
+ end
+ end
+
+ context "when given a label" do
+ let(:rendered) { render_inline(table) { |row| row.enum(:status, label: "LABEL") } }
+
+ it "renders header with label" do
+ expect(label).to match_html(<<~HTML)
+ LABEL |
+ HTML
+ end
+
+ it "renders data without label" do
+ expect(data).to match_html(<<~HTML)
+ Published |
+ HTML
+ end
+ end
+
+ context "when given an empty label" do
+ let(:rendered) { render_inline(table) { |row| row.enum(:status, label: "") } }
+
+ it "renders header with an empty label" do
+ expect(label).to match_html(<<~HTML)
+ |
+ HTML
+ end
+ end
+
+ context "with nil data value" do
+ let(:rendered) { render_inline(table) { |row| row.enum(:status) } }
+ let(:collection) { create_list(:banner, 1, status: nil) }
+
+ it "renders an empty cell" do
+ expect(data).to match_html(<<~HTML)
+ |
+ HTML
+ end
+ end
+
+ context "when given a block" do
+ let(:rendered) { render_inline(table) { |row| row.enum(:status) { |cell| cell.tag.span(cell) } } }
+
+ it "renders the default header" do
+ expect(label).to match_html(<<~HTML)
+ Status |
+ HTML
+ end
+
+ it "renders the custom data" do
+ expect(data).to match_html(<<~HTML)
+ Published |
+ HTML
+ end
+ end
+end
diff --git a/spec/templates/app/models/banner.rb b/spec/templates/app/models/banner.rb
new file mode 100644
index 000000000..207d73bd4
--- /dev/null
+++ b/spec/templates/app/models/banner.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Banner < ApplicationRecord
+ enum :status, { draft: 0, published: 1, archived: 2 }, default: :draft
+
+ has_one_attached :image do |image|
+ image.variant :thumb, resize_to_fill: [100, 100]
+ end
+
+ scope :admin_search, ->(query) do
+ where("name LIKE :query", query: "%#{query}%")
+ end
+
+ default_scope -> { order(ordinal: :asc) }
+end
diff --git a/spec/templates/config/locales/en.yml b/spec/templates/config/locales/en.yml
new file mode 100644
index 000000000..17ca92378
--- /dev/null
+++ b/spec/templates/config/locales/en.yml
@@ -0,0 +1,7 @@
+en:
+ active_record:
+ attributes:
+ banner/status:
+ draft: Draft
+ published: Published
+ archived: Archived
diff --git a/spec/templates/spec/factories/banners.rb b/spec/templates/spec/factories/banners.rb
index aab1dc8c5..371028d71 100644
--- a/spec/templates/spec/factories/banners.rb
+++ b/spec/templates/spec/factories/banners.rb
@@ -4,6 +4,7 @@
factory :banner do
name { Faker::Kpop.solo }
sequence(:ordinal)
+ status { %i[draft published archived].sample }
trait :with_image do
image { Rack::Test::UploadedFile.new(Rails.root.join("../fixtures/images/dummy.png"), "image/png") }