katalyst-tables 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +151 -103
  4. data/app/assets/config/katalyst-tables.js +1 -0
  5. data/app/assets/javascripts/controllers/tables/turbo_collection_controller.js +22 -0
  6. data/app/components/concerns/katalyst/tables/configurable_component.rb +31 -0
  7. data/app/components/concerns/katalyst/tables/has_html_attributes.rb +45 -0
  8. data/app/components/concerns/katalyst/tables/has_table_content.rb +33 -0
  9. data/app/components/concerns/katalyst/tables/sortable.rb +32 -0
  10. data/app/components/concerns/katalyst/tables/turbo_replaceable.rb +62 -0
  11. data/app/components/katalyst/table_component.rb +51 -40
  12. data/app/components/katalyst/tables/body_cell_component.rb +4 -4
  13. data/app/components/katalyst/tables/body_row_component.rb +3 -3
  14. data/app/components/katalyst/tables/empty_caption_component.html.erb +6 -0
  15. data/app/components/katalyst/tables/empty_caption_component.rb +38 -0
  16. data/app/components/katalyst/tables/header_cell_component.rb +20 -16
  17. data/app/components/katalyst/tables/header_row_component.rb +6 -5
  18. data/app/components/katalyst/tables/pagy_nav_component.rb +26 -0
  19. data/app/components/katalyst/turbo/pagy_nav_component.rb +23 -0
  20. data/app/components/katalyst/turbo/table_component.rb +48 -0
  21. data/app/models/concerns/katalyst/tables/collection/core.rb +71 -0
  22. data/app/models/concerns/katalyst/tables/collection/pagination.rb +66 -0
  23. data/app/models/concerns/katalyst/tables/collection/sorting.rb +63 -0
  24. data/app/models/katalyst/tables/collection.rb +32 -0
  25. data/config/importmap.rb +7 -0
  26. data/lib/katalyst/tables/backend/sort_form.rb +16 -2
  27. data/lib/katalyst/tables/engine.rb +13 -0
  28. data/lib/katalyst/tables/frontend/helper.rb +4 -14
  29. data/lib/katalyst/tables/version.rb +1 -1
  30. metadata +33 -2
@@ -11,38 +11,61 @@ module Katalyst
11
11
  # <% end %>
12
12
  # ```
13
13
  class TableComponent < ViewComponent::Base
14
- include ActiveSupport::Configurable
15
- include Tables::Frontend::Helper
14
+ include Tables::ConfigurableComponent
15
+ include Tables::HasHtmlAttributes
16
+ include Tables::HasTableContent
17
+
18
+ attr_reader :collection, :object_name
19
+
20
+ config_component :header_row, default: "Katalyst::Tables::HeaderRowComponent"
21
+ config_component :header_cell, default: "Katalyst::Tables::HeaderCellComponent"
22
+ config_component :body_row, default: "Katalyst::Tables::BodyRowComponent"
23
+ config_component :body_cell, default: "Katalyst::Tables::BodyCellComponent"
24
+ config_component :caption, default: "Katalyst::Tables::EmptyCaptionComponent"
25
+
26
+ # Construct a new table component. This entry point supports a large number
27
+ # of options for customizing the table. The most common options are:
28
+ # - `collection`: the collection to render
29
+ # - `sorting`: the sorting to apply to the collection (defaults to collection.storing if available)
30
+ # - `header`: whether to render the header row (defaults to true, supports options)
31
+ # - `caption`: whether to render the caption (defaults to true, supports options)
32
+ # - `object_name`: the name of the object to use for partial rendering (defaults to collection.model_name.i18n_key)
33
+ # - `partial`: the name of the partial to use for rendering each row (defaults to collection.model_name.param_key)
34
+ # - `as`: the name of the local variable to use for rendering each row (defaults to collection.model_name.param_key)
35
+ # In addition to these options, standard HTML attributes can be passed which will be added to the table tag.
36
+ def initialize(collection:,
37
+ sorting: nil,
38
+ header: true,
39
+ caption: false,
40
+ **html_attributes)
41
+ @collection = collection
16
42
 
17
- attr_reader :collection, :sort, :object_name
43
+ # sorting: instance of Katalyst::Tables::Backend::SortForm. If not provided will be inferred from the collection.
44
+ @sorting = sorting || html_attributes.delete(:sort) # backwards compatibility with old `sort` option
18
45
 
19
- # Workaround: ViewComponent::Base.config is incompatible with ActiveSupport::Configurable
20
- @_config = Class.new(Configuration).new
46
+ # header: true means render the header row, header: false means no header row, if a hash, passes as options
47
+ @header = header
48
+ @header_options = (header if header.is_a?(Hash)) || {}
21
49
 
