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") }