katalyst-tables 3.1.0 → 3.3.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +27 -0
  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 +43 -0
  9. data/app/assets/stylesheets/katalyst/tables/_index.scss +1 -0
  10. data/app/assets/stylesheets/katalyst/tables/_select.scss +3 -0
  11. data/app/assets/stylesheets/katalyst/tables/_table.scss +3 -0
  12. data/app/assets/stylesheets/katalyst/tables/typed-columns/_enum.scss +9 -0
  13. data/app/assets/stylesheets/katalyst/tables/typed-columns/_index.scss +1 -0
  14. data/app/components/katalyst/table_component.rb +28 -0
  15. data/app/components/katalyst/tables/cells/enum_component.rb +27 -0
  16. data/app/components/katalyst/tables/filter/modal_component.html.erb +25 -0
  17. data/app/components/katalyst/tables/filter/modal_component.rb +112 -0
  18. data/app/components/katalyst/tables/filter_component.html.erb +18 -0
  19. data/app/components/katalyst/tables/filter_component.rb +91 -0
  20. data/app/helpers/katalyst/tables/frontend.rb +8 -0
  21. data/app/javascript/tables/application.js +5 -0
  22. data/app/javascript/tables/filter/modal_controller.js +13 -0
  23. data/app/models/concerns/katalyst/tables/collection/core.rb +44 -0
  24. data/app/models/concerns/katalyst/tables/collection/filtering.rb +17 -9
  25. data/app/models/concerns/katalyst/tables/collection/pagination.rb +1 -1
  26. data/app/models/concerns/katalyst/tables/collection/query/array_value_parser.rb +56 -0
  27. data/app/models/concerns/katalyst/tables/collection/query/parser.rb +73 -0
  28. data/app/models/concerns/katalyst/tables/collection/query/single_value_parser.rb +24 -0
  29. data/app/models/concerns/katalyst/tables/collection/query/value_parser.rb +34 -0
  30. data/app/models/concerns/katalyst/tables/collection/query.rb +34 -0
  31. data/app/models/concerns/katalyst/tables/collection/sorting.rb +1 -1
  32. data/app/models/katalyst/tables/collection/array.rb +0 -1
  33. data/app/models/katalyst/tables/collection/base.rb +0 -5
  34. data/app/models/katalyst/tables/collection/filter.rb +0 -1
  35. data/app/models/katalyst/tables/collection/type/boolean.rb +22 -0
  36. data/app/models/katalyst/tables/collection/type/date.rb +60 -0
  37. data/app/models/katalyst/tables/collection/type/enum.rb +21 -0
  38. data/app/models/katalyst/tables/collection/type/float.rb +57 -0
  39. data/app/models/katalyst/tables/collection/type/helpers/delegate.rb +50 -0
  40. data/app/models/katalyst/tables/collection/type/helpers/extensions.rb +28 -0
  41. data/app/models/katalyst/tables/collection/type/helpers/multiple.rb +30 -0
  42. data/app/models/katalyst/tables/collection/type/integer.rb +57 -0
  43. data/app/models/katalyst/tables/collection/type/query.rb +19 -0
  44. data/app/models/katalyst/tables/collection/type/search.rb +26 -0
  45. data/app/models/katalyst/tables/collection/type/string.rb +35 -0
  46. data/app/models/katalyst/tables/collection/type/value.rb +66 -0
  47. data/app/models/katalyst/tables/collection/type.rb +39 -0
  48. metadata +30 -3
@@ -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
@@ -69,6 +69,14 @@ module Katalyst
69
69
  render(component, &)
70
70
  end
71
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
+
72
80
  private
73
81
 
74
82
  def default_table_component_class
@@ -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,24 @@ module Katalyst
25
25
  end
26
26
  end
27
27
  end
28
+
29
+ using Type::Helpers::Extensions
30
+
31
+ def attribute(name, type = nil, default: (no_default = true), **)
32
+ type = type.is_a?(Symbol) ? resolve_type_name(type, **) : type || Type::Value.new
33
+
34
+ default = type.default_value if no_default
35
+
36
+ default.nil? && no_default ? super(name, type, **) : super
37
+ end
38
+
39
+ private
40
+
41
+ # @override ActiveModel::AttributeRegistration::ClassMethods#resolve_type_name()
42
+ def resolve_type_name(name, **)
43
+ # note, this is Katalyst::Tables::Collection::Type, not ActiveModel::Type
44
+ Type.lookup(name, **)
45
+ end
28
46
  end
29
47
 
30
48
  included do