22
- config_accessor :header_row
23
- config_accessor :header_cell
24
- config_accessor :body_row
25
- config_accessor :body_cell
50
+ # caption: true means render the caption, caption: false means no caption, if a hash, passes as options
51
+ @caption = caption
52
+ @caption_options = (caption if caption.is_a?(Hash)) || {}
26
53
 
27
- def initialize(collection:,
28
- sort: nil,
29
- header: true,
30
- object_name: collection.try(:model_name)&.i18n_key,
31
- **html_options)
32
- super
33
-
34
- @collection = collection
35
- @sort = sort
36
- @header = header
37
- @object_name = object_name
54
+ super(**html_attributes)
38
55
  end
39
56
 
40
57
  def call
41
- tag.table(**@html_options) do
42
- thead + tbody
58
+ tag.table(**html_attributes) do
59
+ concat(caption)
60
+ concat(thead)
61
+ concat(tbody)
43
62
  end
44
63
  end
45
64
 
65
+ def caption
66
+ caption_component&.new(self)&.render_in(view_context) if @caption
67
+ end
68
+
46
69
  def thead
47
70
  return "".html_safe unless @header
48
71
 
@@ -61,31 +84,19 @@ module Katalyst
61
84
 
62
85
  def render_header
63
86
  # extract the column's block from the slot and pass it to the cell for rendering
64
- self.class.header_row_component.new(self).render_in(view_context, &@__vc_render_in_block)
87
+ header_row_component.new(self, **@header_options).render_in(view_context, &row_proc)
65
88
  end
66
89
 
67
90
  def render_row(record)
68
- # extract the column's block from the slot and pass it to the cell for rendering
69
- block = @__vc_render_in_block
70
- self.class.body_row_component.new(self, record).render_in(view_context) do |row|
71
- block.call(row, record)
91
+ body_row_component.new(self, record).render_in(view_context) do |row|
92
+ row_proc.call(row, record)
72
93
  end
73
94
  end
74
95
 
75
- def self.header_row_component
76
- @header_row_component ||= const_get(config.header_row || "Katalyst::Tables::HeaderRowComponent")
77
- end
78
-
79
- def self.header_cell_component
80
- @header_cell_component ||= const_get(config.header_cell || "Katalyst::Tables::HeaderCellComponent")
81
- end
82
-
83
- def self.body_row_component
84
- @body_row_component ||= const_get(config.body_row || "Katalyst::Tables::BodyRowComponent")
85
- end
96
+ def sorting
97
+ return @sorting if @sorting.present?
86
98
 
87
- def self.body_cell_component
88
- @body_cell_component ||= const_get(config.body_cell || "Katalyst::Tables::BodyCellComponent")
99
+ collection.sorting if collection.respond_to?(:sorting)
89
100
  end
90
101
  end
91
102
  end
@@ -3,12 +3,12 @@
3
3
  module Katalyst
4
4
  module Tables
5
5
  class BodyCellComponent < ViewComponent::Base # :nodoc:
6
- include Frontend::Helper
6
+ include HasHtmlAttributes
7
7
 
8
8
  attr_reader :record
9
9
 
10
- def initialize(table, record, attribute, heading: false, **html_options)
11
- super(**html_options)
10
+ def initialize(table, record, attribute, heading: false, **html_attributes)
11
+ super(**html_attributes)
12
12
 
13
13
  @table = table
14
14
  @record = record
@@ -24,7 +24,7 @@ module Katalyst
24
24
  def call
25
25
  content # ensure content is set before rendering options
26
26
 
27
- content_tag(@type, content, **@html_options)
27
+ content_tag(@type, content, **html_attributes)
28
28
  end
29
29
 
30
30
  # @return the object for this row.
@@ -3,7 +3,7 @@
3
3
  module Katalyst
4
4
  module Tables
5
5
  class BodyRowComponent < ViewComponent::Base # :nodoc:
6
- include Frontend::Helper
6
+ include HasHtmlAttributes
7
7
 
8
8
  renders_many :columns, ->(component) { component }
9
9
 
@@ -17,7 +17,7 @@ module Katalyst
17
17
  def call
18
18
  content # generate content before rendering
19
19
 
20
- tag.tr(**@html_options) do
20
+ tag.tr(**html_attributes) do
21
21
  columns.each do |column|
22
22
  concat(column.to_s)
23
23
  end
@@ -25,7 +25,7 @@ module Katalyst
25
25
  end
26
26
 
27
27
  def cell(attribute, **options, &block)
