querylane 0.1.0 → 0.3.0

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/CONTRIBUTING.md +1 -1
  4. data/README.md +2 -0
  5. data/app/assets/stylesheets/querylane.css +28 -6
  6. data/app/javascript/querylane/controllers/box_controller.js +1 -1
  7. data/app/javascript/querylane/controllers/results_controller.js +12 -1
  8. data/app/views/querylane/_chips.html.erb +2 -1
  9. data/app/views/querylane/_denied.html.erb +2 -1
  10. data/app/views/querylane/_empty.html.erb +10 -4
  11. data/app/views/querylane/_facets.html.erb +3 -2
  12. data/app/views/querylane/_first_load.html.erb +2 -1
  13. data/app/views/querylane/_form.html.erb +19 -9
  14. data/app/views/querylane/_group.html.erb +2 -1
  15. data/app/views/querylane/_help.html.erb +3 -2
  16. data/app/views/querylane/_options.html.erb +5 -3
  17. data/app/views/querylane/_options_row.html.erb +18 -7
  18. data/app/views/querylane/_outcome.html.erb +5 -3
  19. data/app/views/querylane/_pager.html.erb +3 -2
  20. data/app/views/querylane/_preview.html.erb +2 -1
  21. data/app/views/querylane/_record.html.erb +2 -1
  22. data/app/views/querylane/_related.html.erb +2 -1
  23. data/app/views/querylane/_results.html.erb +2 -2
  24. data/app/views/querylane/_rows.html.erb +2 -1
  25. data/app/views/querylane/_saved.html.erb +15 -6
  26. data/app/views/querylane/_suggest_box.html.erb +10 -7
  27. data/app/views/querylane/_suggestions.html.erb +2 -2
  28. data/app/views/querylane/_toolbar.html.erb +2 -1
  29. data/app/views/querylane/_unavailable.html.erb +2 -1
  30. data/app/views/querylane/_writes.html.erb +4 -3
  31. data/config/locales/querylane.en.yml +32 -0
  32. data/lib/querylane/chips.rb +24 -7
  33. data/lib/querylane/configuration.rb +9 -0
  34. data/lib/querylane/controller.rb +5 -1
  35. data/lib/querylane/copy.rb +28 -0
  36. data/lib/querylane/definition.rb +44 -10
  37. data/lib/querylane/parser.rb +16 -13
  38. data/lib/querylane/presenter.rb +19 -7
  39. data/lib/querylane/query.rb +59 -17
  40. data/lib/querylane/request.rb +32 -13
  41. data/lib/querylane/rewriter.rb +44 -2
  42. data/lib/querylane/search.rb +12 -0
  43. data/lib/querylane/searchers/meilisearch.rb +19 -2
  44. data/lib/querylane/version.rb +1 -1
  45. data/lib/querylane.rb +1 -0
  46. metadata +3 -2
@@ -1,8 +1,9 @@
1
- <%# A search box with suggestions for any page (M7 header, M13 palette).
2
- The form posts the text for suggestions into the list frame; the hidden
3
- submitters post it to the search page (full page, never in a URL).
4
- Locals: id, url, search_url, mode (:header or :palette), label, placeholder,
5
- trigger (palette: show its button; ⌘K / Ctrl-K always opens it). %>
1
+ <%# locals: (id:, url:, search_url:, mode:, label:, placeholder:, trigger:) -%>
2
+ <%# A search box with suggestions for any page (M7 header, M13 palette), in a
3
+ search landmark. The form posts the text for suggestions into the list
4
+ frame; the hidden submitters post it to the search page (full page, never
5
+ in a URL). mode is :header or :palette; trigger (palette) shows its
6
+ button; ⌘K / Ctrl-K always opens it. %>
6
7
  <% palette = mode == :palette %>
7
8
  <div class="ql-suggest ql-suggest-<%= mode %>" data-controller="querylane--suggest"
8
9
  data-querylane--suggest-debounce-value="<%= Querylane.configuration.debounce_ms %>"
@@ -17,11 +18,12 @@
17
18
  <dialog class="ql-palette" aria-label="<%= label %>" data-querylane--suggest-target="dialog"
18
19
  data-action="close->querylane--suggest#closed click->querylane--suggest#backdrop">
19
20
  <% end %>
