katalyst-tables 3.2.0 → 3.3.1

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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +25 -0
  4. data/app/assets/stylesheets/katalyst/tables/_filter.scss +9 -0
  5. data/app/components/katalyst/tables/filter/modal_component.html.erb +6 -6
  6. data/app/components/katalyst/tables/filter/modal_component.rb +57 -11
  7. data/app/components/katalyst/tables/filter_component.html.erb +1 -3
  8. data/app/models/concerns/katalyst/tables/collection/core.rb +19 -3
  9. data/app/models/concerns/katalyst/tables/collection/filtering.rb +2 -65
  10. data/app/models/concerns/katalyst/tables/collection/pagination.rb +1 -1
  11. data/app/models/concerns/katalyst/tables/collection/query/parser.rb +9 -1
  12. data/app/models/concerns/katalyst/tables/collection/query.rb +12 -20
  13. data/app/models/concerns/katalyst/tables/collection/sorting.rb +1 -1
  14. data/app/models/katalyst/tables/collection/type/boolean.rb +22 -0
  15. data/app/models/katalyst/tables/collection/type/date.rb +60 -0
  16. data/app/models/katalyst/tables/collection/type/enum.rb +21 -0
  17. data/app/models/katalyst/tables/collection/type/float.rb +57 -0
  18. data/app/models/katalyst/tables/collection/type/helpers/delegate.rb +50 -0
  19. data/app/models/katalyst/tables/collection/type/helpers/extensions.rb +28 -0
  20. data/app/models/katalyst/tables/collection/type/helpers/multiple.rb +30 -0
  21. data/app/models/katalyst/tables/collection/type/integer.rb +57 -0
  22. data/app/models/katalyst/tables/collection/type/query.rb +19 -0
  23. data/app/models/katalyst/tables/collection/type/search.rb +26 -0
  24. data/app/models/katalyst/tables/collection/type/string.rb +35 -0
  25. data/app/models/katalyst/tables/collection/type/value.rb +66 -0
  26. data/app/models/katalyst/tables/collection/type.rb +39 -0
  27. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc8fcc04168c193e4efc390aea35902880a43634861792f099a6307deee8b1ce
4
- data.tar.gz: 87a482c6703813d83cc99a71f54fc5eb23ec3fdb71d3aebd21ad0cf83afde7ce
3
+ metadata.gz: 1431a1666522842053ab70ae3da4f2610bf6fd819be4fe289d992f17c78e0d87
4
+ data.tar.gz: be2d0612b1620a052c58e6115b46e388ce3b54bd732d86ba45681bc679e19d68
5
5
  SHA512:
6
- metadata.gz: b48963d7042ca31ee030a5f8b822ec0a2869a80d5f488baff838d113c238df1629576e3c12b9927c34c51f0e2ff0d556b8335fd2525e61cc0339130e0ea22b8e
7
- data.tar.gz: 6c88896aa14bdb09dc8495b730812909b2b97ce3b6819409032398e0dcb1f475de709b3394584f3a501ed88847a84dca4054232a5b8ef401e3a5560060ca3f04
6
+ metadata.gz: 9d04228e59216901da2268c3aac3dc6076f623fca6c61121a896b215d278265ae2a2fc5f06df97652be02a53a409eefe08fa7d21892a8d28c5d5457efe90a138
7
+ data.tar.gz: 25adbd55a147762f4a28aea24f194032eef8e1c27167bfca66a40bd48055b7a018235c94bbe95d6b1a7fe4d49d62d9f5c402034936a0755b394f923cab3668dc
data/CHANGELOG.md CHANGED
@@ -1,7 +1,24 @@
1
+ ## [3.3.0]
2
+ - Custom types for collections which support extensions for filtering with ranges and arrays.
3
+
4
+ Note that if you have custom types defined for use in your collections, this could be a breaking
5
+ change. We recommend you change your types to inherit from `Katalyst::Tables::Collections::Type::Value`
6
+ and register them with `Katalyst::Tables::Collections::Type`.
7
+
8
+ `Query` and `Filtering` are still optional extensions. We recommend that you add them to a
9
+ based collection class in your application, e.g. `Admin::Collection`.
10
+
1
11
  ## [3.2.0]
2
12
  - Enum columns
3
13
  - Filter component (still in development, optional extension)
4
14
 
15
+ Note: this release adds a dedicated registry for collection filtering types
16
+ instead of using the default provided by ActiveModel.
17
+
18
+ If you have custom types that you use with tables collections,
19
+ you will need to register them with Katalyst::Tables::Collection::Type
20
+ in an initializer or similar.
21
+
5
22
  ## [3.1.0]
