katalyst-tables 3.0.0 → 3.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +17 -3
  4. data/app/assets/builds/katalyst/tables.esm.js +16 -0
  5. data/app/assets/builds/katalyst/tables.js +16 -0
  6. data/app/assets/builds/katalyst/tables.min.js +1 -1
  7. data/app/assets/builds/katalyst/tables.min.js.map +1 -1
  8. data/app/assets/stylesheets/katalyst/tables/_filter.scss +34 -0
  9. data/app/assets/stylesheets/katalyst/tables/_index.scss +2 -0
  10. data/app/assets/stylesheets/katalyst/tables/_select.scss +3 -0
  11. data/app/assets/stylesheets/katalyst/tables/_summary.scss +14 -0
  12. data/app/assets/stylesheets/katalyst/tables/_table.scss +3 -0
  13. data/app/assets/stylesheets/katalyst/tables/typed-columns/_boolean.scss +1 -1
  14. data/app/assets/stylesheets/katalyst/tables/typed-columns/_currency.scss +3 -0
  15. data/app/assets/stylesheets/katalyst/tables/typed-columns/_date.scss +1 -1
  16. data/app/assets/stylesheets/katalyst/tables/typed-columns/_datetime.scss +1 -1
  17. data/app/assets/stylesheets/katalyst/tables/typed-columns/_enum.scss +9 -0
  18. data/app/assets/stylesheets/katalyst/tables/typed-columns/_index.scss +1 -0
  19. data/app/assets/stylesheets/katalyst/tables/typed-columns/_number.scss +3 -0
  20. data/app/components/concerns/katalyst/tables/has_table_content.rb +2 -2
  21. data/app/components/concerns/katalyst/tables/row_renderer.rb +1 -1
  22. data/app/components/concerns/katalyst/tables/sortable.rb +1 -1
  23. data/app/components/katalyst/summary_table_component.html.erb +15 -0
  24. data/app/components/katalyst/summary_table_component.rb +44 -0
  25. data/app/components/katalyst/table_component.rb +28 -0
  26. data/app/components/katalyst/tables/cells/enum_component.rb +27 -0
  27. data/app/components/katalyst/tables/filter/modal_component.html.erb +25 -0
  28. data/app/components/katalyst/tables/filter/modal_component.rb +66 -0
  29. data/app/components/katalyst/tables/filter_component.html.erb +20 -0
  30. data/app/components/katalyst/tables/filter_component.rb +91 -0
  31. data/app/components/katalyst/tables/summary/body_component.html.erb +3 -0
  32. data/app/components/katalyst/tables/summary/body_component.rb +10 -0
  33. data/app/components/katalyst/tables/summary/header_component.html.erb +3 -0
  34. data/app/components/katalyst/tables/summary/header_component.rb +10 -0
  35. data/app/components/katalyst/tables/summary/row_component.html.erb +4 -0
  36. data/app/components/katalyst/tables/summary/row_component.rb +12 -0
  37. data/app/controllers/concerns/katalyst/tables/backend.rb +15 -0
  38. data/app/helpers/katalyst/tables/frontend.rb +27 -0
  39. data/app/javascript/tables/application.js +5 -0
  40. data/app/javascript/tables/filter/modal_controller.js +13 -0
  41. data/app/models/concerns/katalyst/tables/collection/core.rb +30 -0
  42. data/app/models/concerns/katalyst/tables/collection/filtering.rb +80 -9
  43. data/app/models/concerns/katalyst/tables/collection/pagination.rb +2 -2
  44. data/app/models/concerns/katalyst/tables/collection/query/array_value_parser.rb +56 -0
  45. data/app/models/concerns/katalyst/tables/collection/query/parser.rb +65 -0
  46. data/app/models/concerns/katalyst/tables/collection/query/single_value_parser.rb +24 -0
  47. data/app/models/concerns/katalyst/tables/collection/query/value_parser.rb +34 -0
  48. data/app/models/concerns/katalyst/tables/collection/query.rb +43 -0
  49. data/app/models/concerns/katalyst/tables/collection/sorting.rb +2 -2
  50. data/app/models/katalyst/tables/collection/array.rb +0 -1
  51. data/app/models/katalyst/tables/collection/base.rb +0 -5
  52. data/app/models/katalyst/tables/collection/filter.rb +2 -3
  53. data/config/importmap.rb +1 -0
  54. metadata +27 -4
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Filter
6
+ class ModalComponent < ViewComponent::Base
7
+ include Katalyst::HtmlAttributes
8
+ include Katalyst::Tables::Frontend
9
+
10
+ DEFAULT_ATTRIBUTES = %w[page sort search query].freeze
11
+
12
+ renders_one :footer
13
+
14
+ attr_reader :collection, :url
15
+
16
+ def initialize(collection:, **)
17
+ super(**)
18
+
19
+ @collection = collection
20
+ end
21
+
22
+ private
23
+
24
+ def default_html_attributes
25
+ {
26
+ class: "filter-keys-modal",
27
+ data: {
28
+ tables__filter__modal_target: "modal",
29
+ },
30
+ }
31
+ end
32
+
33
+ def attributes
34
+ collection.class.attribute_types.except(*DEFAULT_ATTRIBUTES)
35
+ end
36
+
37
+ def values_for(key, attribute)
38
+ values_method = "#{key.parameterize.underscore}_values"
39
+ if attribute.type == :boolean
40
+ render_options(true, false)
41
+ elsif collection.model.defined_enums.has_key?(key)
42
+ render_array(*collection.model.defined_enums[key].keys)
43
+ elsif collection.respond_to?(values_method)
44
+ if collection.class.enum_attribute?(key)
45
+ render_array(*collection.public_send(values_method))
46
+ else
47
+ render_options(*collection.public_send(values_method))
48
+ end
49
+ end
50
+ end
51
+
52
+ def render_option(value)
53
+ "<code>#{value}</code>".html_safe # rubocop:disable Rails/OutputSafety
54
+ end
55
+
56
+ def render_options(*values)
57
+ safe_join(values.map { |value| render_option(value) }, ", ")
58
+ end
59
+
60
+ def render_array(*values)
61
+ safe_join(["[", render_options(*values), "]"])
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,20 @@
1
+ <%= tag.div(**html_attributes) do %>
2
+ <% if content? %>
3
+ <%= content %>
4
+ <% else %>
5
+ <%= form do |form| %>
6
+ <%= form.text_field(
7
+ :query,
8
+ type: :search,
9
+ size: :full,
10
+ label: nil,
11
+ autocomplete: "off",
12
+ **input_attributes,
13
+ ) %>
14
+
15
+ <%= form.submit("Apply") %>
16
+ <% end %>
17
+ <% end %>
18
+
19
+ <%= modal %>
20
+ <% end %>
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ # A component for rendering a data driven filter for a collection.
6
+ # <%= Katalyst::Tables::FilterComponent.new(collection: @people, url: peoples_path) %>
7
+ #
8
+ # By default, the component will render a form containing a single text field. Interacting with the
9
+ # text field will display a dropdown outlining all available keys and values to be filtered on.
10
+ #
11
+ # You can override how the form and input displays by passing in content to the component.
12
+ # The component provides a helper function `form`
13
+ # to ensure the correct attributes and default form fields are collected.
14
+ # You can pass additional options to the `form` method to modify it.
15
+ #
16
+ # <%= Katalyst::Tables::FilterComponent.new(collection: @people, url: peoples_path) do |filter| %>
17
+ # <%= filter.form(builder: GOVUKFormBuilder) do |form| %>
18
+ # <%= form.govuk_text_field :query %>
19
+ # <%= form.govuk_submit "Apply" %>
20
+ # <% end %>
21
+ # <% end %>
22
+ #
23
+ #
24
+ # Additionally the component allows for access to the dropdown that displays when interacting with the input.
25
+ # The dropdown supports additional "footer" content to be added.
26
+ #
27
+ # <%= Katalyst::Tables::FilterComponent.new(collection: @people, url: peoples_path) do |filter| %>
28
+ # <% filter.with_modal(collection:) do |modal| %>
29
+ # <% modal.with_footer do %>
30
+ # <%= link_to "Docs", docs_path %>
31
+ # <% end %>
32
+ # <% end %>
33
+ # <% end %>
34
+ #
35
+ class FilterComponent < ViewComponent::Base
36
+ include Katalyst::HtmlAttributes
37
+ include Katalyst::Tables::Frontend
38
+
39
+ renders_one :modal, Katalyst::Tables::Filter::ModalComponent
40
+
41
+ define_html_attribute_methods :input_attributes
42
+
43
+ attr_reader :collection, :url
44
+
45
+ def initialize(collection:, url:, **)
46
+ super(**)
47
+
48
+ @collection = collection
49
+ @url = url
50
+ end
51
+
52
+ def before_render
53
+ with_modal(collection:) unless modal?
54
+ end
55
+
56
+ def form(url: @url, **options, &)
57
+ form_with(model: collection,
58
+ url:,
59
+ method: :get,
60
+ **options) do |form|
61
+ concat(form.hidden_field(:sort))
62
+
63
+ yield form if block_given?
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def default_html_attributes
70
+ {
71
+ data: {
72
+ controller: "tables--filter--modal",
73
+ action: <<~ACTIONS.gsub(/\s+/, " "),
74
+ click@window->tables--filter--modal#close
75
+ click->tables--filter--modal#open:stop
76
+ keydown.esc->tables--filter--modal#close
77
+ ACTIONS
78
+ },
79
+ }
80
+ end
81
+
82
+ def default_input_attributes
83
+ {
84
+ data: {
85
+ action: "focus->tables--filter--modal#open",
86
+ },
87
+ }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,3 @@
1
+ <% cells.each do |cell| %>
2
+ <%= cell %>
3
+ <% end %>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Summary
6
+ class BodyComponent < Katalyst::Tables::BodyRowComponent
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ <% cells.each do |cell| %>
2
+ <%= cell %>
3
+ <% end %>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Summary
6
+ class HeaderComponent < Katalyst::Tables::HeaderRowComponent
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,4 @@
1
+ <tr>
2
+ <%= header %>
3
+ <%= body %>
4
+ </tr>
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Summary
6
+ class RowComponent < ViewComponent::Base
7
+ renders_one :header, Summary::HeaderComponent
8
+ renders_one :body, Summary::BodyComponent
9
+ end
10
+ end
11
+ end
12
+ end
@@ -8,6 +8,7 @@ module Katalyst
8
8
 