20
- <%= form_with url: url, method: :post, id: "#{id}-form", class: "ql-suggest-form", html: {role: "search"},
21
+ <search class="ql-search">
22
+ <%= form_with url: url, method: :post, id: "#{id}-form", class: "ql-suggest-form",
21
23
  data: {turbo: true, turbo_frame: "#{id}-list", action: "submit->querylane--suggest#submitted"} do %>
22
24
  <%= hidden_field_tag :revision, 0, id: nil, data: {querylane__suggest_target: "revision"} %>
23
25
  <svg class="ql-box-icon" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="8.5" cy="8.5" r="6" fill="none" stroke="currentColor" stroke-width="2"/><path d="M13 13l5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
24
- <label class="visually-hidden" for="<%= id %>-input"><%= label %></label>
26
+ <label class="ql-visually-hidden" for="<%= id %>-input"><%= label %></label>
25
27
  <input type="text" id="<%= id %>-input" name="<%= Querylane.configuration.param_name %>" class="ql-suggest-input"
26
28
  placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
27
29
  role="combobox" aria-autocomplete="list" aria-controls="<%= id %>-list" aria-expanded="false"
@@ -31,6 +33,7 @@
31
33
  <button type="submit" formaction="<%= search_url %>" data-turbo="false" hidden tabindex="-1" data-querylane--suggest-target="searchAll"></button>
32
34
  <button type="submit" formaction="<%= search_url %>" name="options" value="1" data-turbo="false" hidden tabindex="-1" data-querylane--suggest-target="openOptions"></button>
33
35
  <% end %>
36
+ </search>
34
37
  <div class="ql-suggest-pop" hidden data-querylane--suggest-target="pop">
35
38
  <turbo-frame id="<%= id %>-list" class="ql-suggest-list" role="listbox" aria-label="<%= label %>"
36
39
  data-querylane--suggest-target="list" data-action="combobox-commit->querylane--suggest#commit"></turbo-frame>
@@ -1,6 +1,6 @@
1
+ <%# locals: (result:, presenter:, groups:, id:) -%>
1
2
  <%# Suggestions: operator completions, people (groups), go-to. Options carry
2
- no query text: a completion holds only its word, people their own Action.
3
- Locals: result, presenter, groups, id. %>
3
+ no query text: a completion holds only its word, people their own Action. %>
4
4
  <turbo-frame id="<%= id %>-list" class="ql-suggest-list" role="listbox" data-revision="<%= result.revision %>">
5
5
  <% if result.completions.any? %>
6
6
  <div role="group" aria-labelledby="<%= id %>-operators">
@@ -1,4 +1,5 @@
1
- <%# Count, sort, page size and pager; controls belong to the search form. Locals: result, id, per_id. %>
1
+ <%# locals: (result:, id:, per_id:) -%>
2
+ <%# Count, sort, page size and pager; controls belong to the search form. %>
2
3
  <div class="ql-toolbar">
3
4
  <div class="ql-count">
4
5
  <%= case result.total_kind
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# The backend did not answer: nothing was searched. Hosts override this
2
- partial to add their own way round (e.g. a roster page). Locals: result, id. %>
3
+ partial to add their own way round (e.g. a roster page). %>
3
4
  <div class="ql-state ql-state-unavailable" role="status">
4
5
  <h2><%= t("querylane.states.unavailable_title") %></h2>
5
6
  <p><%= t("querylane.states.unavailable") %></p>
@@ -1,6 +1,7 @@
1
- <%# The options panel's "Writes" line: what Search would put in the box, with
2
- sensitive values masked. Its own frame, refreshed as fields change.
3
- Locals: preview (Querylane::Preview), id. %>
1
+ <%# locals: (preview:, id:) -%>
2
+ <%# The options panel's "Writes" line (a Querylane::Preview): what Search
3
+ would put in the box, with sensitive values masked. Its own frame,
4
+ refreshed as fields change. %>
4
5
  <turbo-frame id="<%= id %>-writes" class="ql-writes" aria-live="polite">
5
6
  <div class="ql-writes-line">
6
7
  <span class="ql-writes-label"><%= t("querylane.options.writes") %></span>
@@ -44,6 +44,9 @@ en:
44
44
  title: "Save this search"
45
45
  name: "Name"
46
46
  save: "Save"
47
+ delete: "Delete"
48
+ delete_label: "Delete saved search %{name}"
49
+ delete_confirm: "Delete this saved search?"
47
50
  yours: "Your saved searches"
