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 +4 -4
- data/CHANGELOG.md +20 -1
- data/app/assets/stylesheets/querylane.css +16 -5
- data/app/javascript/querylane/controllers/box_controller.js +1 -1
- data/app/javascript/querylane/controllers/results_controller.js +4 -0
- data/app/views/querylane/_saved.html.erb +13 -5
- data/config/locales/querylane.en.yml +5 -0
- data/lib/querylane/controller.rb +5 -1
- data/lib/querylane/request.rb +24 -8
- data/lib/querylane/search.rb +10 -0
- data/lib/querylane/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cae1575fd12bcaf1bd45457aebfde411632341db4dbe029019cd3c683e839497
|
|
4
|
+
data.tar.gz: 02c42e8191a53bf52c19360f3542218cd7e8b2c7c158e08cdefcaabc29030999
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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-
|
|
282
|
-
|
|
283
|
-
.ql-saved-
|
|
284
|
-
.ql-saved-
|
|
285
|
-
.ql-saved-
|
|
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
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
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
|
data/lib/querylane/request.rb
CHANGED
|
@@ -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
|
|
16
|
-
values = values.
|
|
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
|
-
@
|
|
42
|
-
@
|
|
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)
|
data/lib/querylane/search.rb
CHANGED
|
@@ -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))
|
data/lib/querylane/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-11-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|