katalyst-tables 3.4.6 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/katalyst/tables.esm.js +15 -3
  3. data/app/assets/builds/katalyst/tables.js +15 -3
  4. data/app/assets/builds/katalyst/tables.min.js +1 -1
  5. data/app/assets/builds/katalyst/tables.min.js.map +1 -1
  6. data/app/assets/stylesheets/katalyst/tables/_query.scss +20 -20
  7. data/app/components/katalyst/tables/query/input_component.html.erb +7 -6
  8. data/app/components/katalyst/tables/query/input_component.rb +23 -7
  9. data/app/components/katalyst/tables/query/modal_component.html.erb +7 -17
  10. data/app/components/katalyst/tables/query/modal_component.rb +7 -62
  11. data/app/components/katalyst/tables/query/suggestion_component.html.erb +3 -0
  12. data/app/components/katalyst/tables/query/suggestion_component.rb +35 -0
  13. data/app/components/katalyst/tables/query_component.rb +0 -1
  14. data/app/javascript/tables/query_controller.js +10 -3
  15. data/app/javascript/tables/query_input_controller.js +5 -0
  16. data/app/models/concerns/katalyst/tables/collection/query/array_value_parser.rb +19 -1
  17. data/app/models/concerns/katalyst/tables/collection/query/parser.rb +12 -11
  18. data/app/models/concerns/katalyst/tables/collection/query/single_value_parser.rb +10 -0
  19. data/app/models/concerns/katalyst/tables/collection/query/untagged_literal.rb +36 -0
  20. data/app/models/concerns/katalyst/tables/collection/query/value_parser.rb +11 -2
  21. data/app/models/concerns/katalyst/tables/collection/query.rb +11 -26
  22. data/app/models/concerns/katalyst/tables/collection/suggestions.rb +120 -0
  23. data/app/models/katalyst/tables/suggestions/attribute.rb +13 -0
  24. data/app/models/katalyst/tables/suggestions/base.rb +31 -0
  25. data/app/models/katalyst/tables/suggestions/constant_value.rb +28 -0
  26. data/app/models/katalyst/tables/suggestions/custom_value.rb +26 -0
  27. data/app/models/katalyst/tables/suggestions/database_value.rb +36 -0
  28. data/app/models/katalyst/tables/suggestions/search_value.rb +13 -0
  29. data/config/locales/tables.en.yml +9 -1
  30. data/lib/katalyst/tables/collection/type/boolean.rb +10 -2
  31. data/lib/katalyst/tables/collection/type/date.rb +7 -5
  32. data/lib/katalyst/tables/collection/type/enum.rb +4 -4
  33. data/lib/katalyst/tables/collection/type/helpers/extensions.rb +1 -11
  34. data/lib/katalyst/tables/collection/type/value.rb +14 -12
  35. metadata +12 -3
  36. data/lib/katalyst/tables/collection/type/example.rb +0 -30
@@ -8,6 +8,7 @@ module Katalyst
8
8
  include Katalyst::Tables::Frontend
9
9
 
10
10
  renders_one :footer
11
+ renders_many :suggestions, SuggestionComponent
11
12
 
12
13
  attr_reader :collection, :url
13
14
 
@@ -17,6 +18,12 @@ module Katalyst
17
18
  @collection = collection
18
19
  end
19
20
 
21
+ def before_render
22
+ collection.suggestions.each do |suggestion|
23
+ with_suggestion(suggestion:)
24
+ end
25
+ end
26
+
20
27
  private
21
28
 
22
29
  def default_html_attributes
@@ -28,68 +35,6 @@ module Katalyst
28
35
  },
29
36
  }
30
37
  end