48
51
  none: "Nothing saved yet."
49
52
  used: "used %{date}"
@@ -53,14 +56,41 @@ en:
53
56
  refusal_alone: "Remove it first."
54
57
  notice:
55
58
  saved: "Search saved."
59
+ deleted: "Search deleted."
60
+ not_available: "Saved search is not available."
56
61
  refused: "This search was not saved: it holds a member ID, SSN or last four."
57
62
  store_failed: "Saved searches are unavailable right now."
58
63
  reasons:
59
64
  more:
60
65
  one: "+1 more"
61
66
  other: "+%{count} more"
67
+ # Query errors (Querylane::Query::MESSAGES holds the same English as defaults).
68
+ # Values a user typed are interpolated; a sensitive value never is.
62
69
  errors:
63
70
  parked: "%{key}: is not available yet."
71
+ unknown_operator: "%{key}: is not an operator."
72
+ did_you_mean: "%{key}: is not an operator. Did you mean %{suggestion}:?"
73
+ filter_value: "\"%{value}\" is not a valid %{key}: value."
74
+ filter_value_hidden: "That value is not a valid %{key}: value."
75
+ flag_value: "%{value} is not a value of %{key}:. Try %{values}."
76
+ text_in_or: "OR joins operators, not names. Search one name at a time."
77
+ text_in_not: "- and NOT apply to operators, not names."
78
+ text_in_group: "Parentheses group operators, not names."
79
+ one_text_operator: "Search %{operators} one at a time, or combine with %{filters}."
80
+ one_text_operator_alone: "Search %{operators} one at a time."
81
+ # Joins the operator and filter lists of the two messages above.
82
+ list_or: " or "
83
+ too_long: "Search is too long (%{max} characters at most)."
84
+ close_quote: "Close the quote."
85
+ needs_value: "%{key}: needs a value."
86
+ unexpected: "Unexpected %{text}."
87
+ or_needs_terms: "OR needs a term on both sides."
88
+ needs_term: "%{operator} needs a term."
89
+ ended_early: "Search ended early."
90
+ too_deep: "Too many nested parentheses (%{max} at most)."
91
+ close_parenthesis: "Add the closing )."
92
+ empty_parentheses: "Empty parentheses."
93
+ too_many_terms: "Too many terms (%{max} at most)."
64
94
  group:
65
95
  more:
66
96
  one: "1 more record for this person"
@@ -107,6 +137,7 @@ en:
107
137
  facets:
108
138
  find: "Find a %{facet}…"
109
139
  find_label: "Find a %{facet} value"
140
+ label: "Filters"
110
141
  note: "Counts are records. Ticking more boxes in one group widens the search (OR); the counts beside unticked boxes show what they would add. Different groups narrow (AND)."
111
142
  unavailable: "Counts unavailable"
112
143
  help:
@@ -124,6 +155,7 @@ en:
124
155
  widen: "Search all %{facet}"
125
156
  drop: "Drop %{label}"
126
157
  relax: "Allow fewer name matches"
158
+ as_words: "Search \"%{value}\" as part of a name"
127
159
  empty_note: "Each button changes the search box, so the new search is visible."
128
160
  empty: "No matching records found"
129
161
  unavailable_title: "Search is not answering right now."
@@ -6,9 +6,11 @@ module Querylane
6
6
  # sensitive value in clear.
7
7
  module Chips
8
8
  # alternatives: other readings of an interpreted term ("Division 5432 instead");
9
- # words: the chip of the query's words (names), not a filter.
10
- Chip = Data.define(:index, :label, :ranges, :alternatives, :words) do
11
- def initialize(index:, label:, ranges:, alternatives: [], words: false)
9
+ # words: the chip of the query's words (names), not a filter;
10
+ # as_words: for a filter whose operator opts in (`as_words: true`), the
11
+ # value as typed, which an empty search offers to search as plain words.
12
+ Chip = Data.define(:index, :label, :ranges, :alternatives, :words, :as_words) do
13
+ def initialize(index:, label:, ranges:, alternatives: [], words: false, as_words: nil)
12
14
  super
13
15
  end
14
16
  end
@@ -29,13 +31,25 @@ module Querylane
29
31
  query.nodes.each do |node|
30
32
  next if node.is_a?(Query::Term) && node.kind == :text
