katalyst-tables 2.0.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
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