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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -1
- data/CONTRIBUTING.md +1 -1
- data/README.md +2 -0
- data/app/assets/stylesheets/querylane.css +28 -6
- data/app/javascript/querylane/controllers/box_controller.js +1 -1
- data/app/javascript/querylane/controllers/results_controller.js +12 -1
- data/app/views/querylane/_chips.html.erb +2 -1
- data/app/views/querylane/_denied.html.erb +2 -1
- data/app/views/querylane/_empty.html.erb +10 -4
- data/app/views/querylane/_facets.html.erb +3 -2
- data/app/views/querylane/_first_load.html.erb +2 -1
- data/app/views/querylane/_form.html.erb +19 -9
- data/app/views/querylane/_group.html.erb +2 -1
- data/app/views/querylane/_help.html.erb +3 -2
- data/app/views/querylane/_options.html.erb +5 -3
- data/app/views/querylane/_options_row.html.erb +18 -7
- data/app/views/querylane/_outcome.html.erb +5 -3
- data/app/views/querylane/_pager.html.erb +3 -2
- data/app/views/querylane/_preview.html.erb +2 -1
- data/app/views/querylane/_record.html.erb +2 -1
- data/app/views/querylane/_related.html.erb +2 -1
- data/app/views/querylane/_results.html.erb +2 -2
- data/app/views/querylane/_rows.html.erb +2 -1
- data/app/views/querylane/_saved.html.erb +15 -6
- data/app/views/querylane/_suggest_box.html.erb +10 -7
- data/app/views/querylane/_suggestions.html.erb +2 -2
- data/app/views/querylane/_toolbar.html.erb +2 -1
- data/app/views/querylane/_unavailable.html.erb +2 -1
- data/app/views/querylane/_writes.html.erb +4 -3
- data/config/locales/querylane.en.yml +32 -0
- data/lib/querylane/chips.rb +24 -7
- data/lib/querylane/configuration.rb +9 -0
- data/lib/querylane/controller.rb +5 -1
- data/lib/querylane/copy.rb +28 -0
- data/lib/querylane/definition.rb +44 -10
- data/lib/querylane/parser.rb +16 -13
- data/lib/querylane/presenter.rb +19 -7
- data/lib/querylane/query.rb +59 -17
- data/lib/querylane/request.rb +32 -13
- data/lib/querylane/rewriter.rb +44 -2
- data/lib/querylane/search.rb +12 -0
- data/lib/querylane/searchers/meilisearch.rb +19 -2
- data/lib/querylane/version.rb +1 -1
- data/lib/querylane.rb +1 -0
- metadata +3 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
<%#
|
|
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).
|
|
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
|
-
<%#
|
|
2
|
-
|
|
3
|
-
|
|
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."
|
data/lib/querylane/chips.rb
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
#
|
data/lib/querylane/controller.rb
CHANGED
|
@@ -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!(
|
|
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
|
data/lib/querylane/definition.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
data/lib/querylane/parser.rb
CHANGED
|
@@ -40,7 +40,7 @@ module Querylane
|
|
|
40
40
|
|
|
41
41
|
def parse
|
|
42
42
|
if @source.length > @config.max_length
|
|
43
|
-
raise Error.new(
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
151
|
-
raise Error.new(
|
|
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(
|
|
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
|
data/lib/querylane/presenter.rb
CHANGED
|
@@ -30,7 +30,13 @@ module Querylane
|
|
|
30
30
|
super
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
|
104
|
-
#
|
|
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
|
|
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.
|
|
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.
|