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