31
33
 
32
- chips << [label(node), [node.range], alternatives(query, node)]
34
+ chips << [label(node), [node.range], alternatives(query, node), false, as_words(query, node)]
33
35
  end
34
- chips.each_with_index.map do |(text, ranges, others, words), index|
35
- Chip.new(index: index, label: text, ranges: ranges, alternatives: others || [], words: words || false)
36
+ chips.each_with_index.map do |(text, ranges, others, words, typed), index|
37
+ Chip.new(index: index, label: text, ranges: ranges, alternatives: others || [], words: words || false, as_words: typed)
36
38
  end
37
39
  end
38
40
 
41
+ # The typed value of a top-level filter term whose operator offers it as
42
+ # plain words (never a sensitive one: the definition refuses that), or nil.
43
+ # Not offered while another text operator holds the words (one per query).
44
+ def as_words(query, node)
45
+ return unless node.is_a?(Query::Term) && node.kind == :filter && node.operator.as_words && !node.interpreted
46
+ return if query.text_operator && !query.text_operator.equal?(query.definition.default_text)
47
+
48
+ typed = node.raw.to_s.strip.presence
49
+ # Offered only when the rewritten query still runs (length and term limits).
50
+ typed if typed && Query.analyze(query.definition, Rewriter.words_in_place(query, node.range, typed), config: query.config).valid?
51
+ end
52
+
39
53
  # Other readings of an interpreted term, each with the explicit text it
40
54
  # would become (e.g. "div:5432").
41
55
  def alternatives(query, node)
@@ -63,7 +77,7 @@ module Querylane
63
77
 
64
78
  def term_label(term)
65
79
  case term.kind
66
- when :filter then format(term.operator, mask(term.value, term.operator.mask))
80
+ when :filter then format(term.operator, mask(filter_value(term), term.operator.mask))
67
81
  when :flag then "#{term.operator.label} #{term.operator.values.fetch(term.value)}"
68
82
  when :text then format(term.operator, term.value)
69
83
  end
@@ -71,6 +85,9 @@ module Querylane
71
85
 
72
86
  def format(operator, value) = operator.label.gsub("%{value}", value.to_s)
73
87
 
88
+ # The value a filter chip shows: normalized, or as typed (`display_value: :typed`).
89
+ def filter_value(term) = (term.operator.display_value == :typed && !term.interpreted) ? term.raw.to_s : term.value.to_s
90
+
74
91
  # @param value [String]
75
92
  # @param mask [Symbol] :none, :all, :last4, :count
76
93
  def mask(value, mask)
@@ -5,6 +5,7 @@ module Querylane
5
5
  #
6
6
  # Querylane.configure do |config|
7
7
  # config.searcher = -> { Querylane::Searchers::Meilisearch.new(client: MeilisearchService.client) }
8
+ # config.logger = Rails.logger
8
9
  # end
9
10
  class Configuration
10
11
  # Name of the query parameter (filtered from logs by the engine).
@@ -18,6 +19,10 @@ module Querylane
18
19
 
19
20
  attr_writer :searcher
20
21
 
22
+ # Where querylane writes why a search could not run. A host passes its
23
+ # own (Rails.logger); without one querylane logs to standard output.
24
+ attr_writer :logger
25
+
21
26
  # How long a group token ("more records", preview) stays valid.
22
27
  attr_accessor :group_token_ttl
23
28
 
@@ -35,8 +40,12 @@ module Querylane
35
40
  @debounce_ms = 300
36
41
  @allow_get_query = false
37
42
  @searcher = nil
43
+ @logger = ActiveSupport::Logger.new($stdout)
38
44
  end
39
45
 
46
+ # @return [Logger]
47
+ attr_reader :logger
48
+
40
49
  # The searcher for this request. A callable is resolved on every call, so
41
50
  # it can follow code reloading and per-request clients.
42
51
  #
@@ -63,7 +63,11 @@ module Querylane
63
63
  # in), and no response is cached. Only the URL's own query string is
64
64
  # checked: a host may pass a query it restored on the server (e.g. a
65
65
  # remembered search) to a GET.
66
- def querylane_guard!(_params, config)
66
+ def querylane_guard!(params, config)
67
+ if Request.delete_saved_param(params) && !request.post?
68
+ raise ActionController::BadRequest, "Saved searches are deleted by POST"
69
+ end
70
+
67
71
  url = request.query_parameters
