querylane 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76873d12e555209a71b9eafed1f3fa4beee0c3377015cf3bc064a13b01098153
4
- data.tar.gz: fd06740404a5f449fe11b43e0669be267cea0f8ef0ce1a44243b472eca985b5b
3
+ metadata.gz: cae1575fd12bcaf1bd45457aebfde411632341db4dbe029019cd3c683e839497
4
+ data.tar.gz: 02c42e8191a53bf52c19360f3542218cd7e8b2c7c158e08cdefcaabc29030999
5
5
  SHA512:
6
- metadata.gz: a31aa8846f0f7338af5ac7cf6c28b0b840a1e0a423653b04d68d5127f0a00c973a47a9d368273b0010679ea1e5165896df38693bb808a5d8d19311949a393ee3
7
- data.tar.gz: e42d68c96660e86fa202330655388f411a5993a7cbda5d7ad14e6341fd66dd25d90b1ce83d4aee6d22e346c2ea8c3341d4e26f0afdb2121b5135f03ae6b80f97
6
+ metadata.gz: 44c16d7ee50b1a3a6bd8408f72f098f49c330e301f66908892c0ed5e32c4dcef3a12330e4769dc7c541b2e41d209a820ffa99a67f1bcd25557fe0def64bd9022
7
+ data.tar.gz: 86e14bc61a17bcefe91ecdd1775ef9333b8dc67aded55ce420f25a167ce394e83036a0217a15cd2f90488fdab6dee35ef1e5ad668b9e2f305019525d2b9f7116
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ 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
+
10
28
  ## [0.2.0] - 2026-11-10
11
29
 
12
30
  ### Added
@@ -151,6 +169,7 @@ The first release.
151
169
  - `search.querylane` and `saved_search_failed.querylane` instrumentation, without
152
170
  query text. The query parameter and `compose` are filtered from logs.
153
171
 
154
- [Unreleased]: https://github.com/clearstackio/querylane/compare/v0.2.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
155
174
  [0.2.0]: https://github.com/clearstackio/querylane/compare/v0.1.0...v0.2.0
156
175
  [0.1.0]: https://github.com/clearstackio/querylane/releases/tag/v0.1.0
@@ -278,11 +278,14 @@
278
278
  .ql-saved-refusal { margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--ql-rule); font-size: 13px; color: var(--ql-text); }
279
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); }
280
280
  .ql-saved-none { margin: 0; padding: 4px 16px 14px; color: var(--ql-muted); font-size: 13px; }
281
- .ql-saved-item { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; width: 100%; padding: 8px 16px; border: 0;
282
- background: none; text-align: left; }
283
- .ql-saved-item:hover, .ql-saved-item:focus-visible { background: var(--ql-accent-soft); }
284
- .ql-saved-name { font-weight: 600; color: var(--ql-heading); }
285
- .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); }
286
289
  .ql-saved-used { grid-column: 2; grid-row: 1 / span 2; align-self: center; font-size: 12px; color: var(--ql-faint); }
287
290
  .ql-notice { margin-top: 8px; font-size: 13px; color: var(--ql-text); }
288
291
  .ql-notice-refused, .ql-notice-store_failed { color: var(--ql-error); }
@@ -290,6 +293,14 @@
290
293
  .ql-box-saved { anchor-name: --ql-saved; }
291
294
  .ql-saved { position-anchor: --ql-saved; inset: auto; top: anchor(bottom); right: anchor(right); margin: 8px 0 0; }
292
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
+ }
293
304
 
294
305
  /* Searching, too short, reasons, unavailable, denied ------------------------ */
295
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", "as_words"].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) {
@@ -81,6 +81,10 @@ export default class extends Controller {
81
81
  document.getElementById(event.target.dataset.querylaneEnter)?.click()
82
82
  }
83
83
 