31
-
32
- using Collection::Type::Helpers::Extensions
33
-
34
- def show_examples?
35
- current_key && attributes[current_key]
36
- end
37
-
38
- def current_key
39
- unless instance_variable_defined?(:@current_key)
40
- attributes.each_key do |key|
41
- @current_key = key if collection.query_active?(key)
42
- end
43
- end
44
-
45
- @current_key ||= nil
46
- end
47
-
48
- def attributes
49
- collection.class.attribute_types
50
- .select { |_, a| a.filterable? && a.type != :search }
51
- .to_h
52
- end
53
-
54
- def available_filters
55
- keys = attributes.keys
56
-
57
- if current_token.present?
58
- keys = keys.select { |k| k.include?(current_token) }
59
- end
60
-
61
- keys.map do |key|
62
- [key, collection.model.human_attribute_name(key)]
63
- end
64
- end
65
-
66
- def examples_for(key)
67
- collection.examples_for(key).filter_map do |example|
68
- case example
69
- when Collection::Type::Example
70
- example if example.value.to_s.present?
71
- else
72
- raise ArgumentError, "Invalid example #{example.inspect} for #{collection.model_name}.#{key}"
73
- end
74
- end
75
- end
76
-
77
- def format_value(value)
78
- if /\A[\w.-]*\z/.match?(value.to_s)
79
- value.to_s
80
- else
81
- %("#{value}")
82
- end
83
- end
84
-
85
- def current_token
86
- return nil unless collection.position&.in?(0..collection.query.length)
87
-
88
- prefix = collection.query[...collection.position].match(/\w*\z/)
89
- suffix = collection.query[collection.position..].match(/\A\w*/)
90
-
91
- "#{prefix}#{suffix}"
92
- end
93
38
  end
94
39
  end
95
40
  end
@@ -0,0 +1,3 @@
1
+ <%= tag.li(**html_attributes) do %>
2
+ <span class="value"><%= value %></span>
3
+ <% end %>
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Query
6
+ class SuggestionComponent < ViewComponent::Base
7
+ include Katalyst::HtmlAttributes
8
+
9
+ delegate :type, :value, to: :@suggestion
10
+
11
+ def initialize(suggestion:, **)
12
+ super(**)
13
+
14
+ @suggestion = suggestion
15
+ end
16
+
17
+ def default_html_attributes
18
+ {
19
+ class: ["suggestion", type.to_s],
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def format_value(value)
26
+ if /\A[\w.-]*\z/.match?(value.to_s)
27
+ value.to_s
28
+ else
29
+ %("#{value}")
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -86,7 +86,6 @@ module Katalyst
86
86
  click->tables--query#openModal:stop
87
87
  focusin@window->tables--query#closeModal
88
88
  focusin->tables--query#openModal:stop
89
- keydown.esc->tables--query#clear:stop
90
89
  submit->tables--query#submit
91
90
  input->tables--query#update
92
91
  ],
@@ -37,11 +37,18 @@ export default class QueryController extends Controller {
37
37
  document.addEventListener("selectionchange", this.selection);
38
38
  }
39
39
 
40
+ /**
41
+ * If the user presses escape once, clear the input.
42
+ * If the user presses escape again, get them out of here.
43
+ */
40
44
  clear() {
41
45
  if (this.query.value === "") {
42
- // if the user presses escape once, browser clears the input
43
- // if the user presses escape again, get them out of here
44
46
  this.closeModal();
47
+ } else {
48
+ this.query.value = "";
49
+ this.query.dispatchEvent(new Event("input"));
50
+ this.query.dispatchEvent(new Event("change"));
51
+ this.update();
45
52
  }
46
53
  }
47
54
 
@@ -95,7 +102,7 @@ export default class QueryController extends Controller {
95
102
  }
96
103
 
97
104
  get query() {
98
- return this.element.querySelector("input[type=search]");
105
+ return this.element.querySelector("[role=searchbox]");
99
106
  }
100
107
 