@@ -39,11 +57,21 @@ module Katalyst
39
57
  clear_changes_information
40
58
  end
41
59
 
60
+ # Collections are filtered when any parameters have changed from their defaults.
61
+ def filtered?
62
+ filters.any?
63
+ end
64
+
42
65
  # Collections that do not include Sorting are never sortable.
43
66
  def sortable?
44
67
  false
45
68
  end
46
69
 
70
+ # Collections that do not include Query are never searchable.
71
+ def searchable?
72
+ false
73
+ end
74
+
47
75
  def apply(items)
48
76
  @items = items
49
77
  reducers.build do |_|
@@ -52,6 +80,22 @@ module Katalyst
52
80
  end.call(self)
53
81
  self
54
82
  end
83
+
84
+ def filter
85
+ # no-op by default
86
+ end
87
+
88
+ def filters
89
+ changes.except("sort", "page", "query").transform_values(&:second)
90
+ end
91
+
92
+ def model
93
+ if items < ActiveRecord::Base
94
+ items
95
+ else
96
+ items.model
97
+ end
98
+ end
55
99
  end
56
100
  end
57
101
  end
@@ -3,19 +3,27 @@
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
9
+ included do
10
+ use(Filter)
11
11
  end
12
12
 
13
- def filtered?
14
- filters.any?
15
- end
13
+ class Filter
14
+ include ActiveRecord::Sanitization::ClassMethods
15
+
16
+ def initialize(app)
17
+ @app = app
18
+ end
19
+
20
+ def call(collection)
21
+ collection.instance_variable_get(:@attributes).each_value do |attribute|
22
+ collection.items = attribute.type.filter(collection.items, attribute)
23
+ end
16
24
 
17
- def filters
18
- changed_attributes.except("sort", "page")
25
+ @app.call(collection)
26
+ end
19
27
  end
20
28
  end
21
29
  end
@@ -19,7 +19,7 @@ module Katalyst
19
19
  included do
20
20
  attr_accessor :pagination
21
21
 
22
- attribute :page, :integer, default: 1
22
+ attribute :page, :integer, default: 1, filter: false
23
23
 
24
24
  config_accessor :paginate
25
25
  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,73 @@
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
+ if untagged.any? && (search = collection.class.search_attribute)
29
+ collection.assign_attributes(search => untagged.join(" "))
30
+ end
31
+
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def skip_whitespace
38
+ query.scan(/\s+/)
39
+ end
40
+
41
+ def take_tagged
42
+ return unless query.scan(/(\w+(\.\w+)?):/)
43
+
44
+ key, = query.values_at(1)
45
+ skip_whitespace
46
+
47
+ parser_for(key).parse(query)
48
+ end
49
+
50
+ def take_untagged
51
+ return unless query.scan(/\S+/)
52
+
53
+ untagged << query.matched
54
+
55
+ untagged
56
+ end
57
+
58
+ using Type::Helpers::Extensions
59
+
60
+ def parser_for(key)
61
+ attribute = collection.class._default_attributes[key]
62
+
63
+ if attribute.type.multiple? || attribute.value.is_a?(::Array)
64
+ ArrayValueParser.new(collection:, attribute:)
65
+ else
66
+ SingleValueParser.new(collection:, attribute:)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ 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
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Query # :nodoc:
7
+ extend ActiveSupport::Concern
8
+
9
+ include Filtering
10
+
11
+ class_methods do
12
+ def search_attribute
13
+ _default_attributes.each_value do |attribute|
14
+ return attribute.name if attribute.type.type == :search
15
+ end
16
+ end
17
+ end
18
+
19
+ included do
20
+ attribute :query, :query, default: ""
21
+
22
+ # Note: this is defined inline so that we can overwrite query=
23
+ def query=(value)
24
+ query = super
25
+
26
+ Parser.new(self).parse(query)
27
+
28
+ query
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -40,7 +40,7 @@ module Katalyst
40
40
  using SortParams
41
41
 
42
42
  included do
43
- attribute :sort, :string
43
+ attribute :sort, :string, filter: false
44
44
 
45
45
  attr_reader :default_sort
46
46
  end
@@ -6,7 +6,6 @@ module Katalyst
6
6
  # Entry point for creating a collection from an array for use with table components.
7
7
  class Array
8
8
  include Core
9
- include Filtering
10
9
 
11
10
  def self.with_params(params)
12
11
  new.with_params(params)
@@ -23,7 +23,6 @@ module Katalyst
23
23
  # ````
24
24
  class Base
25
25
  include Core
26
- include Filtering
27
26
  include Pagination