84
+ deleteSaved(event) {
85
+ if (!window.confirm(event.currentTarget.dataset.confirm)) event.preventDefault()
86
+ }
87
+
84
88
  closePreview() {
85
89
  if (!this.hasPreviewTarget) return
86
90
  this.previewTarget.innerHTML = ""
@@ -3,6 +3,7 @@
3
3
  Name and Save belong to the search form; choosing one sends its id and the
4
4
  server rewrites the box. %>
5
5
  <div id="<%= id %>-saved" popover class="ql-saved">
6
+ <input type="hidden" name="delete_saved_page" value="<%= result.page %>" form="<%= id %>-form">
6
7
  <div class="ql-saved-head">
7
8
  <label for="<%= id %>-saved-name"><%= t("querylane.saved.title") %></label>
8
9
  <input type="text" id="<%= id %>-saved-name" name="saved_name" form="<%= id %>-form" maxlength="80" autocomplete="off"
@@ -23,10 +24,17 @@
23
24
  <p class="ql-saved-none"><%= t("querylane.saved.none") %></p>
24
25
  <% end %>
25
26
  <% result.saved_searches.each do |saved| %>
26
- <button type="submit" form="<%= id %>-form" name="saved" value="<%= saved.id %>" class="ql-saved-item">
27
- <span class="ql-saved-name"><%= saved.name %></span>
28
- <code class="ql-saved-query"><%= saved.query %></code>
29
- <% if saved.used_at %><span class="ql-saved-used"><%= t("querylane.saved.used", date: l(saved.used_at.to_date, format: :short)) %></span><% end %>
30
- </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>
31
39
  <% end %>
32
40
  </div>
@@ -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,6 +56,8 @@ 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:
@@ -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
@@ -7,19 +7,33 @@ module Querylane
7
7
  # remove (chip index), as_words (chip index), clear, instead, widen, relax, compose (with
8
8
  # compose_submit), options, sort, page, per, revision.
9
9
  class Request
10
- attr_reader :fix, :saved, :save_search, :saved_name, :group, :query, :toggle, :remove, :as_words, :clear, :instead, :widen, :relax, :compose, :options, :sort, :page, :per, :revision
10
+ attr_reader :fix, :saved, :delete_saved, :save_search, :saved_name, :group, :query, :toggle, :remove, :as_words, :clear, :instead, :widen, :relax, :compose, :options, :sort, :page, :per, :revision
11
11
 
12
12
  # @param params [ActionController::Parameters, Hash]
13
13
  # @param definition [Querylane::Definition]
14
14
  def self.from_params(params, definition:, config: Querylane.configuration)
15
- values = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
16
- values = values.transform_keys(&:to_s)
15
+ values = normalized_params(params)
16
+ values["page"] = values["delete_saved_page"] if values["delete_saved"].present? && values["delete_saved_page"].present?
17
17
  new(definition: definition, query: values[config.param_name.to_s], toggle: values["toggle"], remove: values["remove"],
18
18
  as_words: values["as_words"], clear: values["clear"],
19
19
  instead: values["instead"], widen: values["widen"], relax: values["relax"],
20
20
  compose: (values["compose"] if values["compose_submit"].present?), options: values["options"],
21
- fix: values["fix"], group: values["group"], saved: values["saved"], save_search: values["save_search"], saved_name: values["saved_name"], sort: values["sort"], page: values["page"], per: values["per"], revision: values["revision"])
21
+ fix: values["fix"], group: values["group"], saved: values["saved"], delete_saved: values["delete_saved"], save_search: values["save_search"], saved_name: values["saved_name"], sort: values["sort"], page: values["page"], per: values["per"], revision: values["revision"])
22
+ end
23
+
24
+ # The normalized, bounded deletion value used by both the request parser and POST guard.
25
+ #
26
+ # @param params [ActionController::Parameters, Hash]
27
+ # @return [String, nil]
28
+ def self.delete_saved_param(params)
29
+ normalized_params(params)["delete_saved"].to_s.presence&.first(64)
30
+ end
31
+
32
+ def self.normalized_params(params)
33
+ values = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
34
+ values.transform_keys(&:to_s)
22
35
  end
36
+ private_class_method :normalized_params
23
37
 
24
38
  # instead: "chip.alternative" (use another reading of an interpreted term);
25
39
  # widen: a facet key (drop that facet's terms); relax: match fewer words;
@@ -27,9 +41,10 @@ module Querylane
27
41
  # compose: options-panel fields {"name" => "…", "policy" => "1 2", "is" => [...], "options" => [...]}
28
42
  # (applied only with compose_submit); options: open the options panel;
29
43
  # group: a GroupToken (all records of one group, see Search#related);
30
- # saved: a saved search id to run; save_search with saved_name: save the query.
44
+ # saved: a saved search id to run; save_search with saved_name: save the query;
45
+ # delete_saved: an owned saved search id to delete without rewriting the query.
31
46
  def initialize(definition:, query: nil, toggle: nil, remove: nil, as_words: nil, clear: nil, instead: nil, widen: nil, relax: nil,
32
- compose: nil, options: nil, fix: nil, group: nil, saved: nil, save_search: nil, saved_name: nil, sort: nil, page: nil, per: nil, revision: nil)
47
+ compose: nil, options: nil, fix: nil, group: nil, saved: nil, delete_saved: nil, save_search: nil, saved_name: nil, sort: nil, page: nil, per: nil, revision: nil)
33
48
  @query = query.to_s
34
49
  @clear = clear.present?
35
50
  @instead = instead.to_s.match(/\A(\d+)\.(\d+)\z/)&.captures&.map(&:to_i)
@@ -38,8 +53,9 @@ module Querylane
38
53
  @options = options.present?
39
54
  @group = group.presence
40
55
  @fix = fix.present?
41
- @saved = saved.to_s.presence&.first(64)
42
- @save_search = save_search.present?
56
+ @delete_saved = delete_saved.to_s.presence&.first(64)
57
+ @saved = saved.to_s.presence&.first(64) unless @delete_saved
58
+ @save_search = save_search.present? && !@delete_saved
43
59
  @saved_name = saved_name.to_s.squish.first(80)
44
60
  @compose = compose.is_a?(Hash) ? compose.to_h.transform_keys(&:to_s) : nil
45
61
  @toggle = parse_toggle(definition, toggle)
@@ -33,6 +33,7 @@ module Querylane
33
33
  query = Query.analyze(@definition, @request.query, config: @config)
34
34
  query, rewritten = rewrite(query)
35
35
  @notice = save(query) if @request.save_search && !@scope.denied?
36
+ @notice = delete_saved(@request.delete_saved) if @request.delete_saved && !@scope.denied?
36
37
  return result(:first_load, query, rewritten) if query.valid? && query.blank?
37
38
  return result(:invalid, query, rewritten, error: query.error) unless query.valid?
38
39
  return result(:short, query, rewritten, chips: Chips.for(query)) if query.too_short?(@definition.min_text_length)
@@ -311,6 +312,15 @@ module Querylane
311
312
  nil
312
313
  end
313
314
 
315
+ def delete_saved(id)
316
+ store = @definition.saved_searches
317
+ return :not_available unless store&.respond_to?(:delete)
318
+
319
+ store.delete(context: @context, id: id) ? :deleted : :not_available
320
+ rescue => error
321
+ saved_store_failed(error)
322
+ end
323
+
314
324
  def saved_list
315
325
  store = @definition.saved_searches
316
326
  store && Array(store.all(context: @context))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Querylane
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: querylane
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajaya Agrawalla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-10 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails