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,1345 @@
1
+ /**
2
+ * A page: one root, and everything that hangs off it.
3
+ *
4
+ * The walk is what this tool is for, and the walk has a cost — every hop is a
5
+ * page you have to find your way back from. Most of the time the questions
6
+ * are not really a walk at all: *who is this customer, how much have they
7
+ * ordered, what did they order lately, what was in it.* Four questions about
8
+ * one record, answered on four different screens.
9
+ *
10
+ * A page answers them at once. It is the record layout with a front door:
11
+ * the same sections keyed by the same foreign keys, plus numbers, plus lists
12
+ * that reach further than one hop, plus a way in that asks *which* customer
13
+ * rather than assuming you already found one.
14
+ *
15
+ * Everything that could be wrong quietly — which rows a section shows — is
16
+ * decided in shared/page.ts and tested there. This file decides how it looks
17
+ * and when it is fetched, and its own rule is that **a section that fails
18
+ * fails alone**: one broken path must not take the other five sections with
19
+ * it, because a page is most useful on the day something is misconfigured.
20
+ */
21
+ import { api, columnKind, currentEntry, disclosure, el, findTable, go, keyOf, labelColumn, labelPath, loadJson, looksLikeDateColumn, openRecord, pageView, primaryKey, quote, rowLabel, saveJson, setPageResolver, state, tableView } from './core.js';
22
+ import { renderGrid } from './table.js';
23
+ import { refJump, valueNode } from './value.js';
24
+ import { gridSkeleton } from './skeleton.js';
25
+ import { dropdown } from './dropdown.js';
26
+ import { canEditRow, editable, pendingBar } from './write.js';
27
+ import { forgetPage, isPinned, rememberVisit, togglePinned } from './pagemarks.js';
28
+ import { syncHash } from './url.js';
29
+ import { openBuilder, savedPages, sharedPrefix } from './pagebuilder.js';
30
+
31
+ /** Roots opened recently, per connection. Enough to recognise, not a log. */
32
+ const RECENT_KEY = 'tablewalk.pages.recent';
33
+ const RECENT_MAX = 8;
34
+
35
+ /** Rows offered when a saved filter is used as a way in. */
36
+ const FILTER_ROWS = 8;
37
+
38
+ /* ---------- recents ---------- */
39
+
40
+ const recentKey = (conn) => `${RECENT_KEY}.${conn ?? ''}`;
41
+
42
+ function recentRoots(conn, pageId) {
43
+ return (loadJson(recentKey(conn), {})[pageId] ?? []).filter(Boolean);
44
+ }
45
+
46
+ function rememberRoot(conn, pageId, key, label) {
47
+ const all = loadJson(recentKey(conn), {});
48
+ const mine = (all[pageId] ?? []).filter((r) => JSON.stringify(r.key) !== JSON.stringify(key));
49
+ mine.unshift({ key, label });
50
+ all[pageId] = mine.slice(0, RECENT_MAX);
51
+ saveJson(recentKey(conn), all);
52
+ }
53
+
54
+ /* ---------- the root bar ---------- */
55
+
56
+ /** Fields offered as filters: what identifies a record of this table. */
57
+ function filterFields(base) {
58
+ const key = primaryKey(base);
59
+ /* The path, not just the column: on a table whose name lives one hop away
60
+ — employee, named by `party_id.display_name` — the label is the one
61
+ field a person could actually have typed, and a filter row without it
62
+ offers three opaque ids and a date. The view runner resolves the hop, so
63
+ a dotted path filters as readily as an own column. */
64
+ const label = labelPath(base);
65
+ /* Ordered by how findable a column makes a record, not by where it sits in
66
+ the table.
67
+
68
+ Text first: a name or a code is what someone has in their head. Then
69
+ dates, which narrow a search properly now that the boxes go through the
70
+ language — `hired_on = last 30 days` is a real way to find someone, where
71
+ a structured `contains` on a date was not, which is why they were left
72
+ out before. References last, and often not at all: `job_title_id` is an
73
+ opaque number nobody knows, and on `employee` three of them took every
74
+ slot and pushed out the one field a person could have typed. */
75
+ const rest = base.columns.filter((c) => !key.includes(c.name) && c.name !== label);
76
+ const identifying = [
77
+ ...rest.filter((c) => !c.references && columnKind(c.type) === 'text' && !looksLikeDateColumn(c)),
78
+ ...rest.filter((c) => looksLikeDateColumn(c)),
79
+ ...rest.filter((c) => c.references),
80
+ ].map((c) => c.name);
81
+ /* Deduplicated, because `labelColumn` falls back to the primary key when a
82
+ table has no text column worth calling a name — `employee` is one — and
83
+ the list then held `id` twice, as two identical boxes filtering the same
84
+ column. A Set rather than a check against `key` alone: the same trap is
85
+ one column away for any table whose label is also a reference. */
86
+ return [...new Set([...key, ...(label ? [label] : []), ...identifying])].slice(0, 4);
87
+ }
88
+
89
+ /**
90
+ * The row that chooses, and keeps choosing, which record the page is about.
91
+ *
92
+ * It used to be a door: a search box on an otherwise empty screen, gone the
93
+ * moment you picked something, so changing your mind meant a button called
94
+ * "change…" that took you back to the empty screen. The filter is the page's
95
+ * own control and belongs on it — one row, always there, holding the fields
96
+ * that identify a record.
97
+ *
98
+ * Which fields those are comes from the schema rather than from a setting:
99
+ * the key, the label, and whatever else names a row. On a table with no
100
+ * label column — `stock_movement`, say — that is the whole of what a person
101
+ * has to go on, and a single "Search stock_movement…" box was offering to
102
+ * search a name that does not exist.
103
+ */
104
+ function rootBar(page, base, current, onPick) {
105
+ const fields = filterFields(base);
106
+ const byName = new Map(base.columns.map((c) => [c.name, c]));
107
+ const keyColumns = primaryKey(base);
108
+ const inputs = new Map();
109
+ const results = el('div', { class: 'root-results' });
110
+ let token = 0;
111
+
112
+ const pick = (row) => {
113
+ const key = keyOf(base, row);
114
+ if (!key) return;
115
+ results.replaceChildren();
116
+ onPick(key, rowLabel(base, row));
117
+ };
118
+
119
+ /**
120
+ * One candidate, as a line you can tell apart from the next one.
121
+ *
122
+ * The detail used to repeat the label and then join every field with dots:
123
+ * `ACC-5000` followed by `1 · ACC-5000 · 37 · AUD`, where two of the four
124
+ * said nothing and the other two said it without saying what they were.
125
+ * Named pairs, and never the value already used as the title.
126
+ */
127
+ const hit = (row) => {
128
+ const name = rowLabel(base, row);
129
+ const detail = fields
130
+ .filter((f) => String(row[f] ?? '') !== '' && String(row[f]) !== name)
131
+ .map((f) => `${f} ${row[f]}`)
132
+ .join(' \u00b7 ');
133
+
134
+ return el('button', {
135
+ type: 'button',
136
+ class: 'root-hit',
137
+ onclick: () => pick(row),
138
+ }, [
139
+ el('span', { class: 'root-hit-label', text: name }),
140
+ detail ? el('span', { class: 'root-hit-key', text: detail }) : null,
141
+ ].filter(Boolean));
142
+ };
143
+
144
+ const search = async () => {
145
+ const mine = ++token;
146
+ /* Every non-empty box, ANDed. Text is matched loosely because a person
147
+ types part of a name; a key is matched exactly because a person types
148
+ all of an id and half of one is a different record. */
149
+ const conditions = [];
150
+ for (const [name, input] of inputs) {
151
+ const value = input.value.trim();
152
+ if (!value) continue;
153
+ const column = byName.get(name);
154
+ const exact = keyColumns.includes(name) || columnKind(column?.type) === 'number';
155
+ conditions.push({ path: name, op: exact ? '=' : 'contains', value });
156
+ }
157
+ if (!conditions.length) {
158
+ results.replaceChildren();
159
+ return;
160
+ }
161
+ results.classList.remove('root-offered');
162
+
163
+ try {
164
+ const data = await api('/api/view/run', {
165
+ view: {
166
+ id: `page:${page.id}:root`,
167
+ name: base.name,
168
+ base: base.id,
169
+ columns: [...new Set([...keyColumns, ...fields])].map((path) => ({ path })),
170
+ filter: { groups: [conditions] },
171
+ limit: 12,
172
+ },
173
+ });
174
+ if (mine !== token) return;
175
+ const rows = data.rows ?? [];
176
+ results.replaceChildren(rows.length
177
+ ? el('div', { class: 'root-hits' }, rows.map((row) => hit(row)))
178
+ : el('p', { class: 'note', text: `Nothing in ${base.name} matches.` }));
179
+ } catch (err) {
180
+ if (mine !== token) return;
181
+ results.replaceChildren(el('p', { class: 'note', text: err.message }));
182
+ }
183
+ };
184
+
185
+ let timer = 0;
186
+ const boxes = fields.map((name) => {
187
+ const dated = looksLikeDateColumn(byName.get(name));
188
+ const input = el('input', {
189
+ type: 'search',
190
+ class: 'root-field',
191
+ // A dotted path names its column by the last segment; the title keeps
192
+ // the full route for anyone wondering where the value lives.
193
+ placeholder: name.includes('.') ? name.split('.').pop() : name,
194
+ /* A date box takes a phrase, not a prefix, and nothing on it says so.
195
+ The title is where that belongs: the placeholder has one job, which
196
+ is naming the column. */
197
+ title: dated
198
+ ? `${name} — a date, or a phrase like "last 30 days" or "between 2024-01-01 and 2024-06-30"`
199
+ : `Filter by ${name}`,
200
+ 'aria-label': `Filter by ${name}`,
201
+ autocomplete: 'off',
202
+ spellcheck: 'false',
203
+ });
204
+ input.addEventListener('input', () => {
205
+ clearTimeout(timer);
206
+ timer = setTimeout(() => void search(), 220);
207
+ });
208
+ input.addEventListener('keydown', (e) => {
209
+ if (e.key === 'Enter') {
210
+ e.preventDefault();
211
+ clearTimeout(timer);
212
+ void search();
213
+ }
214
+ if (e.key === 'ArrowDown') {
215
+ e.preventDefault();
216
+ results.querySelector('.root-hit')?.focus();
217
+ }
218
+ if (e.key === 'Escape') results.replaceChildren();
219
+ });
220
+ inputs.set(name, input);
221
+ return input;
222
+ });
223
+
224
+ /* Collapsed once there is an answer.
225
+
226
+ The filter is four boxes wide and, with a record already chosen, four
227
+ boxes doing nothing — a grey slab across the header advertising a
228
+ capability nobody is using, permanently, next to the title it dwarfs.
229
+ Changing which record the page is about is occasional; looking at the
230
+ record is the whole time.
231
+
232
+ So it shows its answer and puts the boxes away, and opens again on a
233
+ click. With no record chosen there is nothing to collapse to and nothing
234
+ else to do, so they start open. */
235
+ const group = el('div', { class: 'root-fields' }, boxes);
236
+ group.hidden = Boolean(current);
237
+
238
+ const open = () => {
239
+ group.hidden = false;
240
+ boxes[0]?.focus();
241
+ };
242
+
243
+ const bar = el('div', { class: 'root-bar' }, [
244
+ current
245
+ ? el('button', {
246
+ type: 'button',
247
+ class: 'root-current',
248
+ title: `Choose a different ${base.name}`,
249
+ onclick: () => (group.hidden ? open() : onPick(undefined, undefined)),
250
+ }, [
251
+ el('span', { text: current }),
252
+ el('span', { class: 'chip-x', 'aria-hidden': 'true', text: group.hidden ? '\u2304' : '\u00d7' }),
253
+ ])
254
+ : null,
255
+ group,
256
+ ...more(page, base, onPick),
257
+ ].filter(Boolean));
258
+
259
+ /* With no record chosen, the page is an empty frame and a row of boxes —
260
+ and the answer to "which product?" is nearly always one of the last few
261
+ you looked at, or one of the first few there are. Both were already
262
+ computed; one was behind an unlabelled `···` and the other was one query
263
+ away, so the screen that most needed something on it had nothing.
264
+
265
+ Typed input replaces this with its own hits, and choosing a record puts
266
+ the whole thing away. */
267
+ if (!current) void offer();
268
+
269
+ async function offer() {
270
+ const recent = recentRoots(state.activeConnection, page.id);
271
+ const groups = [];
272
+ if (recent.length) {
273
+ groups.push(el('div', { class: 'root-group' }, [
274
+ el('h3', { class: 'root-group-head', text: 'Recently opened' }),
275
+ el('div', { class: 'root-hits' }, recent.map((r) => el('button', {
276
+ type: 'button',
277
+ class: 'root-hit',
278
+ onclick: () => onPick(r.key, r.label),
279
+ }, el('span', { class: 'root-hit-label', text: r.label ?? rootLabel(r.key) })))),
280
+ ]));
281
+ }
282
+
283
+ try {
284
+ const data = await api('/api/view/run', {
285
+ view: {
286
+ id: `page:${page.id}:first`,
287
+ name: base.name,
288
+ base: base.id,
289
+ columns: [...new Set([...keyColumns, ...fields])].map((path) => ({ path })),
290
+ limit: FILTER_ROWS,
291
+ },
292
+ });
293
+ /* Only if nothing was typed while this was in flight: the reader's
294
+ search is the answer to their question, and this is a suggestion. */
295
+ if (token !== 0) return;
296
+ const rows = data.rows ?? [];
297
+ if (rows.length) {
298
+ groups.push(el('div', { class: 'root-group' }, [
299
+ el('h3', { class: 'root-group-head', text: `In ${base.name}` }),
300
+ el('div', { class: 'root-hits' }, rows.map((row) => hit(row))),
301
+ ]));
302
+ }
303
+ } catch {
304
+ /* A suggestion that cannot be fetched is simply not offered. */
305
+ }
306
+ if (token !== 0 || !groups.length) return;
307
+ /* Inline, not floating: with no record chosen this is the page, and a
308
+ dropdown hanging over an empty screen reads as a menu someone left
309
+ open. It goes back to being a dropdown the moment anyone types. */
310
+ results.classList.add('root-offered');
311
+ results.replaceChildren(...groups);
312
+ }
313
+
314
+ return el('div', { class: 'root-wrap' }, [bar, results]);
315
+ }
316
+
317
+ /** Recents and saved filters, folded into the bar rather than stacked below it. */
318
+ function more(page, base, onPick) {
319
+ const recent = recentRoots(state.activeConnection, page.id);
320
+ const saved = savedFiltersFor(base.id);
321
+ if (!recent.length && !saved.length) return [];
322
+
323
+ const menu = el('div', { class: 'root-menu' }, [
324
+ recent.length
325
+ ? el('div', { class: 'root-group' }, [
326
+ el('h3', { class: 'root-group-head', text: 'Recent' }),
327
+ ...recent.map((r) => el('button', {
328
+ type: 'button', class: 'root-menu-item', text: r.label,
329
+ onclick: () => onPick(r.key, r.label),
330
+ })),
331
+ ])
332
+ : null,
333
+ saved.length
334
+ ? el('div', { class: 'root-group' }, [
335
+ el('h3', { class: 'root-group-head', text: 'Saved filters' }),
336
+ ...saved.map((view) => savedFilterEntry(base, view, onPick)),
337
+ ])
338
+ : null,
339
+ ].filter(Boolean));
340
+
341
+ return [disclosure(el('details', { class: 'root-more' }, [
342
+ el('summary', { class: 'root-more-head', title: 'Recent records and saved filters' }, '\u22ef'),
343
+ menu,
344
+ ]))];
345
+ }
346
+
347
+ /**
348
+ * A saved filter, expanded into the records it names.
349
+ *
350
+ * Collapsed until asked, because a page with six saved filters would
351
+ * otherwise run six queries to draw its own controls — five of them for lists
352
+ * nobody was going to open.
353
+ */
354
+ function savedFilterEntry(base, view, onPick) {
355
+ const rows = el('div', { class: 'root-filter-rows' });
356
+ const wrap = disclosure(el('details', { class: 'root-filter' }, [
357
+ el('summary', { class: 'root-filter-head' }, [
358
+ el('span', { class: 'root-filter-name', text: view.name }),
359
+ ]),
360
+ rows,
361
+ ]));
362
+
363
+ let loaded = false;
364
+ wrap.addEventListener('toggle', () => {
365
+ if (!wrap.open || loaded) return;
366
+ loaded = true;
367
+ rows.replaceChildren(el('p', { class: 'note', text: 'Looking\u2026' }));
368
+ void (async () => {
369
+ try {
370
+ const data = await api('/api/run', { q: view.query, limit: FILTER_ROWS });
371
+ const hits = data.rows ?? [];
372
+ rows.replaceChildren(hits.length
373
+ ? el('div', { class: 'root-hits' }, hits.map((row) => {
374
+ const key = keyOf(base, row);
375
+ return key
376
+ ? el('button', {
377
+ type: 'button', class: 'root-menu-item', text: rowLabel(base, row),
378
+ onclick: () => onPick(key, rowLabel(base, row)),
379
+ })
380
+ : null;
381
+ }).filter(Boolean))
382
+ : el('p', { class: 'note', text: 'Nothing matches this filter.' }));
383
+ } catch (err) {
384
+ rows.replaceChildren(el('p', { class: 'note', text: err.message }));
385
+ }
386
+ })();
387
+ });
388
+ return wrap;
389
+ }
390
+
391
+ /** Saved filters for this table, from the browser's own store. */
392
+ function savedFiltersFor(tableId) {
393
+ const all = loadJson(`tablewalk.views.${state.activeConnection ?? ''}`, {});
394
+ return (all[tableId] ?? []).slice(0, 6);
395
+ }
396
+
397
+ /* ---------- sections ---------- */
398
+
399
+ /**
400
+ * The classes a section wears, on the skeleton and on the settled box alike.
401
+ *
402
+ * One function on purpose: the loading slot is *replaced* when its content
403
+ * arrives, so a class only the slot carried — which is how the offsets first
404
+ * shipped — held its place while the section loaded and vanished the moment
405
+ * it drew.
406
+ */
407
+ function sectionClass(section) {
408
+ return `page-section page-${section.kind} width-${section.width ?? 'full'}`
409
+ + `${section.offsetLeft ? ` offset-l-${section.offsetLeft}` : ''}`
410
+ + `${section.offsetRight ? ` offset-r-${section.offsetRight}` : ''}`;
411
+ }
412
+
413
+ function sectionBox(section, body, { foldable = false } = {}) {
414
+ /* A section without a title is named after what it shows.
415
+
416
+ The heading was rendered from `section.title` alone, so a list whose
417
+ title had never been typed got an empty `h3` — a blank line above a table
418
+ with no indication of what the table was. The builder shows the table
419
+ name as a *placeholder*, which reads as a default and was not one. */
420
+ const heading = section.title
421
+ ?? (section.kind === 'fields' ? '' : findTable(section.from)?.name ?? section.from);
422
+
423
+ const box = el('section', { class: sectionClass(section) });
424
+
425
+ /* A group of fields past the first one folds.
426
+
427
+ A record with forty columns is not one list — it is the identity, the
428
+ money and the dates — and a page that says so is readable where a
429
+ forty-row grid is not. But the *first* group never folds: whatever is at
430
+ the top of a form is what the form is about, and a page that opens with
431
+ everything shut is a page that opens empty. */
432
+ if (foldable && heading) {
433
+ const details = el('details', { class: 'page-fold' }, [
434
+ el('summary', { class: 'page-section-head page-fold-head' }, [
435
+ el('span', { text: heading }),
436
+ ]),
437
+ body,
438
+ ]);
439
+ details.open = !section.collapsed;
440
+ box.append(disclosure(details));
441
+ return box;
442
+ }
443
+
444
+ if (heading) box.append(el('h3', { class: 'page-section-head', text: heading }));
445
+ box.append(body);
446
+ return box;
447
+ }
448
+
449
+ /** A section that could not run. Named, in place, and not fatal to the rest. */
450
+ function brokenSection(section, message) {
451
+ // The same placement as the section it stands in for — a broken half at the
452
+ // right offset keeps the rest of the page's geometry honest.
453
+ return el('section', { class: `${sectionClass(section)} page-broken` }, [
454
+ el('h3', { class: 'page-section-head', text: section.title ?? 'This section' }),
455
+ el('p', { class: 'note', text: message }),
456
+ ]);
457
+ }
458
+
459
+ /**
460
+ * The root's own fields.
461
+ *
462
+ * Rendered here rather than by handing off to the record page, because a page
463
+ * is not a record page with extra things below it — the fields are one
464
+ * section among several and have to sit in a row with the others.
465
+ */
466
+ async function fieldsSection(page, section, base, key, onLabel, onEdit) {
467
+ const paths = section.paths?.length
468
+ ? section.paths
469
+ : base.columns.map((c) => c.name);
470
+
471
+ /* The root's name rides along hidden, exactly as the record view does it:
472
+ a table named through a reference — employee, via `party_id.display_name`
473
+ — would otherwise title its page by the key whenever the layout does not
474
+ happen to show that field. */
475
+ const namePath = labelPath(base);
476
+ const fetched = namePath && !paths.includes(namePath) ? [...paths, namePath] : paths;
477
+
478
+ const data = await api('/api/view/run', {
479
+ view: {
480
+ id: `page:${page.id}:fields`,
481
+ name: base.name,
482
+ base: base.id,
483
+ columns: fetched.map((path) => ({ path })),
484
+ filter: { groups: [primaryKey(base).map((c) => ({ path: c, op: '=', value: key[c] }))] },
485
+ limit: 1,
486
+ },
487
+ });
488
+ const row = data.rows?.[0];
489
+ if (!row) throw new Error('That record is no longer there.');
490
+ /* The row is already here, so the filter chip can stop saying `2`.
491
+ Arriving by link there is no label — the link carries a key, because a
492
+ key is what identifies a record and a name is not. This is the first
493
+ moment the name is known. */
494
+ onLabel?.(rowLabel(base, row));
495
+
496
+ const byName = new Map(base.columns.map((c) => [c.name, c]));
497
+ /* Joined columns arrive described by the view compiler — type, nullability
498
+ — which is what lets a pulled-in date or flag render exactly as a local
499
+ one would. Without this the page renderer was the record renderer's poor
500
+ cousin: `active` as `1`, midnights kept, foreign keys as bare integers. */
501
+ const byPath = new Map((data.resolved ?? []).map((c) => [c.path, c]));
502
+
503
+ /* A group of fields that all follow the same reference is that record shown
504
+ inline — the site a work order is at, under the work order's own fields.
505
+ Its heading already says which record it is, so the labels drop the hop
506
+ they all share rather than repeating `site_id.` down the column. The
507
+ builder's rule, imported rather than restated: this file had its own
508
+ one-hop copy, so a two-hop group was stripped in the arranger and
509
+ fully-qualified on the page. */
510
+ const stem = sharedPrefix(paths);
511
+ const shared = stem ? `${stem}.` : '';
512
+
513
+ /* Whether this group is the record's own fields, and therefore editable in
514
+ write mode. A group that follows a reference shows *another* record —
515
+ the record view refuses to edit those for the same reason: the write
516
+ would land on a row the page is not showing. */
517
+ const own = !section.of && !stem;
518
+ const editing = own && canEditRow(base, key);
519
+
520
+ const fields = paths.map((path) => {
521
+ const value = row[path];
522
+ const column = byName.get(path) ?? byPath.get(path);
523
+ /* The same three-way rule as the record view, from the same module:
524
+ null says null, a foreign key is a jump, everything else renders by
525
+ what it is. One renderer, so a page built in the builder can never
526
+ again read worse than the record it was built from. */
527
+ const dd = editing && byName.has(path)
528
+ ? el('dd', { class: 'page-field-edit' }, editable(base, row, byName.get(path), key, onEdit))
529
+ : value === null || value === undefined
530
+ ? el('dd', { class: 'null', text: 'null' })
531
+ : column?.references && String(value) !== ''
532
+ ? el('dd', {}, refJump(column.references, value))
533
+ : el('dd', {}, valueNode(column, value));
534
+ return el('div', { class: 'page-field' }, [
535
+ el('dt', { text: shared ? path.slice(shared.length) : path, title: path }),
536
+ dd,
537
+ ]);
538
+ });
539
+
540
+ /* `page-field-grid`, not `page-fields`: the section wrapper around this is
541
+ already `page-section page-fields width-…`, from `page-${section.kind}`,
542
+ so a rule written for the body was landing on the wrapper too — turning
543
+ the section itself into an auto-fit grid. With no heading there is one
544
+ child and nothing shows; give the section a title and the `<h3>` takes a
545
+ column of its own beside the fields. */
546
+ const grid = el('div', { class: 'page-field-grid' }, fields);
547
+
548
+ /* A fixed column count is somebody having arranged this group, and once
549
+ they have, it must not reflow behind them — `auto-fit` is the right
550
+ default for a page nobody has laid out and the wrong answer for one
551
+ somebody has. The fields fill the columns in `paths` order, left to
552
+ right, so moving a field between columns is moving it in the list. */
553
+ if (section.columns) {
554
+ grid.style.gridTemplateColumns = `repeat(${section.columns}, minmax(0, 1fr))`;
555
+ }
556
+ return grid;
557
+ }
558
+
559
+ /** One number, and a way to see what it counted. */
560
+ async function metricSection(section, plan) {
561
+ if (plan.error) throw new Error(plan.error);
562
+ /* A section that goes through an empty reference — "tickets at its site"
563
+ on a record with no site — has no number, and an em dash is the honest
564
+ spelling of that. Zero would claim the site was checked and had none. */
565
+ if (plan.empty) {
566
+ return el('span', {
567
+ class: 'page-metric-value page-metric-empty',
568
+ title: `This record has no ${section.of ?? 'reference'} to count through.`,
569
+ text: '\u2014',
570
+ });
571
+ }
572
+ const data = await api('/api/view/run', { view: plan.view });
573
+ if (data.errors?.length) throw new Error(data.errors[0].message);
574
+
575
+ /* A count reads the view's own total — exact, because a view only joins
576
+ to-one, so counting it is counting the table it started from. Everything
577
+ else is an aggregate the compiler produced, aliased `value`.
578
+
579
+ Falling back to the first non-key column rather than to zero: a metric
580
+ that quietly reads 0 because an alias moved is the kind of wrong number
581
+ nobody checks. */
582
+ const value = plan.counts
583
+ ? data.total ?? data.rows?.length ?? 0
584
+ : (data.rows?.[0] ?? {}).value
585
+ ?? Object.entries(data.rows?.[0] ?? {}).find(([k]) => k !== 'id')?.[1];
586
+ if (value === undefined) throw new Error('The aggregate came back without a value.');
587
+
588
+ /* A number you cannot get behind is a number you have to take on trust,
589
+ so clicking one opens the rows it counted — narrowed the same way.
590
+
591
+ Which destination depends on what the section counted, and the plan says
592
+ which. A one-hop count is expressible in the query language, so it opens
593
+ the ordinary grid. Anything further out is not — the language has no
594
+ dotted paths — and it opens the composer on the exact view instead. The
595
+ fallback used to be `section.from` on its own, which is the child table
596
+ *unfiltered*: the card said 3 and the grid it opened said 20. */
597
+ const open = plan.query
598
+ ? () => go(tableView(section.from, plan.query), 'push')
599
+ : plan.viewText
600
+ ? () => go(tableView(section.from, section.from, `view+${encodeURIComponent(plan.viewText).replace(/!/g, '%21')}`), 'push')
601
+ : null;
602
+
603
+ return el('button', {
604
+ type: 'button',
605
+ class: 'page-metric-value',
606
+ disabled: !open,
607
+ title: open
608
+ ? `Open the ${section.from} this counted`
609
+ : `${section.from} — this count cannot be reopened as a query`,
610
+ onclick: open ?? undefined,
611
+ }, [
612
+ el('span', { class: 'metric-number', text: typeof value === 'number' ? value.toLocaleString() : String(value) }),
613
+ /* The table, only when the heading did not already say it. "INVOICES /
614
+ 7 invoice" is the same word twice in a box three inches wide. */
615
+ el('span', { class: 'metric-of', text: section.title ? '' : section.from }),
616
+ ]);
617
+ }
618
+
619
+ /**
620
+ * Rows related to the root, a page at a time.
621
+ *
622
+ * A list has a limit and the limit was the end of it: eight of a customer's
623
+ * forty orders, with nothing to say there were more and no way to see them.
624
+ * The whole point of putting a list on a page is that it is the real list,
625
+ * not a sample of one — so it pages, and says where it is.
626
+ *
627
+ * The offset lives here rather than in the entry: it is a property of looking
628
+ * at this section, not of the page, and putting it in the URL would make
629
+ * "page 3 of the orders list" a thing you could link to and then a thing that
630
+ * has to survive the section being moved.
631
+ */
632
+ async function listSection(section, plan) {
633
+ if (plan.error) throw new Error(plan.error);
634
+ const table = findTable(section.from);
635
+ /* Even with nothing to list, the columns are known — they are the section's
636
+ own, chosen when the page was built — so the list keeps its shape and the
637
+ sentence explains it. A bare sentence where a table should be reads as a
638
+ section that failed rather than one that is empty. */
639
+ const columnsOf = () => (section.columns?.length
640
+ ? section.columns
641
+ : (table?.columns ?? []).map((c) => c.name));
642
+
643
+ if (plan.empty) {
644
+ return renderGrid(table, { columns: columnsOf(), rows: [] }, {
645
+ emptyText: `This record has no ${section.of ?? 'reference'} to list through.`,
646
+ });
647
+ }
648
+ const limit = plan.view?.limit ?? 10;
649
+ /* The columns this list is pinned to, and their values: every row in it has
650
+ them whether or not the list shows them. Only single-value equalities on
651
+ the view's own base, which is exactly the "these children belong to that
652
+ parent" filter the page builds — anything else is a condition about the
653
+ result, not a fact about every row in it. */
654
+ const pinned = pinnedBy(plan.view);
655
+
656
+ const host = el('div', { class: 'page-list-body' });
657
+ let offset = 0;
658
+
659
+ const load = async () => {
660
+ const data = await api('/api/view/run', { view: plan.view, offset });
661
+ if (data.errors?.length) throw new Error(data.errors[0].message);
662
+
663
+ const rows = data.rows ?? [];
664
+ const grid = renderGrid(table, { ...data, rows, columns: data.columns ?? columnsOf() }, {
665
+ emptyText: `No ${table?.name ?? section.from} for this record.`,
666
+ onRowClick: (row) => {
667
+ /* Completed from the section's own filter before the key is read.
668
+ A child with a composite key — `work_order_part(work_order_id,
669
+ line_no)` — does not show the column that points back at the root,
670
+ because the whole list is that value. Reading the key off the row
671
+ alone therefore came up short, `keyOf` returned nothing, and
672
+ clicking the row did nothing at all with no way to tell why. */
673
+ const rowKey = table && keyOf(table, { ...pinned, ...row });
674
+ /* As a page, because that is what the walk is already doing — see
675
+ `openRecord`. */
676
+ if (rowKey) void openRecord(table.id, rowKey, rowLabel(table, { ...pinned, ...row }));
677
+ },
678
+ });
679
+
680
+ /* Only when there is somewhere to go. A pager under a list that fits on
681
+ one page is two disabled buttons explaining that nothing can be done. */
682
+ const pager = data.more || offset > 0 ? el('div', { class: 'page-pager' }, [
683
+ el('span', {
684
+ class: 'page-range',
685
+ /* Says where you are, not just that you can move. `9–16 of 41` is the
686
+ thing a reader wants; two arrows are the thing they have to work it
687
+ out from. */
688
+ text: data.total === undefined
689
+ ? `${offset + 1}–${offset + rows.length}`
690
+ : `${offset + 1}–${offset + rows.length} of ${data.total.toLocaleString()}`,
691
+ }),
692
+ el('button', {
693
+ type: 'button', class: 'ghost', text: '←',
694
+ 'aria-label': 'Previous rows', disabled: offset === 0,
695
+ onclick: () => { offset = Math.max(0, offset - limit); void load(); },
696
+ }),
697
+ el('button', {
698
+ type: 'button', class: 'ghost', text: '→',
699
+ 'aria-label': 'More rows', disabled: !data.more,
700
+ onclick: () => { offset += limit; void load(); },
701
+ }),
702
+ ]) : null;
703
+
704
+ host.replaceChildren(...[grid, pager].filter(Boolean));
705
+ };
706
+
707
+ await load();
708
+ return host;
709
+ }
710
+
711
+ /* ---------- the page ---------- */
712
+
713
+ /**
714
+ * Draw a page for one root.
715
+ *
716
+ * Sections are started together and land as they arrive, each replacing its
717
+ * own placeholder. A page with a count over a large child table should not
718
+ * hold up the record's own name, and the alternative — waiting for all of
719
+ * them — makes the slowest section the speed of the page.
720
+ */
721
+ function renderSections(page, base, key, host, plans, onLabel, onEdit, entry) {
722
+ /* Hidden sections are dropped here rather than anywhere further in.
723
+
724
+ Hiding is a layout decision, not a validity one: the section keeps its
725
+ columns, its order and its filter, and the page keeps its error if it has
726
+ one — a section that is wrong is still wrong when it is not on screen.
727
+ What it stops doing is drawing, and — because the fetch hangs off the
728
+ slot — asking. The original index travels with it, because that is what
729
+ the plan is keyed by. */
730
+ const shown = page.sections
731
+ .map((section, index) => ({ section, index }))
732
+ .filter(({ section }) => !section.hidden);
733
+
734
+ /* Which groups of fields fold: every one after the first. Decided over the
735
+ whole page rather than per section, because "is this the first group of
736
+ fields" is not a property a section knows about itself. */
737
+ const firstFields = shown.find(({ section }) => section.kind === 'fields')?.index;
738
+
739
+ const slots = shown.map(({ section }) =>
740
+ el('section', {
741
+ class: `${sectionClass(section)} loading`,
742
+ }, [
743
+ section.title ? el('h3', { class: 'page-section-head', text: section.title }) : null,
744
+ section.kind === 'list' ? gridSkeleton(4, 3) : el('div', { class: 'sk-bar' }),
745
+ ].filter(Boolean)));
746
+
747
+ /* A run of metrics is one item, not several.
748
+
749
+ Metrics are as wide as their numbers, so without something to hold them
750
+ the next section floats up beside them and the page reads as a strip of
751
+ counts with one table wedged into the gap. The first attempt put an empty
752
+ full-width element between them to force a wrap — which works, and costs
753
+ a whole extra flex line: `row-gap` applies *between lines*, so the seam
754
+ after a metric strip was two gaps rather than one, visibly wider than
755
+ every other seam on the page. A negative margin cannot take it back,
756
+ because a line's height clamps at zero.
757
+
758
+ Wrapping the run in one full-width item gives the strip a line of its own
759
+ and the page a single gap after it. */
760
+ /* How long the run of lists containing each section is, so a run of one
761
+ can stay a plain section: a single tab is a heading with a border around
762
+ it, and the tab strip earns its place only where there is a choice. */
763
+ const listRuns = new Map();
764
+ for (let i = 0; i < shown.length; i += 1) {
765
+ if (shown[i].section.kind !== 'list') continue;
766
+ let end = i;
767
+ while (end + 1 < shown.length && shown[end + 1].section.kind === 'list') end += 1;
768
+ for (let at = i; at <= end; at += 1) listRuns.set(at, end - i + 1);
769
+ i = end;
770
+ }
771
+
772
+ const laid = [];
773
+ let run = null;
774
+ /* A run of lists, when the page asks for tabs. Same idea as the metric
775
+ run above and for the same reason — "these belong together" is a fact
776
+ about a run of sections, not about any one of them — except that a tab
777
+ strip also decides what is *not* drawn, so it keeps the indexes it
778
+ covers rather than just holding the nodes. */
779
+ let strip = null;
780
+ const strips = [];
781
+ shown.forEach(({ section }, i) => {
782
+ if (section.kind === 'metric') {
783
+ strip = null;
784
+ if (!run) {
785
+ run = el('div', { class: 'page-metric-row' });
786
+ laid.push(run);
787
+ }
788
+ run.append(slots[i]);
789
+ return;
790
+ }
791
+ run = null;
792
+ /* Tabs unless the page says otherwise — see `listTabs`. A single list
793
+ is left alone: one tab is a heading with a border around it. */
794
+ if (page.listTabs !== false && section.kind === 'list' && listRuns.get(i) > 1) {
795
+ if (!strip) {
796
+ strip = { at: [], node: el('div', { class: 'page-tabs' }) };
797
+ strips.push(strip);
798
+ laid.push(strip.node);
799
+ }
800
+ strip.at.push(i);
801
+ return;
802
+ }
803
+ strip = null;
804
+ laid.push(slots[i]);
805
+ });
806
+
807
+ host.replaceChildren(...laid);
808
+
809
+ /* Which sections a tab strip is responsible for drawing, and when. A tab
810
+ nobody has opened has not been fetched, which is the whole reason six
811
+ lists as tabs are cheaper than six lists down a page. */
812
+ const deferred = new Set();
813
+ for (const { at, node } of strips) {
814
+ tabStrip(page, node, at.map((i) => ({ ...shown[i], at: i, slot: slots[i] })), (i) => draw(i), entry);
815
+ for (const i of at) deferred.add(i);
816
+ }
817
+
818
+ function draw(at) {
819
+ const { section, index } = shown[at];
820
+ const slot = slots[at];
821
+ const plan = plans.find((p) => p.index === index) ?? {};
822
+ const settle = (node) => {
823
+ /* The slot may have been swapped already — a tab drawn once is not
824
+ drawn again — or may not be in the document at all if the page
825
+ moved on while its rows were in flight. */
826
+ if (slot.isConnected) slot.replaceWith(node);
827
+ };
828
+ const run = section.kind === 'fields'
829
+ ? fieldsSection(page, section, base, key, onLabel, onEdit)
830
+ : section.kind === 'metric'
831
+ ? metricSection(section, plan)
832
+ : listSection(section, plan);
833
+
834
+ void run
835
+ .then((body) => settle(sectionBox(section, body, {
836
+ foldable: section.kind === 'fields' && index !== firstFields,
837
+ })))
838
+ /* One bad path must not take the page with it. A page is most useful on
839
+ exactly the day something is misconfigured, and a screen that renders
840
+ five sections and names the sixth is more useful than an error. */
841
+ .catch((err) => settle(brokenSection(section, err.message)));
842
+ }
843
+
844
+ /* Everything not behind a tab starts now; a tab starts when it is opened,
845
+ which for the first tab of each strip is immediately. */
846
+ shown.forEach((_, at) => { if (!deferred.has(at)) draw(at); });
847
+ }
848
+
849
+ /**
850
+ * A run of lists as tabs.
851
+ *
852
+ * The names go across the top and one table sits under them. Six things
853
+ * pointing at a record is six tables stacked down a page, and the sixth is a
854
+ * scroll away from the record it is about; as tabs it is one table deep,
855
+ * with the other five named.
856
+ *
857
+ * Proper tabs, not styled buttons: a tablist with arrow keys, one tab in the
858
+ * tab order at a time, and `aria-controls` pointing at the panel each one
859
+ * shows. A tab strip that cannot be driven from the keyboard is a worse
860
+ * version of the stack it replaced.
861
+ */
862
+ function tabStrip(page, node, items, drawOne, entry) {
863
+ const drawn = new Set();
864
+ /* The tab a link asked for, matched by name rather than by position: a
865
+ link that pointed at "tab 2" would point somewhere else the day someone
866
+ reorders the sections, which is a thing the grips make easy. */
867
+ const named = (item) => item.section.title ?? findTable(item.section.from)?.name ?? item.section.from;
868
+ const asked = items.findIndex((item) => named(item) === entry?.tab);
869
+ let active = asked === -1 ? 0 : asked;
870
+
871
+ const panels = items.map(({ slot }, i) => {
872
+ const panel = el('div', {
873
+ class: 'page-tabpanel',
874
+ role: 'tabpanel',
875
+ id: `${page.id}-tabpanel-${i}`,
876
+ 'aria-labelledby': `${page.id}-tab-${i}`,
877
+ tabindex: '0',
878
+ }, slot);
879
+ panel.hidden = i !== active;
880
+ return panel;
881
+ });
882
+
883
+ const tabs = items.map(({ section }, i) => el('button', {
884
+ type: 'button',
885
+ class: 'page-tab',
886
+ role: 'tab',
887
+ id: `${page.id}-tab-${i}`,
888
+ 'aria-controls': `${page.id}-tabpanel-${i}`,
889
+ 'aria-selected': String(i === active),
890
+ /* One stop in the tab order for the whole strip: Tab moves past the
891
+ group, the arrows move within it. */
892
+ tabindex: i === active ? '0' : '-1',
893
+ text: section.title ?? findTable(section.from)?.name ?? section.from,
894
+ onclick: () => show(i),
895
+ onkeydown: (e) => {
896
+ const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
897
+ const to = step ? (i + step + items.length) % items.length
898
+ : e.key === 'Home' ? 0
899
+ : e.key === 'End' ? items.length - 1 : -1;
900
+ if (to < 0) return;
901
+ e.preventDefault();
902
+ show(to);
903
+ tabs[to].focus();
904
+ },
905
+ }));
906
+
907
+ function show(i) {
908
+ active = i;
909
+ tabs.forEach((tab, at) => {
910
+ tab.setAttribute('aria-selected', String(at === i));
911
+ tab.tabIndex = at === i ? 0 : -1;
912
+ });
913
+ panels.forEach((panel, at) => { panel.hidden = at !== i; });
914
+ if (!drawn.has(i)) {
915
+ drawn.add(i);
916
+ drawOne(items[i].at);
917
+ }
918
+ /* Written into the address bar without re-rendering: opening a tab is
919
+ not a step in the walk — it is what you are looking at, which is
920
+ exactly the thing a link should carry. The first tab writes nothing,
921
+ so an ordinary link stays as short as it was. */
922
+ if (entry) {
923
+ entry.tab = i === 0 ? undefined : named(items[i]);
924
+ syncHash();
925
+ }
926
+ }
927
+
928
+ node.replaceChildren(
929
+ el('div', { class: 'page-tabstrip', role: 'tablist', 'aria-label': 'Related lists' }, tabs),
930
+ ...panels,
931
+ );
932
+ /* The open tab is fetched — and nothing else is until someone asks for
933
+ it. Which tab that is may have come from the link. */
934
+ drawn.add(active);
935
+ drawOne(items[active].at);
936
+ return { show, get active() { return active; } };
937
+ }
938
+
939
+ export async function renderPage(entry, content) {
940
+ const host = el('div', { class: 'page-body' });
941
+ const head = el('div', { class: 'page-head' });
942
+ /* Appended, not replacing: the trail is already in `content`, put there by
943
+ the paint that called this. Replacing swallowed it, so the one view you
944
+ can now walk several steps deep in was the one with nothing saying where
945
+ you were or how to get back. */
946
+ content.append(el('div', { class: 'panel-pad' }, [head, host]));
947
+
948
+ let page;
949
+ try {
950
+ page = await pageById(entry.pageId, entry.table);
951
+ } catch (err) {
952
+ host.replaceChildren(el('p', { class: 'note', text: err.message }));
953
+ return;
954
+ }
955
+ if (page.error) {
956
+ head.replaceChildren(el('h2', { class: 'page-title', text: page.name }));
957
+ host.replaceChildren(el('p', { class: 'note', text: page.error }));
958
+ return;
959
+ }
960
+
961
+ const base = findTable(page.base);
962
+ if (!base) {
963
+ host.replaceChildren(el('p', { class: 'note', text: `"${page.base}" is not a table here.` }));
964
+ return;
965
+ }
966
+
967
+ /* A link carries the root as text, because a single-column key is written
968
+ as a bare value and only the base table says which column that is. */
969
+ if (!entry.key && entry.rootBody !== undefined) {
970
+ entry.key = entry.rootBody.includes('=')
971
+ ? Object.fromEntries(entry.rootBody.split(',').map((pair) => {
972
+ const [column, ...rest] = pair.split('=');
973
+ return [column, rest.join('=')];
974
+ }))
975
+ : { [primaryKey(base)[0]]: entry.rootBody };
976
+ }
977
+
978
+ const open = (key, label) => {
979
+ // Clearing is picking nothing, so one handler covers both.
980
+ if (key) rememberRoot(state.activeConnection, page.id, key, label);
981
+ go({ ...entry, key, label, rootBody: undefined }, 'replace');
982
+ };
983
+
984
+ /* Two rows, not one.
985
+
986
+ The title, the pin, four filter boxes, the `\u00b7\u00b7\u00b7` menu and the
987
+ Layout button in a single flex row put the page's name and its controls
988
+ in competition for the same line, and the boxes — which are the widest
989
+ thing there and the only one you *type* into — ended up wedged between
990
+ two buttons. What the page is called belongs on its own line; how you
991
+ choose the record it is about belongs under it, full width. */
992
+ const identity = el('div', { class: 'page-head-top' }, [
993
+ /* The title is a picker when this table has more than one page: the same
994
+ record read two ways is the reason to build a second page at all, and
995
+ switching between them meant going back to the command palette and
996
+ finding the other one by name. The root travels across the switch —
997
+ that is the whole point of it. */
998
+ await pagePicker(page, entry),
999
+ pinButton(page),
1000
+ el('button', {
1001
+ type: 'button',
1002
+ class: 'ghost page-edit',
1003
+ /* A page from the config file or from the schema is not this browser's
1004
+ to change, so editing one starts a copy — which is what someone means
1005
+ by opening the builder on a page they cannot edit. Said on the button
1006
+ rather than discovered on save. */
1007
+ /* "Layout", because "Edit" on a screen full of a record's values is a
1008
+ promise to edit the record — which this button does not do, and
1009
+ which write mode now does right there in the fields. */
1010
+ text: page.source === 'saved' ? 'Layout' : 'Copy layout',
1011
+ title: page.source === 'saved'
1012
+ ? 'Add, remove and reorder the sections of this page'
1013
+ : 'This page belongs to the file it came from — editing it starts your own copy',
1014
+ onclick: () => void openBuilder(page, {
1015
+ onSaved: (saved) => go(pageView(saved.id, saved.base, entry.key, entry.label), 'replace'),
1016
+ /* Deleting the page you are standing on has to go somewhere. The
1017
+ table it was about is the honest answer — it is what the page was a
1018
+ view of. */
1019
+ onDeleted: () => {
1020
+ /* Pinned or recently opened, it is neither now: a home screen
1021
+ offering a link to a page that was deleted is a home screen
1022
+ offering an error. */
1023
+ forgetPage(page.id);
1024
+ go(tableView(page.base), 'replace');
1025
+ },
1026
+ }),
1027
+ }),
1028
+ ]);
1029
+
1030
+ head.replaceChildren(
1031
+ identity,
1032
+ rootBar(page, base, entry.key ? (entry.label ?? rootLabel(entry.key)) : null, open),
1033
+ );
1034
+
1035
+ if (!entry.key) {
1036
+ host.replaceChildren(el('p', {
1037
+ class: 'note page-empty',
1038
+ text: `Choose a ${base.name} above to see this page.`,
1039
+ }));
1040
+ return;
1041
+ }
1042
+
1043
+ /* One planning call for the whole page, then a run per section. The plan is
1044
+ where every path is resolved, so a section that cannot be built is named
1045
+ before anything is fetched. */
1046
+ let plan;
1047
+ try {
1048
+ /* A page this browser built is sent whole: the server has never seen it,
1049
+ so an id would name nothing. Config and suggested pages go by id, which
1050
+ keeps the request small and lets the server be the one that decides
1051
+ whether the page is still valid for this connection. */
1052
+ plan = await api('/api/pages/plan', {
1053
+ page: page.source === 'saved' ? page : page.id,
1054
+ key: entry.key,
1055
+ });
1056
+ } catch (err) {
1057
+ host.replaceChildren(el('p', { class: 'note', text: err.message }));
1058
+ return;
1059
+ }
1060
+ /* The unsaved-changes bar, in the head where the record view keeps it —
1061
+ above the fields it is about, and on screen without scrolling back. */
1062
+ const pending = el('div', { class: 'page-pending' });
1063
+ head.append(pending);
1064
+ const refreshPending = (opts) => {
1065
+ if (opts && opts.inPlace === false) {
1066
+ go({ ...entry }, 'replace');
1067
+ return;
1068
+ }
1069
+ pending.replaceChildren(
1070
+ pendingBar(base, entry.key, (done) => refreshPending(done ?? { inPlace: true })) ?? '',
1071
+ );
1072
+ };
1073
+
1074
+ renderSections(plan.page ?? page, base, entry.key, host, plan.plans ?? [], (label) => {
1075
+ /* Written onto the entry as well as the chip, so going away and coming
1076
+ back through the trail keeps the name rather than dropping to the key
1077
+ again. */
1078
+ if (!label || entry.label === label) return;
1079
+ entry.label = label;
1080
+ /* Recorded again now that the record has a name: a link carries a key,
1081
+ so the first recording could only say `1885` — which is not what
1082
+ anybody is looking for when they come back to it. */
1083
+ rememberVisit({ pageId: page.id, name: page.name, base: page.base, key: entry.key, label });
1084
+
1085
+ /* Looked up in the document rather than in the `head` this call built.
1086
+
1087
+ Boot renders once and again after the incoming link is applied, so by
1088
+ the time the fields have come back the head this closure captured can
1089
+ be detached — the chip was updated faithfully, on an element no longer
1090
+ on screen, while the one in front of the reader kept saying `2`.
1091
+
1092
+ Guarded on the entry still being the current one, because a detached
1093
+ head is not the only way to be late: the reader may have walked on. */
1094
+ if (currentEntry() !== entry) return;
1095
+ const shown = document.querySelector('.page-head .root-current > span:first-child');
1096
+ if (shown) shown.textContent = label;
1097
+ }, refreshPending, entry);
1098
+
1099
+ /* Painted once now: an edit staged before a re-render is still staged, and
1100
+ a bar that only appears on the next keystroke is a bar that lies about
1101
+ what is pending. */
1102
+ refreshPending();
1103
+
1104
+ /* Where you were, for the home screen. Recorded after the page is known to
1105
+ be renderable, so a link to a page that no longer exists does not put
1106
+ itself on the list of places to go back to. */
1107
+ rememberVisit({
1108
+ pageId: page.id,
1109
+ name: page.name,
1110
+ base: page.base,
1111
+ key: entry.key,
1112
+ label: entry.label,
1113
+ });
1114
+ }
1115
+
1116
+ /** A key, when there is no label to show instead. */
1117
+ function rootLabel(key) {
1118
+ return Object.values(key).join(' \u00b7 ');
1119
+ }
1120
+
1121
+ /* ---------- lookup ---------- */
1122
+
1123
+ let cache = { connection: null, pages: null };
1124
+
1125
+ /** Every page for this connection: the config's, plus the suggested one. */
1126
+ /**
1127
+ * The column values every row of a list already satisfies.
1128
+ *
1129
+ * A page's list is "the children of this record", which compiles to one
1130
+ * equality on the child's reference column. That column is deliberately not
1131
+ * shown — the heading says it — so it has to be put back before anything
1132
+ * reads the row's key.
1133
+ *
1134
+ * One group only, and only bare `=` on the view's own base: an OR of two
1135
+ * groups pins nothing, and a condition on a joined path is about the row's
1136
+ * relations rather than about the row.
1137
+ */
1138
+ function pinnedBy(view) {
1139
+ const groups = view?.filter?.groups ?? [];
1140
+ if (groups.length !== 1) return {};
1141
+ const pinned = {};
1142
+ for (const condition of groups[0]) {
1143
+ if (condition.op !== '=' || typeof condition.path !== 'string') continue;
1144
+ if (condition.path.includes('.')) continue;
1145
+ pinned[condition.path] = condition.value;
1146
+ }
1147
+ return pinned;
1148
+ }
1149
+
1150
+ /**
1151
+ * The page's name, and the other pages for this table behind it.
1152
+ *
1153
+ * A plain heading when there is nothing to switch to, so a database with one
1154
+ * page per table never grows a control that opens onto a list of one. The
1155
+ * suggested layout is always in the list where it is not the page you are
1156
+ * on: it is the page every table has, and it is the way back from a saved
1157
+ * page that has been cut down too far.
1158
+ */
1159
+ async function pagePicker(page, entry) {
1160
+ let others = [];
1161
+ try {
1162
+ others = (await pagesFor(page.base)).filter((p) => !p.error);
1163
+ } catch {
1164
+ /* A config that cannot be read still leaves the page you are looking at. */
1165
+ }
1166
+ const suggested = { id: `suggested:${page.base}`, name: `${page.base} (default)`, source: 'suggested' };
1167
+ const items = [...others, ...(others.some((p) => p.id === suggested.id) ? [] : [suggested])]
1168
+ .filter((p) => p.id !== page.id);
1169
+
1170
+ if (!items.length) return el('h2', { class: 'page-title', text: page.name });
1171
+
1172
+ const picker = el('div', { class: 'page-title-pick' }, dropdown({
1173
+ ariaLabel: 'Page',
1174
+ value: page.id,
1175
+ items: [
1176
+ { value: page.id, label: page.name, detail: sourceOf(page) },
1177
+ ...items.map((p) => ({ value: p.id, label: p.name, detail: sourceOf(p) })),
1178
+ ],
1179
+ onChange: (id) => {
1180
+ if (id === page.id) return;
1181
+ /* Replace, not push: reading the same record another way is not a step
1182
+ along the walk, and a trail full of layout switches is a trail that
1183
+ cannot be walked back. The root rides along. */
1184
+ go({ ...entry, pageId: id }, 'replace');
1185
+ },
1186
+ }));
1187
+ /* The page's name is `.page-title` whether or not it is also a control.
1188
+ One name for "what this page is called on screen" — anything reading the
1189
+ other spelling would be reading a heading that exists on some pages. */
1190
+ picker.querySelector('.dd-value')?.classList.add('page-title');
1191
+ return picker;
1192
+ }
1193
+
1194
+ /**
1195
+ * Pin this page, so the landing offers it without being asked.
1196
+ *
1197
+ * Beside the name rather than in the layout dialog: pinning is about how
1198
+ * often you come back to a page, which is a thing you know while you are
1199
+ * looking at it and not a thing you set while editing its sections.
1200
+ */
1201
+ function pinButton(page) {
1202
+ const button = el('button', {
1203
+ type: 'button',
1204
+ class: 'ghost page-pin',
1205
+ onclick: () => {
1206
+ paint(togglePinned(page));
1207
+ /* The landing reads this list, and it is behind us — it will read the
1208
+ new one when it is next drawn. */
1209
+ },
1210
+ });
1211
+ const paint = (on) => {
1212
+ button.textContent = on ? '\u2605' : '\u2606';
1213
+ button.classList.toggle('is-pinned', on);
1214
+ button.title = on ? `"${page.name}" is pinned to the home screen` : `Pin "${page.name}" to the home screen`;
1215
+ button.setAttribute('aria-pressed', String(on));
1216
+ button.setAttribute('aria-label', button.title);
1217
+ };
1218
+ paint(isPinned(page.id));
1219
+ return button;
1220
+ }
1221
+
1222
+ /** Where a page came from, which is the same question as who may change it. */
1223
+ function sourceOf(page) {
1224
+ return page.source === 'saved' ? 'yours, in this browser'
1225
+ : page.source === 'config' ? 'from your tablewalk.json'
1226
+ : 'built from the schema';
1227
+ }
1228
+
1229
+ /* Which page a record opens as, once the walk is in page view.
1230
+ *
1231
+ * A page someone built or configured for that table wins; otherwise the
1232
+ * suggested one, which every table has. Registered at import time, so the
1233
+ * rule is in force wherever a record is opened from — a list row, a
1234
+ * reference chip — without each of those places knowing about pages. */
1235
+ setPageResolver(async (tableId) => {
1236
+ const [configured] = await pagesFor(tableId);
1237
+ return configured && !configured.error ? configured.id : `suggested:${tableId}`;
1238
+ });
1239
+
1240
+ export async function pagesFor(tableId) {
1241
+ if (cache.connection !== state.activeConnection || !cache.pages) {
1242
+ const data = await api('/api/pages');
1243
+ cache = { connection: state.activeConnection, pages: data.pages ?? [] };
1244
+ }
1245
+ /* This browser's own pages first. A page someone built here is the one they
1246
+ were just working on; a page from the config file is the team's, and both
1247
+ are the same kind of thing to whoever is looking for one — they differ
1248
+ only in whether you may edit it. Read fresh rather than cached, because
1249
+ the builder writes them and the next read must see it. */
1250
+ const all = [...savedPages(), ...cache.pages];
1251
+ return tableId ? all.filter((p) => p.base === tableId) : all;
1252
+ }
1253
+
1254
+ /**
1255
+ * Told when the set of pages changes, so the menu can be rebuilt.
1256
+ *
1257
+ * A callback rather than an import: the builder saves pages and the menu
1258
+ * lists them, and having the builder call into `app.js` to say so made a
1259
+ * cycle — app → page → pagebuilder → app — which in a test that stubs the DOM
1260
+ * dragged boot code into a module that only wanted a function. The direction
1261
+ * of the dependency is the thing worth keeping: the builder announces, and
1262
+ * whoever cares listens.
1263
+ */
1264
+ let listener = null;
1265
+
1266
+ export function whenPagesChange(fn) {
1267
+ listener = fn;
1268
+ }
1269
+
1270
+ export function forgetPages() {
1271
+ cache = { connection: null, pages: null };
1272
+ listener?.();
1273
+ }
1274
+
1275
+ async function pageById(id, tableId) {
1276
+ const mine = savedPages().find((p) => p.id === id);
1277
+ if (mine) return mine;
1278
+ if (id?.startsWith('suggested:')) {
1279
+ const data = await api(`/api/pages/suggested?table=${encodeURIComponent(id.slice('suggested:'.length))}`);
1280
+ return data.page;
1281
+ }
1282
+ const pages = await pagesFor();
1283
+ const found = pages.find((p) => p.id === id);
1284
+ if (found) return found;
1285
+
1286
+ /* A link may name the page rather than identify it — `@customer-overview`
1287
+ rather than `@saved:customer:1787509869158`, which is what someone
1288
+ pastes into a ticket. Ids are tried first, so a page whose name happens
1289
+ to look like an id still wins on its id. */
1290
+ const named = pages.filter((p) => pageSlug(p) === id);
1291
+ const mineFirst = named.find((p) => !tableId || p.base === tableId) ?? named[0];
1292
+ if (mineFirst) return mineFirst;
1293
+
1294
+ /* And a bare table name is that table's suggested page, which is the page
1295
+ every table has and nobody had to build. */
1296
+ const asTable = findTable(id);
1297
+ if (asTable) {
1298
+ const data = await api(`/api/pages/suggested?table=${encodeURIComponent(asTable.id)}`);
1299
+ return data.page;
1300
+ }
1301
+ if (tableId) {
1302
+ const data = await api(`/api/pages/suggested?table=${encodeURIComponent(tableId)}`);
1303
+ return data.page;
1304
+ }
1305
+ throw new Error(`There is no page called "${id}".`);
1306
+ }
1307
+
1308
+ /**
1309
+ * A page's name as it is written in a link.
1310
+ *
1311
+ * `saved:customer:1787509869158` is an identity, not a name: it says nothing
1312
+ * about which page it is and carries a timestamp into every link someone
1313
+ * pastes. The name someone gave the page is the readable half, and it is
1314
+ * what a reader of the URL is trying to learn.
1315
+ */
1316
+ export function pageSlug(page) {
1317
+ return String(page?.name ?? '')
1318
+ .toLowerCase()
1319
+ .replace(/[^a-z0-9]+/g, '-')
1320
+ .replace(/^-+|-+$/g, '');
1321
+ }
1322
+
1323
+ /**
1324
+ * The token a page is written as in the hash: its slug where that names it
1325
+ * unambiguously, its id otherwise.
1326
+ *
1327
+ * Synchronous, because the address bar is stamped during a render and cannot
1328
+ * wait on a fetch — so before the page list has loaded a link is written with
1329
+ * the id, which still resolves. It becomes the readable form on the next
1330
+ * paint, and both forms mean the same page forever.
1331
+ */
1332
+ export function pageToken(pageId) {
1333
+ if (!pageId) return pageId;
1334
+ if (pageId.startsWith('suggested:')) return pageId.slice('suggested:'.length);
1335
+ const known = [...savedPages(), ...(cache.pages ?? [])];
1336
+ const page = known.find((p) => p.id === pageId);
1337
+ if (!page) return pageId;
1338
+ const slug = pageSlug(page);
1339
+ /* Ambiguity keeps the id: two pages called "Overview" would otherwise both
1340
+ answer to `@overview`, and a link that opens the wrong page is worse
1341
+ than one that is hard to read. A table of the same name counts too,
1342
+ since a bare table name is its suggested page. */
1343
+ const clashes = known.filter((p) => pageSlug(p) === slug).length > 1 || Boolean(findTable(slug));
1344
+ return slug && !clashes ? slug : pageId;
1345
+ }