68
72
  carries_text = [config.param_name, :compose, :group].any? { |name| url[name.to_s].present? }
69
73
  if request.get? && carries_text && !config.allow_get_query
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Querylane
4
+ # Host-supplied copy that may be a locale key or plain text: a panel row's
5
+ # `hint:` and `placeholder:`, a filter's `invalid:` sentence. Resolved when
6
+ # shown (never when the definition is built), so a memoized definition still
7
+ # follows the current locale.
8
+ #
9
+ # Copy.text(:"search.hints.name") # => I18n.t("search.hints.name")
10
+ # Copy.text("search.hints.name") # => the same (a dotted key)
11
+ # Copy.text("Exact last name, any case") # => as given
12
+ module Copy
13
+ # A dotted locale key: no spaces, at least one dot between word parts.
14
+ KEY = /\A[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+\z/
15
+
16
+ module_function
17
+
18
+ # @param value [String, Symbol, nil] a locale key or text
19
+ # @return [String, nil] the translation for a key, the text otherwise
20
+ def text(value)
21
+ case value
22
+ when nil then nil
23
+ when Symbol then I18n.t(value)
24
+ else value.to_s.match?(KEY) ? I18n.t(value.to_s) : value.to_s
25
+ end
26
+ end
27
+ end
28
+ end
@@ -17,7 +17,16 @@ module Querylane
17
17
  class Definition
18
18
  Index = Data.define(:key, :name)
19
19
  Text = Data.define(:key, :attributes, :sensitive, :default, :label, :mask)
20
- FilterOperator = Data.define(:key, :attribute, :sensitive, :normalize, :format, :label, :mask)
20
+ # invalid: a second sentence for the bad-value error (locale key or text);
21
+ # as_words: an empty search offers to search the value as plain words;
22
+ # display_value: :normalized (chips and the panel show the normalized value) or
23
+ # :typed (they show the value as typed).
24
+ FilterOperator = Data.define(:key, :attribute, :sensitive, :normalize, :format, :label, :mask, :invalid, :as_words, :display_value) do
25
+ # The newer members default, so code built for the earlier members still works.
26
+ def initialize(key:, attribute:, sensitive:, normalize:, format:, label:, mask:, invalid: nil, as_words: false, display_value: :normalized)
27
+ super
28
+ end
29
+ end
21
30
  Flags = Data.define(:key, :aliases, :attribute, :values, :rules, :label)
22
31
  # values: for a flag facet, the flag values it shows (nil: all of them).
23
32
  # top: filter-facet values loaded; show: how many show before "Find…";
@@ -44,7 +53,14 @@ module Querylane
44
53
  # values: for a :flags row, the flag values it shows (nil: all of them).
45
54
  # masked: an operator in the row masks its values (mask other than :none):
46
55
  # the field starts empty and hides what is typed.
47
- PanelRow = Data.define(:label, :keys, :kind, :bare, :masked, :values) do
56
+ # hint: a line under the row; placeholder: the empty inputs' placeholder
57
+ # (each a locale key or text, see Querylane::Copy).
58
+ PanelRow = Data.define(:label, :keys, :kind, :bare, :masked, :values, :hint, :placeholder) do
59
+ # hint and placeholder default, so code built for the earlier members still works.
60
+ def initialize(label:, keys:, kind:, bare:, masked:, values:, hint: nil, placeholder: nil)
61
+ super
62
+ end
63
+
48
64
  # @return [String] the compose field name (unique per row, also when two
49
65
  # rows show different values of one flag family)
50
66
  def name
@@ -63,6 +79,7 @@ module Querylane
63
79
  strip: ->(value) { value.strip }
64
80
  }.freeze
65
81
  MASKS = %i[none all last4 count].freeze
82
+ DISPLAYS = %i[normalized typed].freeze
66
83
 
67
84
  attr_reader :name, :indexes, :texts, :filters, :flags, :facets, :sorts, :per_options, :retrieve, :highlight, :hooks, :group,
68
85
  :saved_store, :min_text_length, :parked
@@ -228,11 +245,14 @@ module Querylane
228
245
  [nil, rows]
229
246
  end
230
247
 
