katalyst-tables 1.1.0 → 2.1.0

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