28
- with_column(@table.class.body_cell_component.new(@table, @record, attribute, **options), &block)
28
+ with_column(@table.body_cell_component.new(@table, @record, attribute, **options), &block)
29
29
  end
30
30
 
31
31
  def header?
@@ -0,0 +1,6 @@
1
+ <%= tag.caption(**html_attributes) do %>
2
+ No <%= plural_human_model_name %> found.
3
+ <% if filtered? %>
4
+ <%= link_to("Clear filters", clear_filters_path) %>.
5
+ <% end %>
6
+ <% end %>
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ class EmptyCaptionComponent < ViewComponent::Base # :nodoc:
6
+ include HasHtmlAttributes
7
+
8
+ def initialize(table, **html_attributes)
9
+ super(**html_attributes)
10
+
11
+ @table = table
12
+ end
13
+
14
+ def render?
15
+ @table.collection.empty?
16
+ end
17
+
18
+ def filtered?
19
+ @table.collection.respond_to?(:filtered?) && @table.collection.filtered?
20
+ end
21
+
22
+ def clear_filters_path
23
+ url_for
24
+ end
25
+
26
+ def plural_human_model_name
27
+ human = @table.model_name&.human || @table.object_name.to_s.humanize
28
+ human.pluralize.downcase
29
+ end
30
+
31
+ private
32
+
33
+ def default_attributes
34
+ { align: "bottom" }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -4,25 +4,28 @@ module Katalyst
4
4
  module Tables
5
5
  class HeaderCellComponent < ViewComponent::Base # :nodoc:
6
6
  include Frontend::Helper
7
+ include HasHtmlAttributes
8
+ include Sortable
7
9
 
8
- delegate :object_name, :sort, to: :@table
10
+ delegate :object_name, :collection, :sorting, to: :@table
9
11
 
10
- def initialize(table, attribute, label: nil, **html_options)
11
- super(**html_options)
12
+ def initialize(table, attribute, label: nil, link: {}, **html_attributes)
13
+ super(**html_attributes)
12
14
 
13
- @table = table
14
- @attribute = attribute
15
- @value = label
15
+ @table = table
16
+ @attribute = attribute
17
+ @value = label
18
+ @link_attributes = link
16
19
  end
17
20
 
18
21
  def call
19
- content = if @table.sort&.supports?(@table.collection, @attribute)
20
- sort_link(value) # writes to html_options
21
- else
22
- value
23
- end
24
-
25
- tag.th(content, **@html_options)
22
+ tag.th(**html_attributes) do
23
+ if sortable?(@attribute)
24
+ link_to(value, sort_url(@attribute), **@link_attributes)
25
+ else
26
+ value
27
+ end
28
+ end
26
29
  end
27
30
 
28
31
  def value
@@ -45,9 +48,10 @@ module Katalyst
45
48
 
46
49
  private
47
50
 
48
- def sort_link(content)
49
- (@html_options["data"] ||= {})["sort"] = sort.status(@attribute)
50
- link_to(content, sort_url_for(sort: sort.toggle(@attribute)))
51
+ def default_attributes
52
+ return {} unless sorting&.supports?(collection, @attribute)
53
+
54
+ { data: { sort: sorting.status(@attribute) } }
51
55
  end
52
56
  end
53
57
  end
@@ -3,20 +3,21 @@
3
3
  module Katalyst
4
4
  module Tables
5
5
  class HeaderRowComponent < ViewComponent::Base # :nodoc:
6
- include Frontend::Helper
6
+ include HasHtmlAttributes
7
7
 
8
8
  renders_many :columns, ->(component) { component }
9
9
 
10
- def initialize(table)
10
+ def initialize(table, link: {})
11
11
  super()
12
12
 
13
- @table = table
13
+ @table = table
14
+ @link_attributes = link
14
15
  end
15
16
 
16
17
  def call
17
18
  content # generate content before rendering
18
19
 
19
- tag.tr(**@html_options) do
20
+ tag.tr(**html_attributes) do
20
21
  columns.each do |column|
21
22
  concat(column.to_s)
22
23
  end
@@ -24,7 +25,7 @@ module Katalyst
24
25
  end
25
26
 
26
27
  def cell(attribute, **options, &block)
27
- with_column(@table.class.header_cell_component.new(@table, attribute, **options), &block)
28
+ with_column(@table.header_cell_component.new(@table, attribute, link: @link_attributes, **options), &block)
28
29
  end
29
30
 