231
- # [label, [[row_label, keys, {bare:}], ...]] → PanelGroup, checked against the operators
248
+ # [label, [[row_label, keys, bare, values, hint, placeholder], ...]] → PanelGroup,
249
+ # checked against the operators
232
250
  def build_group((label, rows))
233
- PanelGroup.new(label, rows.map do |row_label, keys, bare, values|
251
+ PanelGroup.new(label, rows.map do |row_label, keys, bare, values, hint, placeholder|
234
252
  if keys.all? { |key| option(key) }
235
- PanelRow.new(row_label, keys, :options, false, false, nil)
253
+ raise ConfigurationError, "#{@name}: panel row #{row_label} takes placeholder: only for inputs" if placeholder
254
+
255
+ PanelRow.new(row_label, keys, :options, false, false, nil, hint, nil)
236
256
  else
237
257
  operators = keys.map { |key| operator(key) || raise(ConfigurationError, "#{@name}: panel field #{key} is not an operator or option") }
238
258
  kind = operators.all?(Flags) ? :flags : :inputs
@@ -242,8 +262,10 @@ module Querylane
242
262
  raise ConfigurationError, "#{@name}: panel row #{row_label} takes values: only for flags" if values && kind != :flags
243
263
  unknown = Array(values) - ((kind == :flags) ? operators.first.values.keys : [])
244
264
  raise ConfigurationError, "#{@name}: panel row #{row_label} has unknown values #{unknown.join(", ")}" if unknown.any?
265
+ raise ConfigurationError, "#{@name}: panel row #{row_label} takes placeholder: only for inputs" if placeholder && kind != :inputs
245
266
 
246
- PanelRow.new(row_label, keys, kind, bare, operators.any? { |operator| operator.respond_to?(:mask) && operator.mask != :none }, values)
267
+ masked = operators.any? { |operator| operator.respond_to?(:mask) && operator.mask != :none }
268
+ PanelRow.new(row_label, keys, kind, bare, masked, values, hint, placeholder)
247
269
  end
248
270
  end.freeze)
249
271
  end
@@ -394,12 +416,22 @@ module Querylane
394
416
 
395
417
  # An exact-match operator. `normalize` runs before `format` is checked.
396
418
  # `mask` controls how chips show sensitive values: :none, :all, :last4, :count.
397
- def filter(key, attribute:, sensitive: false, normalize: nil, format: nil, label: nil, mask: nil)
419
+ # `invalid` (locale key or text) follows "… is not a valid key: value."
420
+ # as a second sentence. `as_words: true` lets an empty search offer the
421
+ # value as plain words. `display_value: :typed` shows the value as typed in
422
+ # chips and the options panel (the search still uses the normalized value).
423
+ def filter(key, attribute:, sensitive: false, normalize: nil, format: nil, label: nil, mask: nil, invalid: nil, as_words: false,
424
+ display_value: :normalized)
398
425
  normalizer = normalize.is_a?(Symbol) ? NORMALIZERS.fetch(normalize) : normalize
399
426
  mask ||= sensitive ? :all : :none
400
427
  raise ConfigurationError, "Unknown mask #{mask}" unless MASKS.include?(mask)
428
+ raise ConfigurationError, "Unknown display_value #{display_value}" unless DISPLAYS.include?(display_value)
429
+ raise ConfigurationError, "filter #{key}: display_value: :typed needs mask: :none" if display_value == :typed && mask != :none
430
+ # The link would repeat the value in clear.
431
+ raise ConfigurationError, "filter #{key}: as_words cannot be sensitive" if as_words && sensitive
401
432
 
402
- @filters << FilterOperator.new(key.to_sym, attribute.to_s, sensitive, normalizer, format, label || "#{key.to_s.humanize} %{value}", mask)
433
+ @filters << FilterOperator.new(key.to_sym, attribute.to_s, sensitive, normalizer, format, label || "#{key.to_s.humanize} %{value}", mask,
434
+ invalid, as_words ? true : false, display_value)
403
435
  end
404
436
 
405
437
  # A family of named values (`is:active`). Either `attribute:` (value
@@ -439,8 +471,10 @@ module Querylane
439
471
  # A labelled row of operators (or option keys). `bare: true` gives the
440
472
  # operators one shared input written without a prefix.
441
473
  # `values:` limits a flags row to some of its values (the rest stay in the box).
442
- def row(label, *keys, bare: false, values: nil)
443
- @rows << [label, keys.map(&:to_sym), bare, values&.map(&:to_sym)]
474
+ # `hint:` is a line under the row; `placeholder:` sits in the row's empty
475
+ # inputs (inputs rows only). Each is a locale key or text (Querylane::Copy).
476
+ def row(label, *keys, bare: false, values: nil, hint: nil, placeholder: nil)
477
+ @rows << [label, keys.map(&:to_sym), bare, values&.map(&:to_sym), hint, placeholder]
444
478
  end
445
479
  end
446
480
  end
@@ -40,7 +40,7 @@ module Querylane
40
40
 
41
41
  def parse
42
42
  if @source.length > @config.max_length
43
- raise Error.new("Search is too long (#{@config.max_length} characters at most).", 0...@source.length)
43
+ raise Error.new(message(:too_long, max: @config.max_length), 0...@source.length)
44
44
  end
45
45
 
46
46
  @tokens = tokenize
@@ -73,7 +73,7 @@ module Querylane
73
73
  elsif scanner.scan(/-(?=[^\s)])/) then token(:minus, scanner, start)
