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