katalyst-tables 3.3.0 → 3.3.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/assets/builds/katalyst/tables.esm.js +332 -6
  4. data/app/assets/builds/katalyst/tables.js +332 -6
  5. data/app/assets/builds/katalyst/tables.min.js +1 -1
  6. data/app/assets/builds/katalyst/tables.min.js.map +1 -1
  7. data/app/assets/stylesheets/katalyst/tables/_index.scss +1 -1
  8. data/app/assets/stylesheets/katalyst/tables/_query.scss +109 -0
  9. data/app/assets/stylesheets/katalyst/tables/typed-columns/_date.scss +1 -1
  10. data/app/assets/stylesheets/katalyst/tables/typed-columns/_datetime.scss +1 -1
  11. data/app/components/katalyst/table_component.rb +13 -2
  12. data/app/components/katalyst/tables/cells/currency_component.rb +21 -2
  13. data/app/components/katalyst/tables/cells/number_component.rb +31 -1
  14. data/app/components/katalyst/tables/query/input_component.html.erb +12 -0
  15. data/app/components/katalyst/tables/query/input_component.rb +46 -0
  16. data/app/components/katalyst/tables/query/modal_component.html.erb +33 -0
  17. data/app/components/katalyst/tables/query/modal_component.rb +89 -0
  18. data/app/components/katalyst/tables/query_component.html.erb +8 -0
  19. data/app/components/katalyst/tables/{filter_component.rb → query_component.rb} +37 -30
  20. data/app/controllers/concerns/katalyst/tables/backend.rb +14 -0
  21. data/app/helpers/katalyst/tables/frontend.rb +14 -8
  22. data/app/javascript/tables/application.js +8 -3
  23. data/app/javascript/tables/query_controller.js +108 -0
  24. data/app/javascript/tables/query_input_controller.js +228 -0
  25. data/app/models/concerns/katalyst/tables/collection/core.rb +5 -3
  26. data/app/models/concerns/katalyst/tables/collection/query/array_value_parser.rb +13 -26
  27. data/app/models/concerns/katalyst/tables/collection/query/parser.rb +16 -13
  28. data/app/models/concerns/katalyst/tables/collection/query/single_value_parser.rb +11 -3
  29. data/app/models/concerns/katalyst/tables/collection/query/value_parser.rb +10 -5
  30. data/app/models/concerns/katalyst/tables/collection/query.rb +44 -6
  31. data/app/models/concerns/katalyst/tables/collection/sorting.rb +11 -1
  32. data/app/models/katalyst/tables/collection/base.rb +10 -0
  33. data/app/models/katalyst/tables/collection/filter.rb +10 -0
  34. data/app/models/katalyst/tables/collection/type/boolean.rb +11 -1
  35. data/app/models/katalyst/tables/collection/type/date.rb +19 -22
  36. data/app/models/katalyst/tables/collection/type/enum.rb +11 -0
  37. data/app/models/katalyst/tables/collection/type/float.rb +3 -39
  38. data/app/models/katalyst/tables/collection/type/helpers/delegate.rb +2 -22
  39. data/app/models/katalyst/tables/collection/type/helpers/extensions.rb +14 -0
  40. data/app/models/katalyst/tables/collection/type/helpers/multiple.rb +30 -0
  41. data/app/models/katalyst/tables/collection/type/helpers/range.rb +59 -0
  42. data/app/models/katalyst/tables/collection/type/integer.rb +3 -39
  43. data/app/models/katalyst/tables/collection/type/value.rb +22 -2
  44. metadata +12 -8
  45. data/app/assets/stylesheets/katalyst/tables/_filter.scss +0 -43
  46. data/app/components/katalyst/tables/filter/modal_component.html.erb +0 -25
  47. data/app/components/katalyst/tables/filter/modal_component.rb +0 -112
  48. data/app/components/katalyst/tables/filter_component.html.erb +0 -18
  49. data/app/javascript/tables/filter/modal_controller.js +0 -13