74
74
  elsif scanner.scan(KEY) then key_token(scanner, start, scanner[1])
75
75
  elsif scanner.scan(QUOTED) then token(:term, scanner, start, value: unescape(scanner[1]), quoted: true)
76
- elsif scanner.scan('"') then raise Error.new("Close the quote.", start...@source.length)
76
+ elsif scanner.scan('"') then raise Error.new(message(:close_quote), start...@source.length)
77
77
  else
78
78
  scanner.scan(BARE)
79
79
  token(:term, scanner, start, value: scanner.matched)
@@ -86,7 +86,7 @@ module Querylane
86
86
  elsif scanner.scan(BARE)
87
87
  token(:term, scanner, start, key: key, value: scanner.matched)
88
88
  else
89
- raise Error.new("#{key}: needs a value.", start...scanner.charpos)
89
+ raise Error.new(message(:needs_value, key: key), start...scanner.charpos)
90
90
  end
91
91
  end
92
92
 
@@ -99,7 +99,7 @@ module Querylane
99
99
  def parse_sequence(closing:)
100
100
  nodes = []
101
101
  until done? || (closing && peek?(:rparen))
102
- raise Error.new("Unexpected ).", current.range) if peek?(:rparen)
102
+ raise Error.new(message(:unexpected, text: ")"), current.range) if peek?(:rparen)
103
103
 
104
104
  nodes << parse_or
105
105
  end
@@ -110,7 +110,7 @@ module Querylane
110
110
  parts = [parse_unary]
111
111
  while peek?(:or)
112
112
  operator = advance
113
- raise Error.new("OR needs a term on both sides.", operator.range) if done? || peek?(:rparen) || peek?(:or)
113
+ raise Error.new(message(:or_needs_terms), operator.range) if done? || peek?(:rparen) || peek?(:or)
114
114
 
115
115
  parts << parse_unary
116
116
  end
@@ -122,7 +122,7 @@ module Querylane
122
122
  def parse_unary
123
123
  if peek?(:minus) || peek?(:not)
124
124
  operator = advance
125
- raise Error.new("#{(operator.type == :not) ? "NOT" : "-"} needs a term.", operator.range) if done?
125
+ raise Error.new(message(:needs_term, operator: (operator.type == :not) ? "NOT" : "-"), operator.range) if done?
126
126
 
127
127
  node = parse_atom
128
128
  return Ast::Not.new(node, operator.range.begin...node.range.end)
@@ -131,24 +131,24 @@ module Querylane
131
131
  end
132
132
 
133
133
  def parse_atom
134
- raise Error.new("Search ended early.", @source.length...@source.length) if done?
134
+ raise Error.new(message(:ended_early), @source.length...@source.length) if done?
135
135
 
136
136
  case current.type
137
137
  when :lparen then parse_group
138
- when :or then raise Error.new("OR needs a term on both sides.", current.range)
138
+ when :or then raise Error.new(message(:or_needs_terms), current.range)
139
139
  when :term then parse_term
140
- else raise Error.new("Unexpected #{@source[current.range]}.", current.range)
140
+ else raise Error.new(message(:unexpected, text: @source[current.range]), current.range)
141
141
  end
142
142
  end
143
143
 
144
144
  def parse_group
145
145
  open = advance