6
23
  - Introduce summary tables
7
24
  - Update ruby requirement >= 3.3
data/README.md CHANGED
@@ -37,6 +37,8 @@ This gem provides entry points for backend and frontend concerns:
37
37
  * `Katalyst::Tables::Frontend` provides `table_with` for inline table generation,
38
38
  * `Katalyst::Tables::Collection::Base` provides a default entry point for
39
39
  building collections in your controller actions
40
+ * `Katalyst::Tables::Collection::Query` provides build-in query parsing and filtering
41
+ based on the attributes defined in your collection.
40
42
 
41
43
  ### Frontend
42
44
 
@@ -206,6 +208,29 @@ detect features such as sorting and generate the appropriate table header links.
206
208
  <%= table_with(collection:) %>
207
209
  ```
208
210
 
211
+ ### Query
212
+
213
+ Include `Katalyst::Tables::Collection::Query` into your collection to add automatic
214
+ query parsing and filtering based on the configured attributes. For example:
215
+
216
+ ```ruby
217
+ class Collection < Katalyst::Tables::Collection::Base
218
+ include Katalyst::Tables::Collection::Query
219
+
220
+ attribute :first_name, :string
221
+ attribute :created_at, :date
222
+ end
223
+ ```
224
+
225
+ With this definition and a text-input named `query`, your users can write tagged
226
+ query expressions such as `first_name:Aaron` or `created_at:>2024-01-01` and these
227
+ will be automatically parsed and applied to the collection attribute, and the collection
228
+ will automatically generate and apply ActiveRecord conditions to filter the given scope.
229
+
230
+ There's also a frontend utility, `filter_with(collection:)` that will generate the form
231
+ and show a modal that helps users to interact with the query interface. More details available
232
+ in the [query](docs/query.md) documentation.
233
+
209
234
  ## Summary tables
210
235
  You can use the `Katalyst::SummaryTableComponent` to render a single record utilizing all the functionality from the
211
236
  `Katalyst::TableComponent`.
@@ -26,6 +26,15 @@
26
26
  pointer-events: unset;
27
27
  }
28
28
 
29
+ table {
30
+ table-layout: fixed;
31
+ }
32
+
33
+ th.label,
34
+ th.key {
35
+ width: 15%;
36
+ }
37
+
29
38
  .footer {
30
39
  display: flex;
31
40
  justify-content: flex-end;
@@ -2,17 +2,17 @@
2
2
  <table>
3
3
  <thead>
4
4
  <tr>
5
- <th></th>
6
- <th>Key</th>
7
- <th>Values</th>
5
+ <th class="label"></th>
6
+ <th class="key">Key</th>
7
+ <th class="values">Values</th>
8
8
  </tr>
9
9
  </thead>
10
10
  <tbody>
11
11
  <% attributes.each do |key, attribute| %>
12
12
  <tr>
13
- <th><%= collection.model.human_attribute_name(key) %></th>
14
- <td><%= key %></td>
15
- <td><%= values_for(key, attribute) %></td>
13
+ <th class="label"><%= collection.model.human_attribute_name(key) %></th>
14
+ <td class="key"><%= key %></td>
15
+ <td class="values"><%= values_for(key, attribute) %></td>
16
16
  </tr>
17
17
  <% end %>
18
18
  </tbody>
@@ -30,27 +30,73 @@ module Katalyst
30
30
  }
31
31
  end
32
32
 
33
+ using Collection::Type::Helpers::Extensions
34
+
33
35
  def attributes
34
- collection.class.attribute_types.except(*DEFAULT_ATTRIBUTES)
36
+ collection.class.attribute_types
37
+ .select { |_, a| a.filterable? && a.type != :search }
35
38
  end
36
39
 
37
40
  def values_for(key, attribute)
38
41
  values_method = "#{key.parameterize.underscore}_values"
39
- if attribute.type == :boolean
42
+ if collection.respond_to?(values_method)
43
+ return scope_values(attribute, values_method)
44
+ end
45
+
46
+ case attribute.type
47
+ when :boolean
40
48
  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
+ when :date
50
+ date_values
51
+ when :integer
52
+ integer_values(attribute)
53
+ when :float
54
+ float_values(attribute)
55
+ when :enum
56
+ enum_values(key)
57
+ when :string
58
+ string_values(attribute)
59
+ end
60
+ end
61
+
62
+ def scope_values(attribute, values_method)
63
+ values = collection.public_send(values_method)
64
+ attribute.multiple? ? render_array(*values) : render_options(*values)
65
+ end
66
+
67
+ def date_values
68
+ render_options("YYYY-MM-DD", ">YYYY-MM-DD", "<YYYY-MM-DD", "YYYY-MM-DD..YYYY-MM-DD")
69
+ end
70
+
71
+ def string_values(attribute)
72
+ options = render_options("example", '"an example"')
73
+ safe_join([options, attribute.exact? ? "(exact match)" : "(fuzzy match)"], " ")
74
+ end
75
+
76
+ def enum_values(key)
77
+ enums = collection.model.defined_enums
78
+
79
+ render_array(*enums[key].keys) if enums.has_key?(key)
80
+ end
81
+
82
+ def float_values(attribute)
83
+ if attribute.multiple?
84
+ render_array("0.5", "1", "...")
85
+ else
86
+ render_options("0.5", ">0.5", "<0.5", "-0.5..0.5")
87
+ end
88
+ end
89
+
90
+ def integer_values(attribute)
91
+ if attribute.multiple?
92
+ render_array("0", "1", "...")
93
+ else
94
+ render_options("10", ">10", "<10", "0..10")
49
95
  end
50
96
  end
51
97
 
52
98
  def render_option(value)
53
- "<code>#{value}</code>".html_safe # rubocop:disable Rails/OutputSafety
99
+ tag.code(value.to_s)
54
100
  end
55
101
 
56
102
  def render_options(*values)
@@ -6,13 +6,11 @@
6
6
  <%= form.text_field(
7
7
  :query,
8
8
  type: :search,
9
- size: :full,
10
- label: nil,
11
9
  autocomplete: "off",
12
10
  **input_attributes,
13
11
  ) %>
14
12
 
15
- <%= form.submit("Apply") %>
13
+ <%= form.button("Apply", type: :submit, name: nil) %>
16
14
  <% end %>
17
15
  <% end %>
18
16
 
@@ -26,8 +26,22 @@ module Katalyst
26
26
  end
27
27
  end
28
28
 
29
- def enum_attribute?(key)
30
- _default_attributes[key].value.is_a?(::Array)
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, **)
31
45
  end
32
46
  end
33
47
 
@@ -72,7 +86,9 @@ module Katalyst
72
86
  end
73
87
 
74
88
  def filters
75
- changes.except("sort", "page", "query").transform_values(&:second)
89
+ changes
90
+ .select { |k, _| self.class._default_attributes[k].type.filterable? }
91
+ .transform_values(&:second)
76
92
  end
77
93
 
78
94
  def model
@@ -6,8 +6,6 @@ module Katalyst
6
6
  module Filtering
7
7
  extend ActiveSupport::Concern
8
8
 
9
- DEFAULT_ATTRIBUTES = %w[sort page query].freeze
10
-
11
9
  included do
12
10
  use(Filter)
13
11
  end
@@ -20,73 +18,12 @@ module Katalyst
20
18
  end
21
19
 
22
20
  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)
21
+ collection.instance_variable_get(:@attributes).each_value do |attribute|
22
+ collection.items = attribute.type.filter(collection.items, attribute)
31
23
  end
32
24
 
33
25
  @app.call(collection)
34
26
  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
90
27
  end
91
28
  end
92
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
@@ -25,6 +25,10 @@ module Katalyst
25
25
  break unless take_tagged || take_untagged
26
26
  end
27
27
 
28
+ if untagged.any? && (search = collection.class.search_attribute)
29
+ collection.assign_attributes(search => untagged.join(" "))
30
+ end
31
+
28
32
  self
29
33
  end
30
34
 
@@ -38,6 +42,8 @@ module Katalyst
38
42
  return unless query.scan(/(\w+(\.\w+)?):/)
39
43
 
40
44
  key, = query.values_at(1)
45
+ skip_whitespace
46
+
41
47
  parser_for(key).parse(query)
42
48
  end
43
49
 
@@ -49,10 +55,12 @@ module Katalyst
49
55
  untagged
50
56
  end
51
57
 
58
+ using Type::Helpers::Extensions
59
+
52
60
  def parser_for(key)
53
61
  attribute = collection.class._default_attributes[key]
54
62
 
55
- if collection.class.enum_attribute?(key)
63
+ if attribute.type.multiple? || attribute.value.is_a?(::Array)
56
64
  ArrayValueParser.new(collection:, attribute:)
57
65
  else
58
66
  SingleValueParser.new(collection:, attribute:)
@@ -8,35 +8,27 @@ module Katalyst
8
8
 
9
9
  include Filtering
10
10
 
11
- included do
12
- config_accessor :search_scope
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
13
18
 
14
- attribute :query, :string, default: ""
15
- attribute :search, :string, default: ""
19
+ included do
20
+ attribute :q, :query, default: ""
21
+ alias_attribute :query, :q
16
22
 
17
23
  # Note: this is defined inline so that we can overwrite query=
18
- def query=(value)
24
+ def q=(value)
19
25
  query = super
20
26
 
21
- parser = Parser.new(self).parse(query)
22
-
23
- if searchable? && parser.untagged.any?
24
- self.search = parser.untagged.join(" ")
25
- end
27
+ Parser.new(self).parse(query)
26
28
 
27
29
  query
28
30
  end
29
31
  end
30
-
31
- # Returns true if the collection supports untagged searching. This
32
- # requires config.search_scope to be set to the name of the scope to use
33
- # in the target record for untagged text searches. If not set, untagged
34
- # search terms will be silently ignored.
35
- #
36
- # @return [true, false]
37
- def searchable?
38
- config.search_scope.present?
39
- end
40
32
  end
41
33
  end
42
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
@@ -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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ module Helpers
8
+ # Lifts a delegating type from value to arrays of values
9
+ module Delegate
10
+ delegate :type, to: :@delegate
11
+
12
+ def initialize(delegate:, **arguments)
13
+ super(**arguments)
14
+
15
+ @delegate = delegate.new(**arguments.except(:filter, :multiple, :scope))
16
+ end
17
+
18
+ using Extensions
19
+
20
+ def deserialize(value)
21
+ if multiple? && value.is_a?(::Array)
22
+ value.map { |v| @delegate.deserialize(v) }
23
+ else
24
+ @delegate.deserialize(value)
25
+ end
26
+ end
27
+
28
+ def serialize(value)
29
+ if multiple? && value.is_a?(::Array)
30
+ value.map { |v| @delegate.serialize(v) }
31
+ else
32
+ @delegate.serialize(value)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def cast_value(value)
39
+ if multiple? && value.is_a?(::Array)
40
+ value.map { |v| @delegate.cast(v) }
41
+ else
42
+ @delegate.cast(value)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ module Helpers
8
+ # Adds support default_value, multiple?, and filterable? to ActiveModel::Type::Value
9
+ module Extensions
10
+ refine(::ActiveModel::Type::Value) do
11
+ def default_value
12
+ nil
13
+ end
14
+
15
+ def multiple?
16
+ false
17
+ end
18
+
19
+ def filterable?
20
+ false
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ module Helpers
8
+ # Adds support for multiple: true
9
+ module Multiple
10
+ def initialize(multiple: false, **)
11
+ super(**)
12
+
13
+ @multiple = multiple
14
+ end
15
+
16
+ def multiple?
17
+ @multiple
18
+ end
19
+
20
+ using Extensions
21
+
22
+ def default_value
23
+ multiple? ? [] : super
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ 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 Integer < Value
8
+ include Helpers::Delegate
9
+ include Helpers::Multiple
10
+
11
+ def initialize(**)
12
+ super(**, delegate: ActiveModel::Type::Integer)
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
+ INTEGER = /(-?\d+)/
32
+ SINGLE_VALUE = /\A#{INTEGER}\z/
33
+ LOWER_BOUND = /\A>#{INTEGER}\z/
34
+ UPPER_BOUND = /\A<#{INTEGER}\z/
35
+ BOUNDED = /\A#{INTEGER}\.\.#{INTEGER}\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
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Query < Value
8
+ def type
9
+ :query
10
+ end
11
+
12
+ def filterable?
13
+ false
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Search < Value
8
+ # @overwrite Value.initialize() to require scope
9
+ # rubocop:disable Lint/UselessMethodDefinition
10
+ def initialize(scope:, **)
11
+ super
12
+ end
13
+ # rubocop:enable Lint/UselessMethodDefinition
14
+
15
+ def type
16
+ :search
17
+ end
18
+
19
+ def filter_condition(model, _, value)
20
+ model.public_send(scope, value)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class String < Value
8
+ include ActiveRecord::Sanitization::ClassMethods
9
+
10
+ attr_reader :exact
11
+ alias_method :exact?, :exact
12
+
13
+ delegate :type, :serialize, :deserialize, :cast, to: :@delegate
14
+
15
+ def initialize(exact: false, **)
16
+ super(**)
17
+
18
+ @exact = exact
19
+ @delegate = ActiveModel::Type::String.new
20
+ end
21
+
22
+ private
23
+
24
+ def filter_condition(model, column, value)
25
+ if exact? || scope
26
+ super
27
+ else
28
+ model.where(model.arel_table[column].matches("%#{sanitize_sql_like(value)}%"))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Type
7
+ class Value < ActiveModel::Type::Value
8
+ using Helpers::Extensions
9
+
10
+ attr_reader :scope
11
+
12
+ def initialize(scope: nil, filter: true)
13
+ super()
14
+
15
+ @scope = scope
16
+ @filterable = filter
17
+ end
18
+
19
+ def filterable?
20
+ @filterable
21
+ end
22
+
23
+ def filter?(attribute, value)
24
+ filterable? && (value.present? || attribute.came_from_user?)
25
+ end
26
+
27
+ def filter(scope, attribute)
28
+ value = filter_value(attribute)
29
+
30
+ return scope unless filter?(attribute, value)
31
+
32
+ scope, model, column = model_and_column_for(scope, attribute)
33
+ condition = filter_condition(model, column, value)
34
+
35
+ scope.merge(condition)
36
+ end
37
+
38
+ private
39
+
40
+ def filter_value(attribute)
41
+ attribute.value
42
+ end
43
+
44
+ def filter_condition(model, column, value)
45
+ if value.nil?
46
+ model.none
47
+ elsif scope
48
+ model.public_send(scope, value)
49
+ else
50
+ model.where(column => value)
51
+ end
52
+ end
53
+
54
+ def model_and_column_for(scope, attribute)
55
+ if attribute.name.include?(".")
56
+ table, column = attribute.name.split(".")
57
+ [scope.joins(table.to_sym), scope.model.reflections[table].klass, column]
58
+ else
59
+ [scope, scope.model, attribute.name]
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_model/type"
4
+
5
+ module Katalyst
6
+ module Tables
7
+ module Collection
8
+ # Based on ActiveModel::Type – provides a registry for Collection filtering
9
+ module Type
10
+ @registry = ActiveModel::Type::Registry.new
11
+
12
+ class << self
13
+ attr_accessor :registry # :nodoc:
14
+
15
+ def register(type_name, klass = nil, &)
16
+ registry.register(type_name, klass, &)
17
+ end
18
+
19
+ def lookup(...)
20
+ registry.lookup(...)
21
+ end
22
+
23
+ def default_value
24
+ @default_value ||= Value.new
25
+ end
26
+ end
27
+
28
+ register(:boolean, Type::Boolean)
29
+ register(:date, Type::Date)
30
+ register(:enum, Type::Enum)
31
+ register(:float, Type::Float)
32
+ register(:integer, Type::Integer)
33
+ register(:string, Type::String)
34
+ register(:query, Type::Query)
35
+ register(:search, Type::Search)
36
+ end
37
+ end
38
+ end
39
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-tables
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-19 00:00:00.000000000 Z
11
+ date: 2024-06-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: katalyst-html-attributes
@@ -132,6 +132,19 @@ files:
132
132
  - app/models/katalyst/tables/collection/array.rb
133
133
  - app/models/katalyst/tables/collection/base.rb
134
134
  - app/models/katalyst/tables/collection/filter.rb
135
+ - app/models/katalyst/tables/collection/type.rb
136
+ - app/models/katalyst/tables/collection/type/boolean.rb
137
+ - app/models/katalyst/tables/collection/type/date.rb
138
+ - app/models/katalyst/tables/collection/type/enum.rb
139
+ - app/models/katalyst/tables/collection/type/float.rb
140
+ - app/models/katalyst/tables/collection/type/helpers/delegate.rb
141
+ - app/models/katalyst/tables/collection/type/helpers/extensions.rb
142
+ - app/models/katalyst/tables/collection/type/helpers/multiple.rb
143
+ - app/models/katalyst/tables/collection/type/integer.rb
144
+ - app/models/katalyst/tables/collection/type/query.rb
145
+ - app/models/katalyst/tables/collection/type/search.rb
146
+ - app/models/katalyst/tables/collection/type/string.rb
147
+ - app/models/katalyst/tables/collection/type/value.rb
135
148
  - config/importmap.rb
136
149
  - config/locales/tables.en.yml
137
150
  - lib/katalyst/tables.rb