101
108
  get position() {
@@ -6,6 +6,11 @@ export default class QueryInputController extends Controller {
6
6
 
7
7
  connect() {
8
8
  this.queryValue = this.inputTarget.value;
9
+ this.element.dataset.connected = "";
10
+ }
11
+
12
+ disconnect() {
13
+ delete this.element.dataset.connected;
9
14
  }
10
15
 
11
16
  update() {
@@ -18,6 +18,7 @@ module Katalyst
18
18
  query.scan(/#{'\['}\s*/)
19
19
 
20
20
  until query.eos?
21
+ @value_start = query.charpos
21
22
  break unless take_quoted_value || take_unquoted_value
22
23
  break unless take_delimiter
23
24
  end
@@ -33,8 +34,25 @@ module Katalyst
33
34
  query.scan(/\s*#{','}\s*/)
34
35
  end
35
36
 
37
+ def value
38
+ @value.map(&:value)
39
+ end
40
+
36
41
  def value=(value)
37
- @value << value
42
+ @value << Value.new(value, @value_start, @query.charpos)
43
+ end
44
+
45
+ def value_at(position)
46
+ @value.detect { |v| v.range.cover?(position) }&.value
47
+ end
48
+
49
+ class Value
50
+ attr_accessor :range, :value
51
+
52
+ def initialize(value, start, fin)
53
+ @value = value
54
+ @range = (start..fin)
55
+ end
38
56
  end
39
57
  end
40
58
  end
@@ -29,6 +29,11 @@ module Katalyst
29
29
  self
30
30
  end
31
31
 
32
+ def token_at_position(position:)
33
+ tagged.values.detect { |v| v.range.cover?(position) } ||
34
+ untagged.detect { |v| v.range.cover?(position) }
35
+ end
36
+
32
37
  private
33
38
 
34
39
  def skip_whitespace
@@ -43,31 +48,27 @@ module Katalyst
43
48
  key, = query.values_at(1)
44
49
  skip_whitespace
45
50
 
46
- tagged[key] = value_parser(start).parse(query)
51
+ tagged[key] = value_parser(key, start).parse(query)
47
52
  end
48
53
 
49
54
  def take_untagged
55
+ start = query.charpos
56
+
50
57
  return unless query.scan(/\S+/)
51
58
 
52
- untagged << query.matched
59
+ untagged << UntaggedLiteral.new(value: query.matched, start:)
53
60
 
54
61
  untagged
55
62
  end
56
63
 
57
64
  using Type::Helpers::Extensions
58
65
 
59
- def value_parser(start)
66
+ def value_parser(key, start)
60
67
  if query.check(/#{'\['}\s*/)
61
- ArrayValueParser.new(start:)
68
+ ArrayValueParser.new(key:, start:)
62
69
  else
63
- SingleValueParser.new(start:)
70
+ SingleValueParser.new(key:, start:)
64
71
  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
71
72
  end
72
73
  end
73
74
  end
@@ -15,6 +15,8 @@ module Katalyst
15
15
  def parse(query)
16
16
  @query = query
17
17
 
18
+ @value_start = query.charpos
19
+
18
20
  take_quoted_value || take_unquoted_value
19
21
 
20
22
  @end = query.charpos
@@ -22,9 +24,17 @@ module Katalyst
22
24
  self
23
25
  end
24
26
 
27
+ def value
28
+ @value
29
+ end
30
+
25
31
  def value=(value)
26
32
  @value = value
27
33
  end
34
+
35
+ def value_at(position)
36
+ @value if (@value_start..@end).cover?(position)
37
+ end
28
38
  end
29
39
  end
30
40
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Query
7
+ class UntaggedLiteral
8
+ attr_accessor :query, :value
9
+
10
+ def initialize(value:, start:)
11
+ @value = value
12
+ @start = start
13
+ @end = start + value.length
14
+ end
15
+
16
+ def literal?
17
+ true
18
+ end
19
+
20
+ def tagged?
21
+ false
22
+ end
23
+
24
+ def range
25
+ @start..@end
26
+ end
27
+
28
+ def to_str
29
+ @value
30
+ end
31
+ alias to_s to_str
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -5,12 +5,21 @@ module Katalyst
5
5
  module Collection
6
6
  module Query
7
7
  class ValueParser
8
- attr_accessor :query, :value
8
+ attr_accessor :query, :key
9
9
 
10
- def initialize(start:)
10
+ def initialize(key:, start:)
11
+ @key = key
11
12
  @start = start
12
13
  end
13
14
 
15
+ def literal?
16
+ false
17
+ end
18
+
19
+ def tagged?
20
+ true
21
+ end
22
+
14
23
  def range
15
24
  @start..@end
16
25
  end
@@ -7,6 +7,7 @@ module Katalyst
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  include Filtering
10
+ include Suggestions
10
11
 
11
12
  class_methods do
12
13
  def search_attribute
@@ -21,26 +22,13 @@ module Katalyst
21
22
  included do
22
23
  attribute :q, :query, default: ""
23
24
  alias_attribute :query, :q
24
-
25
- attribute :p, :integer, filter: false
26
- alias_attribute :position, :p
27
25
  end
28
26
 
29
- using Type::Helpers::Extensions
30
-
31
- def examples_for(key)
32
- key = key.to_s
33
- examples_method = "#{key.parameterize.underscore}_examples"
34
- if respond_to?(examples_method)
35
- public_send(examples_method)&.map { |e| e.is_a?(Example) ? e : Example.new(example) }
36
- elsif @attributes.key?(key)
37
- @attributes[key].type.examples_for(unscoped_items, @attributes[key])
38
- end
27
+ def searchable?
28
+ self.class.search_attribute.present?
39
29
  end
40
30
 
41
- def query_active?(attribute)
42
- @attributes[attribute].query_range&.cover?(position)
43
- end
31
+ using Type::Helpers::Extensions
44
32
 
45
33
  private
46
34
 
@@ -48,19 +36,16 @@ module Katalyst
48
36
  result = super
49
37
 
50
38
  if query_changed?
51
- parser = Parser.new(self).parse(query)
39
+ @query_parser = Parser.new(self).parse(query)
40
+
41
+ @query_parser.tagged.each do |k, p|
42
+ next unless @attributes.key?(k)
52
43
 
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
44
+ _assign_attribute(k, p.value)
60
45
  end
61
46
 
62
- if parser.untagged.any? && (search = self.class.search_attribute)
63
- _assign_attribute(search, parser.untagged.join(" "))
47
+ if @query_parser.untagged.any? && (search = self.class.search_attribute)
48
+ _assign_attribute(search, @query_parser.untagged.map(&:value).join(" "))
64
49
  end
65
50
  end
66
51
 
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Collection
6
+ module Suggestions # :nodoc:
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ attribute :p, :integer, filter: false
11
+ alias_attribute :position, :p
12
+ end
13
+
14
+ using Type::Helpers::Extensions
15
+
16
+ def suggestions(position: self.position)
17
+ query_token = token_at_position(position:)
18
+
19
+ attribute = attribute_for_token(query_token:)
20
+ method = suggestions_method(attribute) if attribute.present?
21
+
22
+ # build a suggestions list
23
+ suggestions = if method && respond_to?(method)
24
+ user_suggestions(attribute:, method:)
25
+ elsif attribute
26
+ value_suggestions(attribute:)
27
+ else
28
+ attribute_suggestions(query_token:)
29
+ end
30
+
31
+ add_context_suggestions(suggestions:, query_token:, attribute:) if query_token
32
+
33
+ suggestions
34
+ end
35
+
36
+ private
37
+
38
+ def attribute_for_token(query_token:)
39
+ return unless query_token&.tagged?
40
+
41
+ attribute = suggestable_attributes[query_token.key]
42
+
43
+ return unless attribute
44
+
45
+ # construct an attribute from the input token, so we can focus on what the user is currently typing instead
46
+ # of searching for the whole input for this attribute (e.g. if we're constructing an array filter)
47
+ ActiveModel::Attribute.from_user(attribute.name, query_token.value_at(position),
48
+ attribute.type, attribute)
49
+ end
50
+
51
+ # Augments suggestions to ensure the user always has some feedback on their input.
52
+ def add_context_suggestions(suggestions:, query_token:, attribute:)
53
+ if query_token.tagged?
54
+ if !attribute
55
+ # user has entered a `:` but we don't know the attribute
56
+ errors.add(:query, :unknown_key, input: query_token.key)
57
+ elsif suggestions.none?
58
+ # the user might know more than us about what values are valid
59
+ suggestions << Tables::Suggestions::SearchValue.new(query_token.value_at(position))
60
+ end
61
+ elsif searchable?
62
+ # user is typing an untagged search term, indicate they can continue
63
+ suggestions << Tables::Suggestions::SearchValue.new(query_token.value)
64
+ else
65
+ errors.add(:query, :no_untagged_search, input: query_token.value)
66
+ end
67
+ end
68
+
69
+ def attribute_suggestions(query_token:)
70
+ attributes = suggestable_attributes.values
71
+
72
+ if query_token&.literal?
73
+ attributes = attributes.select { |a| a.name.include?(query_token.value) }
74
+ end
75
+
76
+ attributes.map { |a| Tables::Suggestions::Attribute.new(a.name) }
77
+ end
78
+
79
+ def user_suggestions(attribute:, method:)
80
+ suggestions = public_send(method, attribute)
81
+
82
+ raise TypeError, "Suggestions must be an array" unless suggestions.is_a?(Enumerable)
83
+
84
+ suggestions.map do |suggestion|
85
+ case suggestion
86
+ when Tables::Suggestions::Base
87
+ suggestion
88
+ else
89
+ Tables::Suggestions::CustomValue.new(suggestion, name: attribute.name, type: attribute.type)
90
+ end
91
+ end
92
+ end
93
+
94
+ def value_suggestions(attribute:)
95
+ attribute.type.suggestions(unscoped_items, attribute)
96
+ end
97
+
98
+ def suggestions_method(attribute)
99
+ :"#{attribute.name.parameterize.underscore}_suggestions" if attribute.present?
100
+ end
101
+
102
+ def suggestable_attributes
103
+ @attributes.keys.filter_map do |name|
104
+ attribute = @attributes[name]
105
+
106
+ # skip if the attribute can't generate useful suggestions
107
+ next unless attribute.type.filterable?
108
+ next if attribute.type.type == :search
109
+
110
+ [name, attribute]
111
+ end.to_h
112
+ end
113
+
114
+ def token_at_position(position: self.position)
115
+ @query_parser&.token_at_position(position:)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Suggestions
6
+ class Attribute < Base
7
+ def type
8
+ :attribute
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Suggestions
6
+ class Base
7
+ attr_reader :value
8
+
9
+ def initialize(value)
10
+ @value = value
11
+ end
12
+
13
+ def type
14
+ raise NotImplementedError
15
+ end
16
+
17
+ def hash
18
+ [self.class, value].hash
19
+ end
20
+
21
+ def eql?(other)
22
+ other.class.eql?(self.class) && other.value.eql?(value)
23
+ end
24
+
25
+ def inspect
26
+ "#<#{self.class.name} value: #{value.inspect}>"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Suggestions
6
+ class ConstantValue < Base
7
+ delegate :to_param, to: :@attribute_type
8
+
9
+ def initialize(name:, type:, model:, column:, value:)
10
+ super(value)
11
+
12
+ @attribute_type = type
13
+ @model = model
14
+ @column = column
15
+ @name = name
16
+ end
17
+
18
+ def type
19
+ :constant_value
20
+ end
21
+
22
+ def value
23
+ to_param(@value)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Suggestions
6
+ class CustomValue < Base
7
+ delegate :to_param, to: :@attribute_type
8
+
9
+ def initialize(value, name:, type:)
10
+ super(value)
11
+
12
+ @name = name
13
+ @attribute_type = type
14
+ end
15
+
16
+ def type
17
+ :custom_value
18
+ end
19
+
20
+ def value
21
+ to_param(@value)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Suggestions
6
+ class DatabaseValue < Base
7
+ attr_reader :model, :column
8
+
9
+ delegate :to_param, to: :@attribute_type
10
+
11
+ def initialize(name:, type:, model:, column:, value:)
12
+ super(value)
13
+
14
+ @attribute_type = type
15
+ @model = model
16
+ @column = column
17
+ @name = name
18
+ end
19
+
20
+ def type
21
+ :database_value
22
+ end
23
+
24
+ using Tables::Collection::Type::Helpers::Extensions
25
+
26
+ def value
27
+ if @attribute_type.multiple? && @value.is_a?(Array) && @value.length == 1
28
+ to_param(@value.first)
29
+ else
30
+ to_param(@value)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Tables
5
+ module Suggestions
6
+ class SearchValue < Base
7
+ def type
8
+ :search_value
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end