9
9
  included do
10
10
  class_attribute :_default_table_component, instance_accessor: false
11
+ class_attribute :_default_summary_table_component, instance_accessor: false
11
12
  end
12
13
 
13
14
  class_methods do
@@ -18,12 +19,26 @@ module Katalyst
18
19
  def default_table_component(component)
19
20
  self._default_table_component = component
20
21
  end
22
+
23
+ # Set the summary table component to be used as the default for all
24
+ # summary tables in the views rendered by this controller and its
25
+ # subclasses.
26
+ #
27
+ # @param component [Class] the summary table component class to use
28
+ def default_summary_table_component(component)
29
+ self._default_summary_table_component = component
30
+ end
21
31
  end
22
32
 
23
33
  # Default table component for this controller
24
34
  def default_table_component
25
35
  self.class._default_table_component
26
36
  end
37
+
38
+ # Default summary table component for this controller
39
+ def default_summary_table_component
40
+ self.class._default_summary_table_component
41
+ end
27
42
  end
28
43
  end
29
44
  end
@@ -55,12 +55,39 @@ module Katalyst
55
55
  render(Selectable::FormComponent.new(collection:, id:, primary_key:), &)
56
56
  end
57
57
 
58
+ # Construct a new summary table component.
59
+ #
60
+ # @param model [ActiveRecord::Base] subject for the table
61
+ #
62
+ # Blocks will receive the table in row-rendering mode (with row and record defined):
63
+ # @yieldparam [Katalyst::TableComponent] the table component to render rows
64
+ # @yieldparam [nil, Object] nil for the header column, or the given model for the value column
65
+ def summary_table_with(model:, **, &)
66
+ component ||= default_summary_table_component_class
67
+ component = component.new(model:, **)
68
+
69
+ render(component, &)
70
+ end
71
+
72
+ # Construct a new filter.
73
+ #
74
+ # @param collection [Katalyst::Tables::Collection::Core] the collection to render
75
+ # @param url [String] the url to submit the form to (e.g. <resources>_path)
76
+ def filter_with(collection:, url: url_for(action: :index), &)
77
+ render(FilterComponent.new(collection:, url:), &)
78
+ end
79
+
58
80
  private
