tablewalk 0.0.1

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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,1972 @@
1
+ /**
2
+ * The view composer: build a multi-table result by picking related tables and
3
+ * their fields, rather than by writing a join.
4
+ *
5
+ * The whole thing is a visual editor for **reference paths** — the dot-walked
6
+ * chains defined in shared/view.ts. `customer_id.name` means "follow
7
+ * invoice.customer_id to customer, then take name", and because the foreign
8
+ * key graph is already in the browser every hop can be proposed rather than
9
+ * typed. Adding a related table adds paths; ticking a field adds a path;
10
+ * filtering across a walk is a condition on a path. One idea, three panels.
11
+ *
12
+ * It is deliberately not a query builder with a JOIN dropdown. Nobody thinks
13
+ * "left join customer on customer.id = invoice.customer_id"; they think "and
14
+ * show me the customer's country". So the suggestions are phrased from the
15
+ * key — *each invoice has one customer* — and the join is a consequence you
16
+ * can go and read in the SQL panel if you want to.
17
+ *
18
+ * ## Self-attaching
19
+ *
20
+ * This module adds its own entry point to the top bar and its own stylesheet,
21
+ * and hooks navigation through the existing hash scheme rather than through
22
+ * app.js. Nothing else in the client imports it, which is what lets the whole
23
+ * feature live in two files.
24
+ *
25
+ * ## Where a view lives in the URL
26
+ *
27
+ * A composer session is a *tab on a table*, exactly like Rows, Definition and
28
+ * Diagram — so `/demo/invoice!view:sales-by-country` needs no second URL
29
+ * format and no change to deeplink.js. The serialisation already carries the
30
+ * tab; the id after the colon names the saved view, and a bare `!view` is a
31
+ * fresh composer on that table.
32
+ *
33
+ * ## Where a view is stored
34
+ *
35
+ * Saved views are per connection in localStorage, like saved filters: they are
36
+ * one person's working set, and `orders` on staging is not `orders` on
37
+ * production. Views defined in `tablewalk.json` come from the server, are
38
+ * marked as such, and cannot be edited from here — a page silently rewriting a
39
+ * file someone checked into a repo is a surprise nobody asked for.
40
+ */
41
+ import {
42
+ $, api, currentEntry, el, findTable, go, labelColumn, loadJson, looksLikeDateColumn,
43
+ primaryKey, quote, refsFrom, refsTo, saveJson, state, tableView, toast,
44
+ /* Aliased rather than renamed at every call site: `hopsOut(customer)` reads
45
+ as a question about one table, where `hopsFrom` reads as half of a call
46
+ that has lost its schema argument. */
47
+ hopName as hopLabel, hopsFrom as hopsOut, tableAtPrefix as tableAt,
48
+ } from './core.js';
49
+ import { toHash } from './url.js';
50
+ import { dropdown } from './dropdown.js';
51
+ import { addMenuItem } from './menu.js';
52
+ import { promptFor } from './prompt.js';
53
+
54
+ /* Mirrors MAX_PATH_DEPTH in shared/view.ts. The cap is not decoration:
55
+ work_order.parent_id points at work_order, so the path space is infinite
56
+ and any breadth-first walk of it runs forever. */
57
+ export const MAX_DEPTH = 4;
58
+
59
+ const STORE = 'tablewalk.composer.v1';
60
+
61
+ /* ---------- module state ---------- */
62
+
63
+ /** The overlay, or null when the composer is closed. */
64
+ let root = null;
65
+ /**
66
+ * What the overlay is currently showing, as `base|viewId`.
67
+ *
68
+ * The base table is part of the identity and not an afterthought: two fresh
69
+ * composers both have an empty view id, so keying on the id alone means
70
+ * moving from `/db/invoice!view` to `/db/work_order!view` looks like no
71
+ * change at all and the panel keeps showing the old table.
72
+ */
73
+ let openFor = null;
74
+ /** The view being edited. */
75
+ let draft = null;
76
+ /** Path prefixes whose table has been added — the joins, in tree form. */
77
+ let opened = new Set();
78
+ /** The prefix added by the last click, so the checklist can point at it. */
79
+ let justAdded = null;
80
+ /** Config-defined views for this connection, fetched once per open. */
81
+ let fromConfig = [];
82
+ /** The last preview response, kept so the SQL toggle needs no round trip. */
83
+ let preview = null;
84
+ let previewError = null;
85
+ let showSql = false;
86
+ /** The text panel, and what has been typed into it but not yet applied. */
87
+ let showText = false;
88
+ let textEdit = null;
89
+ let running = false;
90
+ let runToken = 0;
91
+
92
+ /* ---------- paths ----------
93
+
94
+ The mirror of shared/view.ts these are built on lives in core.js, where the
95
+ layout builder can reach it too — it used to walk the same prefixes with a
96
+ copy of its own, and disagreed with this one about whether a constraint
97
+ name is a legal path segment. */
98
+
99
+ /** A path's final column, or null if any segment does not resolve.
100
+
101
+ Deliberately stricter than resolvePath in shared/view.ts, which also
102
+ accepts a case-insensitive column name and refuses a path deeper than
103
+ MAX_PATH_DEPTH. Both differences can only ever withhold a suggestion the
104
+ server would have accepted; neither can propose one it would refuse. */
105
+ export function columnAt(baseId, path) {
106
+ const cut = path.lastIndexOf('.');
107
+ const table = tableAt(baseId, cut === -1 ? '' : path.slice(0, cut));
108
+ if (!table) return null;
109
+ const name = path.slice(cut + 1);
110
+ return table.columns.find((c) => c.name === name) ?? null;
111
+ }
112
+
113
+ const depthOf = (path) => (path ? path.split('.').length : 0);
114
+
115
+ /* ---------- plain language ----------
116
+
117
+ The phrasing is generated from the key, not written per table, and says the
118
+ two things a foreign key actually tells you: the direction, and whether the
119
+ other row has to exist. "may have one" versus "has one" is the difference
120
+ between a column that will show nulls and one that will not. */
121
+
122
+ function phraseOne(fromTable, hop) {
123
+ const target = findTable(hop.target);
124
+ const subject = fromTable.name;
125
+ const object = target ? target.name : hop.target;
126
+ const verb = hop.optional ? 'may have one' : 'has one';
127
+ const self = target && target.id === fromTable.id;
128
+ return self
129
+ ? `each ${subject} ${verb} parent ${object}`
130
+ : `each ${subject} ${verb} ${object}`;
131
+ }
132
+
133
+ function phraseMany(toTable, fk) {
134
+ const child = findTable(fk.from.table);
135
+ return `each ${toTable.name} has many ${plural(child ? child.name : fk.from.table)}`;
136
+ }
137
+
138
+ /* "each country has many customer" is the kind of almost-English that makes a
139
+ generated sentence read as generated. Three rules cover every table name
140
+ anyone actually writes, and a name they do not cover is still a real word
141
+ with an s on the end. */
142
+ function plural(name) {
143
+ if (/(?:s|x|z|ch|sh)$/i.test(name)) return `${name}es`;
144
+ if (/[^aeiou]y$/i.test(name)) return `${name.slice(0, -1)}ies`;
145
+ return `${name}s`;
146
+ }
147
+
148
+ /* ---------- storage ---------- */
149
+
150
+ const connectionKey = () => state.activeConnection ?? 'default';
151
+
152
+ /**
153
+ * Everything stored for one connection: the views, and which ids are pinned.
154
+ *
155
+ * Pins live beside the views rather than on them because a view defined in
156
+ * `tablewalk.json` can be pinned too, and that view is not ours to write to.
157
+ * A list of ids covers both without the config half needing a copy kept here
158
+ * that could drift from the file.
159
+ *
160
+ * An earlier shape stored the array directly. It is read rather than
161
+ * discarded: someone who saved three views before this existed should not
162
+ * find them gone.
163
+ */
164
+ function bucket() {
165
+ const stored = loadJson(STORE, {})[connectionKey()];
166
+ if (Array.isArray(stored)) return { views: stored, pinned: [] };
167
+ return { views: stored?.views ?? [], pinned: stored?.pinned ?? [] };
168
+ }
169
+
170
+ function savedViews() {
171
+ return bucket().views;
172
+ }
173
+
174
+ const pinnedIds = () => bucket().pinned;
175
+ const isPinned = (id) => pinnedIds().includes(id);
176
+
177
+ function writeBucket(next) {
178
+ const all = loadJson(STORE, {});
179
+ all[connectionKey()] = next;
180
+ if (!saveJson(STORE, all)) {
181
+ toast('Could not save — this browser is not allowing local storage.', 'error');
182
+ return false;
183
+ }
184
+ return true;
185
+ }
186
+
187
+ function writeSaved(list) {
188
+ return writeBucket({ ...bucket(), views: list });
189
+ }
190
+
191
+ function togglePin(id) {
192
+ const current = bucket();
193
+ const pinned = current.pinned.includes(id)
194
+ ? current.pinned.filter((p) => p !== id)
195
+ : [...current.pinned, id];
196
+ if (writeBucket({ ...current, pinned })) redraw();
197
+ }
198
+
199
+ function newId() {
200
+ /* Time plus a little noise. Ids end up in URLs, so they stay in the
201
+ character set the hash scheme already treats as opaque. */
202
+ return `v${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
203
+ }
204
+
205
+ /* ---------- the draft ---------- */
206
+
207
+ function blankView(baseId) {
208
+ const table = findTable(baseId);
209
+ const label = table ? labelColumn(table) : null;
210
+ const key = table ? primaryKey(table) : [];
211
+ /* A brand new view opens with the row's key and its readable column ticked.
212
+ An empty grid is a worse starting point than a small correct one: it
213
+ gives you nothing to recognise your data by while you build. */
214
+ const columns = [...new Set([...key, label].filter(Boolean))].map((path) => ({ path }));
215
+ return { id: '', name: '', base: baseId, columns, aggregates: [], query: '', orderBy: [], limit: 20 };
216
+ }
217
+
218
+ const selected = () => draft.columns.map((c) => c.path);
219
+ const isSelected = (path) => draft.columns.some((c) => c.path === path);
220
+
221
+ function toggleField(path) {
222
+ draft.columns = isSelected(path)
223
+ ? draft.columns.filter((c) => c.path !== path)
224
+ : [...draft.columns, { path }];
225
+ changed();
226
+ }
227
+
228
+ function addJoin(prefix) {
229
+ opened.add(prefix);
230
+ /* A join with nothing showing through it is a join you cannot see: the SQL
231
+ does not even emit it, because no path uses it, so "Add" appears to do
232
+ nothing at all. One field comes along — the readable one where there is
233
+ one — so the click has a visible consequence in the grid as well as in
234
+ the checklist. */
235
+ const table = tableAt(draft.base, prefix);
236
+ const field = table ? defaultField(table, prefix) : null;
237
+ if (field) draft.columns.push({ path: `${prefix}.${field}` });
238
+ justAdded = prefix;
239
+ changed();
240
+ }
241
+
242
+ /** The one field worth showing by default from a newly joined table. */
243
+ function defaultField(table, prefix) {
244
+ const free = (name) => name && !isSelected(`${prefix}.${name}`);
245
+ const label = labelColumn(table);
246
+ if (free(label)) return label;
247
+ const text = table.columns.find((c) => !c.primaryKey && /char|text|varchar|string/i.test(c.type));
248
+ if (free(text?.name)) return text.name;
249
+ const key = primaryKey(table)[0];
250
+ if (free(key)) return key;
251
+ return table.columns.map((c) => c.name).find(free) ?? null;
252
+ }
253
+
254
+ function removeJoin(prefix) {
255
+ // Everything reached *through* this table goes with it, or the view keeps
256
+ // paths whose join has just been taken away.
257
+ for (const open of [...opened]) {
258
+ if (open === prefix || open.startsWith(`${prefix}.`)) opened.delete(open);
259
+ }
260
+ draft.columns = draft.columns.filter((c) => !c.path.startsWith(`${prefix}.`));
261
+ draft.orderBy = (draft.orderBy ?? []).filter((o) => !o.path.startsWith(`${prefix}.`));
262
+ changed();
263
+ }
264
+
265
+ /** Rebase onto another table, discarding paths that no longer mean anything. */
266
+ function setBase(baseId) {
267
+ draft = { ...blankView(baseId), id: draft.id, name: draft.name };
268
+ opened = new Set();
269
+ claim();
270
+ changed();
271
+ }
272
+
273
+ /** Record what is on screen and make the address bar agree with it. */
274
+ function claim() {
275
+ const tab = tabForDraft();
276
+ openFor = `${draft.base}|${viewTabOf({ kind: 'table', tab })}`;
277
+ linkTo(tab);
278
+ }
279
+
280
+ /**
281
+ * One edit: redraw the panels, then re-run the preview.
282
+ *
283
+ * Kept as a pair rather than folded into `redraw`, because `redraw` also runs
284
+ * when nothing about the *view* changed — opening the overlay, toggling the
285
+ * SQL panel — and re-querying for those would put a round trip behind a
286
+ * button that only reveals text.
287
+ */
288
+ function changed() {
289
+ redraw();
290
+ schedulePreview();
291
+ }
292
+
293
+ /* ---------- the view sent to the server ---------- */
294
+
295
+ function payload() {
296
+ return {
297
+ id: draft.id,
298
+ name: draft.name,
299
+ base: draft.base,
300
+ columns: draft.columns,
301
+ aggregates: draft.aggregates ?? [],
302
+ /* Carried, and not only when the box is empty. A view defined in
303
+ `tablewalk.json` holds its filter as a structure, and leaving it out
304
+ here was a bug with the worst possible shape: the view ran, returned
305
+ rows, and returned the wrong ones — an "Australian work orders" view
306
+ quietly listing New Zealand. */
307
+ filter: draft.filter,
308
+ orderBy: draft.orderBy ?? [],
309
+ limit: draft.limit,
310
+ };
311
+ }
312
+
313
+ /* ---------- a filter, written back as text ----------
314
+
315
+ A structural filter that does not appear in the box is a filter you cannot
316
+ see, edit or argue with — the composer would be applying a condition the
317
+ screen denies exists. Rendering it back into the language closes that gap
318
+ and makes the box the single thing the view's filter *is*. */
319
+
320
+ const OP_TEXT = {
321
+ '=': '=', '!=': '!=', '<': '<', '<=': '<=', '>': '>', '>=': '>=',
322
+ contains: 'contains', startsWith: 'startswith', endsWith: 'endswith', like: 'like',
323
+ };
324
+
325
+ function filterToText(filter) {
326
+ if (!filter?.groups?.length) return '';
327
+ return filter.groups
328
+ .map((group) => group.map(conditionToText).filter(Boolean).join(' and '))
329
+ .filter(Boolean)
330
+ .map((text, _, all) => (all.length > 1 ? `(${text})` : text))
331
+ .join(' or ');
332
+ }
333
+
334
+ function conditionToText(condition) {
335
+ const { path, op, value } = condition;
336
+ if (op === 'isNull') return `${path} is empty`;
337
+ if (op === 'isNotNull') return `${path} is not empty`;
338
+ if (op === 'in') {
339
+ const list = (Array.isArray(value) ? value : [value]).map(valueToText).join(', ');
340
+ return `${path} in (${list})`;
341
+ }
342
+ const word = OP_TEXT[op];
343
+ return word ? `${path} ${word} ${valueToText(value)}` : '';
344
+ }
345
+
346
+ function valueToText(value) {
347
+ /* Quoted whenever a bare word would not survive the round trip. The parser
348
+ treats an explicit quote as "this is text", which is exactly right for a
349
+ value that came out of a config file already typed.
350
+
351
+ This kept its own copy with a slightly wider bare-word set (`$`), so the
352
+ same value written here and in the query bar could come out differently.
353
+ A `$` now gets quoted instead of passing bare, which the grammar reads
354
+ identically — one rule beats two nearly-identical ones. */
355
+ return quote(String(value ?? ''));
356
+ }
357
+
358
+ /* ---------- preview ----------
359
+
360
+ Debounced, and every response carries the token of the request that asked
361
+ for it. Without that, a slow query started three keystrokes ago lands after
362
+ a fast one and the grid shows an answer to a question nobody is asking any
363
+ more — the classic race, and invisible when it happens. */
364
+
365
+ let previewTimer = null;
366
+
367
+ function schedulePreview() {
368
+ clearTimeout(previewTimer);
369
+ previewTimer = setTimeout(() => void runPreview(), 220);
370
+ }
371
+
372
+ async function runPreview() {
373
+ if (!draft || (!draft.columns.length && !aggregates().length)) {
374
+ preview = null;
375
+ previewError = null;
376
+ paintPreview();
377
+ return;
378
+ }
379
+ const token = ++runToken;
380
+ running = true;
381
+ paintPreview();
382
+ try {
383
+ const data = await api('/api/view/run', {
384
+ view: payload(),
385
+ query: draft.query,
386
+ limit: draft.limit,
387
+ });
388
+ if (token !== runToken) return;
389
+ if (data.errors && data.errors.length) {
390
+ preview = null;
391
+ previewError = data.errors;
392
+ } else {
393
+ preview = data;
394
+ previewError = null;
395
+ /* The text form only exists once the view has run, so this is where an
396
+ unsaved composer becomes a link worth copying. Stamped rather than
397
+ navigated: `go()` would repaint the whole app underneath the overlay,
398
+ and a network round trip per keystroke is exactly what a live preview
399
+ must not cost. */
400
+ stampHash();
401
+ if (showText) textEdit = null;
402
+ }
403
+ } catch (err) {
404
+ if (token !== runToken) return;
405
+ preview = null;
406
+ previewError = [{ message: err.message }];
407
+ } finally {
408
+ if (token === runToken) {
409
+ running = false;
410
+ paintPreview();
411
+ }
412
+ }
413
+ }
414
+
415
+ /* ---------- open / close ---------- */
416
+
417
+ /** The view id encoded in a stack entry's tab, or null if it is not a view. */
418
+ function viewTabOf(entry) {
419
+ if (!entry || entry.kind !== 'table') return null;
420
+ const tab = entry.tab ?? 'rows';
421
+ if (tab === 'view') return '';
422
+ /* Two forms, and the leading character says which. `view:<id>` names
423
+ something saved; `view+<text>` carries the view itself, so a composer
424
+ session that was never saved is still a link you can send someone. */
425
+ if (tab.startsWith('view:')) return tab.slice(5);
426
+ if (tab.startsWith('view+')) return `+${tab.slice(5)}`;
427
+ return null;
428
+ }
429
+
430
+ /* The hash is one segment of a `/`-separated path, and deeplink.js finds the
431
+ tab by the last `!` in it. Neither may appear in the payload, and
432
+ encodeURIComponent leaves `!` alone. */
433
+ const encodeText = (text) => encodeURIComponent(text).replace(/!/g, '%21');
434
+
435
+ /** Above this the URL stops being pasteable, so the link degrades to the base. */
436
+ const MAX_LINK_TEXT = 1200;
437
+
438
+ function tabForDraft() {
439
+ if (draft.id) return `view:${draft.id}`;
440
+ const text = preview?.text;
441
+ if (text) {
442
+ const encoded = encodeText(text);
443
+ if (encoded.length <= MAX_LINK_TEXT) return `view+${encoded}`;
444
+ }
445
+ return 'view';
446
+ }
447
+
448
+ function closeComposer() {
449
+ if (!root) return;
450
+ root.remove();
451
+ root = null;
452
+ openFor = null;
453
+ draft = null;
454
+ preview = null;
455
+ previewError = null;
456
+ document.body.classList.remove('composing');
457
+ }
458
+
459
+ async function openComposer(baseId, viewId) {
460
+ const base = findTable(baseId) ? baseId : likeliestBase();
461
+ if (!base) return;
462
+
463
+ openFor = `${base}|${viewId}`;
464
+ showSql = false;
465
+ draft = blankView(base);
466
+ opened = new Set();
467
+
468
+ if (!root) {
469
+ root = el('div', { class: 'composer', role: 'dialog', 'aria-modal': 'false', 'aria-label': 'View composer' });
470
+ document.body.append(root);
471
+ document.body.classList.add('composing');
472
+ }
473
+ redraw();
474
+
475
+ /* Config views are fetched rather than cached across opens: they can change
476
+ when the connection does, and the request is one small GET. */
477
+ try {
478
+ const data = await api('/api/views');
479
+ fromConfig = data.views ?? [];
480
+ } catch {
481
+ // A server that cannot list config views is not a reason to refuse to
482
+ // compose one. The saved list still works.
483
+ fromConfig = [];
484
+ }
485
+
486
+ if (viewId && viewId.startsWith('+')) {
487
+ /* The link carries the view itself. Parsed by the server, because the
488
+ grammar lives there — the same call the text panel makes. */
489
+ try {
490
+ const data = await api('/api/view/run', { text: decodeURIComponent(viewId.slice(1)) });
491
+ if (data.errors?.length) {
492
+ previewError = data.errors;
493
+ } else {
494
+ loadView({ ...data.view, id: '', name: draft.name });
495
+ preview = data;
496
+ }
497
+ } catch (err) {
498
+ previewError = [{ message: err.message }];
499
+ }
500
+ redraw();
501
+ return;
502
+ }
503
+
504
+ if (viewId) {
505
+ const found = [...savedViews(), ...fromConfig].find((v) => v.id === viewId);
506
+ if (found) {
507
+ loadView(found);
508
+ /* A saved view carries its own base table, which need not be the one
509
+ named in the link that opened it. Correcting the address bar keeps a
510
+ copied URL truthful rather than merely functional. */
511
+ claim();
512
+ } else {
513
+ toast(`No saved view called "${viewId}" — starting a new one.`, 'error');
514
+ }
515
+ }
516
+ redraw();
517
+ schedulePreview();
518
+ }
519
+
520
+ function loadView(view) {
521
+ draft = {
522
+ id: view.id,
523
+ name: view.name ?? '',
524
+ base: view.base,
525
+ columns: (view.columns ?? []).map((c) => (typeof c === 'string' ? { path: c } : { ...c })),
526
+ aggregates: (view.aggregates ?? []).map((a) => ({ ...a })),
527
+ /* Shown in the box, so the condition a config view applies is visible and
528
+ editable rather than invisible and authoritative. The structure is kept
529
+ alongside as the authority until the box is touched, so a filter that
530
+ does not survive the round trip exactly still runs exactly. */
531
+ query: view.query ?? filterToText(view.filter),
532
+ filter: view.filter,
533
+ orderBy: view.orderBy ?? [],
534
+ limit: view.limit ?? 20,
535
+ source: view.source,
536
+ };
537
+ /* Every prefix a path walks through is a join that must be open, or the
538
+ tables panel would show a view whose own columns are not in it. */
539
+ opened = new Set();
540
+ for (const col of draft.columns) {
541
+ const parts = col.path.split('.');
542
+ for (let i = 1; i < parts.length; i++) opened.add(parts.slice(0, i).join('.'));
543
+ }
544
+ }
545
+
546
+ /* ---------- rendering ---------- */
547
+
548
+ /**
549
+ * Sit below the app's own top bar rather than over it.
550
+ *
551
+ * Covering the whole window would be simpler and would also hide the
552
+ * connection label and the schema button — the two things that tell you
553
+ * *which* database you are composing against. Measuring is a line; guessing a
554
+ * height in the stylesheet is a line that goes wrong the first time the bar
555
+ * wraps on a narrow window.
556
+ */
557
+ function positionRoot() {
558
+ if (!root) return;
559
+ const bar = document.querySelector('.topbar');
560
+ root.style.top = bar ? `${Math.round(bar.getBoundingClientRect().bottom)}px` : '0px';
561
+ }
562
+
563
+ function redraw() {
564
+ if (!root || !draft) return;
565
+ positionRoot();
566
+ const focused = document.activeElement;
567
+ const keepFilter = focused && focused.classList.contains('composer-query');
568
+ const keepText = focused && focused.classList.contains('composer-text');
569
+ const caret = keepFilter || keepText ? focused.selectionStart : null;
570
+
571
+ root.replaceChildren(topBar(), body());
572
+
573
+ if (keepFilter || keepText) {
574
+ const input = root.querySelector(keepFilter ? '.composer-query' : '.composer-text');
575
+ if (input) {
576
+ input.focus();
577
+ input.setSelectionRange(caret, caret);
578
+ }
579
+ }
580
+
581
+ /* Bring a freshly added table's fields into sight. Twenty new checkboxes
582
+ below the fold is indistinguishable from nothing having happened, which
583
+ is precisely how "Add" read before this. */
584
+ if (justAdded) {
585
+ const block = root.querySelector(`.composer-group[data-prefix="${cssEscape(justAdded)}"]`);
586
+ if (block) block.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
587
+ justAdded = null;
588
+ }
589
+ }
590
+
591
+ function topBar() {
592
+ const name = el('input', {
593
+ class: 'composer-name',
594
+ type: 'text',
595
+ value: draft.name,
596
+ placeholder: 'Untitled view',
597
+ 'aria-label': 'View name',
598
+ oninput: (e) => {
599
+ draft.name = e.target.value;
600
+ },
601
+ });
602
+
603
+ return el('header', { class: 'composer-bar' }, [
604
+ el('span', { class: 'composer-eyebrow', text: 'View' }),
605
+ name,
606
+ el('span', { class: 'composer-on', text: `from ${draft.base}` }),
607
+ el('div', { class: 'composer-bar-actions' }, [
608
+ /* Text first, then SQL: one is the view in the language you can edit,
609
+ the other is what it became. Reading left to right, that is also the
610
+ order the translation happens in. */
611
+ el('button', {
612
+ type: 'button',
613
+ class: 'ghost',
614
+ 'aria-pressed': String(showText),
615
+ title: 'The view written down — editable, and the same language as the bar',
616
+ text: 'Text',
617
+ onclick: () => {
618
+ showText = !showText;
619
+ textEdit = null;
620
+ redraw();
621
+ },
622
+ }),
623
+ el('button', {
624
+ type: 'button',
625
+ class: 'ghost',
626
+ 'aria-pressed': String(showSql),
627
+ title: 'The statement this compiles to',
628
+ text: 'SQL',
629
+ onclick: () => {
630
+ showSql = !showSql;
631
+ redraw();
632
+ },
633
+ }),
634
+ el('button', { type: 'button', class: 'ghost', text: 'Save as…', onclick: () => void saveAs() }),
635
+ el('button', { type: 'button', text: 'Save', onclick: () => void saveDraft() }),
636
+ el('button', {
637
+ type: 'button',
638
+ class: 'ghost composer-close',
639
+ 'aria-label': 'Close the composer',
640
+ title: 'Close (Esc)',
641
+ text: '×',
642
+ onclick: () => leave(),
643
+ }),
644
+ ]),
645
+ ]);
646
+ }
647
+
648
+ function body() {
649
+ return el('div', { class: 'composer-body' }, [
650
+ /* Saved views above the relationship list, not below it. The list of
651
+ relationships is long and scrolls; anything under it is, in practice,
652
+ not on the page. The thing you might want *before* building — the view
653
+ you already built — cannot be the thing you have to scroll to find. */
654
+ el('aside', { class: 'composer-side' }, [startPanel(), savedPanel(), relatedPanel()]),
655
+ el('div', { class: 'composer-main' }, [
656
+ fieldsPanel(),
657
+ filterPanel(),
658
+ summaryLine(),
659
+ showText ? textPanel() : null,
660
+ showSql ? sqlPanel() : null,
661
+ el('div', { class: 'composer-preview', id: 'composer-preview' }, previewBody()),
662
+ ]),
663
+ ]);
664
+ }
665
+
666
+ /**
667
+ * What the result *is*, in one sentence, above the grid that shows it.
668
+ *
669
+ * This is the question the panels do not answer. They show ingredients —
670
+ * relationships, checkboxes, a WHERE box — and leave the shape of the answer
671
+ * to be inferred. The two facts worth stating are the ones people get wrong
672
+ * about joins: how many rows there are, and what happens when the other row
673
+ * is missing.
674
+ */
675
+ function summaryLine() {
676
+ const base = findTable(draft.base);
677
+ const name = base ? base.name : draft.base;
678
+
679
+ const sources = [...opened].sort().map((prefix) => {
680
+ const table = tableAt(draft.base, prefix);
681
+ return { prefix, name: table ? table.name : prefix };
682
+ });
683
+ /* Two hops onto the same table are two different things — `raised_by` and
684
+ `assigned_to` are both "employee" — so the path is added when the bare
685
+ name would be ambiguous, and left off when it would just be noise. */
686
+ const counts = new Map();
687
+ for (const s of sources) counts.set(s.name, (counts.get(s.name) ?? 0) + 1);
688
+ const labels = sources.map((s) => (counts.get(s.name) > 1 ? `${s.name} (${s.prefix})` : s.name));
689
+
690
+ const rolled = (draft.aggregates ?? []).map((a) => aggregateLabel(a));
691
+
692
+ const parts = [`One row per ${name}`];
693
+ if (labels.length) parts.push(`, with columns from ${andList(labels)}`);
694
+ if (rolled.length) parts.push(`${labels.length ? ', and' : ', with'} ${andList(rolled)}`);
695
+ parts.push('.');
696
+
697
+ const sentence = parts.join('');
698
+ const promise = labels.length
699
+ ? ` Every ${name} appears exactly once, even where the related row is missing — those cells read null rather than dropping the row.`
700
+ : '';
701
+
702
+ return el('p', { class: 'composer-summary' }, [
703
+ el('strong', { text: sentence }),
704
+ promise ? document.createTextNode(promise) : null,
705
+ ]);
706
+ }
707
+
708
+ function andList(items) {
709
+ if (items.length <= 1) return items[0] ?? '';
710
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
711
+ }
712
+
713
+ /* ---------- panel: start from ---------- */
714
+
715
+ function startPanel() {
716
+ const items = state.schema.tables.map((t) => ({
717
+ value: t.id,
718
+ label: t.id,
719
+ detail: `${t.columns.length} columns${t.approxRows != null ? ` · ${t.approxRows.toLocaleString()} rows` : ''}`,
720
+ badge: t.isView ? 'view' : undefined,
721
+ }));
722
+ return el('section', { class: 'panel composer-panel' }, [
723
+ el('h3', { text: 'Start from' }),
724
+ el('div', { class: 'composer-pad' }, dropdown({
725
+ items,
726
+ value: draft.base,
727
+ ariaLabel: 'Base table',
728
+ onChange: (id) => setBase(id),
729
+ })),
730
+ ]);
731
+ }
732
+
733
+ /* ---------- panel: related tables ---------- */
734
+
735
+ function relatedPanel() {
736
+ const panel = el('section', { class: 'panel composer-panel composer-related' }, el('h3', { text: 'Related tables' }));
737
+ const list = el('ul', { class: 'composer-rel' });
738
+ outgoing(list, draft.base, '', 0);
739
+
740
+ /* The other direction, and the honest treatment of it.
741
+
742
+ A has-many cannot be joined into this statement without multiplying: one
743
+ work order with four parts and three notes becomes twelve rows, each
744
+ repeating the work order's own columns, and "20 rows" stops meaning 20
745
+ work orders. Almost nobody asking for "work orders with their parts"
746
+ wants that, and a composer that produces it silently is worse than one
747
+ that refuses — the duplication reads as a bug in the data.
748
+
749
+ So the many side is offered as the two things that *are* one row per
750
+ record: a summary of the children, or a view starting from them. */
751
+ const incoming = refsTo(draft.base);
752
+ if (incoming.length) {
753
+ const base = findTable(draft.base);
754
+ list.append(el('li', { class: 'composer-rel-head', text: 'Pointing back at it' }));
755
+ for (const fk of incoming) {
756
+ const child = findTable(fk.from.table);
757
+ if (!child) continue;
758
+ list.append(el('li', { class: 'composer-rel-item many' }, [
759
+ el('span', { class: 'composer-phrase', text: phraseMany(base, fk) }),
760
+ el('span', {
761
+ class: 'composer-via',
762
+ text: `via ${fk.from.columns.join(', ')} · joining it would repeat each ${base.name} once per ${child.name}`,
763
+ }),
764
+ el('span', { class: 'composer-many-actions' }, [
765
+ countButton(fk, child, base),
766
+ summariseMenu(fk, child),
767
+ el('button', {
768
+ type: 'button',
769
+ class: 'composer-add',
770
+ title: `Start a view from ${child.name}, showing this ${base.name}`,
771
+ text: 'Start here',
772
+ onclick: () => startFromChild(fk),
773
+ }),
774
+ ]),
775
+ ]));
776
+ }
777
+ }
778
+
779
+ panel.append(list);
780
+ return panel;
781
+ }
782
+
783
+ /* ---------- summaries of the many side ---------- */
784
+
785
+ const aggregates = () => draft.aggregates ?? [];
786
+
787
+ const sameAggregate = (a, b) =>
788
+ a.fn === b.fn && a.via === b.via && (a.on ?? '') === (b.on ?? '') && (a.column ?? '') === (b.column ?? '');
789
+
790
+ function addAggregate(next) {
791
+ if (aggregates().some((a) => sameAggregate(a, next))) return;
792
+ draft.aggregates = [...aggregates(), next];
793
+ changed();
794
+ }
795
+
796
+ function removeAggregate(target) {
797
+ draft.aggregates = aggregates().filter((a) => !sameAggregate(a, target));
798
+ changed();
799
+ }
800
+
801
+ function countButton(fk, child, base) {
802
+ const spec = { fn: 'count', via: fk.name };
803
+ const on = aggregates().some((a) => sameAggregate(a, spec));
804
+ return el('button', {
805
+ type: 'button',
806
+ class: `composer-add${on ? ' on' : ''}`,
807
+ 'aria-pressed': String(on),
808
+ title: on
809
+ ? `Remove the ${child.name} count`
810
+ : `Add a column counting each ${base.name}'s ${plural(child.name)} — one row per ${base.name}, no repeats`,
811
+ text: on ? 'Counted' : 'Count',
812
+ onclick: () => (on ? removeAggregate(spec) : addAggregate(spec)),
813
+ });
814
+ }
815
+
816
+ /**
817
+ * Sum, min, max and average over a child column.
818
+ *
819
+ * A themed listbox rather than a row of buttons, because the useful
820
+ * combinations are (four functions × every number column) and that is a menu,
821
+ * not a toolbar. Dates get min and max only: an average of two timestamps is
822
+ * a number nobody asked for.
823
+ */
824
+ /**
825
+ * Plain language for a summary, rather than function syntax.
826
+ *
827
+ * `sum(quantity)` asks the reader to translate; "Total quantity" is the thing
828
+ * they were already trying to say. Every tool that does this well — Metabase's
829
+ * "Count of rows", Airtable's rollups — leads with the question rather than
830
+ * the function, and the function name is recoverable from the result column
831
+ * anyway.
832
+ */
833
+ function summaryLabel(fn, column, isDate) {
834
+ switch (fn) {
835
+ case 'sum': return `Total ${column}`;
836
+ case 'avg': return `Average ${column}`;
837
+ case 'min': return isDate ? `Earliest ${column}` : `Smallest ${column}`;
838
+ case 'max': return isDate ? `Latest ${column}` : `Largest ${column}`;
839
+ default: return `${fn} ${column}`;
840
+ }
841
+ }
842
+
843
+ function summariseMenu(fk, child) {
844
+ const items = [{ value: '', label: 'Summarise…', detail: 'a total, an average, a range' }];
845
+ for (const column of child.columns) {
846
+ if (column.primaryKey) continue;
847
+ /* Foreign keys are excluded. They are numeric, so a type check alone lets
848
+ them through, and the menu offered `sum(customer_id)` and
849
+ `avg(sold_by)` — adding up identifiers, which is never a question
850
+ anyone has. A key is a name that happens to be spelled with digits. */
851
+ if (column.references) continue;
852
+ const type = column.type.toLowerCase();
853
+ const numeric = /int|numeric|decimal|real|double|float|money|serial/.test(type);
854
+ const dateish = /date|time/.test(type);
855
+ if (!numeric && !dateish) continue;
856
+ /* A flag is numeric in SQLite and averaging it is meaningless. */
857
+ if (numeric && /^(is|has|can|should)_|^(active|enabled|urgent|billable)/.test(column.name)) continue;
858
+ const fns = numeric ? ['sum', 'avg', 'min', 'max'] : ['min', 'max'];
859
+ for (const fn of fns) {
860
+ items.push({
861
+ value: `${fn}:${column.name}`,
862
+ label: summaryLabel(fn, column.name, dateish),
863
+ // What the resulting column will be called, so the trade between a
864
+ // readable label and a namable column is visible before committing.
865
+ detail: `${fn}(${column.name})`,
866
+ });
867
+ }
868
+ }
869
+ if (items.length === 1) return null;
870
+
871
+ return el('span', { class: 'composer-menu' }, dropdown({
872
+ items,
873
+ value: '',
874
+ ariaLabel: `Summarise ${child.name}`,
875
+ onChange: (value) => {
876
+ if (!value) return;
877
+ const [fn, column] = value.split(':');
878
+ addAggregate({ fn, via: fk.name, column });
879
+ },
880
+ }));
881
+ }
882
+
883
+ /** How a summary reads in a sentence: "a count of work_order_parts". */
884
+ function aggregateLabel(spec) {
885
+ const fk = state.schema.foreignKeys.find((f) => f.name === spec.via);
886
+ const child = fk ? findTable(fk.from.table) : null;
887
+ const name = child ? child.name : spec.via;
888
+ const where = spec.on ? ` on its ${spec.on}` : '';
889
+ return spec.fn === 'count'
890
+ ? `a count of ${plural(name)}${where}`
891
+ : `the ${spec.fn} of ${name}.${spec.column}${where}`;
892
+ }
893
+
894
+ /**
895
+ * One level of the join tree, and recursively the levels below it.
896
+ *
897
+ * Hops are grouped by the table they land on. `work_order` reaches `employee`
898
+ * three ways — raised_by, assigned_to, approved_by — and listing them as three
899
+ * sentences that differ only in a trailing column name is a list you have to
900
+ * read word by word rather than scan. Grouped, the question becomes the one
901
+ * actually being asked: *which* employee.
902
+ *
903
+ * A table reachable exactly one way keeps its full sentence, because a header
904
+ * plus a single row underneath is ceremony around one fact.
905
+ */
906
+ function outgoing(list, tableId, prefix, depth) {
907
+ const table = findTable(tableId);
908
+ if (!table) return;
909
+
910
+ const groups = new Map();
911
+ for (const hop of hopsOut(tableId)) {
912
+ const list_ = groups.get(hop.target) ?? [];
913
+ list_.push(hop);
914
+ groups.set(hop.target, list_);
915
+ }
916
+
917
+ for (const [targetId, hops] of groups) {
918
+ const target = findTable(targetId);
919
+ const rows = target && target.approxRows != null
920
+ ? ` · ${target.approxRows.toLocaleString()} rows`
921
+ : '';
922
+
923
+ if (hops.length === 1) {
924
+ list.append(hopRow(table, hops[0], prefix, depth, {
925
+ phrase: phraseOne(table, hops[0]),
926
+ detail: `via ${hops[0].name}${rows}`,
927
+ }));
928
+ if (opened.has(pathOf(prefix, hops[0]))) {
929
+ outgoing(list, targetId, pathOf(prefix, hops[0]), depth + 1);
930
+ }
931
+ continue;
932
+ }
933
+
934
+ /* The header states the relationship once; "may have one" is used when
935
+ any of the ways is optional, since the group as a whole then cannot
936
+ promise a row. Which particular way is optional is said on its own
937
+ line, where it is actionable. */
938
+ const anyOptional = hops.some((h) => h.optional);
939
+ const self = targetId === table.id;
940
+ list.append(el('li', {
941
+ class: 'composer-rel-group',
942
+ style: depth ? `padding-left:${10 + depth * 14}px` : undefined,
943
+ }, [
944
+ el('span', {
945
+ class: 'composer-phrase',
946
+ text: `each ${table.name} ${anyOptional ? 'may have one' : 'has one'} `
947
+ + `${self ? 'related ' : ''}${target ? target.name : targetId}`,
948
+ }),
949
+ el('span', { class: 'composer-ways', text: `${hops.length} ways${rows}` }),
950
+ ]));
951
+
952
+ for (const hop of hops) {
953
+ list.append(hopRow(table, hop, prefix, depth, {
954
+ detail: `via ${hop.name}${hop.optional ? ' · may be empty' : ''}`,
955
+ inGroup: true,
956
+ }));
957
+ if (opened.has(pathOf(prefix, hop))) outgoing(list, targetId, pathOf(prefix, hop), depth + 1);
958
+ }
959
+ }
960
+ }
961
+
962
+ const pathOf = (prefix, hop) => (prefix ? `${prefix}.${hop.name}` : hop.name);
963
+
964
+ function hopRow(table, hop, prefix, depth, { phrase, detail, inGroup }) {
965
+ const path = pathOf(prefix, hop);
966
+ const isOpen = opened.has(path);
967
+ const tooDeep = depth >= MAX_DEPTH;
968
+ const indent = 10 + depth * 14 + (inGroup ? 12 : 0);
969
+
970
+ return el('li', {
971
+ class: `composer-rel-item${isOpen ? ' open' : ''}${inGroup ? ' in-group' : ''}`,
972
+ style: indent === 10 ? undefined : `padding-left:${indent}px`,
973
+ }, [
974
+ phrase ? el('span', { class: 'composer-phrase', text: phrase }) : null,
975
+ el('span', { class: 'composer-via', text: detail }),
976
+ tooDeep && !isOpen
977
+ ? el('span', { class: 'composer-cap', title: `A path may follow at most ${MAX_DEPTH} references.`, text: 'too deep' })
978
+ : el('button', {
979
+ type: 'button',
980
+ class: `composer-add${isOpen ? ' on' : ''}`,
981
+ 'aria-pressed': String(isOpen),
982
+ title: isOpen
983
+ ? `Remove ${path} and every field taken through it`
984
+ : `Add ${path} — its fields join the checklist above`,
985
+ text: isOpen ? 'Added' : 'Add',
986
+ onclick: () => (isOpen ? removeJoin(path) : addJoin(path)),
987
+ }),
988
+ ]);
989
+ }
990
+
991
+ /**
992
+ * Turn "each customer has many invoices" into a view of invoices.
993
+ *
994
+ * The reverse of a to-many is a to-one, so the child's own key becomes the
995
+ * first hop and the thing you were looking at is a column on it. That is the
996
+ * same result the user wanted, expressed as something a path can say.
997
+ */
998
+ function startFromChild(fk) {
999
+ const child = findTable(fk.from.table);
1000
+ if (!child) return;
1001
+ draft = { ...blankView(child.id), id: draft.id, name: draft.name };
1002
+ opened = new Set();
1003
+ claim();
1004
+ const hop = hopsOut(child.id).find((h) => h.fk.name === fk.name);
1005
+ if (hop) addJoin(hop.name);
1006
+ else changed();
1007
+ }
1008
+
1009
+ /* ---------- panel: fields ---------- */
1010
+
1011
+ function fieldsPanel() {
1012
+ const panel = el('section', { class: 'panel composer-panel composer-fields' }, [
1013
+ el('h3', {}, [
1014
+ document.createTextNode('Fields'),
1015
+ el('span', { class: 'composer-count', text: `${draft.columns.length} shown` }),
1016
+ ]),
1017
+ ]);
1018
+
1019
+ const groups = [{ prefix: '', tableId: draft.base }];
1020
+ for (const prefix of [...opened].sort()) {
1021
+ const table = tableAt(draft.base, prefix);
1022
+ if (table) groups.push({ prefix, tableId: table.id });
1023
+ }
1024
+
1025
+ /* One sentence, once, and only until it has been acted on. A view is an
1026
+ unfamiliar idea and the panel below is just a wall of checkboxes until
1027
+ someone tells you what the wall is for; after the first Add the sentence
1028
+ is describing something you can already see, so it goes away. */
1029
+ if (groups.length === 1) {
1030
+ panel.append(el('p', { class: 'composer-empty' }, [
1031
+ el('strong', { text: `A view is one row per ${findTable(draft.base)?.name ?? draft.base}, ` }),
1032
+ document.createTextNode(
1033
+ 'with any field from any table it points at. Tick fields here; press '
1034
+ + '“Add” beside a related table on the left to bring its fields into this list too.',
1035
+ ),
1036
+ ]));
1037
+ }
1038
+
1039
+ for (const group of groups) {
1040
+ const table = findTable(group.tableId);
1041
+ if (!table) continue;
1042
+ /* The group that just arrived is marked and scrolled to. Adding a related
1043
+ table put twenty new checkboxes on screen; without this they appear
1044
+ below the fold and the click reads as having done nothing. */
1045
+ const fresh = group.prefix && group.prefix === justAdded;
1046
+ const block = el('div', {
1047
+ class: `composer-group${fresh ? ' fresh' : ''}`,
1048
+ 'data-prefix': group.prefix || undefined,
1049
+ }, [
1050
+ el('div', { class: 'composer-group-head' }, [
1051
+ el('span', { class: 'composer-group-name', text: table.name }),
1052
+ group.prefix
1053
+ ? el('span', { class: 'composer-group-path', text: `via ${group.prefix}` })
1054
+ : el('span', { class: 'composer-group-path', text: 'base table' }),
1055
+ fresh ? el('span', { class: 'composer-group-new', text: 'just added' }) : null,
1056
+ ]),
1057
+ ]);
1058
+ const grid = el('div', { class: 'composer-checks' });
1059
+ for (const column of table.columns) {
1060
+ const path = group.prefix ? `${group.prefix}.${column.name}` : column.name;
1061
+ if (depthOf(path) > MAX_DEPTH + 1) continue;
1062
+ const on = isSelected(path);
1063
+ grid.append(el('label', { class: `composer-check${on ? ' on' : ''}`, title: path }, [
1064
+ el('input', {
1065
+ type: 'checkbox',
1066
+ checked: on || undefined,
1067
+ onchange: () => toggleField(path),
1068
+ }),
1069
+ el('span', { class: 'composer-field-name', text: column.name }),
1070
+ el('span', { class: 'composer-field-type', text: column.type.toLowerCase() }),
1071
+ column.primaryKey ? el('span', { class: 'composer-mark pk', text: 'key' }) : null,
1072
+ column.references ? el('span', { class: 'composer-mark fk', title: `→ ${column.references.table}`, text: '→' }) : null,
1073
+ ]));
1074
+ }
1075
+ block.append(grid);
1076
+ panel.append(block);
1077
+ }
1078
+
1079
+ /* Summaries live in the checklist rather than only where they were added,
1080
+ because that is where you look to find out what the result contains — and
1081
+ a column you cannot find is a column you cannot remove. */
1082
+ if (aggregates().length) {
1083
+ const block = el('div', { class: 'composer-group' }, [
1084
+ el('div', { class: 'composer-group-head' }, [
1085
+ el('span', { class: 'composer-group-name', text: 'summaries' }),
1086
+ el('span', { class: 'composer-group-path', text: 'one value per row, from many rows' }),
1087
+ ]),
1088
+ ]);
1089
+ const grid = el('div', { class: 'composer-checks' });
1090
+ for (const spec of aggregates()) {
1091
+ grid.append(el('label', { class: 'composer-check on', title: aggregateLabel(spec) }, [
1092
+ el('input', { type: 'checkbox', checked: true, onchange: () => removeAggregate(spec) }),
1093
+ el('span', { class: 'composer-field-name', text: shortAggregate(spec) }),
1094
+ el('span', { class: 'composer-field-type', text: spec.fn }),
1095
+ ]));
1096
+ }
1097
+ block.append(grid);
1098
+ panel.append(block);
1099
+ }
1100
+ return panel;
1101
+ }
1102
+
1103
+ /** The chip's text: `count(work_order_part)`, matching the column header. */
1104
+ function shortAggregate(spec) {
1105
+ const fk = state.schema.foreignKeys.find((f) => f.name === spec.via);
1106
+ const child = fk ? findTable(fk.from.table) : null;
1107
+ const name = `${spec.on ? `${spec.on}.` : ''}${child ? child.name : spec.via}`;
1108
+ return `${spec.fn}(${name}${spec.column ? `.${spec.column}` : ''})`;
1109
+ }
1110
+
1111
+ /* ---------- panel: filter ---------- */
1112
+
1113
+ function filterPanel() {
1114
+ const input = el('input', {
1115
+ class: 'composer-query',
1116
+ type: 'text',
1117
+ spellcheck: 'false',
1118
+ value: draft.query,
1119
+ 'aria-label': 'Filter',
1120
+ placeholder: 'customer_id.country_code.name = Australia and total > 500',
1121
+ });
1122
+
1123
+ const box = el('div', { class: 'ac composer-ac', hidden: true, role: 'listbox' });
1124
+ attachComplete(input, box);
1125
+
1126
+ input.addEventListener('input', () => {
1127
+ draft.query = input.value;
1128
+ /* The moment the box is edited it becomes the filter, entirely. Keeping
1129
+ the loaded structure as a fallback would mean clearing the box quietly
1130
+ restores a condition you just deleted — which reads as the tool
1131
+ ignoring you. */
1132
+ draft.filter = undefined;
1133
+ schedulePreview();
1134
+ });
1135
+
1136
+ const rows = el('input', {
1137
+ class: 'composer-limit',
1138
+ type: 'number',
1139
+ min: '1',
1140
+ max: '1000',
1141
+ value: String(draft.limit),
1142
+ 'aria-label': 'Rows',
1143
+ onchange: (e) => {
1144
+ const n = Number(e.target.value);
1145
+ draft.limit = Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 1000) : 20;
1146
+ schedulePreview();
1147
+ },
1148
+ });
1149
+
1150
+ return el('section', { class: 'composer-filter' }, [
1151
+ el('div', { class: 'composer-filter-row' }, [
1152
+ el('span', { class: 'composer-filter-label', text: 'where' }),
1153
+ el('span', { class: 'composer-query-wrap' }, [input, box]),
1154
+ el('span', { class: 'composer-filter-label', text: 'rows' }),
1155
+ rows,
1156
+ ]),
1157
+ el('p', {
1158
+ class: 'composer-hint',
1159
+ text: 'Same language as the bar at the top, over paths: '
1160
+ + 'a dot follows a reference, so customer_id.name is the customer’s name.',
1161
+ }),
1162
+ ]);
1163
+ }
1164
+
1165
+ /* ---------- path-aware autocomplete ----------
1166
+
1167
+ complete.js does this for the query bar and is left alone: it completes
1168
+ *columns*, and a path is a different shape — the useful suggestion after
1169
+ `customer_id.` is the next table's columns, which needs the key graph
1170
+ walked. Same keyboard contract, different candidate set. */
1171
+
1172
+ const CLAUSES = ['sort', 'show', 'limit'];
1173
+ const JOINERS = ['and', 'or'];
1174
+ const OPERATORS = ['=', '!=', '>', '>=', '<', '<=', 'contains', 'startswith', 'endswith', 'like', 'in'];
1175
+ const NULL_CHECKS = ['is empty', 'is not empty'];
1176
+ const DATE_VALUES = ['today', 'yesterday', 'this week', 'last week', 'this month', 'last month', 'last 7 days', 'last 30 days'];
1177
+ const EXPECTS_FIELD = new Set(['and', 'or', 'where', 'filter', 'show', 'select', 'sort', 'by', '']);
1178
+
1179
+ function wordAt(text, caret) {
1180
+ const before = text.slice(0, caret);
1181
+ const start = Math.max(before.lastIndexOf(' '), before.lastIndexOf(',')) + 1;
1182
+ return { word: before.slice(start), start };
1183
+ }
1184
+
1185
+ /** Paths reachable from a typed prefix: this table's columns, and its hops. */
1186
+ function pathCandidates(word) {
1187
+ const cut = word.lastIndexOf('.');
1188
+ const prefix = cut === -1 ? '' : word.slice(0, cut);
1189
+ const fragment = (cut === -1 ? word : word.slice(cut + 1)).toLowerCase();
1190
+ const table = tableAt(draft.base, prefix);
1191
+ if (!table) return [];
1192
+ const lead = prefix ? `${prefix}.` : '';
1193
+ const out = [];
1194
+
1195
+ for (const column of table.columns) {
1196
+ if (fragment && !column.name.toLowerCase().startsWith(fragment)) continue;
1197
+ out.push({
1198
+ value: `${lead}${column.name}`,
1199
+ label: `${lead}${column.name}`,
1200
+ detail: `${column.type.toLowerCase()}${column.primaryKey ? ' · key' : ''}`,
1201
+ kind: 'value',
1202
+ });
1203
+ }
1204
+ /* Hops end in a dot and do not close the token, so accepting one leaves the
1205
+ caret mid-path with the next table's columns already listed. That chaining
1206
+ is the whole reason dot-walking is pleasant to type. */
1207
+ if (depthOf(prefix) < MAX_DEPTH) {
1208
+ for (const hop of hopsOut(table.id)) {
1209
+ if (fragment && !hop.name.toLowerCase().startsWith(fragment)) continue;
1210
+ const target = findTable(hop.target);
1211
+ out.push({
1212
+ value: `${lead}${hop.name}.`,
1213
+ label: `${lead}${hop.name}.`,
1214
+ detail: `→ ${target ? target.name : hop.target}`,
1215
+ kind: 'table',
1216
+ partial: true,
1217
+ });
1218
+ }
1219
+ }
1220
+ return out;
1221
+ }
1222
+
1223
+ function completionsFor(text, caret) {
1224
+ const { word, start } = wordAt(text, caret);
1225
+ const previous = text.slice(0, start).trim().split(/\s+/).pop()?.toLowerCase() ?? '';
1226
+ const lower = word.toLowerCase();
1227
+ const matches = (list) => list.filter((s) => !lower || s.value.toLowerCase().startsWith(lower));
1228
+
1229
+ if (word.includes('.')) return pathCandidates(word);
1230
+
1231
+ const previousColumn = previous ? columnAt(draft.base, previous) : null;
1232
+ if (previousColumn) {
1233
+ /* Was a fourth copy of the date test, and the narrowest: its name pattern
1234
+ had lost `datetime`, `timestamp` and `deleted`, so `event_timestamp`
1235
+ got `=` and nothing else in the composer while the query bar offered it
1236
+ the relative-date phrases — the language's best feature, missing on the
1237
+ columns it exists for. */
1238
+ const isDate = looksLikeDateColumn(previousColumn);
1239
+ const dates = isDate
1240
+ ? DATE_VALUES.map((v) => ({ value: `= ${v}`, label: `= ${v}`, detail: 'date range', kind: 'value' }))
1241
+ : [];
1242
+ return matches([
1243
+ ...dates,
1244
+ ...OPERATORS.map((o) => ({ value: o, label: o, detail: 'operator', kind: 'op' })),
1245
+ ...NULL_CHECKS.map((o) => ({ value: o, label: o, detail: 'null check', kind: 'op' })),
1246
+ ]);
1247
+ }
1248
+
1249
+ if (EXPECTS_FIELD.has(previous)) return pathCandidates(word);
1250
+
1251
+ return matches([
1252
+ ...JOINERS.map((j) => ({ value: j, label: j, detail: 'combine', kind: 'kw' })),
1253
+ ...CLAUSES.map((c) => ({ value: c, label: c, detail: 'clause', kind: 'kw' })),
1254
+ ...pathCandidates(word),
1255
+ ]);
1256
+ }
1257
+
1258
+ function attachComplete(input, box) {
1259
+ let items = [];
1260
+ let index = -1;
1261
+
1262
+ const shut = () => {
1263
+ box.hidden = true;
1264
+ items = [];
1265
+ index = -1;
1266
+ };
1267
+
1268
+ const accept = (item) => {
1269
+ const caret = input.selectionStart ?? input.value.length;
1270
+ const { start } = wordAt(input.value, caret);
1271
+ const after = input.value.slice(caret);
1272
+ // A hop keeps the caret against the dot; a finished token gets a space.
1273
+ const insert = item.partial ? item.value : item.value + (after.startsWith(' ') ? '' : ' ');
1274
+ input.value = input.value.slice(0, start) + insert + after;
1275
+ const next = start + insert.length;
1276
+ input.setSelectionRange(next, next);
1277
+ draft.query = input.value;
1278
+ shut();
1279
+ schedulePreview();
1280
+ show();
1281
+ };
1282
+
1283
+ const highlight = (next) => {
1284
+ if (!items.length) return;
1285
+ index = (next + items.length) % items.length;
1286
+ [...box.children].forEach((node, i) => {
1287
+ node.setAttribute('aria-selected', String(i === index));
1288
+ node.classList.toggle('active', i === index);
1289
+ });
1290
+ box.children[index]?.scrollIntoView({ block: 'nearest' });
1291
+ };
1292
+
1293
+ function show() {
1294
+ const caret = input.selectionStart ?? input.value.length;
1295
+ items = completionsFor(input.value, caret).slice(0, 40);
1296
+ if (!items.length) return shut();
1297
+ box.replaceChildren();
1298
+ for (const item of items) {
1299
+ box.append(el('div', {
1300
+ class: `ac-item kind-${item.kind}`,
1301
+ role: 'option',
1302
+ // mousedown, not click: blur fires first on a click and would close
1303
+ // the list before the selection landed.
1304
+ onmousedown: (e) => {
1305
+ e.preventDefault();
1306
+ accept(item);
1307
+ },
1308
+ }, [
1309
+ el('span', { class: 'ac-label', text: item.label }),
1310
+ el('span', { class: 'ac-detail', text: item.detail }),
1311
+ ]));
1312
+ }
1313
+ index = -1;
1314
+ box.hidden = false;
1315
+ }
1316
+
1317
+ input.addEventListener('input', show);
1318
+ input.addEventListener('click', show);
1319
+ input.addEventListener('blur', () => setTimeout(shut, 120));
1320
+ input.addEventListener('keydown', (e) => {
1321
+ if (box.hidden) {
1322
+ if (e.key === ' ' && (e.ctrlKey || e.metaKey)) {
1323
+ e.preventDefault();
1324
+ show();
1325
+ }
1326
+ return;
1327
+ }
1328
+ if (e.key === 'ArrowDown') {
1329
+ e.preventDefault();
1330
+ highlight(index + 1);
1331
+ } else if (e.key === 'ArrowUp') {
1332
+ e.preventDefault();
1333
+ highlight(index - 1);
1334
+ } else if (e.key === 'Escape') {
1335
+ e.preventDefault();
1336
+ e.stopPropagation();
1337
+ shut();
1338
+ } else if (e.key === 'Tab') {
1339
+ e.preventDefault();
1340
+ accept(items[index >= 0 ? index : 0]);
1341
+ } else if (e.key === 'Enter') {
1342
+ if (index >= 0) {
1343
+ e.preventDefault();
1344
+ accept(items[index]);
1345
+ } else {
1346
+ shut();
1347
+ }
1348
+ }
1349
+ });
1350
+ }
1351
+
1352
+ /* ---------- panel: the view, written down ----------
1353
+
1354
+ A view built by clicking is UI state: it can be saved and loaded, and that
1355
+ is all. Everything else in tablewalk holds that the *text* is the source of
1356
+ truth — it is why sorting edits the query, why a link carries the walk, why
1357
+ a filter composes into the bar. A view that could only be clicked could not
1358
+ be hand-edited, diffed in a config file, or pasted to someone who does not
1359
+ have the composer open.
1360
+
1361
+ So the panel is editable, and applying it replaces the draft. The grammar
1362
+ is `viewtext.ts` on the server: one language, parsed once, rather than a
1363
+ second one here that would drift. */
1364
+
1365
+ function textPanel() {
1366
+ const value = textEdit ?? preview?.text ?? '';
1367
+ const area = el('textarea', {
1368
+ class: 'composer-text',
1369
+ spellcheck: 'false',
1370
+ rows: String(Math.min(10, Math.max(3, value.split('\n').length + 1))),
1371
+ 'aria-label': 'The view as text',
1372
+ placeholder: 'invoice\n show id, customer_id.name as customer\n where total > 500',
1373
+ });
1374
+ area.value = value;
1375
+ area.addEventListener('input', () => {
1376
+ textEdit = area.value;
1377
+ const apply = root.querySelector('.composer-text-apply');
1378
+ if (apply) apply.disabled = area.value.trim() === (preview?.text ?? '').trim();
1379
+ });
1380
+ /* Applied on demand rather than on every keystroke. A half-typed clause is
1381
+ the normal state of a multi-line editor, and re-running the view at each
1382
+ character would spend the round trip on text nobody has finished. */
1383
+ area.addEventListener('keydown', (e) => {
1384
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
1385
+ e.preventDefault();
1386
+ void applyText(area.value);
1387
+ }
1388
+ });
1389
+
1390
+ return el('section', { class: 'composer-textpanel' }, [
1391
+ area,
1392
+ el('div', { class: 'composer-text-actions' }, [
1393
+ el('span', {
1394
+ class: 'composer-hint',
1395
+ text: 'Edit and apply, or paste one someone sent you. ⌘↵ applies.',
1396
+ }),
1397
+ el('button', {
1398
+ type: 'button',
1399
+ class: 'ghost composer-text-apply',
1400
+ disabled: textEdit === null || textEdit.trim() === (preview?.text ?? '').trim() ? true : undefined,
1401
+ text: 'Apply',
1402
+ onclick: () => void applyText(area.value),
1403
+ }),
1404
+ ]),
1405
+ ]);
1406
+ }
1407
+
1408
+ /**
1409
+ * Replace the draft with a written view.
1410
+ *
1411
+ * Parsed on the server, because that is where the grammar lives. What comes
1412
+ * back is a real `ViewDef`, so the panels rebuild themselves from it exactly
1413
+ * as they would from a saved view — the text form is not a second model, it
1414
+ * is another way of writing the one that exists.
1415
+ */
1416
+ async function applyText(value) {
1417
+ const text = String(value ?? '').trim();
1418
+ if (!text) return;
1419
+ running = true;
1420
+ paintPreview();
1421
+ try {
1422
+ const data = await api('/api/view/run', { text, limit: draft.limit });
1423
+ if (data.errors?.length) {
1424
+ previewError = data.errors;
1425
+ running = false;
1426
+ paintPreview();
1427
+ return;
1428
+ }
1429
+ // The name and the identity are the composer's, not the text's: retyping
1430
+ // the body of a saved view should still be that saved view.
1431
+ loadView({ ...data.view, id: draft.id, name: draft.name });
1432
+ preview = data;
1433
+ previewError = null;
1434
+ textEdit = null;
1435
+ running = false;
1436
+ claim();
1437
+ redraw();
1438
+ } catch (err) {
1439
+ previewError = [{ message: err.message }];
1440
+ running = false;
1441
+ paintPreview();
1442
+ }
1443
+ }
1444
+
1445
+ /**
1446
+ * Give a column its own name.
1447
+ *
1448
+ * Applies to summaries as well as fields — `count(work_order_part)` is a
1449
+ * worse column heading than `parts` in exactly the same way.
1450
+ *
1451
+ * Matching is by output name rather than by index. The result's column order
1452
+ * is produced by the compiler, and tying a rename to a position would break
1453
+ * the moment a field is ticked above it.
1454
+ */
1455
+ async function renameColumn(meta, fallbackName) {
1456
+ const current = meta?.name ?? fallbackName;
1457
+ const chosen = await promptFor({
1458
+ title: 'Rename column',
1459
+ label: 'Shown as',
1460
+ value: current,
1461
+ hint: meta?.aggregate
1462
+ ? `${meta.aggregate} over ${shortTable(meta.table)}`
1463
+ : meta?.path ?? fallbackName,
1464
+ confirmLabel: 'Rename',
1465
+ });
1466
+ if (chosen === null) return;
1467
+ const alias = chosen.trim();
1468
+
1469
+ if (meta?.aggregate) {
1470
+ const list = aggregates();
1471
+ const hit = list.find((a) => (a.alias ?? '') === current || aggregateOutputName(a) === current);
1472
+ if (hit) hit.alias = alias || undefined;
1473
+ } else if (meta?.path) {
1474
+ const hit = draft.columns.find((c) => c.path === meta.path);
1475
+ // Clearing the box removes the alias rather than setting an empty one,
1476
+ // so the column goes back to naming itself.
1477
+ if (hit) hit.alias = alias && alias !== meta.path ? alias : undefined;
1478
+ }
1479
+ claim();
1480
+ void runPreview();
1481
+ }
1482
+
1483
+ /** What an aggregate is called when it has no alias of its own. */
1484
+ function aggregateOutputName(spec) {
1485
+ const fk = state.schema.foreignKeys.find((f) => f.name === spec.via);
1486
+ const child = fk ? shortTable(fk.from.table) : spec.via;
1487
+ return spec.alias ?? `${spec.fn}(${child}${spec.column ? `.${spec.column}` : ''})`;
1488
+ }
1489
+
1490
+ /* ---------- panel: SQL ---------- */
1491
+
1492
+ function sqlPanel() {
1493
+ const text = preview?.explain
1494
+ ?? (previewError ? 'The view does not compile yet — fix the errors above.' : 'Pick a field to see the statement.');
1495
+ return el('pre', { class: 'explain composer-explain', text });
1496
+ }
1497
+
1498
+ /* ---------- panel: preview ---------- */
1499
+
1500
+ function paintPreview() {
1501
+ if (!root) return;
1502
+ const host = root.querySelector('#composer-preview');
1503
+ if (host) host.replaceChildren(...[].concat(previewBody()));
1504
+ const sql = root.querySelector('.composer-explain');
1505
+ if (sql && showSql) sql.replaceWith(sqlPanel());
1506
+ }
1507
+
1508
+ function previewBody() {
1509
+ if (previewError) {
1510
+ return el('div', { class: 'errors composer-errors' }, previewError.map((e) => el('p', { text: e.message })));
1511
+ }
1512
+ if (!draft.columns.length && !aggregates().length) {
1513
+ return el('p', { class: 'note', text: 'Tick a field to see the rows.' });
1514
+ }
1515
+ if (!preview) {
1516
+ return el('p', { class: 'loading', text: running ? 'Running…' : 'Building…' });
1517
+ }
1518
+
1519
+ const head = el('div', { class: 'result-head' }, [
1520
+ el('h2', { text: draft.name || draft.base }),
1521
+ el('span', {
1522
+ class: 'stat',
1523
+ /* An exact total, not "first 20 rows". It is affordable *and* correct
1524
+ only because a view never multiplies: every join is to-one, so
1525
+ counting the view is counting the table it started from. */
1526
+ text: preview.total !== undefined && preview.total > preview.rows.length
1527
+ ? `${preview.rows.length.toLocaleString()} of ${preview.total.toLocaleString()} rows`
1528
+ : `${(preview.total ?? preview.rows.length).toLocaleString()} row${(preview.total ?? preview.rows.length) === 1 ? '' : 's'}`,
1529
+ }),
1530
+ el('span', { class: 'stat', text: `${preview.ms} ms` }),
1531
+ el('span', { class: 'stat', text: `${preview.joins.length} join${preview.joins.length === 1 ? '' : 's'}` }),
1532
+ running ? el('span', { class: 'stat', text: '…' }) : null,
1533
+ ]);
1534
+
1535
+ const wrap = el('div', { class: 'table-wrap' });
1536
+ if (!preview.rows.length) {
1537
+ wrap.append(el('p', { class: 'note', text: 'No rows match.' }));
1538
+ return [head, wrap];
1539
+ }
1540
+
1541
+ const byName = new Map(preview.resolved.map((c) => [c.name, c]));
1542
+ const headRow = el('tr');
1543
+ for (const name of preview.columns) {
1544
+ const meta = byName.get(name);
1545
+ const sorted = (draft.orderBy ?? []).find((o) => o.path === (meta ? meta.path : name));
1546
+ const summary = meta?.aggregate;
1547
+ headRow.append(el('th', {
1548
+ title: summary
1549
+ ? `${meta.aggregate} over ${shortTable(meta.table)} — one value per row, computed from many`
1550
+ : meta ? `${meta.path} · ${meta.type}` : name,
1551
+ class: `${sorted ? `sorted-${sorted.direction}` : ''}${summary ? ' is-summary' : ''}`.trim() || undefined,
1552
+ // A summary is a subquery, not a column of a joined table, and sorting
1553
+ // by one would need it repeated in ORDER BY. Left unsortable rather
1554
+ // than sortable-but-broken.
1555
+ onclick: summary ? undefined : () => sortByPath(meta ? meta.path : name),
1556
+ }, [
1557
+ // The table qualifier, then the column. A view of thirty fields drawn
1558
+ // from five tables is unreadable if every header is just "name".
1559
+ meta && (meta.hops || summary)
1560
+ ? el('span', {
1561
+ class: 'composer-th-table',
1562
+ text: `${summary ? `${meta.aggregate} ` : ''}${shortTable(meta.table)} `,
1563
+ })
1564
+ : null,
1565
+ /* An explicit alias wins: a view that named a column `owner` should say
1566
+ `owner`, not re-derive `last_name` from where the value came from. */
1567
+ document.createTextNode(
1568
+ meta && meta.name !== meta.path
1569
+ ? meta.name
1570
+ : summary ? (meta.column === '*' ? '' : meta.column) : (meta ? meta.column : name),
1571
+ ),
1572
+ sorted ? el('span', { class: 'composer-th-sort', text: sorted.direction === 'desc' ? ' ↓' : ' ↑' }) : null,
1573
+ /* Renaming lives on the header because that is where the problem is
1574
+ visible: a manager's name arrives as `employee first_name`, which is
1575
+ true and useless. The alias already existed in the model and in the
1576
+ text form — there was simply no way to set one by clicking. */
1577
+ el('button', {
1578
+ class: 'composer-th-rename',
1579
+ type: 'button',
1580
+ title: 'Rename this column',
1581
+ 'aria-label': `Rename ${name}`,
1582
+ text: '✎',
1583
+ onclick: (e) => { e.stopPropagation(); void renameColumn(meta, name); },
1584
+ }),
1585
+ ]));
1586
+ }
1587
+
1588
+ const tbody = el('tbody');
1589
+ for (const row of preview.rows) {
1590
+ const tr = el('tr');
1591
+ for (const name of preview.columns) {
1592
+ const value = row[name];
1593
+ tr.append(value === null || value === undefined
1594
+ ? el('td', { class: 'null', text: 'null' })
1595
+ : el('td', { class: 'mono', title: String(value).length > 40 ? String(value) : undefined, text: String(value) }));
1596
+ }
1597
+ tbody.append(tr);
1598
+ }
1599
+ wrap.append(el('table', {}, [el('thead', {}, headRow), tbody]));
1600
+ if (preview.more) {
1601
+ wrap.append(el('p', {
1602
+ class: 'note',
1603
+ text: `More rows match than are shown. Raise "rows", or narrow the filter.`,
1604
+ }));
1605
+ }
1606
+ return [head, wrap];
1607
+ }
1608
+
1609
+ const shortTable = (id) => (findTable(id)?.name ?? id);
1610
+
1611
+ /* Quotes and backslashes, for an attribute selector. Path segments cannot
1612
+ contain either, but a selector built from data is a selector worth escaping
1613
+ whether or not today's data needs it. */
1614
+ const cssEscape = (text) => String(text).replace(/["\\]/g, '\\$&');
1615
+
1616
+ function sortByPath(path) {
1617
+ const current = (draft.orderBy ?? []).find((o) => o.path === path);
1618
+ // Click sorts ascending, click again reverses, a third clears — the same
1619
+ // three states the results grid has, so the gesture transfers.
1620
+ if (!current) draft.orderBy = [{ path, direction: 'asc' }];
1621
+ else if (current.direction === 'asc') draft.orderBy = [{ path, direction: 'desc' }];
1622
+ else draft.orderBy = [];
1623
+ changed();
1624
+ }
1625
+
1626
+ /* ---------- panel: saved views ---------- */
1627
+
1628
+ function savedPanel() {
1629
+ const panel = el('section', { class: 'panel composer-panel' }, el('h3', { text: 'Saved views' }));
1630
+ const list = el('ul', { class: 'composer-saved' });
1631
+
1632
+ const all = [...savedViews(), ...fromConfig];
1633
+ if (!all.length) {
1634
+ panel.append(el('p', { class: 'note', text: 'Nothing saved yet. Build a view and press Save.' }));
1635
+ return panel;
1636
+ }
1637
+
1638
+ /* Pinned first, and stably: `sort` on a boolean difference leaves equal
1639
+ items in the order they were already in, so unpinning something puts it
1640
+ back where it was rather than shuffling the list under the cursor. */
1641
+ const pins = pinnedIds();
1642
+ const ordered = [...all].sort(
1643
+ (a, b) => Number(pins.includes(b.id)) - Number(pins.includes(a.id)),
1644
+ );
1645
+
1646
+ for (const view of ordered) {
1647
+ const isConfig = view.source === 'config';
1648
+ const pinned = pins.includes(view.id);
1649
+ list.append(el('li', { class: `composer-saved-item${view.id === draft.id ? ' active' : ''}` }, [
1650
+ el('button', {
1651
+ type: 'button',
1652
+ class: `composer-pin${pinned ? ' on' : ''}`,
1653
+ 'aria-pressed': String(pinned),
1654
+ title: pinned ? `Unpin "${view.name}"` : `Pin "${view.name}" to the top`,
1655
+ 'aria-label': pinned ? `Unpin ${view.name}` : `Pin ${view.name}`,
1656
+ text: pinned ? '★' : '☆',
1657
+ onclick: () => togglePin(view.id),
1658
+ }),
1659
+ el('button', {
1660
+ type: 'button',
1661
+ class: 'composer-saved-open',
1662
+ title: view.error
1663
+ ? view.error
1664
+ : `${view.base} · ${(view.columns ?? []).length} fields`
1665
+ + `${(view.aggregates ?? []).length ? ` · ${view.aggregates.length} summaries` : ''}`,
1666
+ disabled: view.error ? true : undefined,
1667
+ onclick: () => openSaved(view),
1668
+ }, [
1669
+ el('span', { class: 'composer-saved-name', text: view.name }),
1670
+ el('span', { class: 'composer-saved-base', text: view.base }),
1671
+ ]),
1672
+ isConfig
1673
+ ? el('span', { class: 'composer-saved-tag', title: 'Defined in tablewalk.json', text: 'config' })
1674
+ : el('button', {
1675
+ type: 'button',
1676
+ class: 'view-remove',
1677
+ title: `Remove "${view.name}"`,
1678
+ 'aria-label': `Remove ${view.name}`,
1679
+ text: '×',
1680
+ onclick: () => void removeSaved(view),
1681
+ }),
1682
+ ]));
1683
+ }
1684
+ panel.append(list);
1685
+ return panel;
1686
+ }
1687
+
1688
+ function openSaved(view) {
1689
+ loadView(view);
1690
+ claim();
1691
+ changed();
1692
+ }
1693
+
1694
+ async function removeSaved(view) {
1695
+ const ok = await askConfirm({
1696
+ title: `Remove "${view.name}"?`,
1697
+ body: 'The view is only stored in this browser, so this cannot be undone from here.',
1698
+ confirmLabel: 'Remove',
1699
+ });
1700
+ if (!ok) return;
1701
+ if (!writeSaved(savedViews().filter((v) => v.id !== view.id))) return;
1702
+ if (draft.id === view.id) {
1703
+ /* The draft stays on screen — losing your work because you tidied the
1704
+ list would be a rude surprise — but it is no longer *that* view, and
1705
+ the link has to stop claiming it is or reloading would 404 it. */
1706
+ draft.id = '';
1707
+ claim();
1708
+ }
1709
+ redraw();
1710
+ }
1711
+
1712
+ /* ---------- saving ---------- */
1713
+
1714
+ async function saveDraft() {
1715
+ if (!draft.columns.length && !aggregates().length) {
1716
+ toast('Pick at least one field before saving.', 'error');
1717
+ return;
1718
+ }
1719
+ if (draft.source === 'config') {
1720
+ toast('This view comes from tablewalk.json. Use "Save as…" to keep your changes here.', 'error');
1721
+ return;
1722
+ }
1723
+ if (!draft.name.trim() || !draft.id) return saveAs();
1724
+
1725
+ const list = savedViews().map((v) => (v.id === draft.id ? snapshot(draft.id) : v));
1726
+ if (!list.some((v) => v.id === draft.id)) list.push(snapshot(draft.id));
1727
+ if (!writeSaved(list)) return;
1728
+ toast(`Saved "${draft.name.trim()}".`, 'ok');
1729
+ claim();
1730
+ redraw();
1731
+ }
1732
+
1733
+ async function saveAs() {
1734
+ if (!draft.columns.length && !aggregates().length) {
1735
+ toast('Pick at least one field before saving.', 'error');
1736
+ return;
1737
+ }
1738
+ const name = await promptFor({
1739
+ title: 'Save this view',
1740
+ label: 'Name',
1741
+ value: draft.name.trim() || suggestName(),
1742
+ hint: `${draft.base} · ${draft.columns.length} fields · ${opened.size} related table${opened.size === 1 ? '' : 's'}`,
1743
+ placeholder: 'e.g. Australian work orders',
1744
+ });
1745
+ if (!name) return;
1746
+
1747
+ draft.name = name;
1748
+ draft.source = undefined;
1749
+ const id = newId();
1750
+ draft.id = id;
1751
+ // A repeated name replaces rather than duplicating: two entries reading
1752
+ // "Open" that do different things is worse than losing the older one.
1753
+ const list = savedViews().filter((v) => v.name !== name);
1754
+ list.push(snapshot(id));
1755
+ if (!writeSaved(list)) return;
1756
+ toast(`Saved "${name}".`, 'ok');
1757
+ claim();
1758
+ redraw();
1759
+ }
1760
+
1761
+ function snapshot(id) {
1762
+ return {
1763
+ id,
1764
+ name: draft.name.trim(),
1765
+ base: draft.base,
1766
+ columns: draft.columns.map((c) => ({ path: c.path, alias: c.alias })),
1767
+ aggregates: draft.aggregates ?? [],
1768
+ query: draft.query,
1769
+ filter: draft.filter,
1770
+ orderBy: draft.orderBy ?? [],
1771
+ limit: draft.limit,
1772
+ };
1773
+ }
1774
+
1775
+ function suggestName() {
1776
+ const table = findTable(draft.base);
1777
+ const extras = [...opened].map((p) => {
1778
+ const joined = tableAt(draft.base, p);
1779
+ return joined ? joined.name : p;
1780
+ });
1781
+ const head = table ? table.name : draft.base;
1782
+ return extras.length ? `${head} with ${extras.slice(0, 2).join(' and ')}` : head;
1783
+ }
1784
+
1785
+ /* ---------- a themed confirm ---------- */
1786
+
1787
+ function askConfirm({ title, body: text, confirmLabel = 'OK' }) {
1788
+ return new Promise((resolve) => {
1789
+ let settled = false;
1790
+ const finish = (value) => {
1791
+ if (settled) return;
1792
+ settled = true;
1793
+ resolve(value);
1794
+ };
1795
+ const dialog = el('dialog', { class: 'confirm' }, el('form', {
1796
+ method: 'dialog',
1797
+ onsubmit: (e) => {
1798
+ e.preventDefault();
1799
+ dialog.close();
1800
+ finish(true);
1801
+ },
1802
+ }, [
1803
+ el('h2', { text: title }),
1804
+ el('p', { text }),
1805
+ el('div', { class: 'confirm-actions' }, [
1806
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
1807
+ el('button', { type: 'submit', class: 'danger', text: confirmLabel }),
1808
+ ]),
1809
+ ]));
1810
+ dialog.addEventListener('close', () => {
1811
+ finish(false);
1812
+ dialog.remove();
1813
+ });
1814
+ document.body.append(dialog);
1815
+ dialog.showModal();
1816
+ });
1817
+ }
1818
+
1819
+ /* ---------- navigation ----------
1820
+
1821
+ A composer session is a tab on a table, which is why none of this needs a
1822
+ second URL format: the hash already carries a tab, deeplink.js already
1823
+ round-trips it, and the app underneath renders the base table — so closing
1824
+ the composer leaves you somewhere sensible rather than on a blank page. */
1825
+
1826
+ function linkTo(tab) {
1827
+ const entry = currentEntry();
1828
+ if (entry && entry.kind === 'table' && entry.table === draft.base) {
1829
+ go({ ...entry, tab }, 'replace');
1830
+ } else {
1831
+ // Rebasing rewrites the crumb rather than adding one: choosing a
1832
+ // different starting table is editing this view, not a step in a walk.
1833
+ go(tableView(draft.base, draft.base, tab), entry ? 'replace' : 'reset');
1834
+ }
1835
+ }
1836
+
1837
+ /**
1838
+ * Update the address bar without re-rendering the app beneath.
1839
+ *
1840
+ * `go()` is the right call when the *position* changes; here only the label
1841
+ * on the position changes, and putting a full render behind every edit would
1842
+ * re-run the base table's query each time a checkbox moved.
1843
+ */
1844
+ function stampHash() {
1845
+ const entry = currentEntry();
1846
+ const tab = tabForDraft();
1847
+ if (!entry || entry.kind !== 'table' || entry.table !== draft.base) return;
1848
+ if (entry.tab === tab) return;
1849
+ entry.tab = tab;
1850
+ openFor = `${draft.base}|${viewTabOf(entry)}`;
1851
+ window.history.replaceState(null, '', toHash());
1852
+ }
1853
+
1854
+ function leave() {
1855
+ const entry = currentEntry();
1856
+ closeComposer();
1857
+ if (entry && entry.kind === 'table') go({ ...entry, tab: 'rows' }, 'replace');
1858
+ }
1859
+
1860
+ /**
1861
+ * Where to start when nothing is selected.
1862
+ *
1863
+ * Not the first table alphabetically: on the demo that is `country`, a
1864
+ * two-column lookup with no outgoing keys, so the composer opens with an
1865
+ * empty "related tables" panel and appears to do nothing. The table with the
1866
+ * most references out of it is the one where the idea explains itself.
1867
+ */
1868
+ function likeliestBase() {
1869
+ const tables = state.schema.tables.filter((t) => !t.isView);
1870
+ const ranked = (tables.length ? tables : state.schema.tables)
1871
+ .map((t) => ({ id: t.id, out: refsFrom(t.id).length }))
1872
+ .sort((a, b) => b.out - a.out);
1873
+ return ranked[0]?.id;
1874
+ }
1875
+
1876
+ function openFromTopbar() {
1877
+ const entry = currentEntry();
1878
+ const base = entry?.table ?? likeliestBase();
1879
+ if (!base) {
1880
+ toast('This connection has no tables to compose from.', 'error');
1881
+ return;
1882
+ }
1883
+ if (entry && entry.kind === 'table') go({ ...entry, tab: 'view' }, 'replace');
1884
+ else go(tableView(base, base, 'view'), 'reset');
1885
+ sync();
1886
+ }
1887
+
1888
+ /**
1889
+ * Reconcile the overlay with wherever the app now is.
1890
+ *
1891
+ * Called after anything that could have navigated. It is a no-op unless the
1892
+ * *view identity* changed, so clicking around inside the composer — which
1893
+ * also produces clicks — cannot reset the draft underneath your hands.
1894
+ */
1895
+ function sync() {
1896
+ if (!state.schema) return;
1897
+ const entry = currentEntry();
1898
+ const id = viewTabOf(entry);
1899
+ if (id === null) {
1900
+ closeComposer();
1901
+ return;
1902
+ }
1903
+ if (root && openFor === `${entry.table}|${id}`) return;
1904
+ void openComposer(entry.table, id);
1905
+ }
1906
+
1907
+ /* ---------- attach ---------- */
1908
+
1909
+ function attach() {
1910
+ if ($('composer-button')) return;
1911
+ const actions = document.querySelector('.topbar-actions');
1912
+ if (!actions) return;
1913
+
1914
+ /* Registered rather than inserted. The composer does not need to know what
1915
+ else is in the header, and the header does not need to know the composer
1916
+ exists — which is what let six top-level buttons become one menu without
1917
+ touching this file's reason for existing. */
1918
+ addMenuItem({
1919
+ label: 'Views…',
1920
+ detail: 'compose across tables',
1921
+ onSelect: () => openFromTopbar(),
1922
+ });
1923
+
1924
+ /* The stylesheet is pulled in by the page, but a module that can be dropped
1925
+ into a build without its <link> should still look right rather than
1926
+ silently render as unstyled boxes. Adding it twice is harmless — the
1927
+ browser dedupes identical hrefs — and missing it is not. */
1928
+ if (!document.querySelector('link[href="/composer.css"]')) {
1929
+ document.head.append(el('link', { rel: 'stylesheet', href: '/composer.css' }));
1930
+ }
1931
+
1932
+ window.addEventListener('popstate', () => setTimeout(sync, 0));
1933
+ window.addEventListener('resize', positionRoot);
1934
+ /* go() updates the address bar with pushState, which fires no event, so a
1935
+ tab click elsewhere in the app has to be noticed some other way. A check
1936
+ per click is cheap and, because sync() only acts on a change of view
1937
+ identity, cannot disturb the composer while it is being used. */
1938
+ document.addEventListener('click', () => setTimeout(sync, 0), true);
1939
+ document.addEventListener('keydown', (e) => {
1940
+ if (e.key === 'Escape' && root && !document.querySelector('dialog[open]')) {
1941
+ const inside = root.contains(document.activeElement);
1942
+ if (inside) document.activeElement.blur();
1943
+ else leave();
1944
+ }
1945
+ });
1946
+
1947
+ /* A link pasted into a fresh tab arrives before app.js has finished asking
1948
+ for the schema, and applying a hash raises no event — so the first sync
1949
+ has to wait for the schema rather than for a signal. Polling a boolean
1950
+ ten times a second for a few seconds is not elegant, but the alternative
1951
+ is an import line in app.js, and the module earns its keep by not
1952
+ needing one. */
1953
+ whenReady(() => {
1954
+ sync();
1955
+ setTimeout(sync, 250);
1956
+ });
1957
+ }
1958
+
1959
+ function whenReady(fn, tries = 0) {
1960
+ if (state.schema) {
1961
+ fn();
1962
+ return;
1963
+ }
1964
+ if (tries > 100) return; // ten seconds; the connection is not coming.
1965
+ setTimeout(() => whenReady(fn, tries + 1), 100);
1966
+ }
1967
+
1968
+ if (document.readyState === 'loading') {
1969
+ document.addEventListener('DOMContentLoaded', attach);
1970
+ } else {
1971
+ attach();
1972
+ }