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,707 @@
1
+ /**
2
+ * The record view: one row, its fields, and everything that points at it.
3
+ *
4
+ * The two panels are fetched together because they are independent, and
5
+ * waiting for the reverse-reference counts to show the field list would make
6
+ * every record open feel slower than it is.
7
+ */
8
+ import {
9
+ api, el, findTable, go, keyFilter, keyOf, labelPath, loadJson, pageView, primaryKey, quote,
10
+ refsTo, renderCrumbs, rowLabel, rowView, saveJson, setExplain, state, tableView, toast,
11
+ } from './core.js';
12
+ import { copyPosition, positionOf } from './handoff.js';
13
+ import { syncHash } from './url.js';
14
+ import { refJump, valueNode } from './value.js';
15
+ import { pagesFor } from './page.js';
16
+ import { canEditRow, editable, pendingBar, whyNotEditable } from './write.js';
17
+ import { deleteButton } from './delete.js';
18
+ import { renderGrid } from './table.js';
19
+ import { activeDraft, editLayoutButton, fieldPathsOf, hasSavedTemplate, listSectionsOf, templateFor } from './template.js';
20
+ import { placeholder, recordSkeleton, relatedSkeleton } from './skeleton.js';
21
+
22
+ /** This record's page, with this record already chosen as its root. */
23
+ async function openAsPage(entry) {
24
+ let pageId = `suggested:${entry.table}`;
25
+ try {
26
+ const [configured] = await pagesFor(entry.table);
27
+ if (configured && !configured.error) pageId = configured.id;
28
+ } catch {
29
+ // A config that cannot be read still leaves the suggested page.
30
+ }
31
+ go(pageView(pageId, entry.table, entry.key, entry.label), 'push');
32
+ }
33
+
34
+ export async function renderRow(entry, content) {
35
+ const table = findTable(entry.table);
36
+
37
+ /* Whether anything *can* point here is a fact about the schema, known
38
+ before any request — so a table with nothing pointing at it costs no
39
+ round trip to discover that. */
40
+ const canBeReferenced = refsTo(entry.table).length > 0;
41
+ const grid = el('div', { class: canBeReferenced ? 'record' : 'record record-solo' });
42
+ content.append(grid);
43
+
44
+ const heading = () => el('h3', { class: 'record-heading' }, [
45
+ el('span', { text: `${entry.table} · ${entry.label}` }),
46
+ /* The record you are on, as a page — the same root, everything around it
47
+ at once. This is the shortest useful path into the feature: you are
48
+ already looking at the customer, and one click adds the counts and the
49
+ lists without choosing a root or writing any config. */
50
+ el('button', {
51
+ type: 'button',
52
+ class: 'ghost record-as-page',
53
+ title: 'Everything about this record on one page',
54
+ text: 'As a page \u2192',
55
+ onclick: () => void openAsPage(entry),
56
+ }),
57
+ el('button', {
58
+ type: 'button',
59
+ class: 'ghost',
60
+ title: 'The row\u2019s key, its shape and how to read it over MCP \u2014 not its values',
61
+ text: 'Copy for agent',
62
+ onclick: () => void copyPosition(
63
+ positionOf(state, entry, { record: { key: entry.key, label: entry.label } }),
64
+ toast,
65
+ ),
66
+ }),
67
+ editLayoutButton(entry.table),
68
+ // Only where the server would accept it; see canDelete.
69
+ deleteButton(table, entry.key, entry.label),
70
+ ].filter(Boolean));
71
+
72
+ const fieldsPanel = el('div', { class: 'panel' }, heading());
73
+ grid.append(fieldsPanel);
74
+
75
+ /* The placeholder is built from the layout this record is about to be drawn
76
+ with, so the headings and the two-column field grid are already where they
77
+ will be. The page does not jump when the row lands; it fills in. */
78
+ const template = activeDraft(entry.table) ?? templateFor(entry.table);
79
+ const doneFields = placeholder(fieldsPanel, () =>
80
+ recordSkeleton(
81
+ template.sections
82
+ .filter((s) => s.kind === 'fields' && !s.off)
83
+ .map((s) => ({ title: s.title, count: s.paths.length }))
84
+ .slice(0, 5),
85
+ ));
86
+
87
+ /* The relationship index lives in its own column, always visible.
88
+
89
+ It was a horizontal strip under the record for a while, which put the
90
+ list of what relates to this row below a screenful of fields — so
91
+ finding out what a record connects to required scrolling past the record.
92
+ A column keeps it in view, and a vertical list has room for a dozen
93
+ relationships where a tab strip wraps. */
94
+ const relatedPanel = canBeReferenced
95
+ ? el('div', { class: 'panel related-panel' }, el('h3', { text: 'Related' }))
96
+ : null;
97
+ if (relatedPanel) grid.append(relatedPanel);
98
+ const doneRelated = relatedPanel
99
+ ? placeholder(relatedPanel, () => relatedSkeleton(refsTo(entry.table).length))
100
+ : () => {};
101
+
102
+
103
+ const filter = keyFilter(entry.key);
104
+
105
+ /* Both sides are fetched together: the field list and the reverse-reference
106
+ counts are independent, and waiting for the second to show the first
107
+ would make every record open feel slower than it is. */
108
+ /* The record runs as a one-row view rather than a plain query.
109
+
110
+ That is what lets a field section hold `customer_id.name` — the value
111
+ lives on another table, and only the view compiler knows how to reach it.
112
+ It also means a record page and a list view are the same machinery
113
+ pointed at different questions, rather than two renderers that will
114
+ disagree about what a joined column is called. */
115
+ // `template` is resolved above, before the placeholder, because the
116
+ // placeholder is built from it. The in-progress draft wins while the builder
117
+ // is open, so the record on screen is the layout being edited rather than
118
+ // the last one saved.
119
+ const paths = fieldPathsOf(template);
120
+ /* The row's name is fetched whether or not the layout shows it.
121
+
122
+ For a table whose name lives one hop away — employee, named by
123
+ `party_id.display_name` — a layout that dropped that field would also
124
+ silently rename every heading and crumb back to the key. The label is a
125
+ property of the row, not of the layout, so it rides along hidden, the
126
+ same way a child row's key does. */
127
+ const namePath = labelPath(table);
128
+ const fetched = namePath && !paths.includes(namePath) ? [...paths, namePath] : paths;
129
+ const [rowResult, refResult] = await Promise.allSettled([
130
+ api('/api/view/run', {
131
+ view: {
132
+ id: `record:${entry.table}`,
133
+ name: entry.table,
134
+ base: entry.table,
135
+ columns: fetched.map((path) => ({ path })),
136
+ filter: { groups: [Object.entries(entry.key).map(([path, value]) => ({ path, op: '=', value }))] },
137
+ limit: 1,
138
+ },
139
+ }),
140
+ canBeReferenced ? api('/api/references', { table: entry.table, key: entry.key }) : Promise.resolve(null),
141
+ ]);
142
+
143
+ doneFields();
144
+ if (rowResult.status === 'rejected' || !rowResult.value.rows.length) {
145
+ fieldsPanel.replaceChildren(el('h3', { text: entry.table }), el('p', { class: 'note', text: 'That row is no longer there.' }));
146
+ } else {
147
+ /* Filtered, because replaceChildren stringifies a null argument into the
148
+ text "null" — unlike el(), which skips it. The pending bar is absent
149
+ most of the time, so this printed a stray "null" above every row. */
150
+ fieldsPanel.replaceChildren(
151
+ ...[
152
+ heading(),
153
+ pendingBar(table, entry.key, (opts) => (opts?.inPlace ? updatePendingBar(table, entry) : go({ ...entry }, 'replace'))),
154
+ fieldList(table, rowResult.value.rows[0], entry, template, rowResult.value.resolved),
155
+ ].filter(Boolean),
156
+ );
157
+ setExplain(rowResult.value.sql.text);
158
+
159
+ /* A row reached by a link knows only its key, so its crumb reads "1002".
160
+ Now that the row is here, the readable label is, too. */
161
+ const readable = rowLabel(table, rowResult.value.rows[0]);
162
+ if (readable && readable !== entry.label) {
163
+ entry.label = readable;
164
+ /* Only the label text, not the heading's contents. Setting
165
+ `textContent` on the whole heading replaced the Layout button with a
166
+ string — so a row reached by key, which is every row reached from a
167
+ link, lost the control the moment its name arrived. */
168
+ const label = fieldsPanel.querySelector('.record-heading > span');
169
+ if (label) label.textContent = `${entry.table} · ${readable}`;
170
+ renderCrumbs();
171
+ }
172
+ }
173
+
174
+ /* The children, as tables on the page.
175
+
176
+ The Referenced-by panel beside this is the index — counts, and a way to
177
+ jump. These are the data. A work order that says "5 parts" and makes you
178
+ click to see them is answering a question nobody asked; the reason you
179
+ opened the record was usually to read them.
180
+
181
+ One query per relationship, keyed on this row — not one per child row,
182
+ and not a join, which would multiply the record away. */
183
+ void renderChildTables(
184
+ entry,
185
+ fieldsPanel,
186
+ relatedPanel,
187
+ template,
188
+ refResult.status === 'fulfilled' ? refResult.value : null,
189
+ ).finally(doneRelated);
190
+
191
+ }
192
+
193
+ /* The column grouping that used to live here — a third client copy of
194
+ groupOwnColumns from shared/recordview.ts, with its own hand-written date
195
+ test — had no callers left once the record started rendering from its
196
+ template. A mirror nothing runs still drifts, and is still read as though
197
+ it were the rule; the one copy now lives in template.js. */
198
+
199
+ function fieldList(table, row, entry, template, resolved) {
200
+ const editing = canEditRow(table, entry.key);
201
+ const blocked = whyNotEditable(table);
202
+ /* Typing must not re-render the whole panel — that would destroy the input
203
+ mid-keystroke and lose the caret. Only the pending bar is refreshed. */
204
+ const refresh = () => updatePendingBar(table, entry);
205
+ const wrap = el('div', { class: 'field-groups' });
206
+
207
+ /* The view runner names a joined column by its path, so the row is keyed
208
+ that way too. Indexed rather than searched, because a record with six
209
+ reference blocks would otherwise scan the column list once per field. */
210
+ const byPath = new Map((resolved ?? []).map((c) => [c.path, c]));
211
+
212
+ const sections = template.sections.filter((s) => s.kind === 'fields' && !s.off);
213
+ for (const section of sections) {
214
+ const list = el('dl', { class: 'fields' });
215
+
216
+ /* Labels are the last segment of a path — see `columnForPath` — which is
217
+ right while a block holds one table's fields and wrong the moment it
218
+ holds two. A section mixing `name` with `country_code.name` labelled
219
+ both of them "name", side by side, with nothing to say which was the
220
+ customer's. The builder does not produce that; a layout written by hand
221
+ easily does, and it is the first thing anyone seeds.
222
+
223
+ So the qualifier comes back only where it is doing work: a label that
224
+ occurs once is left alone, and one that collides is spelled out to the
225
+ hop that distinguishes it. */
226
+ const labels = section.paths.map((path) => columnForPath(table, path, byPath)?.label);
227
+ const collides = new Set(labels.filter((l, i) => l && labels.indexOf(l) !== i));
228
+
229
+ for (const path of section.paths) {
230
+ const resolvedMeta = columnForPath(table, path, byPath);
231
+ if (!resolvedMeta) continue;
232
+ const meta = collides.has(resolvedMeta.label)
233
+ ? { ...resolvedMeta, label: path }
234
+ : resolvedMeta;
235
+ const value = row[byPath.get(path)?.name ?? path];
236
+ /* Only the record's own columns are editable. `customer_id.name` lives
237
+ on another table, and letting it be typed here would write to a row
238
+ the page is not showing — which is the kind of surprise that makes a
239
+ write feature untrustworthy. */
240
+ list.append(fieldRow(table, { [meta.name]: value }, entry, meta, editing && !path.includes('.'), refresh));
241
+ }
242
+ if (!list.childElementCount) continue;
243
+
244
+ if (!section.title) {
245
+ wrap.append(el('section', { class: 'field-group field-group-plain' }, list));
246
+ continue;
247
+ }
248
+ wrap.append(el('section', { class: 'field-group' }, [
249
+ el('button', {
250
+ class: 'field-group-head',
251
+ type: 'button',
252
+ 'aria-expanded': 'true',
253
+ onclick: (e) => {
254
+ const parent = e.currentTarget.parentElement;
255
+ const open = parent.classList.toggle('collapsed');
256
+ e.currentTarget.setAttribute('aria-expanded', String(!open));
257
+ },
258
+ }, [
259
+ el('span', { class: 'field-group-caret', 'aria-hidden': 'true', text: '▾' }),
260
+ el('span', { text: section.title }),
261
+ el('span', { class: 'field-group-count', text: String(list.childElementCount) }),
262
+ ]),
263
+ list,
264
+ ]));
265
+ }
266
+
267
+ if (blocked) wrap.append(el('p', { class: 'note', text: blocked }));
268
+ return wrap;
269
+ }
270
+
271
+ /**
272
+ * The column metadata behind a path.
273
+ *
274
+ * For the record's own column that is just the column; for a pulled-in one
275
+ * the compiler already resolved the type and nullability, and using its
276
+ * answer keeps the record and the grid agreeing about what a joined value is.
277
+ */
278
+ function columnForPath(table, path, byPath) {
279
+ if (!path.includes('.')) return table?.columns.find((c) => c.name === path);
280
+ const resolved = byPath.get(path);
281
+ if (!resolved) return null;
282
+ return {
283
+ name: resolved.name,
284
+ /* Labelled by the last segment. The block's heading already says which
285
+ table these came from, so repeating `customer_id.` on every row inside
286
+ it is the qualifier stated twice — and it pushes the actual column name
287
+ out of a narrow label column. */
288
+ label: path.slice(path.lastIndexOf('.') + 1),
289
+ type: resolved.type,
290
+ nullable: resolved.nullable,
291
+ primaryKey: false,
292
+ };
293
+ }
294
+
295
+ /** One field: its name, its type, and whatever the value is best shown as. */
296
+ function fieldRow(table, row, entry, col, canEdit, refresh) {
297
+ const value = row[col.name];
298
+ const isNull = value === null || value === undefined;
299
+ // The key identifies the row being edited and cannot be changed from under
300
+ // it, whatever the caller allows.
301
+ canEdit = canEdit && !col.primaryKey;
302
+
303
+ const dt = el('dt', {}, [
304
+ document.createTextNode(col.label ?? col.name),
305
+ el('span', { class: 'type', text: col.type.toLowerCase() }),
306
+ col.primaryKey ? el('span', { class: 'pk', text: 'key' }) : null,
307
+ /* NOT NULL is only shown while editing. On a filled field it says
308
+ nothing — the value is right there — and marking two thirds of a wide
309
+ record "required" is noise that makes the useful markers harder to
310
+ see. It becomes actionable the moment you can empty the field. */
311
+ !col.nullable && canEdit ? el('span', { class: 'req', title: 'NOT NULL', text: 'required' }) : null,
312
+ ]);
313
+
314
+ let dd;
315
+ if (canEdit && (isNull || !col.references)) {
316
+ dd = el('dd', {}, editable(table, row, col, entry.key, refresh));
317
+ } else if (isNull) {
318
+ dd = el('dd', { class: 'null', text: 'null' });
319
+ } else if (col.references && String(value) !== '') {
320
+ // A foreign key is the forward walk: jump to the row it points at.
321
+ dd = el('dd', {}, refJump(col.references, value));
322
+ } else {
323
+ dd = el('dd', {}, valueNode(col, value));
324
+ }
325
+
326
+ return el('div', { class: `field${col.references ? ' field-ref' : ''}` }, [dt, dd]);
327
+ }
328
+
329
+ /**
330
+ * Swap the pending-changes bar in place.
331
+ *
332
+ * Deliberately not a re-render: redrawing the field list while someone is
333
+ * typing in it replaces the focused input and throws away the caret position.
334
+ */
335
+ function updatePendingBar(table, entry) {
336
+ const panel = document.querySelector('.record .panel');
337
+ if (!panel) return;
338
+ const existing = panel.querySelector('.pending');
339
+ const next = pendingBar(table, entry.key, (opts) => (opts?.inPlace ? updatePendingBar(table, entry) : go({ ...entry }, 'replace')));
340
+ if (existing && next) existing.replaceWith(next);
341
+ else if (existing) existing.remove();
342
+ else if (next) panel.querySelector('h3')?.after(next);
343
+ }
344
+
345
+ /** Rows to show inline before the section defers to its own page. */
346
+ const INLINE_ROWS = 10;
347
+
348
+ /**
349
+ * Render each incoming relationship as a table under the record.
350
+ *
351
+ * Deliberately after the fields and asynchronously: the record itself must
352
+ * not wait on a relationship that is slow, and a page that renders in two
353
+ * stages is better than one that renders late.
354
+ */
355
+ /**
356
+ * Which relationship was last looked at, per table.
357
+ *
358
+ * Kept for the session so walking from one work order to the next lands on
359
+ * the same tab. Someone comparing the parts of six orders should not have to
360
+ * reselect parts six times.
361
+ */
362
+ /**
363
+ * Whether empty relationships are folded away, per connection.
364
+ *
365
+ * Per connection rather than per table or per session: how many of a
366
+ * record's relationships are empty is a property of the schema — a wide,
367
+ * well-normalised one leaves most of them empty for most records — so the
368
+ * answer someone gives on one table is almost always the answer they want on
369
+ * the next. Defaults to showing everything, because a rail that quietly left
370
+ * things out would be a rail nobody could trust to be the whole list.
371
+ */
372
+ const EMPTIES_KEY = 'tablewalk.related.hideEmpty';
373
+
374
+ const hidingEmpty = () => Boolean(loadJson(EMPTIES_KEY, {})[state.activeConnection ?? '']);
375
+
376
+ function setHidingEmpty(on) {
377
+ const all = loadJson(EMPTIES_KEY, {});
378
+ all[state.activeConnection ?? ''] = on;
379
+ saveJson(EMPTIES_KEY, all);
380
+ }
381
+
382
+ const lastTab = new Map();
383
+
384
+ async function renderChildTables(entry, fieldsPanel, relatedPanel, template, references) {
385
+ /* Every path out of here settles the Related panel.
386
+
387
+ It opens on "Counting…", and a return that left it there turned a record
388
+ with no related tables into one that looks like it is still loading —
389
+ forever. Saying "nothing" is an answer; a spinner that never stops is
390
+ not. */
391
+ const settle = (message) => {
392
+ if (!relatedPanel) return;
393
+ relatedPanel.replaceChildren(
394
+ el('h3', { text: 'Related' }),
395
+ el('p', { class: 'note', text: message }),
396
+ );
397
+ };
398
+
399
+ const sections = listSectionsOf(template);
400
+ if (!sections.length) {
401
+ settle(
402
+ refsTo(entry.table).length
403
+ ? 'This layout has no related tables. Add one under Layout.'
404
+ : 'Nothing points at this row.',
405
+ );
406
+ return;
407
+ }
408
+
409
+ const host = el('div', { class: 'record-children' });
410
+ fieldsPanel.append(host);
411
+
412
+ const results = await Promise.allSettled(sections.map(async (section) => {
413
+ const fk = state.schema.foreignKeys.find((f) => f.name === section.via);
414
+ if (!fk) return null;
415
+ const child = findTable(fk.from.table);
416
+ if (!child) return null;
417
+
418
+ /* Every column of the key has to match, which is what makes this correct
419
+ for a composite key rather than only for a single-column one. */
420
+ const conditions = fk.to.columns.map((parentColumn, i) => ({
421
+ column: fk.from.columns[i] ?? fk.from.columns[0],
422
+ op: '=',
423
+ value: entry.key[parentColumn],
424
+ }));
425
+ if (conditions.some((c) => c.value === undefined || c.value === null)) return null;
426
+
427
+ /* The joining column is dropped: every row here carries the same value
428
+ for it, so a column of it is a column of one repeated value. */
429
+ const columns = (section.columns ?? []).filter((name) => child.columns.some((c) => c.name === name));
430
+ const limit = section.limit ?? INLINE_ROWS;
431
+
432
+ /* The key is fetched whether or not it is shown.
433
+
434
+ A summary drops the primary key on purpose — a column of ids says
435
+ nothing about what distinguishes the rows. But the key is what a row
436
+ click navigates by, and a row that arrives without one produces a link
437
+ to `~invoice:undefined`: a dead end reached by clicking something that
438
+ looked live. So it is requested and then hidden, rather than not
439
+ requested. */
440
+ const key = primaryKey(child);
441
+ const fetched = columns.length ? [...new Set([...key, ...columns])] : undefined;
442
+
443
+ const fetch = async (offset) => {
444
+ const data = await api('/api/query', {
445
+ table: child.id,
446
+ filter: { groups: [conditions] },
447
+ columns: fetched,
448
+ limit,
449
+ offset,
450
+ });
451
+ /* Ordered by the layout, not by the query.
452
+
453
+ The key is fetched and hidden, so the returned column list is not the
454
+ chosen one — it had to be filtered. Filtering `data.columns` kept the
455
+ *database's* order, which was invisible while the only decision a
456
+ layout could make was which columns; the arranger can order them now,
457
+ and an arrangement that does nothing is worse than no arrangement. */
458
+ return columns.length
459
+ ? { ...data, columns: columns.filter((name) => data.columns.includes(name)) }
460
+ : data;
461
+ };
462
+ return { fk, child, data: await fetch(0), section, limit, fetch };
463
+ }));
464
+
465
+ /* Empty relationships stay, as disabled tabs.
466
+
467
+ This used to be the Referenced-by panel's job: "work_order via
468
+ supersedes_id — 0" is a real answer, and hiding it turns "nothing
469
+ supersedes this" into "this tool does not track that". Keeping them here
470
+ is what let the panel go, rather than saying the same thing twice in two
471
+ places. */
472
+ const rendered = results
473
+ .map((r) => (r.status === 'fulfilled' ? r.value : null))
474
+ .filter(Boolean);
475
+
476
+ if (!rendered.length || !relatedPanel) {
477
+ host.remove();
478
+ settle('Nothing points at this row.');
479
+ return;
480
+ }
481
+
482
+ /* Tabs rather than a stack.
483
+
484
+ Four relationships stacked push the record's own fields off the top of
485
+ the window, so reading the thing you navigated to means scrolling past
486
+ everything related to it. Only one of them is being read at a time
487
+ anyway; the rest are a menu, and a menu should be one row high. */
488
+ const counts = new Map(
489
+ (references?.references ?? []).map((r) => [r.constraint, r.count]),
490
+ );
491
+
492
+ /* Ranked by how much there is to read — but only when nobody has arranged
493
+ them. A derived layout lists relationships in schema order, which on a
494
+ wide table buries `stock_movement 7` under a run of zeroes that happen
495
+ to sort earlier; the rows someone came for should be at the top. A saved
496
+ layout's order is its author's, and stays exactly as written. */
497
+ if (!activeDraft(entry.table) && !hasSavedTemplate(entry.table)) {
498
+ const weight = (r) => counts.get(r.fk.name) ?? r.data.rows.length;
499
+ rendered.sort((a, b) => weight(b) - weight(a));
500
+ }
501
+
502
+ const hasRows = (r) => r.data.rows.length > 0;
503
+ /* A link's choice wins over this session's, since it is the more specific
504
+ statement: someone opened *this* link to see *that* relationship. */
505
+ const chosen = entry.tab ?? lastTab.get(entry.table);
506
+ let activeIndex = rendered.findIndex((r) => r.fk.name === chosen && hasRows(r));
507
+ // Opens on something worth reading rather than on an empty table that
508
+ // happens to sort first.
509
+ if (activeIndex < 0) activeIndex = rendered.findIndex(hasRows);
510
+ if (activeIndex < 0) activeIndex = 0;
511
+
512
+ const strip = el('div', { class: 'related-list', role: 'tablist', 'aria-orientation': 'vertical' });
513
+ const panel = el('div', { class: 'child-panel' });
514
+
515
+ /* Where each relationship has been paged to.
516
+
517
+ Kept per relationship rather than one shared page number, so switching
518
+ to another table and back returns to the page you were reading instead
519
+ of silently resetting to the first ten rows. */
520
+ const page = new Map();
521
+
522
+ const show = async (index, { scroll = false, chosen: byHand = false } = {}) => {
523
+ if (!hasRows(rendered[index])) return;
524
+ activeIndex = index;
525
+ lastTab.set(entry.table, rendered[index].fk.name);
526
+ /* Into the address bar when someone *picked* this table — "look at its
527
+ notes" is as worth sending on a record as it is on a page — and not
528
+ when the view opened on it by default. A default is not a choice, and
529
+ writing it would change the link of every record the moment it drew,
530
+ so two identical visits would produce two different URLs. */
531
+ if (byHand) {
532
+ entry.tab = rendered[index].fk.name;
533
+ syncHash();
534
+ }
535
+ [...strip.children].forEach((tab, i) => tab.setAttribute('aria-selected', String(i === index)));
536
+
537
+ const { fk, child, section, limit, fetch } = rendered[index];
538
+ const offset = page.get(fk.name) ?? 0;
539
+ const total = counts.get(fk.name);
540
+
541
+ /* Page one is already in hand from the initial load; later pages are
542
+ fetched. Re-fetching page one on every tab switch would make going
543
+ back to a table you just read cost a round trip to show what was
544
+ already on screen. */
545
+ const data = offset === 0 ? rendered[index].data : await fetch(offset);
546
+
547
+ const openAll = () => {
548
+ const conditions = fk.from.columns
549
+ .map((col, i) => `${col} = ${quote(entry.key[fk.to.columns[i]])}`)
550
+ .join(' and ');
551
+ go(tableView(child.id, `${child.id} ${conditions}`));
552
+ };
553
+
554
+ const turn = (delta) => {
555
+ page.set(fk.name, Math.max(0, offset + delta * limit));
556
+ void show(index, { scroll: true });
557
+ };
558
+
559
+ /* The last page is knowable when the count is: `offset + limit >= total`
560
+ means there is nothing after this. Without a count — a relationship
561
+ whose count query failed — a short page is the signal instead. */
562
+ const more = total === undefined ? data.rows.length === limit : offset + limit < total;
563
+ const paged = offset > 0 || more;
564
+
565
+ panel.replaceChildren(...[
566
+ el('div', { class: 'record-child-head' }, [
567
+ el('h3', { text: child.name }),
568
+ el('span', { class: 'record-child-via', text: `via ${fk.from.columns.join(', ')}` }),
569
+ // Shown only when there is more than fits, so the control appears
570
+ // exactly when it is the answer to a question the reader now has.
571
+ (total ?? data.rows.length) > data.rows.length
572
+ ? el('button', {
573
+ type: 'button', class: 'ghost',
574
+ text: `See all ${total ? total.toLocaleString() : ''} \u2192`.replace(' ', ' '),
575
+ onclick: openAll,
576
+ })
577
+ : null,
578
+ ].filter(Boolean)),
579
+ renderGrid(child, data, { onRowClick: (row) => openChild(child, row) }),
580
+ /* Paging here rather than only through "See all".
581
+
582
+ A relationship with ninety-four rows shows ten of them, and the
583
+ other eighty-four used to be reachable only by leaving the record
584
+ for a table view — which answers the question in a place that has
585
+ lost the context the question was asked in. */
586
+ paged
587
+ ? el('div', { class: 'child-pager' }, [
588
+ el('span', {
589
+ class: 'child-range',
590
+ text: total
591
+ ? `${(offset + 1).toLocaleString()}\u2013${Math.min(offset + data.rows.length, total).toLocaleString()} of ${total.toLocaleString()}`
592
+ : `${(offset + 1).toLocaleString()}\u2013${(offset + data.rows.length).toLocaleString()}`,
593
+ }),
594
+ el('button', {
595
+ type: 'button', class: 'ghost', text: '\u2190 Prev',
596
+ disabled: offset === 0, onclick: () => turn(-1),
597
+ }),
598
+ el('button', {
599
+ type: 'button', class: 'ghost', text: 'Next \u2192',
600
+ disabled: !more, onclick: () => turn(1),
601
+ }),
602
+ ])
603
+ : null,
604
+ ].filter(Boolean));
605
+
606
+ /* Chosen from the sidebar, the table you chose should be the thing you
607
+ are looking at. On a long record the list sits beside the fields and
608
+ the table it controls is a screen further down, so picking one used to
609
+ change something off-screen and look like nothing happened.
610
+
611
+ Only on an explicit choice — scrolling the page on first render would
612
+ move a record the reader had not asked to move. */
613
+ if (scroll) panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
614
+ };
615
+
616
+ /* A table reachable two ways needs two distinguishable tabs. work_order
617
+ points at itself through parent_id and supersedes_id, and two tabs both
618
+ reading "work_order" are a coin flip — the distinction only has to be
619
+ drawn where it exists, so a table appearing once keeps its plain name. */
620
+ const repeated = new Set(
621
+ rendered
622
+ .map((r) => r.child.id)
623
+ .filter((id, i, all) => all.indexOf(id) !== i),
624
+ );
625
+
626
+ /* How many of these have nothing in them, which is the number worth putting
627
+ on the control that hides them. On a wide schema most of a record's
628
+ relationships are empty — eighteen entries, seven of them zero — and the
629
+ ones with rows are what somebody came to read. */
630
+ let emptyCount = 0;
631
+
632
+ rendered.forEach(({ fk, child, section, data }, index) => {
633
+ const total = counts.get(fk.name) ?? data.rows.length;
634
+ const label = section.title
635
+ ?? (repeated.has(child.id) ? `${child.name} · ${fk.from.columns.join(', ')}` : child.name);
636
+ const empty = data.rows.length === 0;
637
+ if (empty) emptyCount += 1;
638
+ strip.append(el('button', {
639
+ type: 'button',
640
+ class: `related-item${empty ? ' empty' : ''}`,
641
+ role: 'tab',
642
+ disabled: empty,
643
+ 'aria-selected': String(index === activeIndex),
644
+ title: `${child.name} via ${fk.from.columns.join(', ')}`,
645
+ onclick: () => show(index, { scroll: true, chosen: true }),
646
+ }, [
647
+ el('span', { class: 'related-count', text: total.toLocaleString() }),
648
+ el('span', { class: 'related-names' }, [
649
+ el('span', { class: 'related-table', text: label }),
650
+ // The relationship, not just the table: work_order appears twice
651
+ // here, once through parent_id and once through supersedes_id.
652
+ el('span', { class: 'related-via', text: `via ${fk.from.columns.join(', ')}` }),
653
+ ]),
654
+ ]));
655
+ });
656
+
657
+ /* Folded away rather than dropped.
658
+
659
+ An empty relationship is a real answer — "nothing points here through
660
+ this" is worth being able to check — so it is never removed, only put
661
+ behind a control that says how many there are. The choice is remembered
662
+ per connection, because it is about the shape of that database rather
663
+ than about this record. */
664
+ const applyEmpty = () => {
665
+ strip.classList.toggle('hide-empty', hidingEmpty());
666
+ toggle.textContent = hidingEmpty()
667
+ ? `show ${emptyCount} empty`
668
+ : `hide ${emptyCount} empty`;
669
+ toggle.setAttribute('aria-pressed', String(hidingEmpty()));
670
+ };
671
+
672
+ const toggle = el('button', {
673
+ type: 'button',
674
+ class: 'ghost related-empties',
675
+ title: 'Relationships with no rows',
676
+ onclick: () => {
677
+ setHidingEmpty(!hidingEmpty());
678
+ applyEmpty();
679
+ },
680
+ });
681
+
682
+ const head = el('div', { class: 'related-head' }, [
683
+ el('h3', { text: 'Related' }),
684
+ /* Only when there is something to hide, and only when hiding it leaves
685
+ something behind: a rail of nothing but empties is better shown than
686
+ collapsed to a single button explaining that it is empty. */
687
+ emptyCount && emptyCount < rendered.length ? toggle : null,
688
+ ].filter(Boolean));
689
+
690
+ if (emptyCount && emptyCount < rendered.length) applyEmpty();
691
+
692
+ relatedPanel.replaceChildren(head, strip);
693
+ host.append(panel);
694
+ void show(activeIndex);
695
+ }
696
+
697
+ /**
698
+ * Open a row of an embedded child table.
699
+ *
700
+ * The key comes from the row rather than from the section's shown columns,
701
+ * which is why the key is fetched even when it is hidden.
702
+ */
703
+ function openChild(child, row) {
704
+ const key = keyOf(child, row);
705
+ if (!key) return;
706
+ go(rowView(child.id, key, rowLabel(child, row)));
707
+ }