28
27
  include Sorting
29
28
 
@@ -34,10 +33,6 @@ module Katalyst
34
33
  new.with_params(params)
35
34
  end
36
35
 
37
- def model
38
- items.model
39
- end
40
-
41
36
  def model_name
42
37
  @model_name ||= items.model_name.dup.tap do |name|
43
38
  name.param_key = ""
@@ -23,7 +23,6 @@ module Katalyst
23
23
  # ````
24
24
  class Filter
25
25
  include Core
26
- include Filtering
27
26
  include Pagination
28
27
  include Sorting
29
28
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Boolean < Value
8
+ include Helpers::Delegate
9
+ include Helpers::Multiple
10
+
11
+ def initialize(**)
12
+ super(**, delegate: ActiveModel::Type::Boolean)
13
+ end
14
+
15
+ def filter?(attribute, value)
16
+ (!value.nil? && !value.eql?([])) || attribute.came_from_user?
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Date < Value
8
+ def type
9
+ :date
10
+ end
11
+
12
+ def serialize(value)
13
+ if value.is_a?(Date)
14
+ value.to_fs(:db)
15
+ elsif value.is_a?(Range)
16
+ if value.begin.nil?
17
+ "<#{value.end.to_fs(:db)}"
18
+ elsif value.end.nil?
19
+ ">#{value.begin.to_fs(:db)}"
20
+ else
21
+ "#{value.begin.to_fs(:db)}..#{value.end.to_fs(:db)}"
22
+ end
23
+ else
24
+ value.to_s
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ ISO_DATE = /\A(\d{4})-(\d\d)-(\d\d)\z/
31
+ LOWER_BOUND = /\A>(\d{4})-(\d\d)-(\d\d)\z/
32
+ UPPER_BOUND = /\A<(\d{4})-(\d\d)-(\d\d)\z/
33
+ BOUNDED = /\A(\d{4})-(\d\d)-(\d\d)\.\.(\d{4})-(\d\d)-(\d\d)\z/
34
+
35
+ def cast_value(value)
36
+ return value unless value.is_a?(::String)
37
+
38
+ if value =~ ISO_DATE
39
+ new_date($1.to_i, $2.to_i, $3.to_i)
40
+ elsif value =~ LOWER_BOUND
41
+ (new_date($1.to_i, $2.to_i, $3.to_i)..)
42
+ elsif value =~ UPPER_BOUND
43
+ (..new_date($1.to_i, $2.to_i, $3.to_i))
44
+ elsif value =~ BOUNDED
45
+ (new_date($1.to_i, $2.to_i, $3.to_i)..new_date($4.to_i, $5.to_i, $6.to_i))
46
+ end
47
+ end
48
+
49
+ def new_date(year, mon, mday)
50
+ return nil if year.nil? || (year.zero? && mon.zero? && mday.zero?)
51
+
52
+ ::Date.new(year, mon, mday)
53
+ rescue ArgumentError, TypeError
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Enum < Value
8
+ include Helpers::Multiple
9
+
10
+ def initialize(multiple: true, **)
11
+ super
12
+ end
13
+
14
+ def type
15
+ :enum
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Float < Value
8
+ include Helpers::Delegate
9
+ include Helpers::Multiple
10
+
11
+ def initialize(**)
12
+ super(**, delegate: ActiveModel::Type::Float)
13
+ end
14
+
15
+ def serialize(value)
16
+ if value.is_a?(Range)
17
+ if value.begin.nil?
18
+ "<#{super(value.end)}"
19
+ elsif value.end.nil?
20
+ ">#{super(value.begin)}"
21
+ else
22
+ "#{super(value.begin)}..#{super(value.end)}"
23
+ end
24
+ else
25
+ super
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ FLOAT = /(-?\d+(?:\.\d+)?)/
32
+ SINGLE_VALUE = /\A#{FLOAT}\z/
33
+ LOWER_BOUND = /\A>#{FLOAT}\z/
34
+ UPPER_BOUND = /\A<#{FLOAT}\z/
35
+ BOUNDED = /\A#{FLOAT}\.\.#{FLOAT}\z/
36
+
37
+ def cast_value(value)
38
+ case value
39
+ when ::Range, ::Integer
40
+ value
41
+ when SINGLE_VALUE
42
+ super($1)
43
+ when LOWER_BOUND
44
+ ((super($1))..)
45
+ when UPPER_BOUND
46
+ (..(super($1)))
47
+ when BOUNDED
48
+ ((super($1))..(super($2)))
49
+ else
50
+ super
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end