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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f62396550c8585a01958050e34f4e31628ddb53ad02cf97074900e0882a6e510
4
- data.tar.gz: 68f1c601f5a1083d1fd6711db29c87fde0a994962091578cb7f58c5b0b12f98d
3
+ metadata.gz: cae1575fd12bcaf1bd45457aebfde411632341db4dbe029019cd3c683e839497
4
+ data.tar.gz: 02c42e8191a53bf52c19360f3542218cd7e8b2c7c158e08cdefcaabc29030999
5
5
  SHA512:
6
- metadata.gz: 8c35bb1029b86f33ca864824980c847c532a0a6b5ad4ee9bcd44a53c6ec3370fded089c1146025cf4db3d17736baf5c1383d8ee31edd030c677e3fb78b43b460
7
- data.tar.gz: 82f3e9d07e46f33876573d5f4227070965f352e3ec67bf479c90f9a98e457b5e017cdb822e1e7355af17a2e208b9a22b479acf474c546c60078cee98474a4b9e
6
+ metadata.gz: 44c16d7ee50b1a3a6bd8408f72f098f49c330e301f66908892c0ed5e32c4dcef3a12330e4769dc7c541b2e41d209a820ffa99a67f1bcd25557fe0def64bd9022
7
+ data.tar.gz: 86e14bc61a17bcefe91ecdd1775ef9333b8dc67aded55ce420f25a167ce394e83036a0217a15cd2f90488fdab6dee35ef1e5ad668b9e2f305019525d2b9f7116
data/CHANGELOG.md CHANGED
@@ -7,6 +7,95 @@ change public APIs; each entry says how.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-11-11
11
+
12
+ ### Added
13
+
14
+ - Confirmed per-entry saved-search deletion through the CSRF-protected POST
15
+ form. Stores implement `delete(context:, id:)` with a current-user-scoped
16
+ lookup and a boolean result. Deletion preserves the active query, sort and
17
+ page, updates the Saved star, and refuses denied scopes and GET mutations.
18
+ The saved-search popover stays inside narrow mobile viewports. Each entry
19
+ shares one hover/focus highlight with its inline delete icon, while Apply
20
+ and Delete keep separate, non-overlapping click targets.
21
+
22
+ ### Fixed
23
+
24
+ - The saved-search POST-only guard and request parser share normalization for
25
+ symbol- and string-keyed params, including false-valued host params, even
26
+ when GET search queries are enabled.
27
+
28
+ ## [0.2.0] - 2026-11-10
29
+
30
+ ### Added
31
+
32
+ - `config.logger`: querylane logs why a search was unavailable (Meilisearch
33
+ unreachable, timed out, or an API error such as a missing index). API
34
+ errors are logged by code, type and HTTP status only, never their message,
35
+ which can echo the filter; connection errors lose any URL credentials and
36
+ query string. The default logger writes to standard output; a Rails host
37
+ passes `Rails.logger`.
38
+ - `p.row ... hint:, placeholder:` on options-panel rows: a line under the row
39
+ (its fields' `aria-describedby`) and a placeholder for its empty inputs (a
40
+ masked field's "In the box: …" still wins). `placeholder:` on a flags or
41
+ options row raises.
42
+ - `d.filter ... invalid:`: a second sentence for the bad-value error.
43
+ - `d.filter ... as_words: true`: when nothing matches, the empty state offers
44
+ "Search "smiht" as part of a name" (`querylane.states.as_words`, request
45
+ param `as_words=<chip index>`, `Rewriter.as_words`), which replaces the
46
+ filter term with its typed value as plain words. Refused on a sensitive
47
+ filter.
48
+ - `d.filter ... display_value: :typed`: chips and the options panel show the
49
+ value as typed, and the panel writes it back as typed; the search still
50
+ uses the normalized value. Needs `mask: :none`.
51
+ - `hint:`, `placeholder:` and `invalid:` take a locale key (a Symbol or a
52
+ dotted String) or plain text (`Querylane::Copy`), translated when shown.
53
+
54
+ ### Changed
55
+
56
+ - Development and CI dependencies: rspec-rails 8.1.0, playwright 1.65.0,
57
+ ruby/setup-ruby 1.338.0.
58
+ - Development Ruby 3.3.13 (from 3.3.10): the lowest supported series' release
59
+ with the Onigmo regexp fixes (CVE-2026-89078, CVE-2026-93577,
60
+ CVE-2026-97229, CVE-2026-110264). `required_ruby_version` and the CI
61
+ matrix are unchanged.
62
+ - Every query error is a translation under `querylane.errors.*` (syntax,
63
+ limits, unknown and parked operators, bad filter and flag values, names in
64
+ OR, NOT and parentheses, one text operator). The English text is unchanged
65
+ and stays the default (`Querylane::Query::MESSAGES`).
66
+ - With no filters declared, the one-text-operator error no longer ends in
67
+ "or combine with ."; it reads "Search name or id one at a time."
68
+ - `Definition::FilterOperator` gains `invalid`, `as_words` and
69
+ `display_value`, `Definition::PanelRow` gains `hint` and `placeholder`, and
70
+ `Chips::Chip` gains `as_words`. Each new member has a default, so code that
71
+ builds these structs with the earlier members, by position or keyword,
72
+ still works.
73
+
74
+ - Every partial declares its locals on its first line (Rails strict locals,
75
+ `<%# locals: (...) -%>`). A host override must keep the line, and a render
76
+ with other locals now raises. `_help` takes `popover_id: nil` instead of
77
+ reading `local_assigns`.
78
+ - Controls follow one hierarchy (theming guide, "Bootstrap"): the box's
79
+ Search and the options panel's Search are outline buttons
80
+ (`btn-outline-primary`); Open and View are Go links (`.ql-go`, still a
81
+ button that posts); the empty state's actions and the panel's Cancel are
82
+ quiet links (`.ql-link`). `Presenter::Action#style` no longer changes the
83
+ look.
84
+ - No Bootstrap utility classes: `.ql-visually-hidden` replaces
85
+ `visually-hidden`, `.ql-open-form` replaces `d-inline`, and `.ql-pager`
86
+ sets its own margin instead of `mb-0`.
87
+ - The search box and the suggestion box sit in a `<search>` landmark
88
+ (`.ql-search`) instead of a form with `role="search"`.
89
+ - The facet column's accessible name comes from `querylane.facets.label`.
90
+
91
+ ### Fixed
92
+
93
+ - Space on a record right after a new answer drew its rows opens the preview.
94
+ The preview state was reset on `turbo:frame-render`, which Turbo fires a few
95
+ repaints after the rows are in, so the preview's answer was dropped as if
96
+ Esc had closed it. It is now reset when the new, empty preview frame
97
+ connects.
98
+
10
99
  ## [0.1.0] - 2026-10-20
11
100
 
12
101
  The first release.
@@ -80,5 +169,7 @@ The first release.
80
169
  - `search.querylane` and `saved_search_failed.querylane` instrumentation, without
81
170
  query text. The query parameter and `compose` are filtered from logs.
82
171
 
83
- [Unreleased]: https://github.com/clearstackio/querylane/compare/v0.1.0...HEAD
172
+ [Unreleased]: https://github.com/clearstackio/querylane/compare/v0.3.0...HEAD
173
+ [0.3.0]: https://github.com/clearstackio/querylane/compare/v0.2.0...v0.3.0
174
+ [0.2.0]: https://github.com/clearstackio/querylane/compare/v0.1.0...v0.2.0
84
175
  [0.1.0]: https://github.com/clearstackio/querylane/releases/tag/v0.1.0
data/CONTRIBUTING.md CHANGED
@@ -73,7 +73,7 @@ also the example the guides quote.
73
73
  request (`spec/requests/`) or browser (`spec/system/`).
74
74
  - **Changelog:** add a line under `[Unreleased]` in [CHANGELOG.md](CHANGELOG.md)
75
75
  for anything a host would notice.
76
- - **CI:** GitHub Actions (`.github/workflows/ci.yml`): `rake` on Ruby 3.3, 3.4
76
+ - **CI:** GitHub Actions ([design](docs/design/ci.md), [operations](docs/ops/ci.md)): `rake` on Ruby 3.3, 3.4
77
77
  and 4.0 against Meilisearch 1.13 and headless Chromium. Actions are pinned to
78
78
  commit SHAs. FracturedJSON is not run in CI; run `bin/lint` before pushing.
79
79
  - **Security:** no secrets in the repo; see [SECURITY.md](SECURITY.md).
data/README.md CHANGED
@@ -169,6 +169,8 @@ A complete, working version of this example lives in [`spec/dummy`](spec/dummy).
169
169
  Rails.application.config.to_prepare do
170
170
  Querylane.configure do |config|
171
171
  config.searcher = -> { Querylane::Searchers::Meilisearch.new(client: Meilisearch::Client.new(ENV["MEILISEARCH_URL"], ENV["MEILISEARCH_API_KEY"])) }
172
+ # Why a search could not run (default: standard output).
173
+ config.logger = Rails.logger
172
174
  end
173
175
  end
174
176
  ```
@@ -6,6 +6,11 @@
6
6
  */
7
7
  /* The hidden attribute wins over the display rules below. */
8
8
  :where(.querylane-form, .ql-results, .ql-suggest, .ql-palette) [hidden] { display: none !important; }
9
+ /* A label read by assistive technology only. */
10
+ .ql-visually-hidden { position: absolute !important; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
11
+ clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
12
+ /* The search landmark around a box and its form. */
13
+ .ql-search { display: block; }
9
14
 
10
15
  :root {
11
16
  --ql-accent: var(--bs-primary, #0d6efd);
@@ -69,6 +74,9 @@
69
74
  border-radius: 13px; background: var(--ql-chip); color: var(--ql-text); }
70
75
  .ql-chip-remove { border: 0; background: none; padding: 0; color: var(--ql-muted); line-height: 1; }
71
76
  .ql-link { border: 0; background: none; padding: 0; color: var(--bs-link-color, #0865d1); font-weight: 500; }
77
+ /* A Go link: opens a record or a page (bold, no box), even when it is a button that posts. */
78
+ .ql-go { border: 0; background: none; padding: 0; color: var(--bs-link-color, #0865d1); font-weight: 700; }
79
+ .ql-link:hover, .ql-go:hover { text-decoration: underline; }
72
80
  [data-querylane-stale] .ql-chips { opacity: 0.55; }
73
81
  .ql-state { padding: 28px 0; font-size: 15px; color: var(--ql-muted); }
74
82
  .ql-state-unavailable { padding: 20px 24px; border-left: 3px solid #977405; background: #fffbea; color: var(--ql-text); }
@@ -95,6 +103,7 @@
95
103
  .ql-count-note { margin-left: 10px; font-size: 13px; font-weight: 400; color: var(--ql-muted); }
96
104
  .ql-count { font-size: 18px; line-height: 22px; font-weight: 700; color: var(--ql-heading); }
97
105
  .ql-toolbar-controls { display: flex; align-items: center; gap: 16px; }
106
+ .ql-pager { margin-bottom: 0; }
98
107
  .ql-control { display: flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; color: var(--ql-muted); }
99
108
  .ql-control .form-select { width: auto; }
100
109
 
@@ -125,7 +134,7 @@
125
134
  .ql-badge-neutral { background: var(--ql-neutral-bg); color: var(--ql-neutral-fg); }
126
135
  .ql-flag-neutral { border: 1px solid var(--ql-border); color: var(--ql-muted); }
127
136
  .ql-reason { font-size: 13px; line-height: 17px; color: var(--ql-muted); }
128
- .ql-open { font-weight: 700; }
137
+ .ql-open-form { display: inline; }
129
138
 
130
139
  /* Footer ---------------------------------------------------------------------- */
131
140
  .ql-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 16px; font-size: 13px; color: var(--ql-muted); }
@@ -173,6 +182,8 @@
173
182
  .ql-options-inputs { display: flex; gap: 12px; }
174
183
  .ql-options-input { position: relative; flex: 1; min-width: 0; }
175
184
  .ql-options-input input { width: 100%; }
185
+ /* A row's hint: under its fields, in the field column. */
186
+ .ql-options-hint { grid-column: 2; margin: -6px 0 0; color: var(--ql-muted); font-size: 12px; line-height: 16px; }
176
187
  .ql-masked { -webkit-text-security: disc; }
177
188
  /* Above the field even when a host lifts a focused .form-control (Bootstrap 3 themes set z-index: 2). */
178
189
  .ql-options-reveal { position: absolute; top: 50%; right: 10px; z-index: 3; transform: translateY(-50%); font-size: 13px; }
@@ -267,11 +278,14 @@
267
278
  .ql-saved-refusal { margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--ql-rule); font-size: 13px; color: var(--ql-text); }
268
279
  .ql-saved-heading { padding: 10px 16px 4px; font-size: 13px; font-weight: 600; letter-spacing: 0.06em; font-variant-caps: all-small-caps; color: var(--ql-muted); }
269
280
  .ql-saved-none { margin: 0; padding: 4px 16px 14px; color: var(--ql-muted); font-size: 13px; }
270
- .ql-saved-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; width: 100%; padding: 8px 16px; border: 0;
271
- background: none; text-align: left; }
272
- .ql-saved-item:hover, .ql-saved-item:focus-visible { background: var(--ql-accent-soft); }
273
- .ql-saved-name { font-weight: 600; color: var(--ql-heading); }
274
- .ql-saved-query { grid-column: 1; font: 400 12px/18px var(--ql-font-mono); color: var(--ql-muted); }
281
+ .ql-saved-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; margin: 2px 8px; padding-right: 4px; border-radius: 4px; }
282
+ .ql-saved-row:hover, .ql-saved-row:focus-within { background: var(--ql-accent-soft); }
283
+ .ql-saved-delete { min-width: 32px; height: 32px; padding: 4px; border: 0; border-radius: 4px; background: none; color: var(--ql-muted); }
284
+ .ql-saved-delete:hover, .ql-saved-delete:focus-visible { color: var(--ql-error); }
285
+ .ql-saved-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; width: 100%; padding: 8px; border: 0;
286
+ min-width: 0; background: none; text-align: left; }
287
+ .ql-saved-name { overflow-wrap: anywhere; font-weight: 600; color: var(--ql-heading); }
288
+ .ql-saved-query { grid-column: 1; overflow-wrap: anywhere; font: 400 12px/18px var(--ql-font-mono); color: var(--ql-muted); }
275
289
  .ql-saved-used { grid-column: 2; grid-row: 1 / span 2; align-self: center; font-size: 12px; color: var(--ql-faint); }
276
290
  .ql-notice { margin-top: 8px; font-size: 13px; color: var(--ql-text); }
277
291
  .ql-notice-refused, .ql-notice-store_failed { color: var(--ql-error); }
@@ -279,6 +293,14 @@
279
293
  .ql-box-saved { anchor-name: --ql-saved; }
280
294
  .ql-saved { position-anchor: --ql-saved; inset: auto; top: anchor(bottom); right: anchor(right); margin: 8px 0 0; }
281
295
  }
296
+ @media (max-width: 600px) {
297
+ .ql-saved { left: 16px; right: 16px; width: auto; }
298
+ .ql-saved-head { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
299
+ .ql-saved-head label { grid-column: 1 / -1; }
300
+ .ql-saved-head input { min-width: 0; }
301
+ .ql-saved-item { grid-template-columns: minmax(0, 1fr); }
302
+ .ql-saved-used { grid-column: 1; grid-row: auto; }
303
+ }
282
304
 
283
305
  /* Searching, too short, reasons, unavailable, denied ------------------------ */
284
306
  .ql-box-busy-label { display: none; flex-shrink: 0; font-size: 13px; color: var(--ql-muted); }
@@ -90,7 +90,7 @@ export default class extends Controller {
90
90
  // new revision; the rewrite comes back at that same revision.
91
91
  submitted(event) {
92
92
  clearTimeout(this.timer)
93
- if (["toggle", "remove", "clear", "compose_submit", "saved", "fix", "instead", "widen", "relax"].includes(event.submitter?.name)) this.bump()
93
+ if (["toggle", "remove", "clear", "compose_submit", "saved", "delete_saved", "fix", "instead", "widen", "relax", "as_words"].includes(event.submitter?.name)) this.bump()
94
94
  }
95
95
 
96
96
  setQuery(event) {
@@ -23,7 +23,6 @@ export default class extends Controller {
23
23
  this.element.addEventListener("turbo:before-frame-render", this.guardPreview)
24
24
  this.restore = (event) => {
25
25
  if (event.target !== this.element) return
26
- this.previewGroup = null
27
26
  this.restoreFocus()
28
27
  }
29
28
  this.element.addEventListener("querylane:focus-first", this.focusFirst)
@@ -82,6 +81,10 @@ export default class extends Controller {
82
81
  document.getElementById(event.target.dataset.querylaneEnter)?.click()
83
82
  }
84
83
 
84
+ deleteSaved(event) {
85
+ if (!window.confirm(event.currentTarget.dataset.confirm)) event.preventDefault()
86
+ }
87
+
85
88
  closePreview() {
86
89
  if (!this.hasPreviewTarget) return
87
90
  this.previewTarget.innerHTML = ""
@@ -95,6 +98,14 @@ export default class extends Controller {
95
98
  if (event.target.matches("select[form]")) event.target.form?.requestSubmit()
96
99
  }
97
100
 
101
+ // A new answer brings a new, empty preview frame: nothing is previewed any
102
+ // more. Stimulus calls this as the rows are swapped in; turbo:frame-render
103
+ // comes a few repaints later, after a quick Space may have started a preview.
104
+ previewTargetConnected() {
105
+ this.previewGroup = null
106
+ this.previewKey = null
107
+ }
108
+
98
109
  // private
99
110
 
100
111
  get records() {
@@ -1,4 +1,5 @@
1
- <%# "Searching for" chips. × removes that term (server rewrites the query). Locals: result, id. %>
1
+ <%# locals: (result:, id:) -%>
2
+ <%# "Searching for" chips. × removes that term (server rewrites the query). %>
2
3
  <% if result.chips.any? %>
3
4
  <div class="ql-chips">
4
5
  <span class="ql-chips-label"><%= t("querylane.chips.searching_for") %></span>
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# The user's access could not be checked (Querylane::Scope.denied): nothing
2
- is shown rather than records outside it. Locals: result, id. %>
3
+ is shown rather than records outside it. %>
3
4
  <div class="ql-state ql-state-denied" role="alert">
4
5
  <h2><%= t("querylane.states.denied_title") %></h2>
5
6
  <p><%= t("querylane.states.denied") %></p>
@@ -1,16 +1,22 @@
1
- <%# No hits: say what found nothing and offer one-click ways to widen. Locals: result, id. %>
1
+ <%# locals: (result:, id:) -%>
2
+ <%# No hits: say what found nothing and offer one-click ways to widen. Each
3
+ way only rewrites the query, so it is a quiet link, never a button. %>
2
4
  <div class="ql-empty">
3
5
  <h2><%= t("querylane.states.empty_title", searched: result.chips.map(&:label).join(" · ")) %></h2>
4
6
  <div class="ql-empty-actions">
5
7
  <% result.widenable_facets.each do |facet| %>
6
- <button type="submit" form="<%= id %>-form" name="widen" value="<%= facet.key %>" class="btn btn-outline-primary btn-sm"><%= t("querylane.states.widen_#{facet.key}", default: t("querylane.states.widen", facet: facet.label.downcase)) %></button>
8
+ <button type="submit" form="<%= id %>-form" name="widen" value="<%= facet.key %>" class="ql-link"><%= t("querylane.states.widen_#{facet.key}", default: t("querylane.states.widen", facet: facet.label.downcase)) %></button>
7
9
  <% end %>
8
10
  <%# Every filter can be dropped; the words are the search itself. %>
9
11
  <% result.chips.reject(&:words).each do |chip| %>
10
- <button type="submit" form="<%= id %>-form" name="remove" value="<%= chip.index %>" class="btn btn-outline-secondary btn-sm"><%= t("querylane.states.drop", label: chip.label) %></button>
12
+ <button type="submit" form="<%= id %>-form" name="remove" value="<%= chip.index %>" class="ql-link"><%= t("querylane.states.drop", label: chip.label) %></button>
13
+ <% end %>
14
+ <%# An exact filter that opts in (as_words) can search its typed value as plain words. %>
15
+ <% result.chips.select(&:as_words).each do |chip| %>
16
+ <button type="submit" form="<%= id %>-form" name="as_words" value="<%= chip.index %>" class="ql-link"><%= t("querylane.states.as_words", value: chip.as_words) %></button>
11
17
  <% end %>
12
18
  <% unless result.relaxed %>
13
- <button type="submit" form="<%= id %>-form" name="relax" value="1" class="btn btn-outline-secondary btn-sm"><%= t("querylane.states.relax") %></button>
19
+ <button type="submit" form="<%= id %>-form" name="relax" value="1" class="ql-link"><%= t("querylane.states.relax") %></button>
14
20
  <% end %>
15
21
  </div>
16
22
  <p class="ql-facet-note"><%= t("querylane.states.empty_note") %></p>
@@ -1,6 +1,7 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# Facets. A value is a submit button with checkbox semantics: it asks the server
2
- to tick or untick that value in the query text. Locals: result, id. %>
3
- <aside class="ql-facets" aria-label="Filters">
3
+ to tick or untick that value in the query text. %>
4
+ <aside class="ql-facets" aria-label="<%= t("querylane.facets.label") %>">
4
5
  <% result.facets.each do |facet| %>
5
6
  <section class="ql-facet">
6
7
  <h3 class="ql-facet-title"><%= facet.label %></h3>
@@ -1,4 +1,5 @@
1
- <%# Before anything is typed: what to type, with the definition's examples. Locals: definition. %>
1
+ <%# locals: (definition:) -%>
2
+ <%# Before anything is typed: what to type, with the definition's examples. %>
2
3
  <div class="ql-first-load">
3
4
  <div class="ql-first-load-lead">
4
5
  <h2><%= t("querylane.states.first_load_title") %></h2>
@@ -1,10 +1,19 @@
1
- <%# The search form and box. Outside the results frame, so typing never loses the caret.
2
- Locals: result, id, url, input_id, label, placeholder, mask, button_id, options, help, extra. %>
3
- <%= form_with url: url, method: :post, id: "#{id}-form", class: "querylane-form",
4
- data: {turbo: true, turbo_frame: "#{id}-results", controller: "querylane--box",
1
+ <%# locals: (result:, id:, url:, input_id:, label:, placeholder:, mask:, button_id:, options:, help:, extra:) -%>
2
+ <%# The search form and box, in a search landmark. Outside the results frame,
3
+ so typing never loses the caret. Search applies the box (an outline
4
+ button): the host's page keeps its one solid button for a commit. %>
5
+ <%
6
+ form_id = "#{id}-form"
7
+ frame_id = "#{id}-results"
8
+ help_id = "#{id}-help-text" if help
9
+ described_by = [help_id, "#{id}-error"].compact.join(" ")
10
+ %>
11
+ <search class="ql-search">
12
+ <%= form_with url: url, method: :post, id: form_id, class: "querylane-form",
13
+ data: {turbo: true, turbo_frame: frame_id, controller: "querylane--box",
5
14
  querylane__box_debounce_value: Querylane.configuration.debounce_ms,
6
15
  querylane__box_mask_value: mask.to_s,
7
- querylane__box_frame_value: "#{id}-results",
16
+ querylane__box_frame_value: frame_id,
8
17
  querylane__box_min_value: result.definition.min_text_length.to_i,
9
18
  action: "submit->querylane--box#submitted turbo:before-cache@document->querylane--box#forget keydown@document->querylane--box#shortcut"} do %>
10
19
  <%# The default button: Enter in the box searches (Options comes first in the box). %>
@@ -14,10 +23,10 @@
14
23
  <svg class="ql-box-icon" viewBox="0 0 20 20" width="20" height="20" 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>
15
24
  <div class="ql-box-field">
16
25
  <div class="ql-box-overlay" aria-hidden="true" data-querylane--box-target="overlay"></div>
17
- <label class="visually-hidden" for="<%= input_id %>"><%= label %></label>
26
+ <label class="ql-visually-hidden" for="<%= input_id %>"><%= label %></label>
18
27
  <input type="text" id="<%= input_id %>" name="<%= Querylane.configuration.param_name %>" value="<%= result.query %>"
19
28
  class="ql-box-input" placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
20
- aria-describedby="<%= [("#{id}-help-text" if help), "#{id}-error"].compact.join(" ") %>"
29
+ aria-describedby="<%= described_by %>"
21
30
  data-querylane--box-target="input"
22
31
  data-action="input->querylane--box#typed keydown->querylane--box#keys scroll->querylane--box#paint select->querylane--box#paint click->querylane--box#paint compositionstart->querylane--box#compose compositionend->querylane--box#composed">
23
32
  </div>
@@ -39,8 +48,9 @@
39
48
  <%= t("querylane.box.options") %>
40
49
  </button>
41
50
  <% end %>
42
- <button type="submit" class="btn btn-primary ql-box-submit" <%= %(id="#{button_id}").html_safe if button_id %>><%= t("querylane.box.search") %></button>
51
+ <button type="submit" class="btn btn-outline-primary ql-box-submit" <%= tag.attributes(id: button_id) %>><%= t("querylane.box.search") %></button>
43
52
  </div>
44
- <% if help %><p class="ql-box-help" id="<%= id %>-help-text"><%= help %></p><% end %>
53
+ <% if help %><p class="ql-box-help" id="<%= help_id %>"><%= help %></p><% end %>
45
54
  <div class="ql-hint" role="status" hidden data-querylane--box-target="hint"><%= t("querylane.states.short", count: result.definition.min_text_length) if result.definition.min_text_length %></div>
46
55
  <% end %>
56
+ </search>
@@ -1,5 +1,6 @@
1
+ <%# locals: (presenter:, group:, columns:) -%>
1
2
  <%# One group (e.g. a person) and its records, with "N more records" only
2
- when the group has records the page does not show. Locals: presenter, group, columns. %>
3
+ when the group has records the page does not show. %>
3
4
  <% token = presenter.group_token(group) %>
4
5
  <div class="ql-group" <%= tag.attributes(data: {querylane_group: token}) %>>
5
6
  <div class="ql-group-head">
@@ -1,6 +1,7 @@
1
+ <%# locals: (definition:, id:, popover_id: nil) -%>
1
2
  <%# Syntax help card generated from the definition; a native popover (no JS).
2
- Locals: definition, id, popover_id (optional, when shown twice on a page). %>
3
- <% popover_id = local_assigns.fetch(:popover_id, "#{id}-help") %>
3
+ popover_id names it when it is shown twice on a page. %>
4
+ <% popover_id ||= "#{id}-help" %>
4
5
  <button type="button" class="ql-link" popovertarget="<%= popover_id %>"><%= t("querylane.chips.syntax_help") %></button>
5
6
  <div id="<%= popover_id %>" popover class="ql-help">
6
7
  <div class="ql-help-head">
@@ -1,6 +1,8 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# The options panel (M3): fields read from the box, Search writes them back.
2
3
  Fields belong to the search form (form=…), so the live box text travels with
3
- them; the server composes. Locals: result, id. %>
4
+ them; the server composes. Search applies the panel (outline), Cancel and
5
+ Reset are quiet links. %>
4
6
  <section class="ql-options" id="<%= id %>-options" aria-labelledby="<%= id %>-options-title"
5
7
  data-controller="querylane--options" data-querylane--options-debounce-value="<%= Querylane.configuration.debounce_ms %>"
6
8
  data-action="input->querylane--options#changed change->querylane--options#changed keydown.esc->querylane--options#cancel keydown.enter->querylane--options#apply">
@@ -25,8 +27,8 @@
25
27
  <%= render "querylane/help", definition: result.definition, id: id, popover_id: "#{id}-options-help" %>
26
28
  <button type="button" class="ql-link" data-action="querylane--options#reset"><%= t("querylane.options.reset") %></button>
27
29
  <span class="ql-options-spacer"></span>
28
- <button type="submit" form="<%= id %>-form" class="btn btn-link" data-querylane--options-target="cancel"><%= t("querylane.options.cancel") %></button>
29
- <button type="submit" form="<%= id %>-form" name="compose_submit" value="1" class="btn btn-primary"
30
+ <button type="submit" form="<%= id %>-form" class="ql-link" data-querylane--options-target="cancel"><%= t("querylane.options.cancel") %></button>
31
+ <button type="submit" form="<%= id %>-form" name="compose_submit" value="1" class="btn btn-outline-primary"
30
32
  data-querylane--options-target="apply"><%= t("querylane.options.search") %></button>
31
33
  </footer>
32
34
  </section>
@@ -1,7 +1,15 @@
1
- <%# One options-panel row. Locals: row (Definition::PanelRow), values, held, definition, id.
1
+ <%# locals: (row:, values:, held:, definition:, id:) -%>
2
+ <%# One options-panel row (a Definition::PanelRow).
2
3
  A masked field starts empty: its current terms stay in the box (shown
3
- masked as the placeholder); what is typed here is added. %>
4
- <% field_id = ->(name) { "#{id}-compose-#{name}" } %>
4
+ masked as the placeholder, which wins over the row's own placeholder);
5
+ what is typed here is added. The row's hint sits under it and describes
6
+ its fields. %>
7
+ <%
8
+ field_id = ->(name) { "#{id}-compose-#{name}" }
9
+ hint = Querylane::Copy.text(row.hint)
10
+ hint_id = "#{field_id.(row.name)}-hint" if hint
11
+ placeholder = Querylane::Copy.text(row.placeholder)
12
+ %>
5
13
  <div class="ql-options-row">
6
14
  <% case row.kind %>
7
15
  <% when :inputs %>
@@ -13,8 +21,8 @@
13
21
  <%# Sensitive fields stay type=text (a password field would invite the browser to save it), masked by CSS. %>
14
22
  <input type="text" id="<%= field_id.(name) %>" name="compose[<%= name %>]" form="<%= id %>-form"
15
23
  value="<%= values[name] %>" class="form-control form-control-sm <%= "ql-masked" if row.masked %>" autocomplete="off" spellcheck="false"
16
- <%= tag.attributes(placeholder: (t("querylane.options.held", text: held[name]) if held[name])) %>
17
- <%= tag.attributes(aria: {label: ("#{row.label} #{name.humanize}" if position.positive?)}) %>
24
+ <%= tag.attributes(placeholder: (held[name] ? t("querylane.options.held", text: held[name]) : placeholder)) %>
25
+ <%= tag.attributes(aria: {label: ("#{row.label} #{name.humanize}" if position.positive?), describedby: hint_id}) %>
18
26
  data-querylane--options-target="field">
19
27
  <% if row.masked %>
20
28
  <button type="button" class="ql-link ql-options-reveal" aria-controls="<%= field_id.(name) %>" aria-pressed="false"
@@ -27,7 +35,7 @@
27
35
  <% when :flags %>
28
36
  <% flags = definition.operator(row.keys.first) %>
29
37
  <span class="ql-options-label" id="<%= field_id.(row.name) %>-label"><%= row.label %></span>
30
- <div class="ql-options-checks" role="group" aria-labelledby="<%= field_id.(row.name) %>-label">
38
+ <div class="ql-options-checks" role="group" aria-labelledby="<%= field_id.(row.name) %>-label" <%= tag.attributes(aria: {describedby: hint_id}) %>>
31
39
  <% definition.flag_values(flags, row.values).each do |value, label| %>
32
40
  <label class="ql-options-check">
33
41
  <input type="checkbox" name="compose[<%= row.name %>][]" value="<%= value %>" form="<%= id %>-form"
@@ -42,10 +50,13 @@
42
50
  <% row.keys.each do |key| %>
43
51
  <label class="ql-options-check">
44
52
  <input type="checkbox" name="compose[options][]" value="<%= key %>" form="<%= id %>-form"
45
- <%= "checked" if values["options"].include?(key.to_s) %> data-querylane--options-target="field">
53
+ <%= "checked" if values["options"].include?(key.to_s) %> <%= tag.attributes(aria: {describedby: hint_id}) %> data-querylane--options-target="field">
46
54
  <%= definition.option(key).label %>
47
55
  </label>
48
56
  <% end %>
49
57
  </div>
50
58
  <% end %>
59
+ <% if hint %>
60
+ <p class="ql-options-hint" id="<%= hint_id %>"><%= hint %></p>
61
+ <% end %>
51
62
  </div>
@@ -1,9 +1,11 @@
1
- <%# Open/View button (full-page POST) or the reason it cannot be opened. Locals: outcome. %>
1
+ <%# locals: (outcome:) -%>
2
+ <%# Open/View or the reason a record cannot be opened. Open/View is a Go link
3
+ (rows hold no buttons) drawn on a button, because it posts (a full page). %>
2
4
  <% case outcome %>
3
5
  <% when Querylane::Presenter::Action %>
4
6
  <%= button_to outcome.label, outcome.url, params: outcome.params || {}, method: :post,
5
- class: "btn btn-sm #{(outcome.style == :primary) ? "btn-primary" : "btn-outline-primary"} ql-open",
6
- form: {class: "d-inline", data: {turbo: false}}, data: {querylane_open: true} %>
7
+ class: "ql-go ql-open",
8
+ form: {class: "ql-open-form", data: {turbo: false}}, data: {querylane_open: true} %>
7
9
  <% when Querylane::Presenter::Reason %>
8
10
  <span class="ql-reason"><%= outcome.text %><% if outcome.link_url %> <%= link_to outcome.link_label, outcome.link_url, data: {turbo: false} %><% end %>
9
11
  <% if outcome.more.any? %>
@@ -1,9 +1,10 @@
1
- <%# Previous, up to five pages, Next. Submit buttons of the search form. Locals: result, id. %>
1
+ <%# locals: (result:, id:) -%>
2
+ <%# Previous, up to five pages, Next. Submit buttons of the search form. %>
2
3
  <% pages = [result.pages, 1].max
3
4
  start = [[result.page - 2, 1].max, [pages - 4, 1].max].min
4
5
  window = (start..[start + 4, pages].min) %>
5
6
  <nav aria-label="<%= t("querylane.toolbar.pages") %>">
6
- <ul class="pagination pagination-sm mb-0 ql-pager">
7
+ <ul class="pagination pagination-sm ql-pager">
7
8
  <li class="page-item previous <%= "disabled" if result.page <= 1 %>">
8
9
  <button type="submit" form="<%= id %>-form" name="page" value="<%= result.page - 1 %>" class="page-link" <%= "disabled" if result.page <= 1 %>><%= t("querylane.toolbar.previous") %></button>
9
10
  </li>
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, presenter:, id:) -%>
1
2
  <%# The preview (Space on a record): every record of its group, any status.
2
- Locals: result (from querylane_related), presenter, id. %>
3
+ result comes from querylane_related. %>
3
4
  <turbo-frame id="<%= id %>-preview" class="ql-preview">
4
5
  <section class="ql-preview-card" aria-labelledby="<%= id %>-preview-title">
5
6
  <% if result.state == :unavailable %>
@@ -1,4 +1,5 @@
1
- <%# One record in lanes. Focusable for ↑/↓/Enter. Locals: presenter, hit, columns. %>
1
+ <%# locals: (presenter:, hit:, columns:) -%>
2
+ <%# One record in lanes. Focusable for ↑/↓/Enter. %>
2
3
  <div class="ql-record" tabindex="-1" data-querylane-record="<%= presenter.record_key(hit) %>">
3
4
  <% presenter.cells(hit).each_with_index do |cell, index| %>
4
5
  <% width = columns[index]&.width %>
@@ -1,5 +1,6 @@
1
+ <%# locals: (result:, presenter:, frame:) -%>
1
2
  <%# "More records": every record of one group the page did not show.
2
- Locals: result (from querylane_related), presenter, frame. %>
3
+ result comes from querylane_related; frame is the requesting frame id. %>
3
4
  <turbo-frame id="<%= frame %>" class="ql-related">
4
5
  <% if result.state == :unavailable %>
5
6
  <div class="ql-related-head" role="status"><%= t("querylane.group.unavailable") %></div>
@@ -1,5 +1,5 @@
1
- <%# The results frame: everything one search request answers.
2
- Locals: result, id, info_id, per_id, first_load, rows. %>
1
+ <%# locals: (result:, id:, info_id:, per_id:, first_load:, rows:) -%>
2
+ <%# The results frame: everything one search request answers. %>
3
3
  <turbo-frame id="<%= id %>-results" class="ql-results" data-revision="<%= result.revision %>" data-controller="querylane--results"
4
4
  data-action="change->querylane--results#submitForm keydown->querylane--results#keys">
5
5
  <%# The answer's state, inside the frame (Turbo keeps the frame element's own
@@ -1,4 +1,5 @@
1
- <%# Grouped records drawn from a Querylane::Presenter. Locals: presenter. %>
1
+ <%# locals: (presenter:) -%>
2
+ <%# Grouped records drawn from a Querylane::Presenter. %>
2
3
  <% columns = presenter.columns %>
3
4
  <div class="ql-record ql-record-head" aria-hidden="true">
4
5
  <% columns.each do |column| %>
@@ -1,7 +1,9 @@
1
+ <%# locals: (result:, id:) -%>
1
2
  <%# Saved searches (M12): a native popover opened by the box's Saved button.
2
3
  Name and Save belong to the search form; choosing one sends its id and the
3
- server rewrites the box. Locals: result, id. %>
4
+ server rewrites the box. %>
4
5
  <div id="<%= id %>-saved" popover class="ql-saved">
6
+ <input type="hidden" name="delete_saved_page" value="<%= result.page %>" form="<%= id %>-form">
5
7
  <div class="ql-saved-head">
6
8
  <label for="<%= id %>-saved-name"><%= t("querylane.saved.title") %></label>
7
9
  <input type="text" id="<%= id %>-saved-name" name="saved_name" form="<%= id %>-form" maxlength="80" autocomplete="off"
@@ -22,10 +24,17 @@
22
24
  <p class="ql-saved-none"><%= t("querylane.saved.none") %></p>
23
25
  <% end %>
24
26
  <% result.saved_searches.each do |saved| %>
25
- <button type="submit" form="<%= id %>-form" name="saved" value="<%= saved.id %>" class="ql-saved-item">
26
- <span class="ql-saved-name"><%= saved.name %></span>
27
- <code class="ql-saved-query"><%= saved.query %></code>
28
- <% if saved.used_at %><span class="ql-saved-used"><%= t("querylane.saved.used", date: l(saved.used_at.to_date, format: :short)) %></span><% end %>
29
- </button>
27
+ <div class="ql-saved-row" data-test="saved-search">
28
+ <button type="submit" form="<%= id %>-form" name="saved" value="<%= saved.id %>" class="ql-saved-item">
29
+ <span class="ql-saved-name"><%= saved.name %></span>
30
+ <code class="ql-saved-query"><%= saved.query %></code>
31
+ <% if saved.used_at %><span class="ql-saved-used"><%= t("querylane.saved.used", date: l(saved.used_at.to_date, format: :short)) %></span><% end %>
32
+ </button>
33
+ <button type="submit" form="<%= id %>-form" name="delete_saved" value="<%= saved.id %>" class="ql-saved-delete"
34
+ aria-label="<%= t("querylane.saved.delete_label", name: saved.name) %>" title="<%= t("querylane.saved.delete_label", name: saved.name) %>"
35
+ data-action="click->querylane--results#deleteSaved" data-confirm="<%= t("querylane.saved.delete_confirm") %>">
36
+ <%= respond_to?(:icon) ? icon("trash-2") : t("querylane.saved.delete") %>
37
+ </button>
38
+ </div>
30
39
  <% end %>
31
40
  </div>