katalyst-tables 1.1.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +166 -134
  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 +102 -0
  12. data/app/components/katalyst/tables/body_cell_component.rb +40 -0
  13. data/app/components/katalyst/tables/body_row_component.rb +40 -0
  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 +58 -0
  17. data/app/components/katalyst/tables/header_row_component.rb +40 -0
  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/backend.rb +8 -8
  28. data/lib/katalyst/tables/engine.rb +24 -0
  29. data/lib/katalyst/tables/frontend/helper.rb +8 -9
  30. data/lib/katalyst/tables/frontend.rb +6 -36
  31. data/lib/katalyst/tables/version.rb +1 -1
  32. data/lib/katalyst/tables.rb +5 -0
  33. metadata +54 -9
  34. data/lib/katalyst/tables/frontend/builder/base.rb +0 -63
  35. data/lib/katalyst/tables/frontend/builder/body_cell.rb +0 -31
  36. data/lib/katalyst/tables/frontend/builder/body_row.rb +0 -29
  37. data/lib/katalyst/tables/frontend/builder/header_cell.rb +0 -55
  38. data/lib/katalyst/tables/frontend/builder/header_row.rb +0 -23
  39. data/lib/katalyst/tables/frontend/table_builder.rb +0 -71
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ # Adds support for turbo stream replacement to ViewComponents. Components
6
+ # that are rendered from a turbo-stream-compatible response will be rendered
7
+ # using turbo stream replacement. Components must define `id`.
8
+ #
9
+ # Turbo stream replacement rendering will only be enabled if the component
10
+ # passes `turbo: true` as a constructor option.
11
+ module TurboReplaceable
12
+ extend ActiveSupport::Concern
13
+
14
+ include ::Turbo::StreamsHelper
15
+
16
+ def turbo?
17
+ @turbo
18
+ end
19
+
20
+ def initialize(turbo: true, **options)
21
+ super(**options)
22
+
23
+ @turbo = turbo
24
+ end
25
+
26
+ class_methods do
27
+ # Redefine the compiler to use our custom compiler.
28
+ # Compiler is set on `inherited` so we need to re-set it if it's not the expected type.
29
+ def compiler
30
+ @vc_compiler = @vc_compiler.is_a?(TurboCompiler) ? @vc_compiler : TurboCompiler.new(self)
31
+ end
32
+ end
33
+
34
+ included do
35
+ # ensure that our custom compiler is used, as `inherited` calls `compile` before our module is included.
36
+ compile(force: true) if compiled?
37
+ end
38
+
39
+ # Wraps the default compiler provided by ViewComponent to add turbo support.
40
+ class TurboCompiler < ViewComponent::Compiler
41
+ private
42
+
43
+ def define_render_template_for # rubocop:disable Metrics/MethodLength
44
+ super
45
+
46
+ redefinition_lock.synchronize do
47
+ component_class.alias_method(:vc_render_template_for, :render_template_for)
48
+ component_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
49
+ def render_template_for(variant = nil)
50
+ return vc_render_template_for(variant) unless turbo?
51
+ controller.respond_to do |format|
52
+ format.html { vc_render_template_for(variant) }
53
+ format.turbo_stream { turbo_stream.replace(id, vc_render_template_for(variant)) }
54
+ end
55
+ end
56
+ RUBY
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ # A component for rendering a table from a collection, with a header row.
5
+ # ```erb
6
+ # <%= Katalyst::TableComponent.new(collection: @people) do |row, person| %>
7
+ # <%= row.cell :name do |cell| %>
8
+ # <%= link_to cell.value, person %>
9
+ # <% end %>
10
+ # <%= row.cell :email %>
11
+ # <% end %>
12
+ # ```
13
+ class TableComponent < ViewComponent::Base
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
42
+
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
45
+
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)) || {}
49
+
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)) || {}
53
+
54
+ super(**html_attributes)
55
+ end
56
+
57
+ def call
58
+ tag.table(**html_attributes) do
59
+ concat(caption)
60
+ concat(thead)
61
+ concat(tbody)
62
+ end
63
+ end
64
+
65
+ def caption
66
+ caption_component&.new(self)&.render_in(view_context) if @caption
67
+ end
68
+
69
+ def thead
70
+ return "".html_safe unless @header
71
+
72
+ tag.thead do
73
+ concat(render_header)
74
+ end
75
+ end
76
+
77
+ def tbody
78
+ tag.tbody do
79
+ collection.each do |record|
80
+ concat(render_row(record))
81
+ end
82
+ end
83
+ end
84
+
85
+ def render_header
86
+ # extract the column's block from the slot and pass it to the cell for rendering
87
+ header_row_component.new(self, **@header_options).render_in(view_context, &row_proc)
88
+ end
89
+
90
+ def render_row(record)
91
+ body_row_component.new(self, record).render_in(view_context) do |row|
92
+ row_proc.call(row, record)
93
+ end
94
+ end
95
+
96
+ def sorting
97
+ return @sorting if @sorting.present?
98
+
99
+ collection.sorting if collection.respond_to?(:sorting)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ class BodyCellComponent < ViewComponent::Base # :nodoc:
6
+ include HasHtmlAttributes
7
+
8
+ attr_reader :record
9
+
10
+ def initialize(table, record, attribute, heading: false, **html_attributes)
11
+ super(**html_attributes)
12
+
13
+ @table = table
14
+ @record = record
15
+ @attribute = attribute
16
+ @type = heading ? :th : :td
17
+ end
18
+
19
+ def before_render
20
+ # fallback if no content block is given
21
+ with_content(value.to_s) unless content?
22
+ end
23
+
24
+ def call
25
+ content # ensure content is set before rendering options
26
+
27
+ content_tag(@type, content, **html_attributes)
28
+ end
29
+
30
+ # @return the object for this row.
31
+ def object
32
+ @record
33
+ end
34
+
35
+ def value
36
+ @record.public_send(@attribute)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ class BodyRowComponent < ViewComponent::Base # :nodoc:
6
+ include HasHtmlAttributes
7
+
8
+ renders_many :columns, ->(component) { component }
9
+
10
+ def initialize(table, record)
11
+ super()
12
+
13
+ @table = table
14
+ @record = record
15
+ end
16
+
17
+ def call
18
+ content # generate content before rendering
19
+
20
+ tag.tr(**html_attributes) do
21
+ columns.each do |column|
22
+ concat(column.to_s)
23
+ end
24
+ end
25
+ end
26
+
27
+ def cell(attribute, **options, &block)
28
+ with_column(@table.body_cell_component.new(@table, @record, attribute, **options), &block)
29
+ end
30
+
31
+ def header?
32
+ false
33
+ end
34
+
35
+ def body?
36
+ true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -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
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ class HeaderCellComponent < ViewComponent::Base # :nodoc:
6
+ include Frontend::Helper
7
+ include HasHtmlAttributes
8
+ include Sortable
9
+
10
+ delegate :object_name, :collection, :sorting, to: :@table
11
+
12
+ def initialize(table, attribute, label: nil, link: {}, **html_attributes)
13
+ super(**html_attributes)
14
+
15
+ @table = table
16
+ @attribute = attribute
17
+ @value = label
18
+ @link_attributes = link
19
+ end
20
+
21
+ def call
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
29
+ end
30
+
31
+ def value
32
+ if !@value.nil?
33
+ @value
34
+ elsif object_name.present?
35
+ translation
36
+ else
37
+ default_value
38
+ end
39
+ end
40
+
41
+ def translation(key = "activerecord.attributes.#{object_name}.#{@attribute}")
42
+ translate(key, default: default_value)
43
+ end
44
+
45
+ def default_value
46
+ @attribute.to_s.humanize.capitalize
47
+ end
48
+
49
+ private
50
+
51
+ def default_attributes
52
+ return {} unless sorting&.supports?(collection, @attribute)
53
+
54
+ { data: { sort: sorting.status(@attribute) } }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ class HeaderRowComponent < ViewComponent::Base # :nodoc:
6
+ include HasHtmlAttributes
7
+
8
+ renders_many :columns, ->(component) { component }
9
+
10
+ def initialize(table, link: {})
11
+ super()
12
+
13
+ @table = table
14
+ @link_attributes = link
15
+ end
16
+
17
+ def call
18
+ content # generate content before rendering
19
+
20
+ tag.tr(**html_attributes) do
21
+ columns.each do |column|
22
+ concat(column.to_s)
23
+ end
24
+ end
25
+ end
26
+
27
+ def cell(attribute, **options, &block)
28
+ with_column(@table.header_cell_component.new(@table, attribute, link: @link_attributes, **options), &block)
29
+ end
30
+
31
+ def header?
32
+ true
33
+ end
34
+
35
+ def body?
36
+ false
37
+ end
38
+ end
39
+ end
40
+ end
@@ -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