30
31
  def header?
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ class PagyNavComponent < ViewComponent::Base # :nodoc:
6
+ include Pagy::Frontend
7
+
8
+ attr_reader :pagy_options
9
+
10
+ def initialize(collection: nil, pagy: nil, **pagy_options)
11
+ super()
12
+
13
+ pagy ||= collection&.pagination if collection.respond_to?(:pagination)
14
+
15
+ raise ArgumentError, "pagy is required" if pagy.blank?
16
+
17
+ @pagy = pagy
18
+ @pagy_options = pagy_options
19
+ end
20
+
21
+ def call
22
+ pagy_nav(@pagy, **pagy_options).html_safe # rubocop:disable Rails/OutputSafety
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Turbo
5
+ class PagyNavComponent < Tables::PagyNavComponent # :nodoc:
6
+ include Tables::TurboReplaceable
7
+
8
+ def initialize(id:, **options)
9
+ super(pagy_id: id, **options)
10
+ end
11
+
12
+ def id
13
+ pagy_options[:pagy_id]
14
+ end
15
+
16
+ private
17
+
18
+ def pagy_options
19
+ super.merge(link_extra: "data-turbo-stream")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Turbo
5
+ # Renders a table that uses turbo stream replacement when sorting or
6
+ # paginating.
7
+ class TableComponent < ::Katalyst::TableComponent
8
+ include Tables::TurboReplaceable
9
+
10
+ def initialize(collection:, id:, header: true, **options)
11
+ header = if header.is_a?(Hash)
12
+ default_header_options.merge(header)
13
+ elsif header
14
+ default_header_options
15
+ end
16
+
17
+ super(collection: collection, header: header, id: id, **options)
18
+ end
19
+
20
+ def id
21
+ html_attributes[:id]
22
+ end
23
+
24
+ private
25
+
26
+ def default_attributes
27
+ {
28
+ data: {
29
+ controller: "tables--turbo-collection",
30
+ tables__turbo_collection_url_value: current_path,
31
+ tables__turbo_collection_sort_value: collection.sort
32
+ }
33
+ }
34
+ end
35
+
36
+ def current_path
37
+ params = collection.to_params
38
+ query_string = params.empty? ? "" : "?#{Rack::Utils.build_nested_query(params)}"
39
+
40
+ "#{request.path}#{query_string}"
41
+ end
42
+
43
+ def default_header_options
44
+ { link: { data: { turbo_stream: "" } } }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Core # :nodoc:
7
+ extend ActiveSupport::Concern
8
+
9
+ include ActiveModel::Model
10
+ include ActiveModel::Attributes
11
+ include ActiveModel::Dirty
12
+ include ActiveSupport::Configurable
13
+
14
+ included do
15
+ class_attribute :reducers, default: ActionDispatch::MiddlewareStack.new
16
+
17
+ class << self
18
+ delegate :use, :before, to: :reducers
19
+ end
20
+
21
+ attr_accessor :items
22
+
23
+ delegate :model, :to_model, :each, :count, :empty?, to: :items, allow_nil: true
24
+ delegate :model_name, to: :model, allow_nil: true
25
+ end
26
+
27
+ def initialize(**options)
28
+ super
29
+
30
+ clear_changes_information
31
+ end
32
+
33
+ def filter
34
+ # no-op by default
35
+ end
36
+
37
+ def filtered?
38
+ !self.class.new.filters.eql?(filters)
39
+ end
40
+
41
+ def filters
42
+ attributes.except("page", "sort")
43
+ end
44
+
45
+ def apply(items)
46
+ @items = items
47
+ reducers.build do |_|
48
+ filter
49
+ self
50
+ end.call(self)
51
+ self
52
+ end
53
+
54
+ def with_params(params)
55
+ self.attributes = params.permit(self.class.attribute_types.keys)
56
+
57
+ self
58
+ end
59
+
60
+ # Returns a hash of the current attributes that have changed from defaults.
61
+ def to_params
62
+ attributes.slice(*changed)
63
+ end
64
+
65
+ def inspect
66
+ "#<#{self.class.name} @attributes=#{attributes.inspect} @model=\"#{model}\" @count=#{items&.count}>"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pagy/backend"
4
+
5
+ module Katalyst
6
+ module Tables
7
+ module Collection
8
+ # Adds pagination support for a collection.
9
+ #
10
+ # Pagination will be applied if the collection is configured to paginate
11
+ # by either specifying `config.paginate = true` or passing
12
+ # `paginate: true` to the initializer.
13
+ #
14
+ # If the value given to `paginate` is a hash, it will be passed to the
15
+ # `pagy` gem as options.
16
+ #
17
+ # If `page` is present in params it will be passed to pagy.
18
+ module Pagination
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ attr_accessor :pagination
23
+
24
+ attribute :page, :integer, default: 1
25
+
26
+ config_accessor :paginate
27
+ end
28
+
29
+ def initialize(paginate: config.paginate, **options)
30
+ super(**options)
31
+
32
+ @paginate = paginate
33
+ end
34
+
35
+ def paginate?
36
+ !!@paginate
37
+ end
38
+
39
+ def paginate_options
40
+ @paginate.is_a?(Hash) ? @paginate : {}
41
+ end
42
+
43
+ class Paginate # :nodoc:
44
+ include Pagy::Backend
45
+
46
+ def initialize(app)
47
+ @app = app
48
+ end
49
+
50
+ def call(collection)
51
+ @collection = @app.call(collection)
52
+ if collection.paginate?
53
+ @collection.pagination, @collection.items = pagy(@collection.items, collection.paginate_options)
54
+ end
55
+ @collection
56
+ end
57
+
58
+ # pagy shim
59
+ def params
60
+ @collection.attributes.with_indifferent_access
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pagy/backend"
4
+
5
+ module Katalyst
6
+ module Tables
7
+ module Collection
8
+ # Adds sorting support to a collection.
9
+ #
10
+ # Sorting will be applied if the collection is configured with a default
11
+ # sorting configuration by either specifying
12
+ # `config.sorting = "column direction"` or passing
13
+ # `sorting: "column direction"` to the initializer.
14
+ #
15
+ # If `sort` is present in params it will override the default sorting.
16
+ module Sorting
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ config_accessor :sorting
21
+ attr_accessor :sorting
22
+
23
+ attribute :sort, :string
24
+ end
25
+
26
+ def initialize(sorting: config.sorting, **options)
27
+ @sorting = Backend::SortForm.parse(sorting) if sorting
28
+
29
+ super(sort: sorting, **options) # set default sort based on config
30
+ end
31
+
32
+ def sort=(value)
33
+ return unless @sorting
34
+
35
+ # update internal proxy
36
+ @sorting = Backend::SortForm.parse(value, default: attribute_was(:sort))
37
+
38
+ # update attribute based on normalized value
39
+ super(@sorting.to_param)
40
+ end
41
+
42
+ class Sort # :nodoc:
43
+ include Backend
44
+
45
+ def initialize(app)
46
+ @app = app
47
+ end
48
+
49
+ def call(collection)
50
+ @collection = @app.call(collection)
51
+ @collection.sorting, @collection.items = @collection.sorting.apply(@collection.items) if @collection.sorting
52
+ @collection
53
+ end
54
+
55
+ # pagy shim
56
+ def params
57
+ @collection.attributes
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ # Entry point for creating a collection for use with table components.
7
+ # This class is intended to be subclassed, i.e.:
8
+ #
9
+ # class ApplicationController < ActionController::Base
10
+ # class Collection < Katalyst::Tables::Collection::Base
11
+ # ...
12
+ # end
13
+ # end
14
+ #
15
+ # In the context of a controller action, construct a collection, apply it
16
+ # to a model, then pass the result to the view component:
17
+ # ```
18
+ # collection = Collection.new.with_params(params).apply(People.all)
19
+ # table = Katalyst::TableComponent.new(collection: collection)
20
+ # render table
21
+ # ````
22
+ class Base
23
+ include Core
24
+ include Pagination
25
+ include Sorting
26
+
27
+ use(Pagination::Paginate)
28
+ use(Sorting::Sort)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
4
+
5
+ pin_all_from Katalyst::Tables::Engine.root.join("app/assets/javascripts"),
6
+ # preload in tests so that we don't start clicking before controllers load
7
+ preload: Rails.env.test?
@@ -8,11 +8,25 @@ module Katalyst
8
8
  class SortForm
9
9
  DIRECTIONS = %w[asc desc].freeze
10
10
 
11
- attr_accessor :column, :direction
11
+ attr_accessor :column, :direction, :default
12
12
 
13
- def initialize(column: nil, direction: nil)
13
+ def self.parse(param, default: nil)
14
+ column, direction = param.to_s.split
15
+ direction = "asc" unless DIRECTIONS.include?(direction)
16
+
17
+ default = SortForm.parse(default).to_param if default.present?
18
+
19
+ SortForm.new(column: column, direction: direction, default: default)
20
+ end
21
+
22
+ def initialize(column: nil, direction: nil, default: nil)
14
23
  self.column = column
15
24
  self.direction = direction
25
+ self.default = default
26
+ end
27
+
28
+ def to_param
29
+ "#{column} #{direction}"
16
30
  end
17
31
 
18
32
  # Returns true if the given collection supports sorting on the given