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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- 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
|
+
}
|