querylane 0.1.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 +7 -0
- data/CHANGELOG.md +84 -0
- data/CONTRIBUTING.md +118 -0
- data/LICENSE +21 -0
- data/README.md +287 -0
- data/SECURITY.md +29 -0
- data/app/assets/stylesheets/querylane.css +300 -0
- data/app/helpers/querylane/search_helper.rb +111 -0
- data/app/javascript/querylane/controllers/box_controller.js +170 -0
- data/app/javascript/querylane/controllers/options_controller.js +54 -0
- data/app/javascript/querylane/controllers/results_controller.js +137 -0
- data/app/javascript/querylane/controllers/suggest_controller.js +187 -0
- data/app/javascript/querylane/index.js +33 -0
- data/app/javascript/querylane/overlay.js +46 -0
- data/app/javascript/querylane/package.json +7 -0
- data/app/views/querylane/_chips.html.erb +15 -0
- data/app/views/querylane/_denied.html.erb +6 -0
- data/app/views/querylane/_empty.html.erb +17 -0
- data/app/views/querylane/_facets.html.erb +30 -0
- data/app/views/querylane/_first_load.html.erb +15 -0
- data/app/views/querylane/_form.html.erb +46 -0
- data/app/views/querylane/_group.html.erb +21 -0
- data/app/views/querylane/_help.html.erb +29 -0
- data/app/views/querylane/_options.html.erb +32 -0
- data/app/views/querylane/_options_row.html.erb +51 -0
- data/app/views/querylane/_outcome.html.erb +16 -0
- data/app/views/querylane/_pager.html.erb +19 -0
- data/app/views/querylane/_preview.html.erb +39 -0
- data/app/views/querylane/_record.html.erb +16 -0
- data/app/views/querylane/_related.html.erb +17 -0
- data/app/views/querylane/_results.html.erb +66 -0
- data/app/views/querylane/_rows.html.erb +13 -0
- data/app/views/querylane/_saved.html.erb +31 -0
- data/app/views/querylane/_suggest_box.html.erb +45 -0
- data/app/views/querylane/_suggestions.html.erb +47 -0
- data/app/views/querylane/_toolbar.html.erb +28 -0
- data/app/views/querylane/_unavailable.html.erb +7 -0
- data/app/views/querylane/_writes.html.erb +16 -0
- data/config/importmap.rb +34 -0
- data/config/locales/querylane.en.yml +136 -0
- data/lib/querylane/ast.rb +17 -0
- data/lib/querylane/chips.rb +85 -0
- data/lib/querylane/compiler.rb +52 -0
- data/lib/querylane/configuration.rb +49 -0
- data/lib/querylane/context.rb +11 -0
- data/lib/querylane/controller.rb +85 -0
- data/lib/querylane/definition.rb +447 -0
- data/lib/querylane/doctor.rb +52 -0
- data/lib/querylane/engine.rb +61 -0
- data/lib/querylane/errors.rb +27 -0
- data/lib/querylane/filter.rb +75 -0
- data/lib/querylane/group_token.rb +33 -0
- data/lib/querylane/parser.rb +178 -0
- data/lib/querylane/presenter.rb +160 -0
- data/lib/querylane/preview.rb +45 -0
- data/lib/querylane/query.rb +183 -0
- data/lib/querylane/request.rb +66 -0
- data/lib/querylane/result.rb +90 -0
- data/lib/querylane/rewriter.rb +245 -0
- data/lib/querylane/saving.rb +49 -0
- data/lib/querylane/scope.rb +53 -0
- data/lib/querylane/search.rb +347 -0
- data/lib/querylane/searchers/meilisearch.rb +226 -0
- data/lib/querylane/suggest.rb +67 -0
- data/lib/querylane/version.rb +5 -0
- data/lib/querylane.rb +60 -0
- data/lib/tasks/querylane.rake +14 -0
- metadata +132 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Lives on the results <turbo-frame> (which persists while its contents are
|
|
4
|
+
// replaced): ↑/↓ move between records, Enter opens the focused record, Space
|
|
5
|
+
// previews its group (the preview follows ↑/↓, Esc closes it), opening a
|
|
6
|
+
// group's "more records" loads them once, the focused record survives a frame
|
|
7
|
+
// refresh, selects submit the search form.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["preview", "previewButton"]
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.focusFirst = () => this.move(0, true)
|
|
13
|
+
this.remember = (event) => {
|
|
14
|
+
if (event.target !== this.element) return
|
|
15
|
+
const focused = this.element.contains(document.activeElement) ? document.activeElement.closest("[data-querylane-record]") : null
|
|
16
|
+
this.focusedKey = focused?.dataset.querylaneRecord
|
|
17
|
+
}
|
|
18
|
+
// A preview answer that arrives after Esc must not reopen it.
|
|
19
|
+
this.guardPreview = (event) => {
|
|
20
|
+
if (event.target !== this.previewTargetOrNull || this.previewGroup) return
|
|
21
|
+
event.detail.render = () => {}
|
|
22
|
+
}
|
|
23
|
+
this.element.addEventListener("turbo:before-frame-render", this.guardPreview)
|
|
24
|
+
this.restore = (event) => {
|
|
25
|
+
if (event.target !== this.element) return
|
|
26
|
+
this.previewGroup = null
|
|
27
|
+
this.restoreFocus()
|
|
28
|
+
}
|
|
29
|
+
this.element.addEventListener("querylane:focus-first", this.focusFirst)
|
|
30
|
+
this.element.addEventListener("turbo:before-frame-render", this.remember)
|
|
31
|
+
this.element.addEventListener("turbo:frame-render", this.restore)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
disconnect() {
|
|
35
|
+
this.element.removeEventListener("querylane:focus-first", this.focusFirst)
|
|
36
|
+
this.element.removeEventListener("turbo:before-frame-render", this.remember)
|
|
37
|
+
this.element.removeEventListener("turbo:frame-render", this.restore)
|
|
38
|
+
this.element.removeEventListener("turbo:before-frame-render", this.guardPreview)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
keys(event) {
|
|
42
|
+
const record = event.target.closest("[data-querylane-record]")
|
|
43
|
+
if (!record) return
|
|
44
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
const next = this.move(this.records.indexOf(record) + (event.key === "ArrowDown" ? 1 : -1))
|
|
47
|
+
if (next && this.previewing) this.showPreview(next)
|
|
48
|
+
} else if (event.key === "Enter" && event.target === record) {
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
record.querySelector("[data-querylane-open]")?.click()
|
|
51
|
+
} else if (event.key === " " && event.target === record) {
|
|
52
|
+
event.preventDefault()
|
|
53
|
+
this.previewing ? this.closePreview() : this.showPreview(record)
|
|
54
|
+
} else if (event.key === "Escape" && this.previewing) {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
this.closePreview()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A group's "more records" <details>: the first open loads them.
|
|
61
|
+
more(event) {
|
|
62
|
+
const details = event.currentTarget
|
|
63
|
+
if (!details.open || details.dataset.loaded) return
|
|
64
|
+
details.dataset.loaded = "true"
|
|
65
|
+
const button = details.querySelector("[data-querylane-more]")
|
|
66
|
+
button?.form?.requestSubmit(button)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A facet's "Find…" box: show the loaded values matching it (all of them,
|
|
70
|
+
// not only the first few); empty shows the first few again.
|
|
71
|
+
findValue(event) {
|
|
72
|
+
const text = event.target.value.trim().toLowerCase()
|
|
73
|
+
event.target.closest(".ql-facet").querySelectorAll(".ql-facet-value").forEach((value) => {
|
|
74
|
+
const label = value.querySelector(".ql-facet-label").textContent.toLowerCase()
|
|
75
|
+
value.hidden = text ? !label.includes(text) : value.hasAttribute("data-querylane-beyond")
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Enter in a field that names its button (data-querylane-enter) clicks it.
|
|
80
|
+
enter(event) {
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
document.getElementById(event.target.dataset.querylaneEnter)?.click()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
closePreview() {
|
|
86
|
+
if (!this.hasPreviewTarget) return
|
|
87
|
+
this.previewTarget.innerHTML = ""
|
|
88
|
+
this.previewGroup = null
|
|
89
|
+
this.element.querySelector("[data-querylane-previewing]")?.removeAttribute("data-querylane-previewing")
|
|
90
|
+
this.element.querySelector(`[data-querylane-record="${CSS.escape(this.previewKey || "")}"]`)?.focus()
|
|
91
|
+
this.previewKey = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
submitForm(event) {
|
|
95
|
+
if (event.target.matches("select[form]")) event.target.form?.requestSubmit()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// private
|
|
99
|
+
|
|
100
|
+
get records() {
|
|
101
|
+
return Array.from(this.element.querySelectorAll("[data-querylane-record]"))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get previewTargetOrNull() {
|
|
105
|
+
return this.hasPreviewTarget ? this.previewTarget : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get previewing() {
|
|
109
|
+
return Boolean(this.previewGroup)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
move(index, first = false) {
|
|
113
|
+
const records = this.records
|
|
114
|
+
if (records.length === 0) return null
|
|
115
|
+
const target = records[Math.max(0, Math.min(first ? 0 : index, records.length - 1))]
|
|
116
|
+
target.focus()
|
|
117
|
+
return target
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Loads the record's group into the preview frame (once per group).
|
|
121
|
+
showPreview(record) {
|
|
122
|
+
const group = record.closest("[data-querylane-group]")
|
|
123
|
+
if (!group || !this.hasPreviewButtonTarget) return
|
|
124
|
+
this.element.querySelector("[data-querylane-previewing]")?.removeAttribute("data-querylane-previewing")
|
|
125
|
+
record.setAttribute("data-querylane-previewing", "")
|
|
126
|
+
this.previewKey = record.dataset.querylaneRecord
|
|
127
|
+
if (this.previewGroup === group.dataset.querylaneGroup) return
|
|
128
|
+
this.previewGroup = group.dataset.querylaneGroup
|
|
129
|
+
this.previewButtonTarget.value = this.previewGroup
|
|
130
|
+
this.previewButtonTarget.form?.requestSubmit(this.previewButtonTarget)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
restoreFocus() {
|
|
134
|
+
if (!this.focusedKey) return
|
|
135
|
+
this.element.querySelector(`[data-querylane-record="${CSS.escape(this.focusedKey)}"]`)?.focus()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import Combobox from "@github/combobox-nav"
|
|
3
|
+
|
|
4
|
+
// A search box with suggestions (header dropdown or ⌘K palette). Typing posts
|
|
5
|
+
// the text into the list frame (debounced, stale answers dropped); ↑/↓ and
|
|
6
|
+
// Enter come from combobox-nav; Tab completes an operator; Enter with nothing
|
|
7
|
+
// chosen, or "Search all", posts the text to the search page.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["input", "list", "pop", "dialog", "revision", "searchAll", "openOptions"]
|
|
10
|
+
static values = { debounce: { type: Number, default: 300 }, palette: Boolean }
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this.combobox = new Combobox(this.inputTarget, this.listTarget, { tabInsertsSuggestions: false })
|
|
14
|
+
this.forget = () => this.clearAll()
|
|
15
|
+
document.addEventListener("turbo:before-cache", this.forget)
|
|
16
|
+
this.guardStale = (event) => this.guard(event)
|
|
17
|
+
this.listTarget.addEventListener("turbo:before-frame-render", this.guardStale)
|
|
18
|
+
this.outside = (event) => {
|
|
19
|
+
if (!this.paletteValue && !this.element.contains(event.target)) this.hide()
|
|
20
|
+
}
|
|
21
|
+
document.addEventListener("click", this.outside)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() {
|
|
25
|
+
clearTimeout(this.timer)
|
|
26
|
+
this.combobox.destroy()
|
|
27
|
+
this.listTarget.removeEventListener("turbo:before-frame-render", this.guardStale)
|
|
28
|
+
document.removeEventListener("click", this.outside)
|
|
29
|
+
document.removeEventListener("turbo:before-cache", this.forget)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Every keystroke makes the shown suggestions stale: they stop answering
|
|
33
|
+
// keys and clicks until the answer for the new text arrives.
|
|
34
|
+
typed() {
|
|
35
|
+
this.bump()
|
|
36
|
+
this.combobox.stop()
|
|
37
|
+
this.listTarget.setAttribute("data-querylane-stale", "")
|
|
38
|
+
clearTimeout(this.timer)
|
|
39
|
+
if (this.composing) return
|
|
40
|
+
if (this.inputTarget.value.trim() === "") return this.hide()
|
|
41
|
+
this.timer = setTimeout(() => this.inputTarget.form.requestSubmit(), this.debounceValue)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
compose() {
|
|
45
|
+
this.composing = true
|
|
46
|
+
clearTimeout(this.timer)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
composed() {
|
|
50
|
+
this.composing = false
|
|
51
|
+
this.typed()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
submitted() {
|
|
55
|
+
clearTimeout(this.timer)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
keys(event) {
|
|
59
|
+
if (event.key === "Tab" && !event.shiftKey && this.visible) {
|
|
60
|
+
// While the next answer loads, Tab waits rather than leaving the box.
|
|
61
|
+
if (this.listTarget.hasAttribute("data-querylane-stale")) return event.preventDefault()
|
|
62
|
+
const option = this.listTarget.querySelector('[aria-selected="true"][data-querylane-complete]') ||
|
|
63
|
+
this.listTarget.querySelector("[data-querylane-complete]")
|
|
64
|
+
if (!option) return
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
this.complete(option.dataset.querylaneComplete)
|
|
67
|
+
} else if (event.key === "Enter" && (this.listTarget.hasAttribute("data-querylane-stale") || !this.listTarget.querySelector('[aria-selected="true"]'))) {
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
this.go("search")
|
|
70
|
+
} else if (event.key === "Escape" && !this.paletteValue) {
|
|
71
|
+
if (this.visible) this.hide()
|
|
72
|
+
else this.inputTarget.blur()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// combobox-nav: an option was chosen (Enter or click). Stale options do nothing.
|
|
77
|
+
commit(event) {
|
|
78
|
+
if (this.listTarget.hasAttribute("data-querylane-stale")) return
|
|
79
|
+
const option = event.target.closest('[role="option"]')
|
|
80
|
+
if (!option) return
|
|
81
|
+
if (option.dataset.querylaneComplete) return this.complete(option.dataset.querylaneComplete)
|
|
82
|
+
if (option.dataset.querylaneGo) return this.go(option.dataset.querylaneGo)
|
|
83
|
+
const open = option.querySelector("[data-querylane-open]")
|
|
84
|
+
open ? open.click() : this.go("search")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// "/" focuses the header box (unless the page has its own box); ⌘K / Ctrl-K opens the palette.
|
|
88
|
+
shortcut(event) {
|
|
89
|
+
if (this.paletteValue) {
|
|
90
|
+
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
this.open()
|
|
93
|
+
}
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return
|
|
97
|
+
if (event.target.closest("input, textarea, select, [contenteditable]")) return
|
|
98
|
+
if (document.querySelector(".ql-box-input")) return
|
|
99
|
+
event.preventDefault()
|
|
100
|
+
this.inputTarget.focus()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
open() {
|
|
104
|
+
this.dialogTarget.showModal()
|
|
105
|
+
this.inputTarget.focus()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
closed() {
|
|
109
|
+
this.hide()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
backdrop(event) {
|
|
113
|
+
if (event.target === this.dialogTarget) this.dialogTarget.close()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Focus shows the last list again; a stale one stays inert until its answer renders.
|
|
117
|
+
reopen() {
|
|
118
|
+
if (this.listTarget.childElementCount === 0 || this.inputTarget.value.trim() === "") return
|
|
119
|
+
if (this.listTarget.hasAttribute("data-querylane-stale")) this.popTarget.hidden = false
|
|
120
|
+
else this.show()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// private
|
|
124
|
+
|
|
125
|
+
get visible() {
|
|
126
|
+
return !this.popTarget.hidden
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get revision() {
|
|
130
|
+
return Number(this.revisionTarget.value)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
bump() {
|
|
134
|
+
this.revisionTarget.value = String(this.revision + 1)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Replaces the last word; the shown completions no longer apply, so they go
|
|
138
|
+
// until the next answer arrives (a quick Enter cannot pick a stale one).
|
|
139
|
+
complete(word) {
|
|
140
|
+
this.listTarget.querySelectorAll("[data-querylane-complete]").forEach((option) => option.remove())
|
|
141
|
+
const value = this.inputTarget.value.replace(/\S*$/, word)
|
|
142
|
+
this.inputTarget.value = word.endsWith(":") ? value : `${value} `
|
|
143
|
+
this.inputTarget.focus()
|
|
144
|
+
this.typed()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
go(where) {
|
|
148
|
+
clearTimeout(this.timer)
|
|
149
|
+
const button = where === "options" ? this.openOptionsTarget : this.searchAllTarget
|
|
150
|
+
this.inputTarget.form.requestSubmit(button)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
show() {
|
|
154
|
+
if (this.inputTarget.value.trim() === "") return
|
|
155
|
+
this.listTarget.removeAttribute("data-querylane-stale")
|
|
156
|
+
this.popTarget.hidden = false
|
|
157
|
+
this.combobox.start()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Before Turbo snapshots the page: nothing typed or suggested is kept.
|
|
161
|
+
clearAll() {
|
|
162
|
+
clearTimeout(this.timer)
|
|
163
|
+
this.bump()
|
|
164
|
+
this.inputTarget.value = ""
|
|
165
|
+
this.inputTarget.removeAttribute("value")
|
|
166
|
+
this.listTarget.innerHTML = ""
|
|
167
|
+
this.hide()
|
|
168
|
+
if (this.paletteValue && this.dialogTarget.open) this.dialogTarget.close()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
hide() {
|
|
172
|
+
this.popTarget.hidden = true
|
|
173
|
+
this.combobox.stop()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
guard(event) {
|
|
177
|
+
if (event.target !== this.listTarget) return
|
|
178
|
+
const answered = Number(event.detail.newFrame?.dataset?.revision)
|
|
179
|
+
if (!Number.isFinite(answered)) return
|
|
180
|
+
const render = event.detail.render
|
|
181
|
+
event.detail.render = (current, next) => {
|
|
182
|
+
if (answered < this.revision) return
|
|
183
|
+
render(current, next)
|
|
184
|
+
this.show()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// querylane — Stimulus controllers and a Turbo stream action.
|
|
2
|
+
//
|
|
3
|
+
// import { registerControllers } from "querylane"
|
|
4
|
+
// registerControllers(application) // the host's Stimulus application
|
|
5
|
+
//
|
|
6
|
+
// Never starts Stimulus or changes Turbo settings; the host owns both. Never
|
|
7
|
+
// imports Turbo either: the stream action is handled through a DOM event, so
|
|
8
|
+
// it works with whichever Turbo copy the page loaded (turbo-rails' bundle too).
|
|
9
|
+
import BoxController from "querylane/controllers/box_controller.js"
|
|
10
|
+
import ResultsController from "querylane/controllers/results_controller.js"
|
|
11
|
+
import OptionsController from "querylane/controllers/options_controller.js"
|
|
12
|
+
import SuggestController from "querylane/controllers/suggest_controller.js"
|
|
13
|
+
|
|
14
|
+
// Server rewrote the query (facet toggle, chip ×, Clear): set the box text in
|
|
15
|
+
// place, only if the box is still at the revision the response answers.
|
|
16
|
+
// Replacing `detail.render` is Turbo's documented hook for a custom action.
|
|
17
|
+
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
18
|
+
if (event.target.getAttribute("action") !== "querylane_set_query") return
|
|
19
|
+
|
|
20
|
+
event.detail.render = (stream) => {
|
|
21
|
+
const detail = { revision: Number(stream.dataset.revision), query: stream.templateContent.textContent }
|
|
22
|
+
stream.targetElements.forEach((box) => box.dispatchEvent(new CustomEvent("querylane:set-query", { detail })))
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export function registerControllers(application) {
|
|
27
|
+
application.register("querylane--box", BoxController)
|
|
28
|
+
application.register("querylane--results", ResultsController)
|
|
29
|
+
application.register("querylane--options", OptionsController)
|
|
30
|
+
application.register("querylane--suggest", SuggestController)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { BoxController, OptionsController, ResultsController, SuggestController }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Paints the decorative overlay behind the transparent input text: operator
|
|
2
|
+
// keys coloured, masked runs shown as dots (same width: the font is
|
|
3
|
+
// monospace), the server's error range underlined. Uses textContent only.
|
|
4
|
+
const TOKEN = /(-?)([A-Za-z][A-Za-z0-9_]*:)|\b(OR|NOT)\b|([()])/g
|
|
5
|
+
|
|
6
|
+
export function paint(overlay, input, { mask = "", error = null } = {}) {
|
|
7
|
+
const text = input.value
|
|
8
|
+
// One entry per UTF-16 unit, matching regexp indexes and input positions.
|
|
9
|
+
const classes = Array.from({ length: text.length }, () => new Set())
|
|
10
|
+
|
|
11
|
+
for (const match of text.matchAll(TOKEN)) {
|
|
12
|
+
const start = match.index + (match[1] ? match[1].length : 0)
|
|
13
|
+
const kind = match[2] ? "ql-tok-key" : "ql-tok-op"
|
|
14
|
+
if (match[1]) classes[match.index].add("ql-tok-op")
|
|
15
|
+
for (let i = start; i < match.index + match[0].length; i++) classes[i].add(kind)
|
|
16
|
+
}
|
|
17
|
+
if (mask) {
|
|
18
|
+
for (const match of text.matchAll(new RegExp(mask, "g"))) {
|
|
19
|
+
for (let i = match.index; i < match.index + match[0].length; i++) if (/[0-9A-Za-z]/.test(text[i])) classes[i].add("ql-tok-mask")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (error) for (let i = error[0]; i < Math.min(error[1], text.length); i++) classes[i].add("ql-tok-error")
|
|
23
|
+
|
|
24
|
+
overlay.replaceChildren(...runs(text, classes))
|
|
25
|
+
overlay.scrollLeft = input.scrollLeft
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runs(text, classes) {
|
|
29
|
+
const spans = []
|
|
30
|
+
let start = 0
|
|
31
|
+
for (let i = 1; i <= text.length; i++) {
|
|
32
|
+
if (i < text.length && same(classes[i], classes[start])) continue
|
|
33
|
+
const span = document.createElement("span")
|
|
34
|
+
const names = Array.from(classes[start])
|
|
35
|
+
if (names.length) span.className = names.join(" ")
|
|
36
|
+
const chunk = text.slice(start, i)
|
|
37
|
+
span.textContent = names.includes("ql-tok-mask") ? chunk.replace(/[0-9A-Za-z]/g, "•") : chunk
|
|
38
|
+
spans.push(span)
|
|
39
|
+
start = i
|
|
40
|
+
}
|
|
41
|
+
return spans
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function same(a, b) {
|
|
45
|
+
return a.size === b.size && [...a].every((name) => b.has(name))
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# "Searching for" chips. × removes that term (server rewrites the query). Locals: result, id. %>
|
|
2
|
+
<% if result.chips.any? %>
|
|
3
|
+
<div class="ql-chips">
|
|
4
|
+
<span class="ql-chips-label"><%= t("querylane.chips.searching_for") %></span>
|
|
5
|
+
<% result.chips.each do |chip| %>
|
|
6
|
+
<span class="ql-chip"><%= chip.label %><button type="submit" form="<%= id %>-form" name="remove" value="<%= chip.index %>"
|
|
7
|
+
class="ql-chip-remove" aria-label="<%= t("querylane.chips.remove", label: chip.label) %>">×</button></span>
|
|
8
|
+
<% chip.alternatives.each do |alternative| %>
|
|
9
|
+
<button type="submit" form="<%= id %>-form" name="instead" value="<%= chip.index %>.<%= alternative.index %>" class="ql-link"><%= alternative.label %></button>
|
|
10
|
+
<% end %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<%= render "querylane/help", definition: result.definition, id: id %>
|
|
13
|
+
<button type="submit" form="<%= id %>-form" name="clear" value="1" class="ql-link"><%= t("querylane.chips.clear") %></button>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# The user's access could not be checked (Querylane::Scope.denied): nothing
|
|
2
|
+
is shown rather than records outside it. Locals: result, id. %>
|
|
3
|
+
<div class="ql-state ql-state-denied" role="alert">
|
|
4
|
+
<h2><%= t("querylane.states.denied_title") %></h2>
|
|
5
|
+
<p><%= t("querylane.states.denied") %></p>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# No hits: say what found nothing and offer one-click ways to widen. Locals: result, id. %>
|
|
2
|
+
<div class="ql-empty">
|
|
3
|
+
<h2><%= t("querylane.states.empty_title", searched: result.chips.map(&:label).join(" · ")) %></h2>
|
|
4
|
+
<div class="ql-empty-actions">
|
|
5
|
+
<% 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>
|
|
7
|
+
<% end %>
|
|
8
|
+
<%# Every filter can be dropped; the words are the search itself. %>
|
|
9
|
+
<% 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>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% 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>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<p class="ql-facet-note"><%= t("querylane.states.empty_note") %></p>
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<%# 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">
|
|
4
|
+
<% result.facets.each do |facet| %>
|
|
5
|
+
<section class="ql-facet">
|
|
6
|
+
<h3 class="ql-facet-title"><%= facet.label %></h3>
|
|
7
|
+
<% if facet.failed %>
|
|
8
|
+
<div class="ql-facet-note"><%= t("querylane.facets.unavailable") %></div>
|
|
9
|
+
<% else %>
|
|
10
|
+
<% shown = facet.values.select { |value| value.ticked || value.count.positive? } %>
|
|
11
|
+
<% if facet.search && shown.size > facet.show.to_i %>
|
|
12
|
+
<input type="search" class="form-control form-control-sm ql-facet-find" autocomplete="off"
|
|
13
|
+
aria-label="<%= t("querylane.facets.find_label", facet: facet.label) %>" placeholder="<%= t("querylane.facets.find", facet: facet.label.downcase) %>"
|
|
14
|
+
data-action="input->querylane--results#findValue">
|
|
15
|
+
<% end %>
|
|
16
|
+
<% shown.each_with_index do |value, position| %>
|
|
17
|
+
<% beyond = facet.show && position >= facet.show && !value.ticked %>
|
|
18
|
+
<button type="submit" form="<%= id %>-form" name="toggle" value="<%= facet.key %>:<%= value.value %>"
|
|
19
|
+
class="ql-facet-value" role="checkbox" aria-checked="<%= value.ticked %>" <%= "hidden" if beyond %>
|
|
20
|
+
<%= tag.attributes(data: {querylane_beyond: (true if beyond)}) %>>
|
|
21
|
+
<span class="ql-check" aria-hidden="true"></span>
|
|
22
|
+
<span class="ql-facet-label"><%= value.label %></span>
|
|
23
|
+
<span class="ql-facet-count"><%= "~" if facet.approximate %><%= number_with_delimiter(value.count) %></span>
|
|
24
|
+
</button>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% end %>
|
|
27
|
+
</section>
|
|
28
|
+
<% end %>
|
|
29
|
+
<p class="ql-facet-note"><%= t("querylane.facets.note") %></p>
|
|
30
|
+
</aside>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# Before anything is typed: what to type, with the definition's examples. Locals: definition. %>
|
|
2
|
+
<div class="ql-first-load">
|
|
3
|
+
<div class="ql-first-load-lead">
|
|
4
|
+
<h2><%= t("querylane.states.first_load_title") %></h2>
|
|
5
|
+
<p><%= t("querylane.states.first_load") %></p>
|
|
6
|
+
</div>
|
|
7
|
+
<% if definition.examples.any? %>
|
|
8
|
+
<div class="ql-first-load-examples">
|
|
9
|
+
<div class="ql-facet-title"><%= t("querylane.states.try") %></div>
|
|
10
|
+
<% definition.examples.each do |example| %>
|
|
11
|
+
<div class="ql-example"><code><%= example.query %></code><span><%= example.description %></span></div>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
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",
|
|
5
|
+
querylane__box_debounce_value: Querylane.configuration.debounce_ms,
|
|
6
|
+
querylane__box_mask_value: mask.to_s,
|
|
7
|
+
querylane__box_frame_value: "#{id}-results",
|
|
8
|
+
querylane__box_min_value: result.definition.min_text_length.to_i,
|
|
9
|
+
action: "submit->querylane--box#submitted turbo:before-cache@document->querylane--box#forget keydown@document->querylane--box#shortcut"} do %>
|
|
10
|
+
<%# The default button: Enter in the box searches (Options comes first in the box). %>
|
|
11
|
+
<button type="submit" hidden tabindex="-1" aria-hidden="true"></button>
|
|
12
|
+
<%= hidden_field_tag :revision, result.revision, id: nil, data: {querylane__box_target: "revision"} %>
|
|
13
|
+
<div class="ql-box" id="<%= id %>-box" data-querylane--box-target="box" data-action="querylane:set-query->querylane--box#setQuery">
|
|
14
|
+
<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
|
+
<div class="ql-box-field">
|
|
16
|
+
<div class="ql-box-overlay" aria-hidden="true" data-querylane--box-target="overlay"></div>
|
|
17
|
+
<label class="visually-hidden" for="<%= input_id %>"><%= label %></label>
|
|
18
|
+
<input type="text" id="<%= input_id %>" name="<%= Querylane.configuration.param_name %>" value="<%= result.query %>"
|
|
19
|
+
class="ql-box-input" placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
|
|
20
|
+
aria-describedby="<%= [("#{id}-help-text" if help), "#{id}-error"].compact.join(" ") %>"
|
|
21
|
+
data-querylane--box-target="input"
|
|
22
|
+
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
|
+
</div>
|
|
24
|
+
<span class="ql-box-busy-label" aria-hidden="true"><%= t("querylane.states.searching") %></span>
|
|
25
|
+
<button type="button" class="ql-box-clear" aria-label="<%= t("querylane.box.clear") %>" data-action="querylane--box#clear">×</button>
|
|
26
|
+
<%= extra %>
|
|
27
|
+
<% if result.definition.saved_store %>
|
|
28
|
+
<%# Filled star only while the box holds one of the user's saved searches. %>
|
|
29
|
+
<button type="button" class="ql-box-saved" popovertarget="<%= id %>-saved" aria-pressed="<%= result.saved_active? %>"
|
|
30
|
+
data-querylane--box-target="saved">
|
|
31
|
+
<span class="ql-star-off" aria-hidden="true">☆</span><span class="ql-star-on" aria-hidden="true">★</span> <%= t("querylane.saved.button") %>
|
|
32
|
+
</button>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if options %>
|
|
35
|
+
<%# Opens the panel; while it is open, a click closes it (a plain search). %>
|
|
36
|
+
<button type="submit" name="options" value="1" class="ql-box-options" aria-expanded="<%= result.options_open? %>" aria-controls="<%= id %>-options"
|
|
37
|
+
data-querylane--box-target="options" data-action="querylane--box#toggleOptions">
|
|
38
|
+
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 5h8M15 5h2M3 10h2M9 10h8M3 15h10M17 15h0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="13" cy="5" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="7" cy="10" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="15" cy="15" r="2" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
|
|
39
|
+
<%= t("querylane.box.options") %>
|
|
40
|
+
</button>
|
|
41
|
+
<% 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>
|
|
43
|
+
</div>
|
|
44
|
+
<% if help %><p class="ql-box-help" id="<%= id %>-help-text"><%= help %></p><% end %>
|
|
45
|
+
<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
|
+
<% end %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# 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
|
+
<% token = presenter.group_token(group) %>
|
|
4
|
+
<div class="ql-group" <%= tag.attributes(data: {querylane_group: token}) %>>
|
|
5
|
+
<div class="ql-group-head">
|
|
6
|
+
<div class="ql-group-title"><%= presenter.title(group) %></div>
|
|
7
|
+
<% if (subtitle = presenter.subtitle(group)) %><div class="ql-group-subtitle"><%= subtitle %></div><% end %>
|
|
8
|
+
<% if (note = presenter.note(group)) %><div class="ql-group-note"><%= note %></div><% end %>
|
|
9
|
+
</div>
|
|
10
|
+
<% group.hits.each do |hit| %>
|
|
11
|
+
<%= render "querylane/record", presenter: presenter, hit: hit, columns: columns %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<% if token && (more = presenter.more_count(group)) %>
|
|
14
|
+
<details class="ql-more" data-action="toggle->querylane--results#more">
|
|
15
|
+
<summary class="ql-link"><span class="ql-more-open"><%= t("querylane.group.more", count: more) %></span><span class="ql-more-hide"><%= t("querylane.group.hide") %></span></summary>
|
|
16
|
+
<button type="submit" form="<%= presenter.id %>-form" name="group" value="<%= token %>" hidden tabindex="-1"
|
|
17
|
+
data-turbo-frame="<%= presenter.related_frame_id(group) %>" data-querylane-more></button>
|
|
18
|
+
<turbo-frame id="<%= presenter.related_frame_id(group) %>" class="ql-related"></turbo-frame>
|
|
19
|
+
</details>
|
|
20
|
+
<% end %>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<%# 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") %>
|
|
4
|
+
<button type="button" class="ql-link" popovertarget="<%= popover_id %>"><%= t("querylane.chips.syntax_help") %></button>
|
|
5
|
+
<div id="<%= popover_id %>" popover class="ql-help">
|
|
6
|
+
<div class="ql-help-head">
|
|
7
|
+
<strong><%= t("querylane.help.title") %></strong>
|
|
8
|
+
<span><%= t("querylane.help.lead") %></span>
|
|
9
|
+
</div>
|
|
10
|
+
<dl class="ql-help-list">
|
|
11
|
+
<% definition.texts.each do |text| %>
|
|
12
|
+
<dt><%= text.default ? t("querylane.help.words") : "#{text.key}:" %></dt><dd><%= text.label.gsub("%{value}", "…") %><%= " · #{t("querylane.help.masked")}" if text.sensitive %></dd>
|
|
13
|
+
<% end %>
|
|
14
|
+
<% definition.filters.each do |filter| %>
|
|
15
|
+
<dt><%= filter.key %>:</dt><dd><%= filter.label.gsub("%{value}", "…") %><%= " · #{t("querylane.help.masked")}" if filter.sensitive %></dd>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% definition.flags.each do |flags| %>
|
|
18
|
+
<dt><%= ([flags.key] + flags.aliases).map { |key| "#{key}:" }.join(" ") %></dt><dd><%= flags.values.keys.join(", ") %></dd>
|
|
19
|
+
<% end %>
|
|
20
|
+
<dt>- NOT OR ( )</dt><dd><%= t("querylane.help.boolean") %></dd>
|
|
21
|
+
</dl>
|
|
22
|
+
<% if definition.examples.any? %>
|
|
23
|
+
<div class="ql-help-examples">
|
|
24
|
+
<% definition.examples.each do |example| %>
|
|
25
|
+
<div><code><%= example.query %></code> <span><%= example.description %></span></div>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<%# The options panel (M3): fields read from the box, Search writes them back.
|
|
2
|
+
Fields belong to the search form (form=…), so the live box text travels with
|
|
3
|
+
them; the server composes. Locals: result, id. %>
|
|
4
|
+
<section class="ql-options" id="<%= id %>-options" aria-labelledby="<%= id %>-options-title"
|
|
5
|
+
data-controller="querylane--options" data-querylane--options-debounce-value="<%= Querylane.configuration.debounce_ms %>"
|
|
6
|
+
data-action="input->querylane--options#changed change->querylane--options#changed keydown.esc->querylane--options#cancel keydown.enter->querylane--options#apply">
|
|
7
|
+
<header class="ql-options-head">
|
|
8
|
+
<h2 id="<%= id %>-options-title"><%= t("querylane.options.title") %></h2>
|
|
9
|
+
<span><%= t("querylane.options.lead") %></span>
|
|
10
|
+
</header>
|
|
11
|
+
<div class="ql-options-groups">
|
|
12
|
+
<% result.definition.panel.each do |group| %>
|
|
13
|
+
<fieldset class="ql-options-group">
|
|
14
|
+
<% if group.label %><legend><%= group.label %></legend><% end %>
|
|
15
|
+
<% group.rows.each do |row| %>
|
|
16
|
+
<%= render "querylane/options_row", row: row, values: result.panel.values, held: result.panel.held, definition: result.definition, id: id %>
|
|
17
|
+
<% end %>
|
|
18
|
+
</fieldset>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<button type="submit" form="<%= id %>-form" name="compose_preview" value="1" data-turbo-frame="<%= id %>-writes" hidden
|
|
22
|
+
tabindex="-1" data-querylane--options-target="preview"></button>
|
|
23
|
+
<%= render "querylane/writes", preview: result.preview, id: id %>
|
|
24
|
+
<footer class="ql-options-foot">
|
|
25
|
+
<%= render "querylane/help", definition: result.definition, id: id, popover_id: "#{id}-options-help" %>
|
|
26
|
+
<button type="button" class="ql-link" data-action="querylane--options#reset"><%= t("querylane.options.reset") %></button>
|
|
27
|
+
<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
|
+
data-querylane--options-target="apply"><%= t("querylane.options.search") %></button>
|
|
31
|
+
</footer>
|
|
32
|
+
</section>
|