katalyst-tables 3.4.6 → 3.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/katalyst/tables.esm.js +16 -4
  3. data/app/assets/builds/katalyst/tables.js +16 -4
  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/_ordinal.scss +1 -1
  7. data/app/assets/stylesheets/katalyst/tables/_query.scss +20 -20
  8. data/app/components/katalyst/tables/query/input_component.html.erb +7 -6
  9. data/app/components/katalyst/tables/query/input_component.rb +23 -7
  10. data/app/components/katalyst/tables/query/modal_component.html.erb +7 -17
  11. data/app/components/katalyst/tables/query/modal_component.rb +7 -62
  12. data/app/components/katalyst/tables/query/suggestion_component.html.erb +3 -0
  13. data/app/components/katalyst/tables/query/suggestion_component.rb +35 -0
  14. data/app/components/katalyst/tables/query_component.rb +0 -1
  15. data/app/components/katalyst/tables/selectable/form_component.html.erb +1 -7
  16. data/app/components/katalyst/tables/selectable/form_component.rb +14 -1
  17. data/app/javascript/tables/query_controller.js +11 -4
  18. data/app/javascript/tables/query_input_controller.js +5 -0
  19. data/app/models/concerns/katalyst/tables/collection/query/array_value_parser.rb +19 -1
  20. data/app/models/concerns/katalyst/tables/collection/query/parser.rb +12 -11
  21. data/app/models/concerns/katalyst/tables/collection/query/single_value_parser.rb +10 -0
  22. data/app/models/concerns/katalyst/tables/collection/query/untagged_literal.rb +36 -0
  23. data/app/models/concerns/katalyst/tables/collection/query/value_parser.rb +11 -2
  24. data/app/models/concerns/katalyst/tables/collection/query.rb +11 -26
  25. data/app/models/concerns/katalyst/tables/collection/suggestions.rb +120 -0
  26. data/app/models/katalyst/tables/suggestions/attribute.rb +13 -0
  27. data/app/models/katalyst/tables/suggestions/base.rb +31 -0
  28. data/app/models/katalyst/tables/suggestions/constant_value.rb +28 -0
  29. data/app/models/katalyst/tables/suggestions/custom_value.rb +26 -0
  30. data/app/models/katalyst/tables/suggestions/database_value.rb +36 -0
  31. data/app/models/katalyst/tables/suggestions/search_value.rb +13 -0
  32. data/config/locales/tables.en.yml +9 -1
  33. data/lib/katalyst/tables/collection/type/boolean.rb +10 -2
  34. data/lib/katalyst/tables/collection/type/date.rb +7 -5
  35. data/lib/katalyst/tables/collection/type/enum.rb +4 -21
  36. data/lib/katalyst/tables/collection/type/helpers/extensions.rb +1 -11
  37. data/lib/katalyst/tables/collection/type/value.rb +14 -12
  38. metadata +12 -3
  39. 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
  ],
@@ -1,10 +1,4 @@
1
- <%= form_with(method: :patch,
2
- id:,
3
- class: "tables--selection--form",
4
- data: { controller: form_controller,
5
- turbo_action: "replace",
6
- turbo_permanent: "" },
7
- html: { action: false, hidden: "" }) do |form| %>
1
+ <%= form_with(method: :patch, **html_attributes) do |form| %>
8
2
  <p class="tables--selection--summary">
9
3
  <span data-<%= form_target("count") %>>0</span>
10
4
  <span data-<%= form_target("singular") %> hidden><%= singular_name %></span>
@@ -4,6 +4,7 @@ module Katalyst
4
4
  module Tables
5
5
  module Selectable
6
6
  class FormComponent < ViewComponent::Base # :nodoc:
7
+ include Katalyst::HtmlAttributes
7
8
  include Katalyst::Tables::Identifiable::Defaults
8
9
 
9
10
  attr_reader :id, :primary_key
@@ -14,7 +15,7 @@ module Katalyst
14
15
  def initialize(collection:,
15
16
  id: nil,
16
17
  primary_key: :id)
17
- super
18
+ super()
18
19
 
19
20
  @collection = collection
20
21
  @id = id || Selectable.default_form_id(collection)
@@ -27,6 +28,18 @@ module Katalyst
27
28
 
28
29
  private
29
30
 
31
+ def default_html_attributes
32
+ {
33
+ id:,
34
+ class: "tables--selection--form",
35
+ data: {
36
+ controller: form_controller,
37
+ turbo_action: "replace",
38
+ },
39
+ html: { action: false, hidden: "" },
40
+ }
41
+ end
42
+
30
43
  def form_controller
31
44
  FORM_CONTROLLER
32
45
  end
@@ -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
 
@@ -83,7 +90,7 @@ export default class QueryController extends Controller {
83
90
  };
84
91
 
85
92
  selection = () => {
86
- if (this.isFocused) this.update();
93
+ if (this.isFocused && this.query.value.length > 0) this.update();
87
94
  };
88
95
 
89
96
  beforeMorphAttribute(e) {
@@ -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