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,1222 @@
1
+ /**
2
+ * Building a page without writing one.
3
+ *
4
+ * A page is a short JSON document and the format is not hard, but two things
5
+ * about it are: knowing which relationships exist, and spelling the path that
6
+ * reaches one. `invoice_id.customer_id` is obvious only once you already know
7
+ * it — which is the wrong way round for the field that decides whether a
8
+ * section shows the right rows.
9
+ *
10
+ * So the builder does not ask for a path. It asks the schema what points at
11
+ * the root, directly and one table further out, and offers the answers with
12
+ * their paths already written. Everything else is a name, a width and a
13
+ * limit, and those are choices rather than knowledge.
14
+ *
15
+ * What comes out is the same `PageDef` the config file holds, saved per
16
+ * connection in the browser — and copyable as JSON, because a page worth
17
+ * keeping is usually a page worth committing, and the format the builder
18
+ * writes has to be the format the file reads or the two will drift.
19
+ */
20
+ import {
21
+ api, disclosure, el, findTable, go, loadJson, pageView, refsFrom, saveJson, state,
22
+ tableAtPrefix, toast,
23
+ } from './core.js';
24
+ import { promptFor } from './prompt.js';
25
+ import { dropdown } from './dropdown.js';
26
+ import { forgetPages } from './page.js';
27
+ /* The arranger under both builders. Renamed on the way in: this module's own
28
+ `fieldArranger` is the page-shaped adapter around it. */
29
+ import { carriedField, dropCarried, fieldArranger as fieldArrangerFor } from './arrange.js';
30
+
31
+ const KEY = 'tablewalk.pages.saved';
32
+ /* A mirror of `WIDTHS` in shared/page.ts, which the client cannot import —
33
+ and which the config parser validates against, so a width offered here that
34
+ the parser refuses is a page the builder can save and the file cannot hold.
35
+ Exported so mirrors.test.ts can compare the two. */
36
+ export const WIDTHS = ['full', 'half', 'third'];
37
+
38
+ /* The same, for the empty space a section can hold to either side. */
39
+ export const OFFSETS = ['quarter', 'third', 'half', 'two-thirds'];
40
+
41
+ /* The same, for how many columns a group of fields fills. Mirrored for the
42
+ same reason and asserted the same way. */
43
+ export const FIELD_COLUMNS = [1, 2, 3];
44
+
45
+ const storeKey = () => `${KEY}.${state.activeConnection ?? ''}`;
46
+
47
+ /** Pages saved for the connection in hand, read from the local mirror. */
48
+ export function savedPages(tableId) {
49
+ const all = loadJson(storeKey(), {});
50
+ const pages = Object.values(all);
51
+ return tableId ? pages.filter((p) => p.base === tableId) : pages;
52
+ }
53
+
54
+ /**
55
+ * Pull this connection's saved pages from the server into the mirror.
56
+ *
57
+ * The same arrangement record layouts have, for the same reason: the mirror
58
+ * is what makes reads synchronous, the file is what makes the work outlive
59
+ * this browser. A failure is silent — the mirror already holds whatever this
60
+ * browser saved.
61
+ */
62
+ export async function loadSavedPages() {
63
+ try {
64
+ const { pages } = await api('/api/pages/saved');
65
+ if (pages && typeof pages === 'object') saveJson(storeKey(), pages);
66
+ } catch {
67
+ /* Server-side pages unavailable; the mirror stands. */
68
+ }
69
+ }
70
+
71
+ export function savePage(page) {
72
+ const all = loadJson(storeKey(), {});
73
+ all[page.id] = page;
74
+ const ok = saveJson(storeKey(), all);
75
+ /* Local first — the next render reads the mirror — then the file, which is
76
+ what makes the page outlive this browser. */
77
+ void api('/api/pages/save', { id: page.id, page })
78
+ .catch((err) => toast(err.message, 'error'));
79
+ /* Also tells the menu to rebuild: it was registered once at boot, so a page
80
+ built afterwards was reachable only from the link you were already on,
81
+ and a deleted one stayed listed as a command that opened nothing. */
82
+ forgetPages();
83
+ return ok;
84
+ }
85
+
86
+ export function deletePage(id) {
87
+ const all = loadJson(storeKey(), {});
88
+ delete all[id];
89
+ saveJson(storeKey(), all);
90
+ void api('/api/pages/save', { id, page: null })
91
+ .catch((err) => toast(err.message, 'error'));
92
+ forgetPages();
93
+ }
94
+
95
+ /* ---------- the panel ---------- */
96
+
97
+ let panel = null;
98
+
99
+ export function closeBuilder() {
100
+ panel?.remove();
101
+ panel = null;
102
+ document.removeEventListener('keydown', onKey, true);
103
+ }
104
+
105
+ function onKey(e) {
106
+ if (e.key !== 'Escape' || !panel) return;
107
+ /* Stopped as well as prevented: the composer and the palette both listen at
108
+ the document, and an Escape that closed three surfaces at once was a
109
+ complaint about this app's keyboard before. */
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+ closeBuilder();
113
+ }
114
+
115
+ /**
116
+ * Open the builder on a page.
117
+ *
118
+ * The draft is a copy, and nothing is written until Save. A builder that
119
+ * edited the live page would make "close without saving" impossible to offer
120
+ * honestly, and the first thing anyone does in a builder is try something.
121
+ */
122
+ export async function openBuilder(page, { onSaved, onDeleted } = {}) {
123
+ closeBuilder();
124
+ const draft = structuredClone(page);
125
+ /* A page that came from the config file or from the schema is not this
126
+ browser's to change. Editing one starts a copy — which is what someone
127
+ means by opening the builder on it. */
128
+ /* One test, `source`, rather than the source *and* the id prefix it used
129
+ to also check: those two could disagree, and did — a suggested page said
130
+ `source: 'saved'`, so the builder here knew it was derived from the
131
+ prefix while the button that opens the builder read the source and
132
+ offered to edit it in place. */
133
+ const derived = page.source !== 'saved';
134
+ if (derived) {
135
+ draft.id = `saved:${page.base}:${Date.now()}`;
136
+ draft.name = `${page.name} (edited)`;
137
+ draft.source = 'saved';
138
+ }
139
+
140
+ let relations = [];
141
+ let references = [];
142
+ try {
143
+ const data = await api(`/api/pages/relations?table=${encodeURIComponent(page.base)}`);
144
+ relations = data.relations ?? [];
145
+ references = data.references ?? [];
146
+ } catch {
147
+ /* Without relations the builder can still reorder and rename what is
148
+ already there, which is worth more than refusing to open. */
149
+ }
150
+
151
+ const body = el('div', { class: 'pb-body' });
152
+ /* Which section has its panel open, if any. One at a time: two open panels
153
+ is a form taller than the window, and the second is nearly always the one
154
+ that was wanted. */
155
+ let open = -1;
156
+ const setOpen = (i) => { open = i; draw(); };
157
+ const draw = () => body.replaceChildren(...sections(draft, relations, references, draw, open, setOpen));
158
+
159
+ panel = el('div', { class: 'pb-backdrop', onclick: (e) => { if (e.target === panel) closeBuilder(); } }, [
160
+ el('div', { class: 'pb', role: 'dialog', 'aria-label': 'Edit page' }, [
161
+ el('div', { class: 'pb-head' }, [
162
+ el('h2', { class: 'pb-title', text: derived ? 'New page' : 'Edit page' }),
163
+ el('span', { class: 'pb-base', text: `about one ${page.base}` }),
164
+ el('button', {
165
+ type: 'button', class: 'ghost pb-close', text: '×',
166
+ 'aria-label': 'Close', onclick: closeBuilder,
167
+ }),
168
+ ]),
169
+ el('div', { class: 'pb-name-row' }, [
170
+ el('label', { class: 'pb-label', text: 'Name' }),
171
+ el('input', {
172
+ /* The label beside it is a `<span>`-shaped `<label>` with no `for`,
173
+ so nothing connects the two — a screen reader reads an unlabelled
174
+ text box next to the word "Name" and cannot say they belong
175
+ together. */
176
+ 'aria-label': 'Page name',
177
+ class: 'pb-input', value: draft.name,
178
+ oninput: (e) => { draft.name = e.target.value; },
179
+ }),
180
+ /* Tabs are the default, so this is the way *out* of them. Beside the
181
+ name rather than on any one section, because it is a fact about
182
+ the page: which lists are tabs of each other is decided by their
183
+ order, which is what the grips are for. */
184
+ el('label', { class: 'pb-tabs-toggle', title: 'Lists that sit next to each other become tabs' }, [
185
+ el('input', {
186
+ type: 'checkbox',
187
+ checked: draft.listTabs !== false,
188
+ onchange: (e) => {
189
+ draft.listTabs = e.target.checked ? undefined : false;
190
+ draw();
191
+ },
192
+ }),
193
+ el('span', { text: 'Lists as tabs' }),
194
+ ]),
195
+ ]),
196
+ body,
197
+ el('div', { class: 'pb-add' }, [
198
+ el('span', { class: 'pb-label', text: 'Add' }),
199
+ addFields(draft, draw),
200
+ addRelation('+ Count', 'metric', draft, relations, references, draw),
201
+ addRelation('+ List', 'list', draft, relations, references, draw),
202
+ ]),
203
+ el('div', { class: 'pb-foot' }, [
204
+ /* Only on a page this browser owns. A config page belongs to the file
205
+ and a suggested one to the schema; neither is this dialog's to
206
+ remove, and offering it would be offering something that cannot
207
+ work. */
208
+ derived ? null : el('button', {
209
+ type: 'button', class: 'ghost pb-delete', text: 'Delete',
210
+ title: `Remove "${page.name}" from this browser`,
211
+ onclick: async () => {
212
+ const sure = await promptFor({
213
+ title: `Delete "${page.name}"?`,
214
+ label: 'Type the page name to confirm',
215
+ placeholder: page.name,
216
+ hint: 'It is stored in this browser only, so this cannot be undone from here.',
217
+ });
218
+ if (sure?.trim() !== page.name) return;
219
+ deletePage(page.id);
220
+ closeBuilder();
221
+ toast(`Deleted "${page.name}".`, 'ok');
222
+ onDeleted?.(page);
223
+ },
224
+ }),
225
+ el('button', {
226
+ type: 'button', class: 'ghost pb-json', text: 'Copy as JSON',
227
+ title: 'Paste into tablewalk.json to share it with the team',
228
+ onclick: () => void copyJson(draft),
229
+ }),
230
+ el('span', { class: 'pb-spacer' }),
231
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: closeBuilder }),
232
+ el('button', {
233
+ type: 'button', class: 'primary', text: 'Save',
234
+ onclick: () => {
235
+ if (!draft.sections.length) {
236
+ toast('A page needs at least one section.', 'error');
237
+ return;
238
+ }
239
+ if (!savePage(draft)) {
240
+ toast('This browser would not store the page.', 'error');
241
+ return;
242
+ }
243
+ closeBuilder();
244
+ toast(`Saved "${draft.name}".`, 'ok');
245
+ onSaved?.(draft);
246
+ },
247
+ }),
248
+ ].filter(Boolean)),
249
+ ]),
250
+ ]);
251
+
252
+ draw();
253
+ document.body.append(panel);
254
+ document.addEventListener('keydown', onKey, true);
255
+ panel.querySelector('.pb-input')?.focus();
256
+ }
257
+
258
+ /**
259
+ * The Add buttons, each opening the list of real relationships.
260
+ *
261
+ * A menu of what the schema says exists, rather than a form asking for a
262
+ * table and a path. Grouped by distance because "points straight at this" and
263
+ * "two references away" are different kinds of answer, and the first is what
264
+ * someone wants nine times in ten.
265
+ */
266
+ /**
267
+ * A group of fields: this record's own, or a referenced record's pulled in.
268
+ *
269
+ * The record view has always been able to show a to-one reference inline —
270
+ * the site a work order is at, laid out under the work order's own fields
271
+ * rather than one click away — and a page could express it too, because a
272
+ * field path may follow references. What was missing was the way to ask for
273
+ * it: `+ Fields` added the base table's columns and nothing else, so the one
274
+ * thing a page could do that a plain record view could not was the one thing
275
+ * the builder would not offer.
276
+ *
277
+ * Composite keys are spelled by constraint, single-column ones by their
278
+ * column — the same two spellings a path takes everywhere else, and the
279
+ * readable one wherever it is available.
280
+ */
281
+ /**
282
+ * Keep an upward-opening menu inside the viewport.
283
+ *
284
+ * The menu anchors above its button and grew a set of reference groups, so
285
+ * on a page with few sections — where the Add row sits high — its top rows
286
+ * ran off the screen and could not be clicked. Where the button sits depends
287
+ * on the page, so the clamp is measured when the menu opens, not styled.
288
+ */
289
+ function clampMenu(wrap, menu) {
290
+ wrap.addEventListener('toggle', () => {
291
+ if (!wrap.open) return;
292
+ const top = wrap.getBoundingClientRect().top;
293
+ menu.style.maxHeight = `${Math.min(352, Math.max(160, top - 16))}px`;
294
+ });
295
+ }
296
+
297
+ function addFields(draft, draw) {
298
+ const base = findTable(draft.base);
299
+ const outgoing = (base ? refsFrom(base.id) : [])
300
+ .filter((fk) => fk.to.table !== draft.base)
301
+ .map((fk) => ({ fk, target: findTable(fk.to.table) }))
302
+ .filter(({ target }) => target);
303
+
304
+ const push = (section) => { draft.sections.push(section); draw(); };
305
+
306
+ /* With nothing to reference there is no choice to make, and a menu with one
307
+ item is a button wearing a hat. */
308
+ if (!outgoing.length) {
309
+ return el('button', {
310
+ type: 'button', class: 'ghost', text: '+ Fields',
311
+ onclick: () => push({ kind: 'fields', width: 'full' }),
312
+ });
313
+ }
314
+
315
+ const menu = el('div', { class: 'pb-menu' });
316
+ const wrap = disclosure(el('details', { class: 'pb-picker' }, [
317
+ el('summary', { class: 'ghost pb-add-btn' }, '+ Fields'),
318
+ menu,
319
+ ]));
320
+ clampMenu(wrap, menu);
321
+
322
+ menu.append(el('div', { class: 'pb-group' }, [
323
+ el('h4', { class: 'pb-group-head', text: 'This record' }),
324
+ el('button', {
325
+ type: 'button', class: 'pb-menu-item',
326
+ onclick: () => { wrap.open = false; push({ kind: 'fields', width: 'full' }); },
327
+ }, [
328
+ el('span', { class: 'pb-menu-label', text: base?.name ?? draft.base }),
329
+ el('span', { class: 'pb-menu-detail', text: 'its own fields' }),
330
+ ]),
331
+ ]));
332
+
333
+ /* One item per referenced record, at either depth. The section a click
334
+ writes is the same shape both times: a prefix, and every column of the
335
+ table it lands on except the key the last hop used. */
336
+ const item = (prefix, viaText, target, lastFk) => el('button', {
337
+ type: 'button', class: 'pb-menu-item',
338
+ title: `${viaText} \u2192 ${target.name}`,
339
+ onclick: () => {
340
+ wrap.open = false;
341
+ push({
342
+ kind: 'fields',
343
+ title: `${target.name} \u00b7 via ${viaText}`,
344
+ /* Everything but the key it was reached by: the value is already
345
+ on the row above, and repeating it is a line of the form spent
346
+ saying what the heading said. */
347
+ paths: target.columns
348
+ .filter((c) => c.name !== lastFk.to.columns[0] || lastFk.to.columns.length > 1)
349
+ .map((c) => `${prefix}.${c.name}`),
350
+ width: 'full',
351
+ columns: 2,
352
+ });
353
+ },
354
+ }, [
355
+ el('span', { class: 'pb-menu-label', text: target.name }),
356
+ el('span', { class: 'pb-menu-detail', text: `via ${viaText}` }),
357
+ ]);
358
+
359
+ const hop = (fk) => (fk.from.columns.length === 1 ? fk.from.columns[0] : fk.name);
360
+
361
+ menu.append(el('div', { class: 'pb-group' }, [
362
+ el('h4', { class: 'pb-group-head', text: 'A record it points at' }),
363
+ ...outgoing.map(({ fk, target }) => item(hop(fk), fk.from.columns.join(', '), target, fk)),
364
+ ]));
365
+
366
+ /* And one reference further: the address of the site, the party of the
367
+ manager. The paths could always say it — `site_id.address_id.city` is
368
+ legal in a config — and the menu simply never offered it. Self-references
369
+ and round trips back to a table already one hop away are left out; the
370
+ one-hop entry is the shorter spelling of the same record. */
371
+ const oneAway = new Set(outgoing.map(({ target }) => target.id));
372
+ const further = outgoing.flatMap(({ fk, target }) =>
373
+ refsFrom(target.id)
374
+ .filter((second) => second.to.table !== draft.base && second.to.table !== target.id && !oneAway.has(second.to.table))
375
+ .map((second) => ({ fk, second, far: findTable(second.to.table) }))
376
+ .filter(({ far }) => far));
377
+ if (further.length) {
378
+ menu.append(el('div', { class: 'pb-group' }, [
379
+ el('h4', { class: 'pb-group-head', text: 'Two references along' }),
380
+ ...further.map(({ fk, second, far }) => item(
381
+ `${hop(fk)}.${hop(second)}`,
382
+ `${fk.from.columns.join(', ')} \u2192 ${second.from.columns.join(', ')}`,
383
+ far,
384
+ second,
385
+ )),
386
+ ]));
387
+ }
388
+
389
+ return wrap;
390
+ }
391
+
392
+ function addRelation(label, kind, draft, relations, references, draw) {
393
+ const menu = el('div', { class: 'pb-menu' });
394
+ const wrap = disclosure(el('details', { class: 'pb-picker' }, [
395
+ el('summary', { class: 'ghost pb-add-btn' }, label),
396
+ menu,
397
+ ]));
398
+ clampMenu(wrap, menu);
399
+
400
+ if (!relations.length && !references.length) {
401
+ menu.append(el('p', { class: 'note', text: 'Nothing points at this table.' }));
402
+ return wrap;
403
+ }
404
+
405
+ /* What is already on the page, of this kind, is not on offer.
406
+
407
+ The menu listed every relationship whether or not the page already had a
408
+ list of it, so `+ List` on a page with three lists offered those same
409
+ three at the top — and a second identical section is almost never what
410
+ someone means by clicking Add.
411
+
412
+ Per kind, not per relationship: counting a thing and also listing it is a
413
+ reasonable page, and is exactly what someone building one by hand does. */
414
+ const used = new Set(
415
+ draft.sections.filter((s) => s.kind === kind).map((s) => `${s.of ?? ''}|${s.from}|${s.path}`),
416
+ );
417
+ const available = relations.filter((r) => !used.has(`|${r.from}|${r.path}`));
418
+ /* The same offer, one reference out: what points at each record the root
419
+ points at. "Tickets at its site" is a real section and was previously a
420
+ hand-written config or nothing. */
421
+ const farther = references
422
+ .map((ref) => ({
423
+ ...ref,
424
+ relations: ref.relations.filter((r) => !used.has(`${ref.of}|${r.from}|${r.path}`)),
425
+ }))
426
+ .filter((ref) => ref.relations.length);
427
+
428
+ if (!available.length && !farther.length) {
429
+ menu.append(el('p', {
430
+ class: 'note',
431
+ text: kind === 'list'
432
+ ? 'Every relationship is already listed on this page.'
433
+ : 'Every relationship is already counted on this page.',
434
+ }));
435
+ return wrap;
436
+ }
437
+
438
+ /* Grouped by distance, and named for every distance that turns up. It said
439
+ "Two references away" over everything past the first hop, which on a wide
440
+ schema is also where the three-hop routes are — a hundred entries under a
441
+ heading that was wrong for most of them. */
442
+ const DISTANCE = ['Points straight at this', 'One reference away', 'Two references away', 'Three references away'];
443
+ const byHops = [...new Set(available.map((r) => r.hops))].sort((a, b) => a - b);
444
+ const groups = byHops.map((hops) => [
445
+ hops === 1 ? DISTANCE[0] : DISTANCE[hops] ?? `${hops} references away`,
446
+ available.filter((r) => r.hops === hops),
447
+ ]);
448
+
449
+ /* A hundred relationships is a scroll, and the one being looked for has a
450
+ name. Shown only where the list is long enough to need it — a search box
451
+ over six entries is furniture. */
452
+ const rows = [];
453
+ if (available.length + farther.reduce((n, ref) => n + ref.relations.length, 0) > 12) {
454
+ const search = el('input', {
455
+ type: 'search',
456
+ class: 'pb-input pb-menu-search',
457
+ placeholder: 'Filter\u2026',
458
+ 'aria-label': 'Filter relationships',
459
+ autocomplete: 'off',
460
+ spellcheck: 'false',
461
+ });
462
+ search.addEventListener('input', () => {
463
+ const needle = search.value.trim().toLowerCase();
464
+ let shown = 0;
465
+ for (const { row, hay } of rows) {
466
+ const hit = !needle || hay.includes(needle);
467
+ row.hidden = !hit;
468
+ if (hit) shown += 1;
469
+ }
470
+ /* A heading with nothing under it reads as a category with no members
471
+ rather than as one that was filtered away. */
472
+ for (const group of menu.querySelectorAll('.pb-group')) {
473
+ group.hidden = ![...group.querySelectorAll('.pb-menu-item')].some((b) => !b.hidden);
474
+ }
475
+ empty.hidden = shown > 0;
476
+ });
477
+ /* Typed into rather than clicked through: the picker opens on a click on
478
+ its summary, and the caret should already be where the next keystroke
479
+ is going. */
480
+ wrap.addEventListener('toggle', () => { if (wrap.open) search.focus(); });
481
+ menu.append(search);
482
+ }
483
+ const empty = el('p', { class: 'note', text: 'Nothing matches.', hidden: true });
484
+
485
+ for (const [title, list] of groups) {
486
+ if (!list.length) continue;
487
+ menu.append(el('div', { class: 'pb-group' }, [
488
+ el('h4', { class: 'pb-group-head', text: title }),
489
+ ...list.map((relation) => el('button', {
490
+ type: 'button',
491
+ class: 'pb-menu-item',
492
+ // The route, so a choice between two `contract_line` entries is a
493
+ // choice between two things you can tell apart.
494
+ title: relation.via.join(' ← '),
495
+ onclick: () => {
496
+ wrap.open = false;
497
+ draft.sections.push(kind === 'metric'
498
+ ? {
499
+ kind: 'metric', fn: 'count', from: relation.from, path: relation.path,
500
+ title: relation.label, width: 'third',
501
+ }
502
+ : {
503
+ kind: 'list', from: relation.from, path: relation.path,
504
+ title: relation.label, limit: 10, width: 'half',
505
+ });
506
+ draw();
507
+ },
508
+ }, [
509
+ el('span', { class: 'pb-menu-label', text: relation.label }),
510
+ el('span', { class: 'pb-menu-detail', text: relation.via.join(' \u2190 ') }),
511
+ ])),
512
+ ]));
513
+ }
514
+ /* Sections about a record the root points at, grouped per reference so
515
+ "of its site" reads as the destination it is rather than as one more
516
+ distance. Picking one writes `of` onto the section; everything else —
517
+ planning, filters, the empty state — is the machinery above. */
518
+ for (const ref of farther) {
519
+ menu.append(el('div', { class: 'pb-group' }, [
520
+ el('h4', { class: 'pb-group-head', text: `Points at its ${ref.targetName} (via ${ref.via})` }),
521
+ ...ref.relations.map((relation) => el('button', {
522
+ type: 'button',
523
+ class: 'pb-menu-item',
524
+ title: `${relation.via.join(' \u2190 ')} \u2190 ${ref.targetName} of this record`,
525
+ onclick: () => {
526
+ wrap.open = false;
527
+ const title = `${relation.label} \u00b7 of its ${ref.targetName}`;
528
+ draft.sections.push(kind === 'metric'
529
+ ? {
530
+ kind: 'metric', fn: 'count', from: relation.from, path: relation.path,
531
+ of: ref.of, title, width: 'third',
532
+ }
533
+ : {
534
+ kind: 'list', from: relation.from, path: relation.path,
535
+ of: ref.of, title, limit: 10, width: 'half',
536
+ });
537
+ draw();
538
+ },
539
+ }, [
540
+ el('span', { class: 'pb-menu-label', text: relation.label }),
541
+ el('span', { class: 'pb-menu-detail', text: `of its ${ref.targetName}` }),
542
+ ])),
543
+ ]));
544
+ }
545
+ for (const button of menu.querySelectorAll('.pb-menu-item')) {
546
+ rows.push({ row: button, hay: button.textContent.toLowerCase() });
547
+ }
548
+ menu.append(empty);
549
+ return wrap;
550
+ }
551
+
552
+ /** The two lines a drop can be about to land on, cleared together. */
553
+ function clearDropMarks(row) {
554
+ row.classList.remove('pb-drop-above', 'pb-drop-below');
555
+ }
556
+
557
+ /* ---------- one section ---------- */
558
+
559
+ function sections(draft, relations, references, draw, open, setOpen) {
560
+ if (!draft.sections.length) {
561
+ return [el('p', { class: 'note', text: 'No sections yet. Add one below.' })];
562
+ }
563
+ return draft.sections.map((section, i) =>
564
+ sectionRow(draft, section, i, relations, references, draw, open, setOpen));
565
+ }
566
+
567
+ /**
568
+ * The relation a section came from, searched in the right pool.
569
+ *
570
+ * A section with `of` is about a record the root points at, so its relation
571
+ * lives under that reference — matching it against the root's own relations
572
+ * would either find nothing or, worse, find a same-named path that resolves
573
+ * somewhere else entirely.
574
+ */
575
+ function relationBehind(section, relations, references) {
576
+ const pool = section.of
577
+ ? references.find((ref) => ref.of === section.of)?.relations ?? []
578
+ : relations;
579
+ return pool.find(
580
+ (r) => r.from === section.from && (r.path === section.path || r.aliases?.includes(section.path)),
581
+ );
582
+ }
583
+
584
+ function sectionRow(draft, section, index, relations, references, draw, open, setOpen) {
585
+ const move = (to) => {
586
+ if (to < 0 || to >= draft.sections.length) return;
587
+ const [moved] = draft.sections.splice(index, 1);
588
+ draft.sections.splice(to, 0, moved);
589
+ draw();
590
+ };
591
+
592
+ /* The order of these rows is the order of the page, so dragging one is the
593
+ most direct way to say where a section goes — the arrows are two clicks
594
+ each and four rows of travel. The grip rather than the whole row,
595
+ because the row is mostly inputs and dropdowns: a draggable row swallows
596
+ the click that puts a caret in the title. `draggable` is switched on
597
+ only while the pointer is on the grip, and off again the moment the drag
598
+ ends, so the inputs keep their own behaviour the rest of the time. */
599
+ const grip = el('span', {
600
+ class: 'pb-section-grip',
601
+ title: 'Drag to reorder',
602
+ 'aria-hidden': 'true',
603
+ text: '\u2237',
604
+ onmousedown: () => { row.draggable = true; },
605
+ onmouseup: () => { row.draggable = false; },
606
+ });
607
+
608
+ const controls = [
609
+ el('input', {
610
+ class: 'pb-input pb-title-input',
611
+ value: section.title ?? '',
612
+ placeholder: section.kind === 'fields' ? 'Untitled' : section.from,
613
+ 'aria-label': 'Section title',
614
+ oninput: (e) => { section.title = e.target.value || undefined; },
615
+ }),
616
+ /* Whose fields this group actually holds, said beside the title —
617
+ because the title is the author's and can say anything. A base-fields
618
+ group renamed "Country" showed every customer column under a heading
619
+ that promised the country's, and nothing on the row disagreed. */
620
+ section.kind === 'fields'
621
+ ? el('span', {
622
+ class: 'pb-holds',
623
+ text: `${(sharedPrefix(section.paths)
624
+ ? tableAtPrefix(draft.base, sharedPrefix(section.paths))?.name
625
+ : findTable(draft.base)?.name) ?? draft.base}'s fields`,
626
+ })
627
+ : null,
628
+ ].filter(Boolean);
629
+
630
+ /* One button for both kinds, built here so the panel below can relabel it
631
+ as boxes are ticked. Without that the count in the panel and the count on
632
+ the button disagreed until something else redrew the form. */
633
+ let relabel = null;
634
+
635
+ if (section.kind !== 'metric') {
636
+ const isFields = section.kind === 'fields';
637
+ // Matched on any spelling of the path, in the pool `of` says to search.
638
+ const relation = isFields ? null : relationBehind(section, relations, references);
639
+ /* For a group of fields that follows a reference, "all" is the *other*
640
+ table's columns — a site group reading "4 of 9" was counting against
641
+ the employee the page is about. Same rule the arranger uses, read off
642
+ the paths rather than stored twice. */
643
+ const fieldsPrefix = isFields ? sharedPrefix(section.paths) : '';
644
+ const fieldsTable = isFields
645
+ ? (fieldsPrefix ? tableAtPrefix(draft.base, fieldsPrefix) : findTable(draft.base))
646
+ : null;
647
+ const all = isFields
648
+ ? fieldsTable?.columns.map((c) => c.name) ?? []
649
+ : relation?.all ?? findTable(section.from)?.columns.map((c) => c.name) ?? [];
650
+
651
+ /* A record section says `all N`, because that is what no selection means
652
+ there. A list says `default`, because there it means the columns
653
+ tablewalk picks — not a fixed list, so saying "6 of 13" would claim a
654
+ choice nobody made. */
655
+ const label = () => {
656
+ const chosen = isFields ? section.paths : section.columns;
657
+ if (chosen?.length) return `${chosen.length} of ${all.length}`;
658
+ return isFields ? `all ${all.length}` : 'default';
659
+ };
660
+
661
+ const button = el('button', {
662
+ type: 'button',
663
+ class: `ghost pb-fields-btn${open === index ? ' open' : ''}`,
664
+ title: isFields
665
+ ? 'Choose which fields this section shows'
666
+ : 'Choose which columns this list shows',
667
+ 'aria-expanded': String(open === index),
668
+ text: label(),
669
+ /* Opens in place rather than in a dialog of its own. A modal on top of
670
+ a modal buries the thing being edited — you lose sight of the section
671
+ you are configuring at the moment you are configuring it — and the
672
+ second has to be dismissed before the first can be touched again. */
673
+ onclick: () => { setOpen(open === index ? -1 : index); },
674
+ });
675
+ relabel = () => { button.textContent = label(); };
676
+ controls.push(button);
677
+ }
678
+
679
+ if (section.kind === 'list') {
680
+ const table = findTable(section.from);
681
+ const columns = table?.columns.map((c) => c.name) ?? [];
682
+ const current = section.orderBy?.[0];
683
+ controls.push(dropdown({
684
+ ariaLabel: 'Sort by',
685
+ value: current ? `${current.column}:${current.direction}` : '',
686
+ items: [
687
+ { value: '', label: 'default order', detail: 'whatever the table gives back' },
688
+ ...columns.flatMap((c) => [
689
+ { value: `${c}:asc`, label: `${c} ↑`, detail: 'lowest first' },
690
+ { value: `${c}:desc`, label: `${c} ↓`, detail: 'highest first' },
691
+ ]),
692
+ ],
693
+ onChange: (v) => {
694
+ if (!v) { section.orderBy = undefined; return; }
695
+ const [column, direction] = v.split(':');
696
+ section.orderBy = [{ column, direction }];
697
+ },
698
+ }));
699
+ }
700
+
701
+ if (section.kind === 'metric') {
702
+ // Matched on any spelling of the path, in the pool `of` says to search.
703
+ const relation = relationBehind(section, relations, references);
704
+ const numeric = relation?.columns ?? [];
705
+
706
+ controls.push(dropdown({
707
+ ariaLabel: 'Function',
708
+ value: section.fn,
709
+ items: ['count', 'sum', 'avg', 'min', 'max'].map((fn) => ({
710
+ value: fn,
711
+ label: fn,
712
+ /* Said where the choice is made rather than discovered on save: only
713
+ `count` reaches past one reference, and only `count` can be
714
+ narrowed by a filter. */
715
+ detail: fn === 'count'
716
+ ? 'rows, at any distance, and can be filtered'
717
+ : `${fn} of a column, one reference away`,
718
+ disabled: fn !== 'count' && numeric.length === 0,
719
+ })),
720
+ onChange: (fn) => {
721
+ section.fn = fn;
722
+ /* `count` takes no column, and leaving a stale one set would save a
723
+ page whose JSON says `count of total` — legal, ignored, and
724
+ confusing to whoever reads the file next. */
725
+ if (fn === 'count') section.column = undefined;
726
+ draw();
727
+ },
728
+ }));
729
+
730
+ if (section.fn !== 'count') {
731
+ controls.push(dropdown({
732
+ ariaLabel: 'Column',
733
+ value: section.column ?? '',
734
+ items: [
735
+ { value: '', label: numeric.length ? 'column…' : 'no number columns' },
736
+ ...numeric.map((c) => ({ value: c, label: c })),
737
+ ],
738
+ onChange: (c) => { section.column = c || undefined; },
739
+ }));
740
+ }
741
+ }
742
+
743
+ if (section.kind === 'list') {
744
+ controls.push(el('input', {
745
+ class: 'pb-input pb-limit',
746
+ type: 'number', min: '1', max: '100',
747
+ value: String(section.limit ?? 10),
748
+ 'aria-label': 'Rows',
749
+ oninput: (e) => { section.limit = Number(e.target.value) || undefined; },
750
+ }));
751
+ }
752
+
753
+ if (section.kind === 'fields') {
754
+ /* How the fields inside this group are laid out, which is a different
755
+ question from how wide the group is: `half` is the group's share of the
756
+ page, `2` is the number of columns the fields fill inside it. */
757
+ controls.push(el('div', { class: 'pb-cols' }, dropdown({
758
+ ariaLabel: 'Field columns',
759
+ value: String(section.columns ?? ''),
760
+ items: [
761
+ { value: '', label: 'fit', detail: 'as many as the width allows' },
762
+ ...FIELD_COLUMNS.map((n) => ({
763
+ value: String(n),
764
+ label: `${n} col`,
765
+ detail: n === 1 ? 'one under another' : `${n} across, filled left to right`,
766
+ })),
767
+ ],
768
+ onChange: (v) => { section.columns = v ? Number(v) : undefined; draw(); },
769
+ })));
770
+ }
771
+
772
+ const WIDTH_MEANS = {
773
+ full: 'a row to itself',
774
+ half: 'two across',
775
+ third: 'three across',
776
+ };
777
+ const OFFSET_SHORT = { quarter: '\u00bc', third: '\u2153', half: '\u00bd', 'two-thirds': '\u2154' };
778
+ controls.push(el('div', { class: 'pb-width' }, dropdown({
779
+ ariaLabel: 'Width',
780
+ value: section.width ?? 'full',
781
+ items: WIDTHS.map((w) => ({ value: w, label: w, detail: WIDTH_MEANS[w] })),
782
+ onChange: (w) => { section.width = w; },
783
+ })));
784
+
785
+ /* Where the section sits, not just how wide it is: empty space to either
786
+ side, in the same fractions the widths come in. Two controls rather than
787
+ a matrix of combinations, because "half width, quarter each side" is
788
+ three independent choices and should read as three. */
789
+ const OFFSET_MEANS = {
790
+ quarter: 'a quarter of the row',
791
+ third: 'a third of the row',
792
+ half: 'half the row',
793
+ 'two-thirds': 'two thirds of the row',
794
+ };
795
+ const offsetControl = (side, label, key) => el('div', { class: 'pb-width pb-offset' }, dropdown({
796
+ ariaLabel: `${side} offset`,
797
+ value: section[key] ?? '',
798
+ items: [
799
+ { value: '', label: `${label} 0`, detail: 'flush with the row' },
800
+ ...OFFSETS.map((o) => ({
801
+ value: o,
802
+ label: `${label} ${OFFSET_SHORT[o]}`,
803
+ detail: `${OFFSET_MEANS[o]} of empty space`,
804
+ })),
805
+ ],
806
+ onChange: (o) => { section[key] = o || undefined; },
807
+ }));
808
+ controls.push(offsetControl('Left', '\u21e5', 'offsetLeft'));
809
+ controls.push(offsetControl('Right', '\u21e4', 'offsetRight'));
810
+
811
+ const row = el('div', {
812
+ class: `pb-section pb-${section.kind}${open === index ? ' pb-open' : ''}${section.hidden ? ' pb-hidden' : ''}`,
813
+ /* A closed group of fields is a drop target for a field being dragged
814
+ out of an open one. The `\u2192` menu on each field row remains — it is
815
+ the keyboard's way of doing this — the drag is the direct version of
816
+ the same move. */
817
+ ondragstart: (e) => {
818
+ sectionDrag = index;
819
+ row.classList.add('pb-dragging');
820
+ e.dataTransfer.effectAllowed = 'move';
821
+ /* Firefox refuses to start a drag with nothing on the transfer, and
822
+ the payload is never read — the index above is the state. */
823
+ e.dataTransfer.setData('text/plain', String(index));
824
+ /* Stops the row from also being read as a field drop target while it
825
+ is the thing being dragged. */
826
+ e.stopPropagation();
827
+ },
828
+ ondragend: () => {
829
+ sectionDrag = null;
830
+ row.draggable = false;
831
+ row.classList.remove('pb-dragging');
832
+ clearDropMarks(row);
833
+ },
834
+ ondragover: (e) => {
835
+ /* A section being carried takes precedence: both drags land on the
836
+ same rows, and only one of them can be happening. */
837
+ if (sectionDrag !== null) {
838
+ if (sectionDrag === index) return;
839
+ e.preventDefault();
840
+ e.dataTransfer.dropEffect = 'move';
841
+ /* Above or below, decided by which half of the row the pointer is
842
+ in — so a drop lands where the line is drawn rather than where the
843
+ row happens to start. */
844
+ const box = row.getBoundingClientRect();
845
+ const below = e.clientY > box.top + box.height / 2;
846
+ row.classList.toggle('pb-drop-above', !below);
847
+ row.classList.toggle('pb-drop-below', below);
848
+ return;
849
+ }
850
+ const drag = carriedField();
851
+ if (section.kind !== 'fields' || !drag || drag.owner === section) return;
852
+ e.preventDefault();
853
+ e.dataTransfer.dropEffect = 'move';
854
+ row.classList.add('pb-drop');
855
+ },
856
+ ondragleave: () => {
857
+ row.classList.remove('pb-drop');
858
+ clearDropMarks(row);
859
+ },
860
+ ondrop: (e) => {
861
+ if (sectionDrag !== null) {
862
+ e.preventDefault();
863
+ clearDropMarks(row);
864
+ const box = row.getBoundingClientRect();
865
+ const below = e.clientY > box.top + box.height / 2;
866
+ const from = sectionDrag;
867
+ sectionDrag = null;
868
+ /* Insert-before semantics, corrected for the row that is about to
869
+ leave the list: dropping row 1 below row 3 means index 3, not 4. */
870
+ let to = below ? index + 1 : index;
871
+ if (from < to) to -= 1;
872
+ if (to === from) { draw(); return; }
873
+ const [moved] = draft.sections.splice(from, 1);
874
+ draft.sections.splice(to, 0, moved);
875
+ draw();
876
+ return;
877
+ }
878
+ if (section.kind !== 'fields') return;
879
+ e.preventDefault();
880
+ row.classList.remove('pb-drop');
881
+ dropFieldInto(draft, section, draw);
882
+ },
883
+ }, [
884
+ grip,
885
+ el('span', { class: 'pb-kind', text: section.kind }),
886
+ ...controls,
887
+ el('div', { class: 'pb-section-actions' }, [
888
+ /* Hidden, not removed. Removing throws away the columns, the order and
889
+ the filter that were chosen for this section; hiding keeps all of it
890
+ and stops drawing it, which is what "not on this page for now"
891
+ means. The row stays in the builder, dimmed, so the page can be got
892
+ back with one click rather than rebuilt. */
893
+ el('button', {
894
+ type: 'button',
895
+ class: 'ghost pb-hide',
896
+ text: section.hidden ? '\u25cc' : '\u25cf',
897
+ title: section.hidden ? 'Hidden — click to show' : 'Shown — click to hide',
898
+ 'aria-label': section.hidden ? 'Show this section' : 'Hide this section',
899
+ 'aria-pressed': String(Boolean(section.hidden)),
900
+ onclick: () => { section.hidden = section.hidden ? undefined : true; draw(); },
901
+ }),
902
+ el('button', {
903
+ type: 'button', class: 'ghost', text: '↑', title: 'Move up',
904
+ 'aria-label': 'Move up', disabled: index === 0,
905
+ onclick: () => move(index - 1),
906
+ }),
907
+ el('button', {
908
+ type: 'button', class: 'ghost', text: '↓', title: 'Move down',
909
+ 'aria-label': 'Move down', disabled: index === draft.sections.length - 1,
910
+ onclick: () => move(index + 1),
911
+ }),
912
+ el('button', {
913
+ type: 'button', class: 'ghost pb-remove', text: '×', title: 'Remove',
914
+ 'aria-label': 'Remove section',
915
+ onclick: () => { draft.sections.splice(index, 1); draw(); },
916
+ }),
917
+ ]),
918
+ ]);
919
+
920
+ if (section.kind === 'metric' || open !== index) return row;
921
+ return el('div', { class: 'pb-group-row' }, [
922
+ row,
923
+ panelFor(draft, section, relations, draw, relabel),
924
+ ]);
925
+ }
926
+
927
+ /**
928
+ * Which columns a section shows, chosen in place.
929
+ *
930
+ * One picker for both kinds. A record section chooses among the page's own
931
+ * table; a list chooses among the table it lists — the same question about a
932
+ * different table, and two implementations of it would be two chances for
933
+ * them to disagree about what "all" means.
934
+ *
935
+ * A grid rather than a list: forty column names in one column is a scroll,
936
+ * and the names are short enough to read three or four across. Each row is a
937
+ * label, so the whole line toggles — a checkbox is a twelve-pixel target and
938
+ * the name beside it is not.
939
+ */
940
+ function columnPicker({ title, all, chosen, isDefault, onChange, draw }) {
941
+ const picked = new Set(chosen);
942
+
943
+ const boxes = all.map((name) => el('label', { class: 'pb-field-row' }, [
944
+ el('input', {
945
+ type: 'checkbox',
946
+ checked: picked.has(name),
947
+ onchange: (e) => {
948
+ if (e.target.checked) picked.add(name);
949
+ else picked.delete(name);
950
+ commit();
951
+ },
952
+ }),
953
+ el('span', { class: 'pb-field-name', text: name }),
954
+ ]));
955
+
956
+ const count = el('span', { class: 'pb-field-count' });
957
+ const label = () => `${picked.size} of ${all.length}`;
958
+
959
+ function commit() {
960
+ onChange(all.filter((n) => picked.has(n)));
961
+ count.textContent = label();
962
+ }
963
+
964
+ const setAll = (on) => {
965
+ picked.clear();
966
+ if (on) for (const n of all) picked.add(n);
967
+ for (const box of boxes) box.querySelector('input').checked = on;
968
+ commit();
969
+ draw();
970
+ };
971
+
972
+ count.textContent = label();
973
+
974
+ return el('div', { class: 'pb-panel' }, [
975
+ el('div', { class: 'pb-panel-head' }, [
976
+ el('span', { class: 'pb-label', text: title }),
977
+ count,
978
+ el('span', { class: 'pb-spacer' }),
979
+ /* Only where there is one to go back to. A record section's "all" is
980
+ every column; a list's default is a chosen handful, and losing it by
981
+ clicking around would mean rebuilding it by hand. */
982
+ isDefault ? el('button', {
983
+ type: 'button', class: 'ghost', text: 'Default',
984
+ title: 'The columns tablewalk would pick',
985
+ onclick: () => { isDefault(); draw(); },
986
+ }) : null,
987
+ el('button', { type: 'button', class: 'ghost', text: 'All', onclick: () => setAll(true) }),
988
+ el('button', { type: 'button', class: 'ghost', text: 'None', onclick: () => setAll(false) }),
989
+ ].filter(Boolean)),
990
+ el('div', { class: 'pb-field-grid' }, boxes),
991
+ ]);
992
+ }
993
+
994
+ /**
995
+ * The hop every one of these paths starts with, if they all start with one.
996
+ *
997
+ * One segment, not the longest common run: a group two references deep is a
998
+ * thing someone can write in the file and the builder does not offer, and
999
+ * treating `a.b.c` and `a.b.d` as a group about `a.b` would have the arranger
1000
+ * offer columns of a table the rest of the page cannot name.
1001
+ */
1002
+ /**
1003
+ * The hop every path in a group shares — `site_id`, or `site_id.address_id`
1004
+ * two references along — or '' when the group is mixed.
1005
+ *
1006
+ * Read as everything up to the last segment, at any depth, so a config that
1007
+ * writes `a.b.c` gets the same treatment as one that writes `a.b`: the
1008
+ * arranger offers the right table's columns and the page drops the prefix
1009
+ * from every label. This deliberately handled one hop for a while; the depth
1010
+ * limit was in the reading, not in anything the paths could not say.
1011
+ */
1012
+ export function sharedPrefix(paths) {
1013
+ if (!paths?.length) return '';
1014
+ const first = paths[0];
1015
+ if (!first.includes('.')) return '';
1016
+ const prefix = first.slice(0, first.lastIndexOf('.'));
1017
+ return paths.every(
1018
+ (p) => p.startsWith(`${prefix}.`) && !p.slice(prefix.length + 1).includes('.'),
1019
+ ) ? prefix : '';
1020
+ }
1021
+
1022
+ /* The section being carried, while it is being carried.
1023
+ Module-level for the same reason the arranger's carried field is: the row
1024
+ under the pointer and the row being dragged are two closures that never
1025
+ meet, and the drag is one gesture across both. */
1026
+ let sectionDrag = null;
1027
+
1028
+ /**
1029
+ * A fields section's current paths, materialised.
1030
+ *
1031
+ * `undefined` means every column of the page's base in table order — the
1032
+ * *target's* default. It was read as the source arranger's `all` for a
1033
+ * while, which is one prefix away from being a different table's columns:
1034
+ * sending `site_id.code` out of a site group into an untouched base group
1035
+ * rewrote the base group as the site's fields.
1036
+ */
1037
+ function fieldsOf(draft, target) {
1038
+ return target.paths ?? (findTable(draft.base)?.columns.map((c) => c.name) ?? []);
1039
+ }
1040
+
1041
+ /**
1042
+ * Put a field into a group, once.
1043
+ *
1044
+ * A group still on its default holds every base column already, so blindly
1045
+ * appending would list the field twice — once where it always was and once
1046
+ * where it just landed.
1047
+ */
1048
+ function receiveField(draft, target, name) {
1049
+ const into = fieldsOf(draft, target);
1050
+ target.paths = into.includes(name) ? into : [...into, name];
1051
+ }
1052
+
1053
+ /** Move a dragged field into `target`, keeping both groups' invariants. */
1054
+ function dropFieldInto(draft, target, draw) {
1055
+ dropCarried(target, (name) => receiveField(draft, target, name), draw);
1056
+ }
1057
+
1058
+ /**
1059
+ * The fields on this page section, in the order they are laid out.
1060
+ *
1061
+ * The interaction is `arrange.js`; this is only the translation between a
1062
+ * page section and what the arranger asks for. Two things are page-specific
1063
+ * and stay here: which table the group's fields come from, read off the
1064
+ * shared prefix of its paths, and the storage rule — `undefined` means "every
1065
+ * field in the table's own order", so a *reordered* "all" has to be written
1066
+ * out or the arrangement is thrown away on save.
1067
+ */
1068
+ function fieldArranger(draft, section, draw, relabel) {
1069
+ const base = findTable(draft.base);
1070
+ if (!base) return el('div');
1071
+
1072
+ /* Which table this group is about.
1073
+
1074
+ A group whose paths all start with the same hop is a *referenced* record
1075
+ pulled inline — `site_id.code`, `site_id.name` — and the fields on offer
1076
+ there are the site's, not the work order's. Read off the paths rather
1077
+ than stored beside them, because the paths are the thing that has to stay
1078
+ true: a group that says it is about `site` and holds a path that is not
1079
+ would be two facts and one of them wrong. */
1080
+ const prefix = sharedPrefix(section.paths);
1081
+ const on = (prefix ? tableAtPrefix(draft.base, prefix) : base) ?? base;
1082
+ const qualify = (name) => (prefix ? `${prefix}.${name}` : name);
1083
+
1084
+ const all = on.columns.map((c) => qualify(c.name));
1085
+ const types = new Map(on.columns.map((c) => [qualify(c.name), c.type]));
1086
+
1087
+ return fieldArrangerFor({
1088
+ all,
1089
+ /* `undefined` means every field, in table order. The moment anything is
1090
+ arranged that has to become an explicit list, or the arrangement has
1091
+ nowhere to live. */
1092
+ chosen: section.paths ?? all,
1093
+ columns: section.columns ?? 1,
1094
+ noun: 'page',
1095
+ heading: 'Fields',
1096
+ /* Shown without the hop that every one of them shares: the heading
1097
+ already says which record this is, and repeating `site_id.` on every
1098
+ line is the same eleven characters twelve times. */
1099
+ label: (path) => (prefix && path.startsWith(`${prefix}.`) ? path.slice(prefix.length + 1) : path),
1100
+ typeOf: (path) => types.get(path) ?? '',
1101
+ owner: section,
1102
+ onChange: (next) => {
1103
+ /* Stored as no selection only when it is genuinely the default — every
1104
+ field, in the table's own order. A reordered "all" is a choice, and
1105
+ writing it back as `undefined` would throw the arrangement away on
1106
+ save. */
1107
+ const isDefault = next.length === all.length && next.every((n, i) => n === all[i]);
1108
+ section.paths = isDefault ? undefined : next;
1109
+ relabel?.();
1110
+ },
1111
+ /* Every other group of fields on this page, for sending a field to one. */
1112
+ others: draft.sections
1113
+ .map((s, i) => ({ s, i }))
1114
+ .filter(({ s }) => s.kind === 'fields' && s !== section)
1115
+ .map(({ s, i }) => ({
1116
+ title: s.title || `group ${i + 1}`,
1117
+ receive: (name) => receiveField(draft, s, name),
1118
+ })),
1119
+ onSend: draw,
1120
+ });
1121
+ }
1122
+
1123
+ /** The picker for whichever kind of section is open. */
1124
+ function panelFor(draft, section, relations, draw, relabel) {
1125
+ if (section.kind === 'fields') return fieldArranger(draft, section, draw, relabel);
1126
+
1127
+ /* Matched on any spelling of the path: a hand-written config names a hop
1128
+ by its column and this list names it by its constraint, and both resolve
1129
+ to the same relationship. */
1130
+ const relation = relations.find(
1131
+ (r) => r.from === section.from && (r.path === section.path || r.aliases?.includes(section.path)),
1132
+ );
1133
+ const all = relation?.all ?? findTable(section.from)?.columns.map((c) => c.name) ?? [];
1134
+ const defaults = relation?.defaults ?? [];
1135
+ return columnPicker({
1136
+ title: 'Columns',
1137
+ all,
1138
+ chosen: section.columns ?? defaults,
1139
+ /* Undefined means "whatever tablewalk would pick", which is not the same
1140
+ as the list those columns happen to be today — a page that said
1141
+ `default` keeps working when the table gains a better one. */
1142
+ isDefault: () => { section.columns = undefined; relabel?.(); },
1143
+ onChange: (kept) => { section.columns = kept; relabel?.(); },
1144
+ draw,
1145
+ });
1146
+ }
1147
+
1148
+ /* ---------- taking it out ---------- */
1149
+
1150
+ /**
1151
+ * The page as it would appear in tablewalk.json.
1152
+ *
1153
+ * The same shape the parser reads, not a description of it: a builder that
1154
+ * emitted a near-miss would be a builder whose output someone has to correct
1155
+ * before it works, which is worse than no button. `id` and `source` are left
1156
+ * out because the file gives its own.
1157
+ */
1158
+ export function asConfig(page) {
1159
+ return JSON.stringify({
1160
+ pages: [{
1161
+ name: page.name,
1162
+ base: page.base,
1163
+ sections: page.sections.map((s) => {
1164
+ const out = { kind: s.kind };
1165
+ if (s.title) out.title = s.title;
1166
+ if (s.kind === 'metric') {
1167
+ out.fn = s.fn;
1168
+ out.from = s.from;
1169
+ out.path = s.path;
1170
+ if (s.column) out.column = s.column;
1171
+ }
1172
+ if (s.kind === 'list') {
1173
+ out.from = s.from;
1174
+ out.path = s.path;
1175
+ if (s.limit) out.limit = s.limit;
1176
+ if (s.orderBy) out.orderBy = s.orderBy;
1177
+ }
1178
+ if (s.filter) out.filter = s.filter;
1179
+ if (s.width && s.width !== 'full') out.width = s.width;
1180
+ return out;
1181
+ }),
1182
+ }],
1183
+ }, null, 2);
1184
+ }
1185
+
1186
+ async function copyJson(page) {
1187
+ const text = asConfig(page);
1188
+ try {
1189
+ await navigator.clipboard.writeText(text);
1190
+ toast('Copied. Paste it into your tablewalk.json to share it.', 'ok');
1191
+ } catch {
1192
+ /* Clipboard access can be refused, and the text is the point — a prompt
1193
+ someone can select from is a worse offer than the clipboard and a much
1194
+ better one than nothing. */
1195
+ await promptFor({
1196
+ title: 'Copy this into tablewalk.json',
1197
+ label: 'Page',
1198
+ value: text,
1199
+ multiline: true,
1200
+ });
1201
+ }
1202
+ }
1203
+
1204
+ /** Start a page from scratch for a table. */
1205
+ export async function newPage(tableId, { onSaved } = {}) {
1206
+ const table = findTable(tableId);
1207
+ if (!table) return;
1208
+ const name = await promptFor({
1209
+ title: 'New page',
1210
+ label: 'Name',
1211
+ value: `${table.name}`,
1212
+ placeholder: 'e.g. Customer 360',
1213
+ });
1214
+ if (!name) return;
1215
+ await openBuilder({
1216
+ id: `saved:${table.id}:${Date.now()}`,
1217
+ name,
1218
+ base: table.id,
1219
+ sections: [{ kind: 'fields', width: 'full' }],
1220
+ source: 'saved',
1221
+ }, { onSaved: onSaved ?? ((page) => go(pageView(page.id, page.base), 'replace')) });
1222
+ }