katalyst-tables 3.0.0 → 3.2.0

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