@@ -0,0 +1,228 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class QueryInputController extends Controller {
4
+ static targets = ["input", "highlight"];
5
+ static values = { query: String };
6
+
7
+ connect() {
8
+ this.queryValue = this.inputTarget.value;
9
+ }
10
+
11
+ update() {
12
+ this.queryValue = this.inputTarget.value;
13
+ }
14
+
15
+ queryValueChanged(query) {
16
+ this.highlightTarget.innerHTML = "";
17
+
18
+ new Parser().parse(query).tokens.forEach((token) => {
19
+ this.highlightTarget.appendChild(token.render());
20
+ });
21
+ }
22
+ }
23
+
24
+ class Parser {
25
+ constructor() {
26
+ this.tokens = [];
27
+ this.values = null;
28
+ }
29
+
30
+ parse(input) {
31
+ const query = new StringScanner(input);
32
+
33
+ while (!query.isEos()) {
34
+ this.push(this.skipWhitespace(query));
35
+
36
+ const value = this.takeTagged(query) || this.takeUntagged(query);
37
+
38
+ if (!this.push(value)) break;
39
+ }
40
+
41
+ return this;
42
+ }
43
+
44
+ push(token) {
45
+ if (token) {
46
+ this.values ? this.values.push(token) : this.tokens.push(token);
47
+ }
48
+
49
+ return !!token;
50
+ }
51
+
52
+ skipWhitespace(query) {
53
+ if (!query.scan(/\s+/)) return;
54
+
55
+ return new Token(query.matched());
56
+ }
57
+
58
+ takeUntagged(query) {
59
+ if (!query.scan(/\S+/)) return;
60
+
61
+ return new Untagged(query.matched());
62
+ }
63
+
64
+ takeTagged(query) {
65
+ if (!query.scan(/(\w+(?:\.\w+)?)(:\s*)/)) return;
66
+
67
+ const key = query.valueAt(1);
68
+ const separator = query.valueAt(2);
69
+
70
+ const value =
71
+ this.takeArrayValue(query) || this.takeSingleValue(query) || new Token();
72
+
73
+ return new Tagged(key, separator, value);
74
+ }
75
+
76
+ takeArrayValue(query) {
77
+ if (!query.scan(/\[\s*/)) return;
78
+
79
+ const start = new Token(query.matched());
80
+ const values = (this.values = []);
81
+
82
+ while (!query.isEos()) {
83
+ if (!this.push(this.takeSingleValue(query))) break;
84
+ if (!this.push(this.takeDelimiter(query))) break;
85
+ }
86
+
87
+ query.scan(/\s*]/);
88
+ const end = new Token(query.matched());
89
+
90
+ this.values = null;
91
+
92
+ return new Array(start, values, end);
93
+ }
94
+
95
+ takeDelimiter(query) {
96
+ if (!query.scan(/\s*,\s*/)) return;
97
+
98
+ return new Token(query.matched());
99
+ }
100
+
101
+ takeSingleValue(query) {
102
+ return this.takeQuotedValue(query) || this.takeUnquotedValue(query);
103
+ }
104
+
105
+ takeQuotedValue(query) {
106
+ if (!query.scan(/"([^"]*)"/)) return;
107
+
108
+ return new Value(query.matched());
109
+ }
110
+
111
+ takeUnquotedValue(query) {
112
+ if (!query.scan(/[^ \],]*/)) return;
113
+
114
+ return new Value(query.matched());
115
+ }
116
+ }
117
+
118
+ class Token {
119
+ constructor(value = "") {
120
+ this.value = value;
121
+ }
122
+
123
+ render() {
124
+ return document.createTextNode(this.value);
125
+ }
126
+ }
127
+
128
+ class Value extends Token {
129
+ render() {
130
+ const span = document.createElement("span");
131
+ span.className = "value";
132
+ span.innerText = this.value;
133
+
134
+ return span;
135
+ }
136
+ }
137
+
138
+ class Tagged extends Token {
139
+ constructor(key, separator, value) {
140
+ super();
141
+
142
+ this.key = key;
143
+ this.separator = separator;
144
+ this.value = value;
145
+ }
146
+
147
+ render() {
148
+ const span = document.createElement("span");
149
+ span.className = "tag";
150
+
151
+ const key = document.createElement("span");
152
+ key.className = "key";
153
+ key.innerText = this.key;
154
+
155
+ span.appendChild(key);
156
+ span.appendChild(document.createTextNode(this.separator));
157
+ span.appendChild(this.value.render());
158
+
159
+ return span;
160
+ }
161
+ }
162
+
163
+ class Untagged extends Token {
164
+ render() {
165
+ const span = document.createElement("span");
166
+ span.className = "untagged";
167
+ span.innerText = this.value;
168
+ return span;
169
+ }
170
+ }
171
+
172
+ class Array extends Token {
173
+ constructor(start, values, end) {
174
+ super();
175
+
176
+ this.start = start;
177
+ this.values = values;
178
+ this.end = end;
179
+ }
180
+
181
+ render() {
182
+ const array = document.createElement("span");
183
+ array.className = "array-values";
184
+ array.appendChild(this.start.render());
185
+
186
+ this.values.forEach((value) => {
187
+ const span = document.createElement("span");
188
+ span.appendChild(value.render());
189
+ array.appendChild(span);
190
+ });
191
+
192
+ array.appendChild(this.end.render());
193
+
194
+ return array;
195
+ }
196
+ }
197
+
198
+ class StringScanner {
199
+ constructor(input) {
200
+ this.input = input;
201
+ this.position = 0;
202
+ this.last = null;
203
+ }
204
+
205
+ isEos() {
206
+ return this.position >= this.input.length;
207
+ }
208
+
209
+ scan(regex) {
210
+ const match = regex.exec(this.input.substring(this.position));
211
+ if (match?.index === 0) {
212
+ this.last = match;
213
+ this.position += match[0].length;
214
+ return true;
215
+ } else {
216
+ this.last = {};
217
+ return false;
218
+ }
219
+ }
220
+
221
+ matched() {
222
+ return this.last && this.last[0];
223
+ }
224
+
225
+ valueAt(index) {
226
+ return this.last && this.last[index];
227
+ }
228
+ }
@@ -46,7 +46,7 @@ module Katalyst
46
46
  end
