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