From aecb45baab303b6458edd403995031fa72ffcc8c Mon Sep 17 00:00:00 2001 From: Alan Cornthwaite Date: Mon, 13 May 2024 16:46:14 +0930 Subject: [PATCH] Add koi specific custom typed cells --- .../koi/tables/cells/attachment_component.rb | 60 ++++++++++++ .../koi/tables/cells/link_component.rb | 43 +++++++++ app/components/koi/tables/table_component.rb | 52 ++++++++++- .../tables/cells/attachment_component_spec.rb | 83 +++++++++++++++++ .../koi/tables/cells/link_component_spec.rb | 92 +++++++++++++++++++ spec/support/frontend_examples.rb | 14 +++ spec/support/match_html.rb | 6 +- spec/views/admin/posts/index.html.erb_spec.rb | 5 +- 8 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 app/components/koi/tables/cells/attachment_component.rb create mode 100644 app/components/koi/tables/cells/link_component.rb create mode 100644 spec/components/koi/tables/cells/attachment_component_spec.rb create mode 100644 spec/components/koi/tables/cells/link_component_spec.rb create mode 100644 spec/support/frontend_examples.rb diff --git a/app/components/koi/tables/cells/attachment_component.rb b/app/components/koi/tables/cells/attachment_component.rb new file mode 100644 index 000000000..c9e6fa007 --- /dev/null +++ b/app/components/koi/tables/cells/attachment_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Koi + module Tables + module Cells + # Shows an attachment + # + # The value is expected to be an ActiveStorage attachment + # + # If it is representable, shows as a image tag using the specified variant. + # + # Otherwise shows as a link to download. + class AttachmentComponent < Katalyst::Tables::CellComponent + def initialize(variant:, **) + super(**) + + @variant = variant + end + + def rendered_value + representation + end + + def representation + if value.try(:variable?) && named_variant.present? + image_tag(value.variant(@variant)) + elsif value.try(:attached?) + filename.to_s + else + "" + end + end + + def filename + value.blob.filename + end + + # Utility for accessing the path Rails provides for retrieving the + # attachment for use in cells. Example: + # <% row.attachment :file do |cell| %> + # <%= link_to "Download", cell.internal_path %> + # <% end %> + def internal_path + rails_blob_path(value, disposition: :attachment) + end + + private + + def default_html_attributes + { class: "type-attachment" } + end + + # Find the reflective variant by name (i.e. :thumb by default) + def named_variant + record.attachment_reflections[@column.to_s].named_variants[@variant.to_sym] + end + end + end + end +end diff --git a/app/components/koi/tables/cells/link_component.rb b/app/components/koi/tables/cells/link_component.rb new file mode 100644 index 000000000..b1d2eda45 --- /dev/null +++ b/app/components/koi/tables/cells/link_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Koi + module Tables + module Cells + # Displays a link to the record + # The link text is the value of the attribute + class LinkComponent < Katalyst::Tables::CellComponent + define_html_attribute_methods :link_attributes + + def initialize(url:, link:, **) + super(**) + + @url = url + + self.link_attributes = link + end + + def rendered_value + link_to(value, url, **link_attributes) + end + + def url + case @url + when Symbol + # helpers are not available until the component is rendered + @url = helpers.public_send(@url, record) + when Proc + @url = @url.call(record) + else + @url + end + end + + private + + def default_html_attributes + { class: "type-link" } + end + end + end + end +end diff --git a/app/components/koi/tables/table_component.rb b/app/components/koi/tables/table_component.rb index 62a865d59..b742560c7 100644 --- a/app/components/koi/tables/table_component.rb +++ b/app/components/koi/tables/table_component.rb @@ -2,9 +2,57 @@ module Koi module Tables - # Custom table component, in order to override the default header and body row components - # which enables us to use our own custom header and body cell components class TableComponent < Katalyst::TableComponent + # Generates a column that links to the record's show page (by default). + # + # @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 url [Symbol|String|Proc] arguments for url_For, defaults to the record + # @param link [Hash] options to be passed to the link_to helper + # @param ** [Hash] HTML attributes to be added to column cells + # @param & [Proc] optional block to alter the cell content + # + # If a block is provided, it will be called with the link cell component as an argument. + # @yieldparam cell [Katalyst::Tables::Cells::LinkComponent] the cell component + # + # @return [void] + # + # @example Render a column containing the record's title, linked to its show page + # <% row.link :title %> # => About us + # @example Render a column containing the record's title, linked to its edit page + # <% row.link :title, url: :edit_admin_post_path do |cell| %> + # Edit <%= cell %> + # <% end %> + # # => Edit About us + def link(column, label: nil, heading: false, url: [:admin, record], link: {}, **, &) + with_cell(Tables::Cells::LinkComponent.new( + collection:, row:, column:, record:, label:, heading:, url:, link:, **, + ), &) + 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 + # @param label [String|nil] the label to use for the column header + # @param heading [boolean] if true, data cells will use `th` tags + # @param variant [Symbol] the variant to use when rendering the image (default :thumb) + # @param ** [Hash] HTML attributes to be added to column cells + # @param & [Proc] optional block to alter the cell content + # + # If a block is provided, it will be called with the attachment cell component as an argument. + # @yieldparam cell [Katalyst::Tables::Cells::AttachmentComponent] the cell component + # + # @return [void] + # + # @example Render a column containing a download link to the record's background image + # <% row.attachment :background %> # => background.png + def attachment(column, label: nil, heading: false, variant: :thumb, **, &) + with_cell(Tables::Cells::AttachmentComponent.new( + collection:, row:, column:, record:, label:, heading:, variant:, **, + ), &) + end + def default_html_attributes { class: "index-table" } end diff --git a/spec/components/koi/tables/cells/attachment_component_spec.rb b/spec/components/koi/tables/cells/attachment_component_spec.rb new file mode 100644 index 000000000..850b1694b --- /dev/null +++ b/spec/components/koi/tables/cells/attachment_component_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Koi::Tables::Cells::AttachmentComponent do + let(:table) { Koi::Tables::TableComponent.new(collection:) } + let(:collection) { create_list(:banner, 1, :with_image) } + let(:rendered) { render_inline(table) { |row| row.attachment(:image) } } + 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) + Image + HTML + end + + it "renders column data" do + expect(data).to have_css("td.type-attachment img[src*='dummy.png']") + end + + context "with html_options" do + let(:rendered) { render_inline(table) { |row| row.attachment(:image, **Test::HTML_ATTRIBUTES) } } + + it "renders header with html_options" do + expect(label).to match_html(<<~HTML) + Image + HTML + end + + it "renders data with html_options" do + expect(data).to have_css("td.type-attachment.CLASS[data-foo=bar] img[src*='dummy.png']") + end + end + + context "when given a label" do + let(:rendered) { render_inline(table) { |row| row.attachment(:image, 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 have_css("td.type-attachment img[src*='dummy.png']") + end + end + + context "when given an empty label" do + let(:rendered) { render_inline(table) { |row| row.attachment(:image, 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(:collection) { build_list(:banner, 1) } + + it "renders data as falsey" do + expect(data).to match_html(<<~HTML) + + HTML + end + end + + context "when given a block" do + let(:rendered) { render_inline(table) { |row| row.attachment(:image) { |cell| cell.tag.span(cell) } } } + + it "renders the default header" do + expect(label).to match_html(<<~HTML) + Image + HTML + end + + it "renders the custom data" do + expect(data).to have_css("td.type-attachment > span > img[src*='dummy.png']") + end + end +end diff --git a/spec/components/koi/tables/cells/link_component_spec.rb b/spec/components/koi/tables/cells/link_component_spec.rb new file mode 100644 index 000000000..8730f6bb6 --- /dev/null +++ b/spec/components/koi/tables/cells/link_component_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Koi::Tables::Cells::LinkComponent do + let(:table) { Koi::Tables::TableComponent.new(collection:) } + let(:collection) { create_list(:post, 1) } + let(:rendered) { render_inline(table) { |row, _post| row.link(:name) } } + 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) + Name + HTML + end + + it "renders column data" do + expect(data).to match_html(<<~HTML) + #{collection.first.name} + HTML + end + + context "with html_options" do + let(:rendered) { render_inline(table) { |row| row.link(:name, **Test::HTML_ATTRIBUTES) } } + + it "renders header with html_options" do + expect(label).to match_html(<<~HTML) + Name + HTML + end + + it "renders data with html_options" do + expect(data).to match_html(<<~HTML) + #{collection.first.name} + HTML + end + end + + context "when given a label" do + let(:rendered) { render_inline(table) { |row| row.link(:name, 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) + #{collection.first.name} + HTML + end + end + + context "when given an empty label" do + let(:rendered) { render_inline(table) { |row| row.link(:name, 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.link(:name) } } + let(:collection) { create_list(:post, 1, name: nil) } + + it "renders data as url" do + expect(data).to match_html(<<~HTML) + /admin/posts/#{collection.first.id} + HTML + end + end + + context "when given a block" do + let(:rendered) { render_inline(table) { |row| row.link(:name) { |cell| cell.tag.span(cell) } } } + + it "renders the default header" do + expect(label).to match_html(<<~HTML) + Name + HTML + end + + it "renders the custom data" do + expect(data).to match_html(<<~HTML) + #{collection.first.name} + HTML + end + end +end diff --git a/spec/support/frontend_examples.rb b/spec/support/frontend_examples.rb new file mode 100644 index 000000000..518ad61d0 --- /dev/null +++ b/spec/support/frontend_examples.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rack/request" +require "rails_helper" + +module Test + HTML_ATTRIBUTES = { + id: "ID", + class: "CLASS", + html: { style: "style" }, + data: { foo: "bar" }, + aria: { label: "LABEL" }, + }.freeze +end diff --git a/spec/support/match_html.rb b/spec/support/match_html.rb index aad1e6ad8..b50d2b939 100644 --- a/spec/support/match_html.rb +++ b/spec/support/match_html.rb @@ -27,8 +27,8 @@ def initialize(expected_html, **options) def matches?(response) case response when Nokogiri::XML::Node - @actual_doc = response @actual_html = response.to_html + @actual_doc = Nokogiri::HTML.fragment(@actual_html) else @actual_html = response @actual_doc = Nokogiri::HTML.fragment(response) @@ -57,8 +57,8 @@ def diff module RSpec module Matchers - def match_html(expected_html, **options) - HTMLMatcher.new(expected_html, **options) + def match_html(expected_html, **) + HTMLMatcher.new(expected_html, **) end end end diff --git a/spec/views/admin/posts/index.html.erb_spec.rb b/spec/views/admin/posts/index.html.erb_spec.rb index b6724f8b8..7f733623c 100644 --- a/spec/views/admin/posts/index.html.erb_spec.rb +++ b/spec/views/admin/posts/index.html.erb_spec.rb @@ -12,12 +12,13 @@ view.extend(Pagy::Frontend) collection = Admin::PostsController::Collection.new.apply(Post.all) - table = Koi::Tables::TableComponent.new(collection:, id: "index-table") # Workaround for https://github.com/rspec/rspec-rails/issues/2729 view.lookup_context.prefixes.prepend "admin/posts" - render locals: { collection:, table: } + allow(view).to receive(:default_table_component_class).and_return(Koi::Tables::TableComponent) + + render locals: { collection: } end it { expect(rendered).to have_css("th", text: "Name") }