47
47
 
48
48
  included do
49
- attr_accessor :items
49
+ attr_accessor :items, :unscoped_items
50
50
 
51
51
  delegate :each, :count, :empty?, to: :items, allow_nil: true
52
52
  end
@@ -73,7 +73,7 @@ module Katalyst
73
73
  end
74
74
 
75
75
  def apply(items)
76
- @items = items
76
+ @unscoped_items = @items = items
77
77
  reducers.build do |_|
78
78
  filter
79
79
  self
@@ -86,7 +86,9 @@ module Katalyst
86
86
  end
87
87
 
88
88
  def filters
89
- changes.except("sort", "page", "query").transform_values(&:second)
89
+ changes
90
+ .select { |k, _| self.class._default_attributes[k].type.filterable? }
91
+ .transform_values(&:second)
90
92
  end
91
93
 
92
94
  def model
@@ -5,49 +5,36 @@ module Katalyst
5
5
  module Collection
6
6
  module Query
7
7
  class ArrayValueParser < ValueParser
8
+ def initialize(...)
9
+ super
10
+
11
+ @value = []
12
+ end
13
+
8
14
  # @param query [StringScanner]
9
15
  def parse(query)
10
16
  @query = query
11
17
 
12
- skip_whitespace
13
-
14
- if query.scan(/#{'\['}/)
15
- take_values
16
- else
17
- take_value
18
- end
19
- end
18
+ query.scan(/#{'\['}\s*/)
20
19
 
21
- def take_values
22
20
  until query.eos?
23
- skip_whitespace
24
21
  break unless take_quoted_value || take_unquoted_value
25
-
26
- skip_whitespace
27
22
  break unless take_delimiter
28
23
  end
29
24
 
30
- skip_whitespace
31
- take_end_of_list
32
- end
25
+ query.scan(/\s*#{'\]'}?/)
33
26
 
34
- def take_value
35
- take_quoted_value || take_unquoted_value
36
- end
27
+ @end = query.charpos
37
28
 
38
- def take_delimiter
39
- query.scan(/#{','}/)
29
+ self
40
30
  end
41
31
 
42
- def take_end_of_list
43
- query.scan(/#{']'}/)
32
+ def take_delimiter
33
+ query.scan(/\s*#{','}\s*/)
44
34
  end
45
35
 
46
36
  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])
37
+ @value << value
51
38
  end
52
39
  end
53
40
  end
@@ -7,11 +7,12 @@ module Katalyst
7
7
  class Parser # :nodoc:
8
8
  # query [StringScanner]
9
9
  attr_accessor :query
10
- attr_reader :collection, :untagged
10
+ attr_reader :collection, :untagged, :tagged
11
11
 
12
12
  def initialize(collection)
13
13
  @collection = collection
14
- @untagged = []
14
+ @tagged = {}
15
+ @untagged = []
15
16
  end
16
17
 
17
18
  # @param query [String]
@@ -25,10 +26,6 @@ module Katalyst
25
26
  break unless take_tagged || take_untagged
26
27
  end
27
28
 
28
- if untagged.any? && (search = collection.class.search_attribute)
29
- collection.assign_attributes(search => untagged.join(" "))
30
- end
31
-
32
29
  self
33
30
  end
34
31
 
@@ -39,12 +36,14 @@ module Katalyst
39
36
  end
40
37
 
41
38
  def take_tagged
39
+ start = query.charpos
40
+
42
41
  return unless query.scan(/(\w+(\.\w+)?):/)
43
42
 
44
43
  key, = query.values_at(1)
45
44
  skip_whitespace
46
45
 
47
- parser_for(key).parse(query)
46
+ tagged[key] = value_parser(start).parse(query)
48
47
  end
49
48
 
50
49
  def take_untagged
@@ -57,14 +56,18 @@ module Katalyst
57
56
 
58
57
  using Type::Helpers::Extensions
59
58
 
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:)
59
+ def value_parser(start)
60
+ if query.check(/#{'\['}\s*/)
61
+ ArrayValueParser.new(start:)
65
62
  else
66
- SingleValueParser.new(collection:, attribute:)
63
+ SingleValueParser.new(start:)
67
64
  end
65
+
66
+ # if attribute.type.multiple? || attribute.value.is_a?(::Array)
67
+ # ArrayValueParser.new(attribute:, pos:)
68
+ # else
69
+ # SingleValueParser.new(attribute:, pos:)
70
+ # end
68
71
  end
69
72
  end
70
73
  end
@@ -5,17 +5,25 @@ module Katalyst
5
5
  module Collection
6
6
  module Query
7
7
  class SingleValueParser < ValueParser
8
+ def initialize(...)
9
+ super
10
+
11
+ @value = nil
12
+ end
13
+
8
14
  # @param query [StringScanner]
9
15
  def parse(query)
10
16
  @query = query
11
17
 
12
18
  take_quoted_value || take_unquoted_value
19
+
20
+ @end = query.charpos
21
+
22
+ self
13
23
  end
14
24
 
15
25
  def value=(value)
16
- return if @attribute.type_cast(value).nil? # undefined attribute
17
-
18
- @collection.assign_attributes(@attribute.name => value)
26
+ @value = value
19
27
  end
20
28
  end
21
29
  end
@@ -5,11 +5,14 @@ module Katalyst
5
5
  module Collection
6
6
  module Query
7
7
  class ValueParser
8
- attr_accessor :query
8
+ attr_accessor :query, :value
9
9
 
10
- def initialize(collection:, attribute:)
11
- @collection = collection
12
- @attribute = attribute
10
+ def initialize(start:)
11
+ @start = start
12
+ end
13
+
14
+ def range
15
+ @start..@end
13
16
  end
14
17
 
15
18
  def take_quoted_value
@@ -19,7 +22,9 @@ module Katalyst
19
22
  end
20
23
 
21
24
  def take_unquoted_value
22
- return unless query.scan(/([^" \],]*)/)
25
+ # note, we allow unquoted values to begin with a " so that partial
26
+ # inputs can be accepted
27
+ return unless query.scan(/"?([^ \],]*)/)
23
28
 
24
29
  self.value, = query.values_at(1)
25
30
  end
@@ -13,20 +13,58 @@ module Katalyst
13
13
  _default_attributes.each_value do |attribute|
14
14
  return attribute.name if attribute.type.type == :search
15
15
  end
16
+
17
+ nil
16
18
  end
17
19
  end
18
20
 
19
21
  included do
20
- attribute :query, :query, default: ""
22
+ attribute :q, :query, default: ""
23
+ alias_attribute :query, :q
24
+
25
+ attribute :p, :integer, filter: false
26
+ alias_attribute :position, :p
27
+ end
28
+
29
+ using Type::Helpers::Extensions
30
+
31
+ def examples_for(key)
32
+ key = key.to_s
33
+ values_method = "#{key.parameterize.underscore}_values"
34
+ if respond_to?(values_method)
35
+ public_send(values_method)
36
+ elsif @attributes.key?(key)
37
+ @attributes[key].type.examples_for(unscoped_items, @attributes[key])
38
+ end
39
+ end
21
40
 
22
- # Note: this is defined inline so that we can overwrite query=
23
- def query=(value)
24
- query = super
41
+ def query_active?(attribute)
42
+ @attributes[attribute].query_range&.cover?(position)
43
+ end
44
+
45
+ private
25
46
 
26
- Parser.new(self).parse(query)
47
+ def _assign_attributes(new_attributes)
48
+ result = super
27
49
 
28
- query
50
+ if query_changed?
51
+ parser = Parser.new(self).parse(query)
52
+
53
+ parser.tagged.each do |k, p|
54
+ if @attributes.key?(k)
55
+ _assign_attribute(k, p.value)
56
+ @attributes[k].query_range = p.range
57
+ else
58
+ errors.add(k, :unknown)
59
+ end
60
+ end
61
+
62
+ if parser.untagged.any? && (search = self.class.search_attribute)
63
+ _assign_attribute(search, parser.untagged.join(" "))
64
+ end
29
65
  end
66
+
67
+ result
30
68
  end
31
69
  end
32
70
  end
@@ -35,6 +35,16 @@ module Katalyst
35
35
  { column:, direction: }
36
36
  end
37
37
  end
38
+
39
+ refine Symbol do
40
+ def to_param
41
+ to_s.to_param
42
+ end
43
+
44
+ def to_h
45
+ to_s.to_h
46
+ end
47
+ end
38
48
  end
39
49
 
40
50
  using SortParams
@@ -116,7 +126,7 @@ module Katalyst
116
126
 
117
127
  if collection.items.respond_to?(:"order_by_#{column}")
118
128
  collection.items = collection.items.reorder(nil).public_send(:"order_by_#{column}", direction.to_sym)
119
- elsif collection.items.model.has_attribute?(column)
129
+ elsif collection.model.has_attribute?(column)
120
130
  collection.items = collection.items.reorder(column => direction)
121
131
  end
122
132
 
@@ -48,6 +48,16 @@ module Katalyst
48
48
  self
49
49
  end
50
50
 
51
+ def apply(items)
52
+ if items.is_a?(Class) && items < ActiveRecord::Base
53
+ super(items.all)
54
+ elsif items.is_a?(ActiveRecord::Relation)
55
+ super
56
+ else
57
+ raise ArgumentError, "Collection requires an ActiveRecord scope, given #{items.class}"
58
+ end
59
+ end
60
+
51
61
  def inspect
52
62
  "#<#{self.class.name} @attributes=#{attributes.inspect} @model_name=\"#{model_name}\" @count=#{items&.count}>"
53
63
  end
@@ -69,6 +69,16 @@ module Katalyst
69
69
  end
70
70
  end
71
71
 
72
+ def apply(items)
73
+ if items.is_a?(Class) && items < ActiveRecord::Base
74
+ super(items.all)
75
+ elsif items.is_a?(ActiveRecord::Relation)
76
+ super
77
+ else
78
+ raise ArgumentError, "Collection requires an ActiveRecord scope, given #{items.class}"
79
+ end
80
+ end
81
+
72
82
  def inspect
73
83
  "#<#{self.class.name} @param_key=#{param_key.inspect} " +
74
84
  "@attributes=#{attributes.inspect} @model=\"#{model}\" @count=#{items&.count}>"
@@ -13,7 +13,17 @@ module Katalyst
13
13
  end
14
14
 
15
15
  def filter?(attribute, value)
16
- (!value.nil? && !value.eql?([])) || attribute.came_from_user?
16
+ return false unless filterable?
17
+
18
+ if attribute.came_from_user?
19
+ attribute.value_before_type_cast.present? || value === false
20
+ else
21
+ !value.nil? && !value.eql?([])
22
+ end
23
+ end
24
+
25
+ def examples_for(...)
26
+ [true, false]
17
27
  end
18
28
  end
19
29
  end
@@ -5,44 +5,41 @@ module Katalyst
5
5
  module Collection
6
6
  module Type
7
7
  class Date < Value
8
+ include Helpers::Range
9
+
10
+ define_range_patterns /\d{4}-\d\d-\d\d/
11
+
8
12
  def type
9
13
  :date
10
14
  end
11
15
 
12
16
  def serialize(value)
13
- if value.is_a?(Date)
17
+ if value.is_a?(::Date)
14
18
  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
19
  else
24
- value.to_s
20
+ super
25
21
  end
26
22
  end
27
23
 
24
+ def examples_for(...)
25
+ [
26
+ ::Date.current,
27
+ ::Date.yesterday,
28
+ ::Date.current.beginning_of_week..,
29
+ ::Date.current.beginning_of_month..,
30
+ ::Date.current.beginning_of_year..,
31
+ ].map { |d| serialize(d) }
32
+ end
33
+
28
34
  private
29
35
 
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/
36
+ ISO_DATE = /\A(?<year>\d{4})-(?<month>\d\d)-(?<day>\d\d)\z/
34
37
 
35
38
  def cast_value(value)
36
39
  return value unless value.is_a?(::String)
37
40
 
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))
41
+ if /\A(?<year>\d{4})-(?<month>\d\d)-(?<day>\d\d)\z/ =~ value
42
+ new_date(year.to_i, month.to_i, day.to_i)
46
43
  end
47
44
  end
48
45
 
@@ -14,6 +14,17 @@ module Katalyst
14
14
  def type
15
15
  :enum
16
16
  end
17
+
18
+ def examples_for(scope, attribute)
19
+ _, model, column = model_and_column_for(scope, attribute)
20
+ keys = model.defined_enums[column]&.keys
21
+
22
+ if attribute.value_before_type_cast.present?
23
+ keys.select { |key| key.include?(attribute.value_before_type_cast.last) }
24
+ else
25
+ keys
26
+ end
27
+ end
17
28
  end
18
29
  end
19
30
  end