katalyst-tables 3.1.0 → 3.3.0

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