Blog/Components/Pages/Query.razor.js 12.1 K · 363 lines · raw · history

1 import {getById, writeError, writeDebug, h, t} from '/common.module.js'
2 import {DACTAL} from "/dactal.js"
3 import {query as jsonql} from "/jsonql-js/index.js"
4
5 const enterEvent = new KeyboardEvent('keydown', {
6 key: 'Enter',
7 code: 'Enter',
8 keyCode: 13,
9 which: 13,
10 bubbles: true,
11 cancelable: true
12 });
13
14 const defaultQueries = {
15 jsonql: `SELECT *
16 FROM personen AS p
17 WHERE 'EenhoofdigOuderlijkGezag' IN p.gezag[].type`,
18 dactal: "personen.gezag:type=EenhoofdigOuderlijkGezag",
19 }
20
21 const DB_NAME = "mb-storage"
22 const HISTORY_STORE = "queryHistory"
23 const FAVORITES_STORE = "queryFavorites"
24 const HISTORY_LIMIT = 50
25
26 const input = getById("input")
27 const lang = getById("lang")
28 const favoriteButton = getById("favoriteButton")
29 const dactal = new DACTAL()
30
31 const db = await openDB()
32 await renderHistory()
33 await renderFavorites()
34
35 fetch("/brp.json").then(async res => {
36 const cdata = await res.json()
37 dactal.load(cdata, "personen")
38 writeDebug("Loaded brp data")
39 await runQuery(input.value, lang.value)
40 })
41
42 input.onkeydown = async (e) => {
43 if (e.key === "Enter" && !e.shiftKey) {
44 e.preventDefault()
45 await runQuery(input.value, lang.value)
46 // Only real keystrokes count as a run worth remembering, not the
47 // synthetic Enter dispatched below to trigger the initial query.
48 if (e.isTrusted) {
49 await recordHistory(lang.value, input.value)
50 }
51 }
52 }
53
54 lang.onchange = async () => {
55 input.value = defaultQueries[lang.value]
56 await runQuery(input.value, lang.value)
57 }
58
59 favoriteButton.onclick = async () => {
60 const name = await getFavoriteName()
61 if (name) {
62 await addFavorite(lang.value, input.value, name)
63 }
64 }
65
66 getById("scrollTopButton").onclick = () => window.scrollTo({top: 0, behavior: "smooth"})
67 getById("scrollFavoritesButton").onclick = () =>
68 getById("favoritesList").closest(".panel").scrollIntoView({behavior: "smooth", block: "start"})
69
70 input.dispatchEvent(enterEvent)
71
72 function openDB() {
73 return new Promise((resolve, reject) => {
74 const request = window.indexedDB.open(DB_NAME)
75
76 request.onerror = () => reject(request.error)
77
78 request.onsuccess = () => {
79 const database = request.result
80 const missingStores = [HISTORY_STORE, FAVORITES_STORE]
81 .filter(name => !database.objectStoreNames.contains(name))
82
83 if (missingStores.length === 0) {
84 resolve(database)
85 return
86 }
87
88 const version = database.version + 1
89 database.close()
90
91 const upgradeRequest = window.indexedDB.open(DB_NAME, version)
92 upgradeRequest.onupgradeneeded = (event) => {
93 const upgradeDb = event.target.result
94 for (const storeName of missingStores) {
95 if (!upgradeDb.objectStoreNames.contains(storeName)) {
96 upgradeDb.createObjectStore(storeName, {autoIncrement: true})
97 }
98 }
99 }
100 upgradeRequest.onblocked = () => writeError("Please close other tabs with this site open to enable query history.")
101 upgradeRequest.onerror = () => reject(upgradeRequest.error)
102 upgradeRequest.onsuccess = () => resolve(upgradeRequest.result)
103 }
104 })
105 }
106
107 function loadEntries(storeName) {
108 return new Promise((resolve, reject) => {
109 const entries = []
110 const request = db.transaction(storeName).objectStore(storeName).openCursor()
111
112 request.onerror = () => reject(request.error)
113 request.onsuccess = (event) => {
114 const cursor = event.target.result
115 if (cursor) {
116 entries.push({key: cursor.key, ...JSON.parse(cursor.value)})
117 cursor.continue()
118 } else {
119 resolve(entries)
120 }
121 }
122 })
123 }
124
125 function deleteEntry(storeName, key, rerender) {
126 const transaction = db.transaction(storeName, "readwrite")
127 transaction.oncomplete = () => rerender()
128 transaction.objectStore(storeName).delete(key)
129 }
130
131 async function loadEntry(entry) {
132 lang.value = entry.lang
133 input.value = entry.query
134 await runQuery(entry.query, entry.lang)
135 }
136
137 async function recordHistory(queryLang, query) {
138 const entries = await loadEntries(HISTORY_STORE)
139 const last = entries[entries.length - 1]
140 if (last && last.lang === queryLang && last.query === query) {
141 return
142 }
143
144 const transaction = db.transaction(HISTORY_STORE, "readwrite")
145 transaction.objectStore(HISTORY_STORE).add(JSON.stringify({lang: queryLang, query}))
146 transaction.oncomplete = async () => {
147 await trimHistory()
148 await renderHistory()
149 }
150 }
151
152 async function trimHistory() {
153 const entries = await loadEntries(HISTORY_STORE)
154 const excess = entries.length - HISTORY_LIMIT
155 if (excess <= 0) return
156
157 const transaction = db.transaction(HISTORY_STORE, "readwrite")
158 const store = transaction.objectStore(HISTORY_STORE)
159 for (const entry of entries.slice(0, excess)) {
160 store.delete(entry.key)
161 }
162 }
163
164 async function addFavorite(favoriteLang, query, name) {
165 const transaction = db.transaction(FAVORITES_STORE, "readwrite")
166 transaction.objectStore(FAVORITES_STORE).add(JSON.stringify({lang: favoriteLang, query, name}))
167 transaction.oncomplete = () => renderFavorites()
168 }
169
170 async function renderHistory() {
171 const list = getById("historyList")
172 list.replaceChildren()
173
174 const entries = await loadEntries(HISTORY_STORE)
175 for (const entry of entries.reverse()) {
176 list.appendChild(h("span", `[${entry.lang}] ${previewQuery(entry.query)}`))
177 list.appendChild(h("button", {onClick: () => loadEntry(entry)}, "Load"))
178 list.appendChild(h("button", {onClick: () => deleteEntry(HISTORY_STORE, entry.key, renderHistory)}, "Delete"))
179 }
180 }
181
182 async function renderFavorites() {
183 const list = getById("favoritesList")
184 list.replaceChildren()
185
186 const entries = await loadEntries(FAVORITES_STORE)
187 for (const entry of entries) {
188 list.appendChild(h("span", `${entry.name} [${entry.lang}]`))
189 list.appendChild(h("button", {onClick: () => loadEntry(entry)}, "Load"))
190 list.appendChild(h("button", {onClick: () => deleteEntry(FAVORITES_STORE, entry.key, renderFavorites)}, "Delete"))
191 }
192 }
193
194 function previewQuery(query) {
195 const oneLine = query.replace(/\s+/g, " ").trim()
196 return oneLine.length > 60 ? oneLine.slice(0, 60) + "…" : oneLine
197 }
198
199 function getFavoriteName() {
200 return new Promise((resolve) => {
201 const dialog = getById("favoriteNameDialog")
202 const form = getById("favoriteNameForm")
203 const nameInput = getById("favoriteNameInput")
204 const cancelButton = getById("cancelFavoriteNameButton")
205
206 dialog.showModal()
207 nameInput.focus()
208
209 const onCancel = () => {
210 dialog.close("cancelFavoriteName")
211 cancelButton.removeEventListener("click", onCancel)
212 form.removeEventListener("submit", onSubmit)
213 resolve(null)
214 }
215
216 const onSubmit = () => {
217 const value = nameInput.value
218 nameInput.value = ""
219 form.removeEventListener("submit", onSubmit)
220 cancelButton.removeEventListener("click", onCancel)
221 resolve(value || null)
222 }
223
224 cancelButton.addEventListener("click", onCancel)
225 form.addEventListener("submit", onSubmit)
226 })
227 }
228
229 async function runQuery(query, lang) {
230 const results = getById("results")
231
232 try {
233 let result = lang === "jsonql"
234 ? jsonql(query, {personen: dactal.data.personen ?? []})
235 : await dactal.query(query)
236
237 results.replaceChildren(renderValue(result.slice(0, 100)))
238
239 if (result.length > 100) {
240 results.appendChild(h("div", [h("span", `Not showing ${result.length - 100} more results.`)]))
241 }
242 } catch (e) {
243 writeError(e.message)
244 results.replaceChildren()
245 }
246 }
247
248 // How many children/entries a nested container reveals up front, and how
249 // many more each "more..." click loads. Some queries (e.g. the dactal
250 // grouping query personen/geslacht) produce a handful of top-level items
251 // that each nest most of the dataset as children, so a click on "more" has
252 // to pull in another page rather than the rest of the array in one go.
253 const PAGE_SIZE = 10
254
255 function renderValue(obj, depth = 0) {
256 if (Array.isArray(obj)) {
257 return renderList(obj, depth)
258 } else if (obj === null) {
259 // Every branch has to return a node: aggregates (MIN/SUM over an
260 // all-null group) and LEFT JOIN fills both put nulls in the results,
261 // and returning undefined here makes appendChild throw.
262 return h("span", {class: "json-null"}, "null")
263 } else if (typeof obj === "object") {
264 return renderContainer("ul", Object.entries(obj), ([name, item]) => renderEntry(name, item, depth))
265 } else if (typeof obj === "boolean") {
266 return h("span", {class: obj ? "json-true" : "json-false"}, obj.toString())
267 } else if (typeof obj === "number") {
268 return h("span", {class: "json-number"}, obj.toString())
269 } else if (typeof obj === "string") {
270 return h("span", obj)
271 }
272 }
273
274 function renderEntry(name, item, depth) {
275 return h("li", {class: "dash"}, [
276 h("span", {class: "name"}, name),
277 t(" "),
278 renderValue(item, depth + 1)
279 ])
280 }
281
282 // Same idea as renderContainer, but for arrays: each page is its own
283 // <ol start="...">, appended alongside (not inside) the previous one, so
284 // numbering carries on (11, 12, 13, ...) instead of restarting at 1.
285 function renderList(items, depth) {
286 const renderItem = (item) => h("li", [renderValue(item, depth + 1)])
287
288 if (depth === 0) {
289 return h("ol", items.map(renderItem))
290 }
291
292 const container = h("div")
293 appendListPage(container, items, renderItem, 0)
294 return container
295 }
296
297 function appendListPage(container, items, renderItem, offset) {
298 const page = items.slice(offset, offset + PAGE_SIZE)
299 const ol = h("ol", page.map(renderItem))
300 if (offset > 0) {
301 ol.setAttribute("start", String(offset + 1))
302 }
303 container.appendChild(ol)
304
305 const nextOffset = offset + page.length
306 const rest = items.length - nextOffset
307 if (rest === 0) {
308 return
309 }
310
311 const details = h("details")
312 details.appendChild(h("summary", `${rest} more…`))
313 details.addEventListener("toggle", () => {
314 if (details.open) {
315 details.remove()
316 appendListPage(container, items, renderItem, nextOffset)
317 }
318 }, {once: true})
319
320 container.appendChild(details)
321 }
322
323 // Eagerly renders only the first page of entries of an object so a single
324 // deeply nested record can't blow up the DOM; each further page is built
325 // lazily, one PAGE_SIZE batch at a time, the first time its "more..."
326 // toggle is expanded - so a record with hundreds of children never dumps
327 // them all into the DOM from a single click. Objects are never the
328 // top-level results value (that's always an array, see renderList), so
329 // there's no depth-0 case to exempt here.
330 function renderContainer(tag, items, renderItem) {
331 const container = h(tag, [])
332 appendContainerPage(container, items, renderItem, 0)
333 return container
334 }
335
336 function appendContainerPage(container, items, renderItem, offset) {
337 const page = items.slice(offset, offset + PAGE_SIZE)
338 for (const item of page) {
339 container.appendChild(renderItem(item))
340 }
341
342 const nextOffset = offset + page.length
343 const rest = items.length - nextOffset
344 if (rest === 0) {
345 return
346 }
347
348 const details = h("details")
349 details.appendChild(h("summary", `${rest} more…`))
350
351 const moreItem = h("li", {class: "dash"}, [details])
352 // The "toggle" event doesn't bubble, so it has to be bound to <details>
353 // itself - but it's the wrapping <li> that needs removing, or an empty
354 // dash bullet is left behind once the details element is gone.
355 details.addEventListener("toggle", () => {
356 if (details.open) {
357 moreItem.remove()
358 appendContainerPage(container, items, renderItem, nextOffset)
359 }
360 }, {once: true})
361
362 container.appendChild(moreItem)
363 }