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