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,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record templates on the client: storage, and the builder.
|
|
3
|
+
*
|
|
4
|
+
* A template says how a record of one table is laid out — which of its own
|
|
5
|
+
* fields show and in what blocks, which references get pulled in beside them,
|
|
6
|
+
* and which child tables appear underneath. It is bound to the *table*, so
|
|
7
|
+
* configuring `work_order` once lays out every work order.
|
|
8
|
+
*
|
|
9
|
+
* The default is derived rather than blank. A blank record page is a worse
|
|
10
|
+
* starting point than the layout the record view already produces, and the
|
|
11
|
+
* fastest way to explain what a template is is to show someone one made of
|
|
12
|
+
* their own data — the builder opens on a working layout they can subtract
|
|
13
|
+
* from, rather than an empty canvas they have to fill.
|
|
14
|
+
*
|
|
15
|
+
* The shared model lives in src/shared/recordview.ts and is TypeScript, which
|
|
16
|
+
* the browser cannot load, so the derivation is mirrored here. It is a mirror
|
|
17
|
+
* with a test on the other side rather than a second source of truth: the
|
|
18
|
+
* grouping rules and thresholds are asserted there.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
api, el, findTable, labelColumn, loadJson, looksLikeDateColumn, primaryKey,
|
|
22
|
+
refsFrom, refsTo, render, saveJson, state, tableAtPrefix, toast,
|
|
23
|
+
} from './core.js';
|
|
24
|
+
import { promptFor } from './prompt.js';
|
|
25
|
+
import { fieldArranger } from './arrange.js';
|
|
26
|
+
|
|
27
|
+
const STORE = 'tablewalk.templates.v1';
|
|
28
|
+
|
|
29
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
30
|
+
|
|
31
|
+
/* ---------- storage ----------
|
|
32
|
+
|
|
33
|
+
Layouts live on the server, in a file the user can read and copy between
|
|
34
|
+
machines, and are mirrored into localStorage on the way past.
|
|
35
|
+
|
|
36
|
+
The mirror is not a cache for speed — it is what makes rendering
|
|
37
|
+
synchronous. A record is drawn from its layout, and a layout that had to be
|
|
38
|
+
awaited would make every record either flash the derived layout first or
|
|
39
|
+
wait on a round trip to show fields the browser already had. So the server
|
|
40
|
+
copy is loaded once per connection and the mirror answers every read after
|
|
41
|
+
that.
|
|
42
|
+
|
|
43
|
+
It is also the fallback. If the server cannot write the file — a read-only
|
|
44
|
+
home directory, a permissions problem — the layout is still kept here and
|
|
45
|
+
the toast says which of the two happened, rather than reporting a success
|
|
46
|
+
that only half occurred. */
|
|
47
|
+
|
|
48
|
+
function allTemplates() {
|
|
49
|
+
return loadJson(STORE, {})[connectionKey()] ?? {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mirror(templates) {
|
|
53
|
+
const all = loadJson(STORE, {});
|
|
54
|
+
all[connectionKey()] = templates;
|
|
55
|
+
saveJson(STORE, all);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pull this connection's layouts from the server into the mirror.
|
|
60
|
+
*
|
|
61
|
+
* Called when a connection is opened. A failure is silent: the mirror already
|
|
62
|
+
* holds whatever this browser saved, and a tool that refuses to draw a record
|
|
63
|
+
* because a preferences request failed would be worse than one that draws it
|
|
64
|
+
* from the copy it has.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadTemplates() {
|
|
67
|
+
try {
|
|
68
|
+
const { layouts } = await api('/api/layouts');
|
|
69
|
+
if (layouts && typeof layouts === 'object') mirror(layouts);
|
|
70
|
+
} catch {
|
|
71
|
+
/* Server-side layouts unavailable; the mirror stands. */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeTemplate(tableId, template) {
|
|
76
|
+
const all = loadJson(STORE, {});
|
|
77
|
+
all[connectionKey()] = { ...(all[connectionKey()] ?? {}) };
|
|
78
|
+
if (template) all[connectionKey()][tableId] = template;
|
|
79
|
+
else delete all[connectionKey()][tableId];
|
|
80
|
+
const local = saveJson(STORE, all);
|
|
81
|
+
|
|
82
|
+
/* Sent after the local write, not before. The browser copy is what the next
|
|
83
|
+
render reads, so it has to be in place before the page redraws; the file
|
|
84
|
+
is what makes the layout outlive this browser. */
|
|
85
|
+
void api('/api/layouts', { table: tableId, layout: template ?? null })
|
|
86
|
+
.catch((err) => toast(err.message, 'error'));
|
|
87
|
+
|
|
88
|
+
if (!local) {
|
|
89
|
+
toast('Could not save the layout — this browser is not allowing local storage.', 'error');
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when this table's layout has been configured rather than derived. */
|
|
96
|
+
export const hasSavedTemplate = (tableId) => Boolean(allTemplates()[tableId]);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The template to render a record with: the saved one, or a derived one.
|
|
100
|
+
*
|
|
101
|
+
* Never null. Every table has a layout; the only question is whether anyone
|
|
102
|
+
* has changed it.
|
|
103
|
+
*/
|
|
104
|
+
export function templateFor(tableId) {
|
|
105
|
+
return allTemplates()[tableId] ?? deriveTemplate(tableId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ---------- derivation (mirrors shared/recordview.ts) ---------- */
|
|
109
|
+
|
|
110
|
+
const GROUP_THRESHOLD = 12;
|
|
111
|
+
const AUDIT_NAME = /^(sys_|created|updated|modified|deleted|inserted)|_(at|by)$|^(version|etag|revision)$/i;
|
|
112
|
+
/* Mirrors DEFAULT_LIST_LIMIT in shared/recordview.ts, which is the source of
|
|
113
|
+
truth and says ten. This copy said eight — the two drifted, and the drift
|
|
114
|
+
was invisible because each side looked right on its own. */
|
|
115
|
+
const DEFAULT_LIST_LIMIT = 10;
|
|
116
|
+
|
|
117
|
+
/* The date test comes from core.js rather than being written out again here.
|
|
118
|
+
The copy that used to live at this spot had a narrower name pattern than
|
|
119
|
+
shared/schema.ts — no `datetime` or `timestamp` — so `event_timestamp TEXT`
|
|
120
|
+
landed in Details on a wide record while the server's grouping put it under
|
|
121
|
+
Dates, and the same record was laid out two different ways depending on
|
|
122
|
+
which side derived it. */
|
|
123
|
+
export function groupOwnColumns(table) {
|
|
124
|
+
const columns = table.columns;
|
|
125
|
+
if (columns.length < GROUP_THRESHOLD) return [[null, columns]];
|
|
126
|
+
|
|
127
|
+
const label = labelColumn(table);
|
|
128
|
+
const groups = new Map([
|
|
129
|
+
['Identity', []], ['Bookkeeping', []], ['References', []], ['Details', []], ['Dates', []],
|
|
130
|
+
]);
|
|
131
|
+
for (const col of columns) {
|
|
132
|
+
if (col.primaryKey || col.name === label) groups.get('Identity').push(col);
|
|
133
|
+
else if (col.references) groups.get('References').push(col);
|
|
134
|
+
else if (AUDIT_NAME.test(col.name)) groups.get('Bookkeeping').push(col);
|
|
135
|
+
else if (looksLikeDateColumn(col)) groups.get('Dates').push(col);
|
|
136
|
+
else groups.get('Details').push(col);
|
|
137
|
+
}
|
|
138
|
+
return [...groups].filter(([, cols]) => cols.length);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A few columns that say what a row is, skipping keys and the join column. */
|
|
142
|
+
/** Mirrors listColumns in shared/recordview.ts. */
|
|
143
|
+
export function listColumns(table, exclude = []) {
|
|
144
|
+
const skip = new Set(exclude);
|
|
145
|
+
const chosen = table.columns.filter((c) => !skip.has(c.name)).map((c) => c.name);
|
|
146
|
+
return chosen.length ? chosen : primaryKey(table).slice(0, 1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function summaryColumns(table, exclude = [], max = 5) {
|
|
150
|
+
const skip = new Set(exclude);
|
|
151
|
+
const chosen = [];
|
|
152
|
+
for (const column of table.columns) {
|
|
153
|
+
if (skip.has(column.name)) continue;
|
|
154
|
+
if (column.primaryKey && table.columns.some((c) => !c.primaryKey && !skip.has(c.name))) continue;
|
|
155
|
+
chosen.push(column.name);
|
|
156
|
+
if (chosen.length >= max) break;
|
|
157
|
+
}
|
|
158
|
+
return chosen.length ? chosen : primaryKey(table).slice(0, 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function deriveTemplate(tableId) {
|
|
162
|
+
const table = findTable(tableId);
|
|
163
|
+
if (!table) return { id: `derived:${tableId}`, name: tableId, base: tableId, sections: [] };
|
|
164
|
+
|
|
165
|
+
const sections = groupOwnColumns(table).map(([title, columns]) => ({
|
|
166
|
+
kind: 'fields',
|
|
167
|
+
title: title ?? undefined,
|
|
168
|
+
paths: columns.map((c) => c.name),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
/* Reference blocks are derived but start switched off. Pulling every
|
|
172
|
+
related row's columns into every record by default would double the
|
|
173
|
+
length of a page nobody asked to change — the point is that they are one
|
|
174
|
+
click away and visibly available, not that they are all already there. */
|
|
175
|
+
for (const fk of refsFrom(tableId)) {
|
|
176
|
+
if (fk.to.table === tableId) continue;
|
|
177
|
+
const target = findTable(fk.to.table);
|
|
178
|
+
if (!target) continue;
|
|
179
|
+
const via = fk.from.columns.join('+');
|
|
180
|
+
sections.push({
|
|
181
|
+
kind: 'fields',
|
|
182
|
+
title: `${target.name} · via ${fk.from.columns.join(', ')}`,
|
|
183
|
+
paths: summaryColumns(target).map((c) => `${via}.${c}`),
|
|
184
|
+
off: true,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const fk of refsTo(tableId)) {
|
|
189
|
+
const child = findTable(fk.from.table);
|
|
190
|
+
if (!child) continue;
|
|
191
|
+
sections.push({
|
|
192
|
+
kind: 'list',
|
|
193
|
+
title: child.name,
|
|
194
|
+
via: fk.name,
|
|
195
|
+
// Every column except the ones being joined on. See listColumns in
|
|
196
|
+
// shared/recordview.ts for why a child table is not summarised.
|
|
197
|
+
columns: listColumns(child, fk.from.columns),
|
|
198
|
+
limit: DEFAULT_LIST_LIMIT,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { id: `derived:${tableId}`, name: table.name, base: tableId, sections };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Paths the base row must supply, across every enabled field section. */
|
|
206
|
+
export function fieldPathsOf(template) {
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const section of template.sections) {
|
|
209
|
+
if (section.kind !== 'fields' || section.off) continue;
|
|
210
|
+
for (const path of section.paths) seen.add(path);
|
|
211
|
+
}
|
|
212
|
+
return [...seen];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Enabled list sections, in order. */
|
|
216
|
+
export const listSectionsOf = (template) =>
|
|
217
|
+
template.sections.filter((s) => s.kind === 'list' && !s.off);
|
|
218
|
+
|
|
219
|
+
/* ---------- the builder ---------- */
|
|
220
|
+
|
|
221
|
+
let editing = null;
|
|
222
|
+
let editingTable = null;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The layout being edited, if the builder is open for this table.
|
|
226
|
+
*
|
|
227
|
+
* The record renders from this rather than from storage while the builder is
|
|
228
|
+
* open, which is what makes the preview live: a toggle changes the draft, the
|
|
229
|
+
* record re-renders, and you see the result before deciding to keep it. A
|
|
230
|
+
* builder that only shows its effect after saving asks you to commit to a
|
|
231
|
+
* change you have not seen.
|
|
232
|
+
*/
|
|
233
|
+
export const activeDraft = (tableId) => (editingTable === tableId ? editing : null);
|
|
234
|
+
|
|
235
|
+
export function openTemplateBuilder(tableId) {
|
|
236
|
+
const table = findTable(tableId);
|
|
237
|
+
if (!table) return;
|
|
238
|
+
// Edited on a copy, so Cancel is a real cancel rather than an undo the user
|
|
239
|
+
// has to perform themselves.
|
|
240
|
+
editing = structuredClone(templateFor(tableId));
|
|
241
|
+
editingTable = tableId;
|
|
242
|
+
document.body.classList.add('editing-layout');
|
|
243
|
+
// Escape closes it, the way every other dismissable surface here does. The
|
|
244
|
+
// listener is added on open and removed on close so a session that opens
|
|
245
|
+
// the builder twenty times does not accumulate twenty handlers.
|
|
246
|
+
document.addEventListener('keydown', onBuilderKey, true);
|
|
247
|
+
paintBuilder(tableId, table);
|
|
248
|
+
render();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function onBuilderKey(e) {
|
|
252
|
+
if (e.key !== 'Escape') return;
|
|
253
|
+
/* Not while a text field is being edited — Escape there means "undo this
|
|
254
|
+
entry", and closing the whole builder would throw away the rest of the
|
|
255
|
+
work to cancel one word. */
|
|
256
|
+
const tag = document.activeElement?.tagName;
|
|
257
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
|
258
|
+
document.activeElement.blur();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
close();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Redraw the builder and the record it is previewing, in that order. */
|
|
266
|
+
function applied(tableId, table) {
|
|
267
|
+
paintBuilder(tableId, table);
|
|
268
|
+
render();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The preview alone, at most once a frame's worth of gesture.
|
|
273
|
+
*
|
|
274
|
+
* The arranger commits on every step of a drag — that is what makes the list
|
|
275
|
+
* follow the pointer — and a record render re-runs the query behind it. Left
|
|
276
|
+
* undebounced, dragging one field down a block of twenty fired twenty
|
|
277
|
+
* fetches, and the preview lagged the thing it was previewing. */
|
|
278
|
+
let previewing = null;
|
|
279
|
+
function preview() {
|
|
280
|
+
clearTimeout(previewing);
|
|
281
|
+
previewing = setTimeout(() => { previewing = null; render(); }, 120);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function paintBuilder(tableId, table) {
|
|
285
|
+
document.querySelector('.tpl')?.remove();
|
|
286
|
+
|
|
287
|
+
const body = el('div', { class: 'tpl-body' },
|
|
288
|
+
editing.sections.map((section, index) => sectionCard(section, index, tableId, table)));
|
|
289
|
+
|
|
290
|
+
/* The layout has a name, and the name is editable here.
|
|
291
|
+
|
|
292
|
+
It was always called after the table, which was fine while a table had
|
|
293
|
+
exactly one layout and useless the moment you want two — "the one with
|
|
294
|
+
the finance fields" and "the one for the service desk" are different
|
|
295
|
+
things and calling them both `work_order` says so of neither. Naming it
|
|
296
|
+
is also the smallest step towards a table having more than one, which is
|
|
297
|
+
where this is going. */
|
|
298
|
+
const name = el('input', {
|
|
299
|
+
type: 'text',
|
|
300
|
+
class: 'tpl-name',
|
|
301
|
+
/* A layout nobody has named is the default layout, and says so. It was
|
|
302
|
+
seeded with the table's name, which named nothing — the eyebrow and
|
|
303
|
+
the "for …" beside it already say the table twice. */
|
|
304
|
+
value: editing.name ?? 'Default layout',
|
|
305
|
+
'aria-label': 'Layout name',
|
|
306
|
+
placeholder: 'Default layout',
|
|
307
|
+
spellcheck: 'false',
|
|
308
|
+
});
|
|
309
|
+
// Written straight onto the draft: a name typed and then not saved should
|
|
310
|
+
// be discarded with everything else Cancel discards.
|
|
311
|
+
name.addEventListener('input', () => { editing.name = name.value; });
|
|
312
|
+
|
|
313
|
+
const panel = el('div', { class: 'tpl' }, [
|
|
314
|
+
el('div', { class: 'tpl-head' }, [
|
|
315
|
+
el('div', { class: 'tpl-title' }, [
|
|
316
|
+
el('span', { class: 'tpl-eyebrow', text: 'Record layout' }),
|
|
317
|
+
name,
|
|
318
|
+
el('span', { class: 'tpl-for', text: `for ${table.name}` }),
|
|
319
|
+
]),
|
|
320
|
+
el('p', {
|
|
321
|
+
class: 'tpl-lede',
|
|
322
|
+
text: 'Applies to every record of this table. The record beside this updates as you change it.',
|
|
323
|
+
}),
|
|
324
|
+
el('div', { class: 'tpl-head-actions' }, [
|
|
325
|
+
el('button', {
|
|
326
|
+
type: 'button', class: 'ghost', text: 'Reset to default',
|
|
327
|
+
onclick: () => {
|
|
328
|
+
editing = deriveTemplate(tableId);
|
|
329
|
+
applied(tableId, table);
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: close }),
|
|
333
|
+
el('button', {
|
|
334
|
+
type: 'button', text: 'Save layout',
|
|
335
|
+
onclick: () => {
|
|
336
|
+
// A blank name is the default layout, not an empty heading.
|
|
337
|
+
editing.name = (editing.name ?? '').trim() || 'Default layout';
|
|
338
|
+
if (writeTemplate(tableId, editing)) {
|
|
339
|
+
toast(`Saved “${editing.name}” for ${table.name}.`, 'ok');
|
|
340
|
+
close();
|
|
341
|
+
render();
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
]),
|
|
346
|
+
]),
|
|
347
|
+
body,
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
document.body.append(panel);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function close() {
|
|
354
|
+
clearTimeout(previewing);
|
|
355
|
+
previewing = null;
|
|
356
|
+
document.querySelector('.tpl')?.remove();
|
|
357
|
+
editing = null;
|
|
358
|
+
editingTable = null;
|
|
359
|
+
document.body.classList.remove('editing-layout');
|
|
360
|
+
document.removeEventListener('keydown', onBuilderKey, true);
|
|
361
|
+
render();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function move(index, delta, tableId, table) {
|
|
365
|
+
const next = index + delta;
|
|
366
|
+
if (next < 0 || next >= editing.sections.length) return;
|
|
367
|
+
const [section] = editing.sections.splice(index, 1);
|
|
368
|
+
editing.sections.splice(next, 0, section);
|
|
369
|
+
applied(tableId, table);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function sectionCard(section, index, tableId, table) {
|
|
373
|
+
const isList = section.kind === 'list';
|
|
374
|
+
const child = isList ? childOf(section) : null;
|
|
375
|
+
|
|
376
|
+
return el('section', { class: `tpl-section${section.off ? ' off' : ''}` }, [
|
|
377
|
+
el('div', { class: 'tpl-section-head' }, [
|
|
378
|
+
el('label', { class: 'tpl-toggle' }, [
|
|
379
|
+
el('input', {
|
|
380
|
+
type: 'checkbox',
|
|
381
|
+
checked: !section.off,
|
|
382
|
+
onchange: (e) => {
|
|
383
|
+
section.off = !e.currentTarget.checked;
|
|
384
|
+
applied(tableId, table);
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
el('span', { class: 'tpl-section-name', text: section.title ?? 'Fields' }),
|
|
388
|
+
]),
|
|
389
|
+
el('span', {
|
|
390
|
+
class: 'tpl-section-kind',
|
|
391
|
+
text: isList ? `table · ${child ? child.name : section.via}` : 'fields',
|
|
392
|
+
}),
|
|
393
|
+
el('div', { class: 'tpl-section-actions' }, [
|
|
394
|
+
el('button', {
|
|
395
|
+
type: 'button', class: 'tpl-move', title: 'Move up', 'aria-label': 'Move up',
|
|
396
|
+
text: '↑', disabled: index === 0, onclick: () => move(index, -1, tableId, table),
|
|
397
|
+
}),
|
|
398
|
+
el('button', {
|
|
399
|
+
type: 'button', class: 'tpl-move', title: 'Move down', 'aria-label': 'Move down',
|
|
400
|
+
text: '↓', disabled: index === editing.sections.length - 1,
|
|
401
|
+
onclick: () => move(index, 1, tableId, table),
|
|
402
|
+
}),
|
|
403
|
+
el('button', {
|
|
404
|
+
type: 'button', class: 'tpl-move', title: 'Rename', 'aria-label': 'Rename section',
|
|
405
|
+
text: '✎',
|
|
406
|
+
onclick: async () => {
|
|
407
|
+
const name = await promptFor({
|
|
408
|
+
title: 'Rename section',
|
|
409
|
+
label: 'Heading',
|
|
410
|
+
value: section.title ?? '',
|
|
411
|
+
confirmLabel: 'Rename',
|
|
412
|
+
});
|
|
413
|
+
if (name === null) return;
|
|
414
|
+
section.title = name.trim() || undefined;
|
|
415
|
+
applied(tableId, table);
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
]),
|
|
419
|
+
]),
|
|
420
|
+
section.off ? null : isList
|
|
421
|
+
? listChoices(section, child, tableId, table)
|
|
422
|
+
: fieldChoices(section, tableId, table),
|
|
423
|
+
].filter(Boolean));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function childOf(section) {
|
|
427
|
+
const fk = state.schema.foreignKeys.find((f) => f.name === section.via);
|
|
428
|
+
return fk ? findTable(fk.from.table) : null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Which of the section's fields are shown, and in what order.
|
|
433
|
+
*
|
|
434
|
+
* The arranger is `arrange.js`, shared with the page builder. It used to be a
|
|
435
|
+
* grid of checkboxes here, which could say which fields but not where they
|
|
436
|
+
* go — and a record draws its fields in the order the layout lists them, so
|
|
437
|
+
* the order was a decision the builder had no way to make. Everything below
|
|
438
|
+
* is the translation between a record section and what the arranger asks for.
|
|
439
|
+
*/
|
|
440
|
+
function fieldChoices(section, tableId, table) {
|
|
441
|
+
/* The candidates are the columns of whichever table the section's paths
|
|
442
|
+
address — the base for a plain block, the referenced table for a pulled-in
|
|
443
|
+
one — so a reference block offers every column of the customer rather than
|
|
444
|
+
only the few the derivation picked. */
|
|
445
|
+
const prefix = section.paths[0]?.includes('.')
|
|
446
|
+
? section.paths[0].slice(0, section.paths[0].lastIndexOf('.'))
|
|
447
|
+
: '';
|
|
448
|
+
const source = prefix ? tableAtPrefix(tableId, prefix) : table;
|
|
449
|
+
if (!source) return el('p', { class: 'note', text: 'That reference is no longer in the schema.' });
|
|
450
|
+
|
|
451
|
+
const qualify = (name) => (prefix ? `${prefix}.${name}` : name);
|
|
452
|
+
const all = source.columns.map((c) => qualify(c.name));
|
|
453
|
+
const types = new Map(source.columns.map((c) => [qualify(c.name), c.type.toLowerCase()]));
|
|
454
|
+
|
|
455
|
+
return fieldArranger({
|
|
456
|
+
all,
|
|
457
|
+
chosen: section.paths,
|
|
458
|
+
/* No label of its own: the card this sits in is headed with the block's
|
|
459
|
+
name, and "Fields fields · Fields 7 of 7" is one word three times. */
|
|
460
|
+
heading: null,
|
|
461
|
+
label: (path) => (prefix && path.startsWith(`${prefix}.`) ? path.slice(prefix.length + 1) : path),
|
|
462
|
+
typeOf: (path) => types.get(path) ?? '',
|
|
463
|
+
owner: section,
|
|
464
|
+
/* The record beside the builder, and nothing else.
|
|
465
|
+
|
|
466
|
+
`applied` repaints the builder too, which would replace the row being
|
|
467
|
+
dragged with a new one halfway through the gesture and end the drag.
|
|
468
|
+
The arranger repaints itself; what it cannot do is repaint the preview,
|
|
469
|
+
which is the whole point of editing a layout with the record open. */
|
|
470
|
+
onChange: (next) => { section.paths = next; preview(); },
|
|
471
|
+
others: otherFieldSections(section).map(({ s, i }) => ({
|
|
472
|
+
title: s.title || `block ${i + 1}`,
|
|
473
|
+
receive: (name) => { if (!s.paths.includes(name)) s.paths = [...s.paths, name]; },
|
|
474
|
+
})),
|
|
475
|
+
onSend: () => applied(tableId, table),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Every other block of fields in the draft, for sending a field to one. */
|
|
480
|
+
function otherFieldSections(section) {
|
|
481
|
+
return editing.sections
|
|
482
|
+
.map((s, i) => ({ s, i }))
|
|
483
|
+
.filter(({ s }) => s.kind === 'fields' && s !== section && !s.off);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function listChoices(section, child, tableId, table) {
|
|
487
|
+
if (!child) return el('p', { class: 'note', text: 'That relationship is no longer in the schema.' });
|
|
488
|
+
const types = new Map(child.columns.map((c) => [c.name, c.type.toLowerCase()]));
|
|
489
|
+
|
|
490
|
+
return el('div', {}, [
|
|
491
|
+
/* The same arranger as a field block, because a list's columns are the
|
|
492
|
+
same kind of decision: which ones, and in what order they read left to
|
|
493
|
+
right. Nowhere to send a column, though — the other blocks on this
|
|
494
|
+
layout are about a different table. */
|
|
495
|
+
fieldArranger({
|
|
496
|
+
heading: 'Columns',
|
|
497
|
+
all: child.columns.map((c) => c.name),
|
|
498
|
+
chosen: section.columns,
|
|
499
|
+
typeOf: (name) => types.get(name) ?? '',
|
|
500
|
+
owner: section,
|
|
501
|
+
onChange: (next) => { section.columns = next; preview(); },
|
|
502
|
+
}),
|
|
503
|
+
el('label', { class: 'tpl-rows' }, [
|
|
504
|
+
el('span', { text: 'Rows on the page' }),
|
|
505
|
+
el('input', {
|
|
506
|
+
type: 'number', min: '1', max: '50',
|
|
507
|
+
value: String(section.limit ?? DEFAULT_LIST_LIMIT),
|
|
508
|
+
onchange: (e) => {
|
|
509
|
+
section.limit = Math.max(1, Math.min(50, Number(e.currentTarget.value) || 8));
|
|
510
|
+
applied(tableId, table);
|
|
511
|
+
},
|
|
512
|
+
}),
|
|
513
|
+
]),
|
|
514
|
+
]);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The control that opens the builder, for the record page to place. */
|
|
518
|
+
export function editLayoutButton(tableId) {
|
|
519
|
+
const saved = allTemplates()[tableId];
|
|
520
|
+
const table = findTable(tableId);
|
|
521
|
+
/* The name shows on the button when it says something the table name does
|
|
522
|
+
not. A layout still called `work_order` adds nothing to a page already
|
|
523
|
+
headed `work_order`; one called "Service desk" is the answer to "why does
|
|
524
|
+
this record look like this". */
|
|
525
|
+
const named = saved?.name && saved.name !== table?.name ? saved.name : null;
|
|
526
|
+
|
|
527
|
+
return el('button', {
|
|
528
|
+
type: 'button',
|
|
529
|
+
class: `ghost record-layout${saved ? ' is-custom' : ''}`,
|
|
530
|
+
title: named
|
|
531
|
+
? `Editing “${named}” — the layout for every ${table?.name ?? tableId} record`
|
|
532
|
+
: 'Choose what this record shows',
|
|
533
|
+
onclick: () => openTemplateBuilder(tableId),
|
|
534
|
+
}, [
|
|
535
|
+
el('span', { text: 'Layout' }),
|
|
536
|
+
named ? el('span', { class: 'record-layout-name', text: named }) : null,
|
|
537
|
+
!named && saved ? el('span', { 'aria-hidden': 'true', text: '\u2713' }) : null,
|
|
538
|
+
].filter(Boolean));
|
|
539
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark, or whatever the machine is set to.
|
|
3
|
+
*
|
|
4
|
+
* The palette for all three has been in style.css from early on: a full token
|
|
5
|
+
* override under `@media (prefers-color-scheme: dark)`, guarded as
|
|
6
|
+
* `:root:not([data-theme="light"])`, and a second copy under
|
|
7
|
+
* `:root[data-theme="dark"]`. That guard only means anything if something can
|
|
8
|
+
* stamp `data-theme`, and nothing ever did — so the dark palette was reachable
|
|
9
|
+
* by changing your operating system and by no other route, and the light one
|
|
10
|
+
* was unreachable from a dark machine at all.
|
|
11
|
+
*
|
|
12
|
+
* This is that missing piece and nothing more. It writes one attribute.
|
|
13
|
+
*
|
|
14
|
+
* Three states rather than two, because "system" is a real answer and not the
|
|
15
|
+
* absence of one: a reader who has told their machine to go dark at sunset
|
|
16
|
+
* means it here too, and a toggle that only remembers light-or-dark quietly
|
|
17
|
+
* opts them out of that. Absent attribute *is* the system state, which is why
|
|
18
|
+
* the CSS was written to key off its absence.
|
|
19
|
+
*/
|
|
20
|
+
const KEY = 'tablewalk.theme';
|
|
21
|
+
const ORDER = ['system', 'light', 'dark'];
|
|
22
|
+
|
|
23
|
+
const LABEL = {
|
|
24
|
+
system: 'match the system',
|
|
25
|
+
light: 'always light',
|
|
26
|
+
dark: 'always dark',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let current = 'system';
|
|
30
|
+
|
|
31
|
+
function stored() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = window.localStorage?.getItem(KEY);
|
|
34
|
+
/* Validated, not trusted: this comes back from storage a stale build or a
|
|
35
|
+
hand edit may have written, and an unknown value would stamp
|
|
36
|
+
`data-theme="banana"` — which matches no rule, so the page would render
|
|
37
|
+
in the light palette while claiming to be in another one. */
|
|
38
|
+
return ORDER.includes(raw) ? raw : 'system';
|
|
39
|
+
} catch {
|
|
40
|
+
return 'system';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function apply(name) {
|
|
45
|
+
current = ORDER.includes(name) ? name : 'system';
|
|
46
|
+
const root = document.documentElement;
|
|
47
|
+
if (current === 'system') root.removeAttribute('data-theme');
|
|
48
|
+
else root.setAttribute('data-theme', current);
|
|
49
|
+
/* Tells the browser which way to paint the things the page does not: form
|
|
50
|
+
controls, scrollbars, the canvas behind an overscroll. Without it a dark
|
|
51
|
+
page keeps light scrollbars. */
|
|
52
|
+
root.style.colorScheme = current === 'system' ? '' : current;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read the stored choice and put it on the page. Called once, at boot. */
|
|
56
|
+
export function initTheme() {
|
|
57
|
+
apply(stored());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What is in force, for a menu item that has to say so. */
|
|
61
|
+
export const themeChoice = () => current;
|
|
62
|
+
export const themeLabel = () => LABEL[current];
|
|
63
|
+
|
|
64
|
+
/** system → light → dark → system. */
|
|
65
|
+
export function cycleTheme() {
|
|
66
|
+
const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
|
|
67
|
+
apply(next);
|
|
68
|
+
try {
|
|
69
|
+
window.localStorage?.setItem(KEY, next);
|
|
70
|
+
} catch {
|
|
71
|
+
/* A theme that cannot be remembered is still a theme for this tab. */
|
|
72
|
+
}
|
|
73
|
+
return next;
|
|
74
|
+
}
|