59
81
 
60
82
  def default_table_component_class
61
83
  component = controller.try(:default_table_component) || TableComponent
62
84
  component.respond_to?(:constantize) ? component.constantize : component
63
85
  end
86
+
87
+ def default_summary_table_component_class
88
+ component = controller.try(:default_summary_table_component) || SummaryTableComponent
89
+ component.respond_to?(:constantize) ? component.constantize : component
90
+ end
64
91
  end
65
92
  end
66
93
  end
@@ -3,6 +3,7 @@ import OrderableListController from "./orderable/list_controller";
3
3
  import OrderableFormController from "./orderable/form_controller";
4
4
  import SelectionFormController from "./selection/form_controller";
5
5
  import SelectionItemController from "./selection/item_controller";
6
+ import FilterModalController from "./filter/modal_controller";
6
7
 
7
8
  const Definitions = [
8
9
  {
@@ -25,6 +26,10 @@ const Definitions = [
25
26
  identifier: "tables--selection--item",
26
27
  controllerConstructor: SelectionItemController,
27
28
  },
29
+ {
30
+ identifier: "tables--filter--modal",
31
+ controllerConstructor: FilterModalController,
32
+ },
28
33
  ];
29
34
 
30
35
  export { Definitions as default };
@@ -0,0 +1,13 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class FilterModalController extends Controller {
4
+ static targets = ["modal"];
5
+
6
+ close(e) {
7
+ delete this.modalTarget.dataset.open;
8
+ }
9
+
10
+ open(e) {
11
+ this.modalTarget.dataset.open = "true";
12
+ }
13
+ }
@@ -25,6 +25,10 @@ module Katalyst
25
25
  end
26
26
  end
27
27
  end
28
+
29
+ def enum_attribute?(key)
30
+ _default_attributes[key].value.is_a?(::Array)
31
+ end
28
32
  end
29
33
 
30
34
  included do
@@ -39,11 +43,21 @@ module Katalyst
39
43
  clear_changes_information
40
44
  end
41
45
 
46
+ # Collections are filtered when any parameters have changed from their defaults.
47
+ def filtered?
48
+ filters.any?
49
+ end
50
+
42
51
  # Collections that do not include Sorting are never sortable.
43
52
  def sortable?
44
53
  false
45
54
  end
46
55
 
56
+ # Collections that do not include Query are never searchable.
57
+ def searchable?
58
+ false
59
+ end
60
+
47
61
  def apply(items)
48
62
  @items = items
49
63
  reducers.build do |_|
@@ -52,6 +66,22 @@ module Katalyst
52
66
  end.call(self)
53
67
  self
54
68
  end
69
+
70
+ def filter
71
+ # no-op by default
72
+ end
73
+
74
+ def filters
75
+ changes.except("sort", "page", "query").transform_values(&:second)
76
+ end
77
+
78
+ def model
79
+ if items < ActiveRecord::Base
80
+ items
81
+ else
82
+ items.model
83
+ end
84
+ end
55
85
  end
56
86
  end
57
87
  end
@@ -3,19 +3,90 @@
3
3
  module Katalyst
4
4
  module Tables
5
5
  module Collection
6
- using HasParams
6
+ module Filtering
7
+ extend ActiveSupport::Concern
7
8
 
8
- module Filtering # :nodoc:
9
- def filter
10
- # no-op by default
11
- end
9
+ DEFAULT_ATTRIBUTES = %w[sort page query].freeze
12
10
 
13
- def filtered?
14
- filters.any?
11
+ included do
12
+ use(Filter)
15
13
  end
16
14
 
17
- def filters
18
- changed_attributes.except("sort", "page")
15
+ class Filter
16
+ include ActiveRecord::Sanitization::ClassMethods
17
+
18
+ def initialize(app)
19
+ @app = app
20
+ end
21
+
22
+ def call(collection)
23
+ collection.class._default_attributes.each_value do |attribute|
24
+ key = attribute.name
25
+
26
+ next if DEFAULT_ATTRIBUTES.include?(key)
27
+
28
+ value = collection.attributes[key]
29
+
30
+ filter_attribute(collection, key, value, attribute.type.type)
31
+ end
32
+
33
+ @app.call(collection)
34
+ end
35
+
36
+ def filter_attribute(collection, key, value, type)
37
+ if key == "search"
38
+ search(collection, value)
39
+ elsif type == :string
40
+ filter_matches(collection, key, value)
41
+ elsif type == :boolean
42
+ filter_eq(collection, key, value) unless value.nil?
43
+ elsif value.present?
44
+ filter_eq(collection, key, value)
45
+ end
46
+ end
47
+
48
+ def search(collection, search)
49
+ return if search.blank? || !collection.searchable?
50
+
51
+ collection.items = collection.items.public_send(collection.config.search_scope, search)
52
+ end
53
+
54
+ def filter_matches(collection, key, value)
55
+ return if value.nil?
56
+
57
+ model, column = join_key(collection, key)
58
+ arel_column = model.arel_table[column]
59
+
60
+ collection.items = collection.items.where(arel_column.matches("%#{sanitize_sql_like(value)}%"))
61
+ end
62
+
63
+ def filter_eq(collection, key, value)
64
+ model, column = join_key(collection, key)
65
+
66
+ condition = if model.attribute_types.has_key?(column)
67
+ model.where(column => value)
68
+ else
69
+ model.public_send(column, value)
70
+ end
71
+
72
+ collection.items = collection.items.merge(condition)
73
+ end
74
+
75
+ private
76
+
77
+ def join_key(collection, key)
78
+ if key.include?(".")
79
+ table, column = key.split(".")
80
+ collection.items = collection.items.joins(table.to_sym)
81
+ [collection.items.reflections[table].klass, column]
82
+ else
83
+ [collection.items.model, key]
84
+ end
85
+ end
86
+
87
+ def column_for(key)
88
+ key.include?(".") ? key.split(".").last : key
89
+ end
19
90
  end
20
91
  end
21
92
  end
@@ -24,8 +24,8 @@ module Katalyst
24
24
  config_accessor :paginate
25
25
  end
26
26
 
27
- def initialize(paginate: config.paginate, **options)
28
- super(**options)
27
+ def initialize(paginate: config.paginate, **)
28
+ super(**)
29
29
 
30
30
  @paginate = paginate.freeze
31
31
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Query
7
+ class ArrayValueParser < ValueParser
8
+ # @param query [StringScanner]
9
+ def parse(query)
10
+ @query = query
11
+
12
+ skip_whitespace
13
+
14
+ if query.scan(/#{'\['}/)
15
+ take_values
16
+ else
17
+ take_value
18
+ end
19
+ end
20
+
21
+ def take_values
22
+ until query.eos?
23
+ skip_whitespace
24
+ break unless take_quoted_value || take_unquoted_value
25
+
26
+ skip_whitespace
27
+ break unless take_delimiter
28
+ end
29
+
30
+ skip_whitespace
31
+ take_end_of_list
32
+ end
33
+
34
+ def take_value
35
+ take_quoted_value || take_unquoted_value
36
+ end
37
+
38
+ def take_delimiter
39
+ query.scan(/#{','}/)
40
+ end
41
+
42
+ def take_end_of_list
43
+ query.scan(/#{']'}/)
44
+ end
45
+
46
+ def value=(value)
47
+ return if @attribute.type_cast(value).nil? # undefined attribute
48
+
49
+ current = @collection.attributes[@attribute.name]
50
+ @collection.assign_attributes(@attribute.name => current + [value])
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Query
7
+ class Parser # :nodoc:
8
+ # query [StringScanner]
9
+ attr_accessor :query
10
+ attr_reader :collection, :untagged
11
+
12
+ def initialize(collection)
13
+ @collection = collection
14
+ @untagged = []
15
+ end
16
+
17
+ # @param query [String]
18
+ def parse(query)
19
+ @query = StringScanner.new(query)
20
+
21
+ until @query.eos?
22
+ skip_whitespace
23
+
24
+ # break to ensure we don't loop indefinitely on bad input
25
+ break unless take_tagged || take_untagged
26
+ end
27
+
28
+ self
29
+ end
30
+
31
+ private
32
+
33
+ def skip_whitespace
34
+ query.scan(/\s+/)
35
+ end
36
+
37
+ def take_tagged
38
+ return unless query.scan(/(\w+(\.\w+)?):/)
39
+
40
+ key, = query.values_at(1)
41
+ parser_for(key).parse(query)
42
+ end
43
+
44
+ def take_untagged
45
+ return unless query.scan(/\S+/)
46
+
47
+ untagged << query.matched
48
+
49
+ untagged
50
+ end
51
+
52
+ def parser_for(key)
53
+ attribute = collection.class._default_attributes[key]
54
+
55
+ if collection.class.enum_attribute?(key)
56
+ ArrayValueParser.new(collection:, attribute:)
57
+ else
58
+ SingleValueParser.new(collection:, attribute:)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Query
7
+ class SingleValueParser < ValueParser
8
+ # @param query [StringScanner]
9
+ def parse(query)
10
+ @query = query
11
+
12
+ take_quoted_value || take_unquoted_value
13
+ end
14
+
15
+ def value=(value)
16
+ return if @attribute.type_cast(value).nil? # undefined attribute
17
+
18
+ @collection.assign_attributes(@attribute.name => value)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Query
7
+ class ValueParser
8
+ attr_accessor :query
9
+
10
+ def initialize(collection:, attribute:)
11
+ @collection = collection
12
+ @attribute = attribute
13
+ end
14
+
15
+ def take_quoted_value
16
+ return unless query.scan(/"([^"]*)"/)
17
+
18
+ self.value, = query.values_at(1)
19
+ end
20
+
21
+ def take_unquoted_value
22
+ return unless query.scan(/([^" \],]*)/)
23
+
24
+ self.value, = query.values_at(1)
25
+ end
26
+
27
+ def skip_whitespace
28
+ query.scan(/\s+/)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end