146
146
  @depth += 1
147
- raise Error.new("Too many nested parentheses (#{@config.max_depth} at most).", open.range) if @depth > @config.max_depth
147
+ raise Error.new(message(:too_deep, max: @config.max_depth), open.range) if @depth > @config.max_depth
148
148
 
149
149
  nodes = parse_sequence(closing: true)
150
- raise Error.new("Add the closing ).", open.range.begin...@source.length) unless peek?(:rparen)
151
- raise Error.new("Empty parentheses.", open.range) if nodes.empty?
150
+ raise Error.new(message(:close_parenthesis), open.range.begin...@source.length) unless peek?(:rparen)
151
+ raise Error.new(message(:empty_parentheses), open.range) if nodes.empty?
152
152
 
153
153
  close = advance
154
154
  @depth -= 1
@@ -158,11 +158,14 @@ module Querylane
158
158
  def parse_term
159
159
  term = advance
160
160
  @terms += 1
161
- raise Error.new("Too many terms (#{@config.max_terms} at most).", term.range) if @terms > @config.max_terms
161
+ raise Error.new(message(:too_many_terms, max: @config.max_terms), term.range) if @terms > @config.max_terms
162
162
 
163
163
  Ast::Term.new(term.key&.downcase, term.value, term.quoted, term.range)
164
164
  end
165
165
 
166
+ # Syntax errors share Query's localized messages (`querylane.errors.*`).
167
+ def message(name, **values) = Query.message(name, **values)
168
+
166
169
  def current = @tokens[@position]
167
170
 
168
171
  def advance
@@ -30,7 +30,13 @@ module Querylane
30
30
  super
31
31
  end
32
32
  end
33
- Group = Data.define(:key, :hits)
33
+ # lead: the hit that placed the group on the page (the first of its hits
34
+ # to come back, in the page's sort); hits may be reordered after it.
35
+ Group = Data.define(:key, :hits, :lead) do
36
+ def initialize(key:, hits:, lead: hits.first)
37
+ super
38
+ end
39
+ end
34
40
  # A group in suggestions: a short detail, its status, and what choosing it
35
41
  # does (an Action opens it; nil runs the full search).
36
42
  Suggestion = Data.define(:detail, :badge, :action)
@@ -87,7 +93,8 @@ module Querylane
87
93
  # @return [String] the id of the group's "more records" frame
88
94
  def related_frame_id(group) = "#{id}-related-#{record_key(group.hits.first)}"
89
95
 
90
- # @return [Array<Group>] in hit order, the best-ranked hit leading each group
96
+ # @return [Array<Group>] in hit order: each group sits where its first hit
97
+ # came back (best-ranked, or first in the chosen sort), which leads it
91
98
  def groups
92
99
  order = []
93
100
  grouped = {}
@@ -96,12 +103,15 @@ module Querylane
96
103
  order << key unless grouped.key?(key)
97
104
  (grouped[key] ||= []) << hit
98
105
  end
99
- order.map { |key| Group.new(key, order_records(grouped.fetch(key))) }
106
+ order.map do |key|
107
+ hits = grouped.fetch(key)
108
+ Group.new(key: key, hits: order_records(hits), lead: hits.first)
109
+ end
100
110
  end
101
111
 
102
112
  # A group's records in the definition's group order (the order "more
103
- # records" and the preview use); unchanged without one. The best-ranked
104
- # record still decides where the group sits on the page.
113
+ # records" and the preview use); unchanged without one. The lead record
114
+ # still decides where the group sits on the page and what it is called.
105
115
  def order_records(hits)
106
116
  order = result.definition.group&.order
107
117
  return hits if order.nil? || hits.one?
@@ -120,10 +130,12 @@ module Querylane
120
130
  0
121
131
  end
122
132
 
123
- # Group heading (HTML-safe); default: the first highlighted text attribute.
133
+ # Group heading (HTML-safe); default: the lead record's first highlighted
134
+ # text attribute, so headings follow the page's order (Name A–Z reads in
135
+ # name order even when one person's records carry different names).
124
136
  def title(group)
125
137
  attribute = result.definition.highlight.first
126
- view.querylane_highlight(group.hits.first, attribute)
138
+ view.querylane_highlight(group.lead, attribute)
127
139
  end
128
140
 
129
141
  # Secondary heading next to the title (HTML-safe or plain), or nil.