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,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts every other client module needs: the API, the navigation stack,
|
|
3
|
+
* the schema mirrors, and DOM building.
|
|
4
|
+
*
|
|
5
|
+
* This file deliberately depends on nothing else in the client. Everything
|
|
6
|
+
* else depends on it and not on each other, which is what keeps the module
|
|
7
|
+
* graph a fan-out rather than a web — and lets a feature live in its own file
|
|
8
|
+
* without reaching into the renderer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const $ = (id) => document.getElementById(id);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @type {{schema: any, stack: any[], page: number, canWrite: boolean}}
|
|
15
|
+
*
|
|
16
|
+
* `stack` is a navigation stack, and each entry is somewhere you have been —
|
|
17
|
+
* a table with a filter, or one row. The breadcrumb is that stack rendered,
|
|
18
|
+
* so walking back restores the filter you walked away from.
|
|
19
|
+
*/
|
|
20
|
+
export const state = { schema: null, stack: [], page: 0, canWrite: false, navMode: 'replace' };
|
|
21
|
+
|
|
22
|
+
/* Matches DEFAULT_LIMIT on the server. Small on purpose: the first page of a
|
|
23
|
+
browse should be instant, and paging is one click. */
|
|
24
|
+
export const PAGE_SIZE = 20;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How many rows a page actually holds, which the reader chooses.
|
|
28
|
+
*
|
|
29
|
+
* `PAGE_SIZE` stays the default and stays equal to the server's
|
|
30
|
+
* `DEFAULT_LIMIT` — twenty is right for a first look, and the first look is
|
|
31
|
+
* what most browsing is. It was also the *only* size, which turned a
|
|
32
|
+
* sixty-thousand-row table into three thousand clicks: a table you could read
|
|
33
|
+
* the front of and nothing else.
|
|
34
|
+
*
|
|
35
|
+
* Capped at 250 rather than offering "all". The grid renders every row it is
|
|
36
|
+
* given, so the honest ceiling is the one where the DOM is still the reader's
|
|
37
|
+
* friend; past that the answer is a filter, a sort, or Export.
|
|
38
|
+
*/
|
|
39
|
+
export const PAGE_SIZES = [20, 50, 100, 250];
|
|
40
|
+
const PAGE_SIZE_KEY = 'tablewalk.page-size';
|
|
41
|
+
let chosenPageSize = null;
|
|
42
|
+
|
|
43
|
+
export function pageSize() {
|
|
44
|
+
if (chosenPageSize === null) {
|
|
45
|
+
const stored = Number(loadJson(PAGE_SIZE_KEY, PAGE_SIZE));
|
|
46
|
+
/* Validated rather than trusted: the value comes from localStorage, where
|
|
47
|
+
a stale build or a hand edit can leave anything at all, and a page size
|
|
48
|
+
of `null` asks the server for `LIMIT NaN`. */
|
|
49
|
+
chosenPageSize = PAGE_SIZES.includes(stored) ? stored : PAGE_SIZE;
|
|
50
|
+
}
|
|
51
|
+
return chosenPageSize;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setPageSize(size) {
|
|
55
|
+
chosenPageSize = PAGE_SIZES.includes(Number(size)) ? Number(size) : PAGE_SIZE;
|
|
56
|
+
saveJson(PAGE_SIZE_KEY, chosenPageSize);
|
|
57
|
+
/* A page *number* means nothing once the size changes — page 40 of twenty
|
|
58
|
+
is not page 40 of a hundred — so the only honest place to land is the
|
|
59
|
+
front. */
|
|
60
|
+
state.page = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Above this many rows the table is being fetched to be processed rather
|
|
64
|
+
than read, and the DOM is the bottleneck rather than the database. Until
|
|
65
|
+
the result grid is virtualised, render this many and say what was held
|
|
66
|
+
back — a silent truncation would read as "that is all there is". */
|
|
67
|
+
export const RENDER_CAP = 2000;
|
|
68
|
+
|
|
69
|
+
/* ---------- API ---------- */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Endpoints that are *about* connections rather than answered by one.
|
|
73
|
+
*
|
|
74
|
+
* Naming a connection on these is at best redundant and at worst wrong:
|
|
75
|
+
* `/api/connections/remove` is about an id in its body, and resolving a second
|
|
76
|
+
* one from the same request would refuse it the moment the two disagreed.
|
|
77
|
+
*/
|
|
78
|
+
const CONNECTION_ENDPOINTS = /^\/api\/connections(\/|$)/;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every request says which database it is about.
|
|
82
|
+
*
|
|
83
|
+
* The server holds one "active" connection for the whole process, which is
|
|
84
|
+
* fine for one tab and wrong for two: switching database in one tab changed
|
|
85
|
+
* what every other tab's next request ran against, while those tabs went on
|
|
86
|
+
* rendering the schema they still had. The same query, the same screen,
|
|
87
|
+
* different data, and nothing to say so.
|
|
88
|
+
*
|
|
89
|
+
* So the connection stops being server state that the client nudges and
|
|
90
|
+
* becomes part of the question. The server's active connection remains as the
|
|
91
|
+
* answer to "no one said" — the first request of a session, before the client
|
|
92
|
+
* knows what it is connected to.
|
|
93
|
+
*/
|
|
94
|
+
export async function api(path, body) {
|
|
95
|
+
const conn = state.activeConnection;
|
|
96
|
+
const scoped = conn && !CONNECTION_ENDPOINTS.test(path.split('?')[0]);
|
|
97
|
+
|
|
98
|
+
let url = path;
|
|
99
|
+
if (scoped && !body) {
|
|
100
|
+
/* Appended rather than assumed to be the only parameter — `/api/ddl` and
|
|
101
|
+
`/api/pages/suggested` already carry one. */
|
|
102
|
+
url += `${path.includes('?') ? '&' : '?'}conn=${encodeURIComponent(conn)}`;
|
|
103
|
+
}
|
|
104
|
+
/* A caller that named one wins. `/api/connections/select` is followed by
|
|
105
|
+
reads that must go to the connection being switched *to*, which is not yet
|
|
106
|
+
the active one. */
|
|
107
|
+
const payload = scoped && body && body.conn === undefined ? { ...body, conn } : body;
|
|
108
|
+
|
|
109
|
+
let res;
|
|
110
|
+
try {
|
|
111
|
+
res = await fetch(url, {
|
|
112
|
+
method: payload ? 'POST' : 'GET',
|
|
113
|
+
headers: payload ? { 'content-type': 'application/json' } : undefined,
|
|
114
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
/* `fetch` rejects with "Failed to fetch" or "Load failed" depending on the
|
|
118
|
+
browser, and neither says the one thing that is true and useful: the
|
|
119
|
+
server this page came from is not answering. It is a local process, so
|
|
120
|
+
that nearly always means it was stopped — and "Failed to fetch" sends
|
|
121
|
+
people to look at their network. */
|
|
122
|
+
throw new Error(
|
|
123
|
+
'tablewalk is not answering. The server it was started with may have stopped — '
|
|
124
|
+
+ 'restart it and reload this page.',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let data;
|
|
129
|
+
try {
|
|
130
|
+
data = await res.json();
|
|
131
|
+
} catch {
|
|
132
|
+
/* An answer that is not JSON is a proxy, a login page, or a crash — none
|
|
133
|
+
of which the caller can do anything with, and all of which read better
|
|
134
|
+
as what they are than as "Unexpected token < in JSON". */
|
|
135
|
+
throw new Error(
|
|
136
|
+
`The server answered ${res.status} with something that is not JSON. `
|
|
137
|
+
+ 'Something between this page and tablewalk may be intercepting the request.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
/* The status travels with the message. Callers have always had to guess
|
|
143
|
+
the kind of failure from the text of it, which is how "No connection is
|
|
144
|
+
open." — a 409 with a specific meaning and a specific screen to show
|
|
145
|
+
for it — could only be recognised by matching a sentence. */
|
|
146
|
+
const failure = new Error(data.error ?? `Request failed (${res.status})`);
|
|
147
|
+
failure.status = res.status;
|
|
148
|
+
throw failure;
|
|
149
|
+
}
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Run something when a node leaves the document.
|
|
155
|
+
*
|
|
156
|
+
* Observers outlive the thing they watch unless someone stops them, and these
|
|
157
|
+
* views are rebuilt constantly — browsing thirty tables left dozens of them
|
|
158
|
+
* holding detached DOM, each still re-measuring a table nobody can see on
|
|
159
|
+
* every resize, for as long as the tab was open.
|
|
160
|
+
*
|
|
161
|
+
* Watching the node's own parent does not work, and that is the whole reason
|
|
162
|
+
* this is a registry rather than one observer each: a view swap replaces the
|
|
163
|
+
* children of `#content`, which detaches the entire subtree in one operation.
|
|
164
|
+
* No `childList` record is delivered for the intermediate parents — they were
|
|
165
|
+
* not modified, they were carried off — so a watcher on the immediate parent
|
|
166
|
+
* of a nested table never hears anything.
|
|
167
|
+
*
|
|
168
|
+
* So one observer watches the place the swapping actually happens, and
|
|
169
|
+
* everything registered is swept for `isConnected` when it fires. One
|
|
170
|
+
* observer for the app rather than one per element is also the cheaper end of
|
|
171
|
+
* the trade.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Make a `<details>` announce itself as the control it looks like.
|
|
175
|
+
*
|
|
176
|
+
* A `<summary>` styled as a button is read by Chromium as `group: + Count` —
|
|
177
|
+
* no role, no state, nothing to say it can be opened or that it currently is.
|
|
178
|
+
* The reader sees a button; the screen reader is told about a grouping box.
|
|
179
|
+
* Every disclosure in this app is a button in appearance and in intent, so
|
|
180
|
+
* each one says so, and keeps `aria-expanded` in step with the element's own
|
|
181
|
+
* `open` — through the `toggle` event, because a `<details>` can be opened by
|
|
182
|
+
* the keyboard, by a click on the marker, or by `open` being set in code, and
|
|
183
|
+
* only the event sees all three.
|
|
184
|
+
*/
|
|
185
|
+
export function disclosure(details) {
|
|
186
|
+
const summary = details.querySelector(':scope > summary');
|
|
187
|
+
if (!summary) return details;
|
|
188
|
+
summary.setAttribute('role', 'button');
|
|
189
|
+
summary.setAttribute('aria-expanded', String(details.open));
|
|
190
|
+
details.addEventListener('toggle', () => {
|
|
191
|
+
summary.setAttribute('aria-expanded', String(details.open));
|
|
192
|
+
});
|
|
193
|
+
return details;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Anything that has to be rebuilt when the database underneath changes.
|
|
198
|
+
*
|
|
199
|
+
* A connection switch is not a render: the trail is cleared, the schema is
|
|
200
|
+
* replaced and every per-connection thing — layouts, saved statements, write
|
|
201
|
+
* mode — is reloaded. Long-lived surfaces that are *not* inside `#content`
|
|
202
|
+
* miss all of it. The SQL editor was the case that showed it: painted once
|
|
203
|
+
* from the state it opened with, it went on naming the previous database
|
|
204
|
+
* while the statement it ran went to the new one, and it kept offering "Run
|
|
205
|
+
* as write…" after a switch to a connection the server would refuse.
|
|
206
|
+
*
|
|
207
|
+
* A set rather than the single `state.onSwitched` slot beside it, because the
|
|
208
|
+
* second thing that needed to know would have silently replaced the first.
|
|
209
|
+
*/
|
|
210
|
+
const switchWatchers = new Set();
|
|
211
|
+
|
|
212
|
+
/** Register a rebuild. Returns the way to stop listening. */
|
|
213
|
+
export function whenConnectionChanges(run) {
|
|
214
|
+
switchWatchers.add(run);
|
|
215
|
+
return () => switchWatchers.delete(run);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Say that the connection changed. Called by the two places that switch —
|
|
220
|
+
* the picker and a deep link into another database — after the new schema
|
|
221
|
+
* and everything keyed to it have landed, so a watcher reads the new state
|
|
222
|
+
* rather than the half-swapped one.
|
|
223
|
+
*/
|
|
224
|
+
export function connectionChanged() {
|
|
225
|
+
/* A copy, because a watcher may well unregister itself as its last act. */
|
|
226
|
+
for (const run of [...switchWatchers]) {
|
|
227
|
+
try {
|
|
228
|
+
run();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
/* One surface failing to rebuild must not stop the next one. It is
|
|
231
|
+
already the wrong database on screen; making it the wrong database
|
|
232
|
+
everywhere is not an improvement. */
|
|
233
|
+
console.error(err);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const removals = new Set();
|
|
239
|
+
let sweeper = null;
|
|
240
|
+
|
|
241
|
+
export function whenRemoved(node, run) {
|
|
242
|
+
const entry = { node, run };
|
|
243
|
+
removals.add(entry);
|
|
244
|
+
|
|
245
|
+
if (!sweeper) {
|
|
246
|
+
const root = $('content') ?? document.body;
|
|
247
|
+
sweeper = new MutationObserver(() => {
|
|
248
|
+
for (const item of [...removals]) {
|
|
249
|
+
if (item.node.isConnected) continue;
|
|
250
|
+
removals.delete(item);
|
|
251
|
+
item.run();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
sweeper.observe(root, { childList: true, subtree: true });
|
|
255
|
+
}
|
|
256
|
+
return () => removals.delete(entry);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* ---------- schema helpers (mirrors of src/shared/schema.ts) ---------- */
|
|
260
|
+
|
|
261
|
+
export const findTable = (id) => state.schema.tables.find((t) => t.id === id);
|
|
262
|
+
export const refsTo = (id) => state.schema.foreignKeys.filter((fk) => fk.to.table === id);
|
|
263
|
+
export const refsFrom = (id) => state.schema.foreignKeys.filter((fk) => fk.from.table === id);
|
|
264
|
+
export const primaryKey = (table) => table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
265
|
+
|
|
266
|
+
const LABEL_CANDIDATES = ['name', 'full_name', 'display_name', 'first_name', 'username', 'title', 'label', 'subject', 'number', 'email', 'slug', 'code'];
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Mirrors columnKind in shared/schema.ts.
|
|
270
|
+
*
|
|
271
|
+
* Here rather than beside the renderer that formats a column, because the
|
|
272
|
+
* date test below is defined in terms of it and every copy of that test the
|
|
273
|
+
* client grew — one per module that needed it — drifted from this one in a
|
|
274
|
+
* different direction.
|
|
275
|
+
*/
|
|
276
|
+
export function columnKind(type) {
|
|
277
|
+
const t = (type ?? '').toLowerCase();
|
|
278
|
+
if (/bool/.test(t)) return 'boolean';
|
|
279
|
+
if (/int|serial|numeric|decimal|real|double|float|money/.test(t)) return 'number';
|
|
280
|
+
if (/date|time/.test(t)) return 'date';
|
|
281
|
+
if (/char|text|uuid|json|enum|clob/.test(t)) return 'text';
|
|
282
|
+
return 'other';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Mirrors looksLikeDate in shared/schema.ts. */
|
|
286
|
+
const DATE_NAME = /(^|_)(date|time|datetime|timestamp)(_|$)|_(at|on)$|^(created|updated|modified|deleted)(_|$)/i;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Whether a column should be treated as a date for presentation.
|
|
290
|
+
*
|
|
291
|
+
* Exported, and the only copy: this rule had been rewritten by hand in five
|
|
292
|
+
* client modules and no two of them said the same thing. `event_timestamp
|
|
293
|
+
* TEXT` got relative-date filters in one place and not another; a `uuid`
|
|
294
|
+
* column named `created_at` was a date to the server and plain text here,
|
|
295
|
+
* because the type test was a `char|text|varchar` regex rather than the
|
|
296
|
+
* classifier the shared copy uses.
|
|
297
|
+
*/
|
|
298
|
+
export function looksLikeDateColumn(col) {
|
|
299
|
+
const kind = columnKind(col?.type);
|
|
300
|
+
if (kind === 'date') return true;
|
|
301
|
+
// Only text columns get the name-based benefit of the doubt; a number named
|
|
302
|
+
// `updated_at` is an epoch and comparing it to a timestamp string is wrong.
|
|
303
|
+
return kind === 'text' && DATE_NAME.test(col?.name ?? '');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function labelColumn(table) {
|
|
307
|
+
const hit = LABEL_CANDIDATES.find((c) => table.columns.some((col) => col.name === c));
|
|
308
|
+
if (hit) return hit;
|
|
309
|
+
/* A date is never a good label. Two rows created the same day are
|
|
310
|
+
indistinguishable, and "2025-03-14" says nothing about *which* invoice you
|
|
311
|
+
are looking at — the key at least identifies it.
|
|
312
|
+
|
|
313
|
+
This guard exists in shared/schema.ts and had been dropped from this copy,
|
|
314
|
+
so the server and the browser disagreed about what a row is called: a
|
|
315
|
+
table whose first text column is a date got breadcrumbs and record
|
|
316
|
+
headings reading "2025-03-14" while every server-rendered label used the
|
|
317
|
+
key. */
|
|
318
|
+
/* Nor is a column the database gave a vocabulary: `status` reads like a
|
|
319
|
+
name and identifies nothing, since every sixth row shares it. */
|
|
320
|
+
const text = table.columns.find(
|
|
321
|
+
(c) => !c.primaryKey && /char|text|varchar|string/i.test(c.type)
|
|
322
|
+
&& !looksLikeDateColumn(c) && !(c.allowed && c.allowed.length),
|
|
323
|
+
);
|
|
324
|
+
return text ? text.name : primaryKey(table)[0];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Mirrors IDENTITY_TABLE in shared/schema.ts. */
|
|
328
|
+
const IDENTITY_TABLE = /(^|_)(party|parties|person|persons|people|user|users|profile|profiles|identity|identities)$/i;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Mirrors labelPath in shared/schema.ts: where a row's name lives, as a view
|
|
332
|
+
* path. A table whose best label is its own key asks one forward hop further
|
|
333
|
+
* — employee has no name, `party_id.display_name` does — before settling for
|
|
334
|
+
* the number. Only across a plausibly one-to-one link: a reference that is
|
|
335
|
+
* the whole primary key, or a reference into an identity table. The invoice
|
|
336
|
+
* must not be named after its customer.
|
|
337
|
+
*/
|
|
338
|
+
export function labelPath(table) {
|
|
339
|
+
const own = labelColumn(table);
|
|
340
|
+
const key = primaryKey(table);
|
|
341
|
+
if (own && !key.includes(own)) return own;
|
|
342
|
+
for (const column of table.columns) {
|
|
343
|
+
const ref = column.references;
|
|
344
|
+
if (!ref || ref.table === table.id) continue;
|
|
345
|
+
const oneToOne = key.length === 1 && key[0] === column.name;
|
|
346
|
+
const target = findTable(ref.table);
|
|
347
|
+
if (!target) continue;
|
|
348
|
+
if (!oneToOne && !IDENTITY_TABLE.test(target.name)) continue;
|
|
349
|
+
const theirs = labelColumn(target);
|
|
350
|
+
if (!theirs || primaryKey(target).includes(theirs)) continue;
|
|
351
|
+
return `${column.name}.${theirs}`;
|
|
352
|
+
}
|
|
353
|
+
return own;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ---------- paths (mirrors of src/shared/view.ts) ----------
|
|
357
|
+
|
|
358
|
+
Every hop is derivable from the schema the page already holds, so proposing
|
|
359
|
+
the next segment of a path is local and instant; asking the server per
|
|
360
|
+
keystroke would make the one interaction that has to feel immediate the one
|
|
361
|
+
that does not. The server still resolves every path for real before
|
|
362
|
+
anything runs, so a mirror here can cost a suggestion and never a result.
|
|
363
|
+
|
|
364
|
+
Here rather than in the composer that reads them most, because the layout
|
|
365
|
+
builder walked the same prefixes with its own copy — one that matched a hop
|
|
366
|
+
only by its columns, so a section whose path names the *constraint* instead
|
|
367
|
+
resolved to nothing and the builder reported a reference that was still
|
|
368
|
+
perfectly present. */
|
|
369
|
+
|
|
370
|
+
/** Mirrors hopName in shared/view.ts. Composite keys join their columns with +. */
|
|
371
|
+
export function hopName(fk) {
|
|
372
|
+
return fk.from.columns.length === 1 ? fk.from.columns[0] : fk.from.columns.join('+');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Mirrors hopsFrom in shared/view.ts: every forward hop out of a table. */
|
|
376
|
+
export function hopsFrom(tableId) {
|
|
377
|
+
const table = findTable(tableId);
|
|
378
|
+
return refsFrom(tableId).map((fk) => ({
|
|
379
|
+
name: hopName(fk),
|
|
380
|
+
fk,
|
|
381
|
+
target: fk.to.table,
|
|
382
|
+
optional: fk.from.columns.some(
|
|
383
|
+
(c) => (table ? table.columns.find((col) => col.name === c) : null)?.nullable !== false,
|
|
384
|
+
),
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Mirrors tableAtPrefix in shared/view.ts. An empty prefix is the base. */
|
|
389
|
+
export function tableAtPrefix(baseId, prefix) {
|
|
390
|
+
let table = findTable(baseId);
|
|
391
|
+
if (!prefix) return table;
|
|
392
|
+
for (const segment of prefix.split('.')) {
|
|
393
|
+
if (!table) return null;
|
|
394
|
+
const hop = hopsFrom(table.id).find((h) => h.name === segment || h.fk.name === segment);
|
|
395
|
+
if (!hop) return null;
|
|
396
|
+
table = findTable(hop.target);
|
|
397
|
+
}
|
|
398
|
+
return table;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** How a row is named in a breadcrumb: the readable column, else the key. */
|
|
402
|
+
export function rowLabel(table, row) {
|
|
403
|
+
/* The walked-to name first. A row fetched as a view carries the joined
|
|
404
|
+
label under its path — `party_id.display_name` — while a plain query row
|
|
405
|
+
simply lacks that key and falls through to the table's own label, then
|
|
406
|
+
the primary key. One function for both, so a caller never has to know
|
|
407
|
+
which kind of row it is holding. */
|
|
408
|
+
for (const name of [labelPath(table), labelColumn(table)]) {
|
|
409
|
+
const value = name != null ? row[name] : undefined;
|
|
410
|
+
if (value !== null && value !== undefined && String(value) !== '') return String(value);
|
|
411
|
+
}
|
|
412
|
+
const pk = primaryKey(table).map((c) => row[c]).filter((v) => v != null);
|
|
413
|
+
return pk.length ? pk.join('/') : '(row)';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The primary key of a row, or null when this row cannot supply one.
|
|
418
|
+
*
|
|
419
|
+
* Null rather than a key with `undefined` in it. A projected query — `show
|
|
420
|
+
* quantity`, or a child section showing four summary columns — returns rows
|
|
421
|
+
* that genuinely do not carry the key, and building `{invoice_id: undefined}`
|
|
422
|
+
* out of one produced a link to `~invoice_line:invoice_id=undefined` that
|
|
423
|
+
* landed on "That row is no longer there." Nothing had gone missing; the
|
|
424
|
+
* navigation was built from a row that never had the key to begin with.
|
|
425
|
+
*
|
|
426
|
+
* Callers use the null to decide whether a row is clickable at all, so a row
|
|
427
|
+
* that cannot be opened does not look like one that can.
|
|
428
|
+
*/
|
|
429
|
+
export function keyOf(table, row) {
|
|
430
|
+
const columns = primaryKey(table);
|
|
431
|
+
if (!columns.length) return null;
|
|
432
|
+
const key = {};
|
|
433
|
+
for (const col of columns) {
|
|
434
|
+
if (row[col] === undefined) return null;
|
|
435
|
+
key[col] = row[col];
|
|
436
|
+
}
|
|
437
|
+
return key;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A filter matching exactly one row by its primary key. */
|
|
441
|
+
export function keyFilter(key) {
|
|
442
|
+
return { groups: [Object.entries(key).map(([column, value]) => ({ column, op: '=', value }))] };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* ---------- DOM ---------- */
|
|
446
|
+
|
|
447
|
+
export function el(tag, attrs = {}, children = []) {
|
|
448
|
+
const node = document.createElement(tag);
|
|
449
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
450
|
+
if (v === undefined || v === false || v === null) continue;
|
|
451
|
+
if (k === 'class') node.className = v;
|
|
452
|
+
else if (k === 'text') node.textContent = v;
|
|
453
|
+
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
|
454
|
+
else node.setAttribute(k, v === true ? '' : String(v));
|
|
455
|
+
}
|
|
456
|
+
for (const child of [].concat(children)) {
|
|
457
|
+
if (child == null || child === false) continue;
|
|
458
|
+
node.append(child);
|
|
459
|
+
}
|
|
460
|
+
return node;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function svgEl(tag, attrs = {}, children = []) {
|
|
464
|
+
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
465
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
466
|
+
if (v === undefined || v === null) continue;
|
|
467
|
+
if (k.startsWith('on')) node.addEventListener(k.slice(2), v);
|
|
468
|
+
else node.setAttribute(k, String(v));
|
|
469
|
+
}
|
|
470
|
+
for (const child of [].concat(children)) if (child) node.append(child);
|
|
471
|
+
return node;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Keep the keyboard inside a panel, and give it back on the way out.
|
|
476
|
+
*
|
|
477
|
+
* Eight surfaces here declare `role="dialog"` and `aria-modal="true"` on a
|
|
478
|
+
* plain `<div>`. The attribute is a promise: it tells a screen reader that
|
|
479
|
+
* everything behind this is inert and not to be reached. Nothing was
|
|
480
|
+
* enforcing it, so Tab walked straight out of the panel and into the table
|
|
481
|
+
* underneath — the page reading as sealed to one user and open to another,
|
|
482
|
+
* which is worse than not claiming it at all. The `<dialog>` elements
|
|
483
|
+
* elsewhere get this from the platform; these had to be told.
|
|
484
|
+
*
|
|
485
|
+
* It also puts focus back where it came from. Closing a panel used to drop
|
|
486
|
+
* focus on `<body>`, so the next Tab started again from the top of the
|
|
487
|
+
* document — a keyboard user who opened the connection manager from the
|
|
488
|
+
* toolbar and pressed Escape had to walk the whole page to get back.
|
|
489
|
+
*
|
|
490
|
+
* Returns the release function. Call it when the panel goes.
|
|
491
|
+
*/
|
|
492
|
+
export function trapFocus(panel) {
|
|
493
|
+
const cameFrom = document.activeElement;
|
|
494
|
+
|
|
495
|
+
/* Queried per keystroke rather than cached: these panels rebuild their own
|
|
496
|
+
contents constantly — a filter that repaints a list, a tour that swaps
|
|
497
|
+
the card — and a list captured on open goes stale immediately. */
|
|
498
|
+
const focusable = () => [...panel.querySelectorAll(
|
|
499
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]),'
|
|
500
|
+
+ ' textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
501
|
+
)].filter((node) => node.offsetWidth > 0 || node.offsetHeight > 0 || node === document.activeElement);
|
|
502
|
+
|
|
503
|
+
const onKey = (e) => {
|
|
504
|
+
if (e.key !== 'Tab') return;
|
|
505
|
+
const stops = focusable();
|
|
506
|
+
/* Nothing to move to yet — a panel painted but not filled. Swallowing the
|
|
507
|
+
key is the honest answer: the alternative is Tab escaping into the page
|
|
508
|
+
this panel has told a screen reader is inert. Escape still closes. */
|
|
509
|
+
if (!stops.length) { e.preventDefault(); return; }
|
|
510
|
+
const first = stops[0];
|
|
511
|
+
const last = stops[stops.length - 1];
|
|
512
|
+
/* Focus outside the panel entirely — it can happen, because a panel may
|
|
513
|
+
be opened while something behind it is focused. Pull it back rather
|
|
514
|
+
than letting the first Tab escape. */
|
|
515
|
+
if (!panel.contains(document.activeElement)) {
|
|
516
|
+
e.preventDefault();
|
|
517
|
+
(e.shiftKey ? last : first).focus();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
521
|
+
e.preventDefault();
|
|
522
|
+
last.focus();
|
|
523
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
524
|
+
e.preventDefault();
|
|
525
|
+
first.focus();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Focus that ends up outside, brought back.
|
|
531
|
+
*
|
|
532
|
+
* Tab is not the only way out. These panels repaint themselves — the
|
|
533
|
+
* connection list arrives and replaces the loading state, a filter redraws
|
|
534
|
+
* the rows — and replacing the node that had focus drops it on `<body>`,
|
|
535
|
+
* outside the thing that claims to be modal. A click on the page behind
|
|
536
|
+
* does the same.
|
|
537
|
+
*
|
|
538
|
+
* A `<dialog>` on top is the exception, and it has to be: the connections
|
|
539
|
+
* panel opens one to add a connection, and pulling focus back to the panel
|
|
540
|
+
* underneath would make that dialog impossible to type into. The platform
|
|
541
|
+
* is already trapping focus there, so this stands aside.
|
|
542
|
+
*/
|
|
543
|
+
const onFocusIn = (e) => {
|
|
544
|
+
if (!panel.isConnected) return;
|
|
545
|
+
if (panel.contains(e.target)) return;
|
|
546
|
+
if (document.querySelector('dialog[open]')) return;
|
|
547
|
+
const stops = focusable();
|
|
548
|
+
if (stops.length) stops[0].focus();
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The panel rebuilding itself out from under the keyboard.
|
|
553
|
+
*
|
|
554
|
+
* This is the one that actually bit. These panels paint twice — a loading
|
|
555
|
+
* state, then the answer — and `replaceChildren` destroys whatever had
|
|
556
|
+
* focus. Removing a focused element does not reliably fire `blur` or
|
|
557
|
+
* `focusout` in Chrome, so nothing above hears it; focus simply becomes
|
|
558
|
+
* `<body>` and the next Tab starts from the top of the document, outside
|
|
559
|
+
* the panel that claims to be modal.
|
|
560
|
+
*
|
|
561
|
+
* Watching the subtree catches it whatever caused it — a repaint, a row
|
|
562
|
+
* that removed itself, a list that filtered. If the panel put focus
|
|
563
|
+
* somewhere itself, as the connection filter does, this sees focus already
|
|
564
|
+
* inside and stands aside.
|
|
565
|
+
*/
|
|
566
|
+
const watcher = new MutationObserver(() => {
|
|
567
|
+
if (!panel.isConnected) return;
|
|
568
|
+
if (panel.contains(document.activeElement)) return;
|
|
569
|
+
if (document.querySelector('dialog[open]')) return;
|
|
570
|
+
const stops = focusable();
|
|
571
|
+
if (stops.length) stops[0].focus();
|
|
572
|
+
});
|
|
573
|
+
watcher.observe(panel, { childList: true, subtree: true });
|
|
574
|
+
|
|
575
|
+
document.addEventListener('keydown', onKey, true);
|
|
576
|
+
document.addEventListener('focusin', onFocusIn);
|
|
577
|
+
return () => {
|
|
578
|
+
watcher.disconnect();
|
|
579
|
+
document.removeEventListener('keydown', onKey, true);
|
|
580
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
581
|
+
/* Only if the panel still holds it. Something that closed itself by
|
|
582
|
+
sending you somewhere — a connection row that opens that database —
|
|
583
|
+
has already put focus where it belongs. */
|
|
584
|
+
if (cameFrom?.isConnected && !document.activeElement?.closest?.('dialog')) {
|
|
585
|
+
if (!panel.isConnected || panel.contains(document.activeElement)) cameFrom.focus?.();
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* ---------- navigation ---------- */
|
|
591
|
+
|
|
592
|
+
/* The renderer is registered rather than imported, so this module stays at
|
|
593
|
+
the bottom of the dependency graph and every view module can call `go`
|
|
594
|
+
without a cycle. */
|
|
595
|
+
let renderer = () => {};
|
|
596
|
+
export function setRenderer(fn) {
|
|
597
|
+
renderer = fn;
|
|
598
|
+
}
|
|
599
|
+
export function render() {
|
|
600
|
+
renderer();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* Redrawing the trail alone, without re-running the view.
|
|
604
|
+
A row restored from a link knows only its key, so its crumb starts as the
|
|
605
|
+
key and is corrected once the row arrives. Calling render() for that would
|
|
606
|
+
re-fetch the row that just resolved it — and loop. */
|
|
607
|
+
let crumbRenderer = () => {};
|
|
608
|
+
export function setCrumbRenderer(fn) {
|
|
609
|
+
crumbRenderer = fn;
|
|
610
|
+
}
|
|
611
|
+
export function renderCrumbs() {
|
|
612
|
+
crumbRenderer();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Move to a view and render.
|
|
617
|
+
*
|
|
618
|
+
* push — a step along a reference; the trail grows
|
|
619
|
+
* replace — the same view, re-run (a sort, an edited filter, a tab)
|
|
620
|
+
* reset — a fresh start; the trail is discarded
|
|
621
|
+
*
|
|
622
|
+
* The distinction is the whole meaning of the breadcrumb. A trail is a walk:
|
|
623
|
+
* every crumb should be reachable from the one before it by following a key.
|
|
624
|
+
* Jumping to an unrelated table is not a step along that walk, so it starts a
|
|
625
|
+
* new one rather than appending browsing history.
|
|
626
|
+
*/
|
|
627
|
+
export function go(entry, mode = 'push') {
|
|
628
|
+
if (mode === 'reset') state.stack = [entry];
|
|
629
|
+
else if (mode === 'replace') state.stack[state.stack.length - 1] = entry;
|
|
630
|
+
else state.stack.push(entry);
|
|
631
|
+
if (mode !== 'replace') state.page = 0;
|
|
632
|
+
/* How the address bar should record this. A step in the walk earns a
|
|
633
|
+
history entry so the browser's Back button undoes it; a refinement of the
|
|
634
|
+
view you are already on does not, or Back would spend three presses
|
|
635
|
+
undoing sort toggles. */
|
|
636
|
+
state.navMode = mode === 'replace' ? 'replace' : 'push';
|
|
637
|
+
render();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function backTo(index) {
|
|
641
|
+
state.stack.length = index + 1;
|
|
642
|
+
state.page = 0;
|
|
643
|
+
// Clicking a crumb is a navigation, so Back returns you to where you were.
|
|
644
|
+
state.navMode = 'push';
|
|
645
|
+
render();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** A table view, optionally filtered. The query text is what the bar shows. */
|
|
649
|
+
export function tableView(tableId, query, tab) {
|
|
650
|
+
return { kind: 'table', table: tableId, query: query ?? tableId, tab: tab ?? 'rows' };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* A breakdown: counts and sums, grouped by something.
|
|
655
|
+
*
|
|
656
|
+
* `table` is the base being counted, so the sidebar highlights it and the
|
|
657
|
+
* crumb reads like a table's. It is deliberately not a `tab` of the table
|
|
658
|
+
* view: the rows of a breakdown are groups rather than records, and a tab
|
|
659
|
+
* would promise they behave like the rows beside them.
|
|
660
|
+
*/
|
|
661
|
+
export function breakdownView(tableId, query) {
|
|
662
|
+
return { kind: 'breakdown', table: tableId, query };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** A single row. The key is enough to re-fetch it, so nothing goes stale. */
|
|
666
|
+
export function rowView(tableId, key, label) {
|
|
667
|
+
return { kind: 'row', table: tableId, key, label };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* A page, with or without a root yet.
|
|
672
|
+
*
|
|
673
|
+
* `table` is the page's base rather than a table being browsed, so that the
|
|
674
|
+
* sidebar highlights the right thing and a page can find its way back to a
|
|
675
|
+
* suggested layout when the id names no configured page.
|
|
676
|
+
*/
|
|
677
|
+
export function pageView(pageId, tableId, key, label) {
|
|
678
|
+
return { kind: 'page', pageId, table: tableId, key, label };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export const currentEntry = () => state.stack[state.stack.length - 1];
|
|
682
|
+
|
|
683
|
+
/* How a record opens, when the walk is already looking at pages.
|
|
684
|
+
*
|
|
685
|
+
* A page is a way of reading a record, not a place: someone who chose the
|
|
686
|
+
* page view for a customer wants the invoice they clicked to open the same
|
|
687
|
+
* way, and dropping them into the plain record view — and making them press
|
|
688
|
+
* "As a page" again — is the walk changing shape underneath them.
|
|
689
|
+
*
|
|
690
|
+
* The resolver is registered rather than imported because core must not know
|
|
691
|
+
* about pages: `page.js` already imports this file, and the preference for a
|
|
692
|
+
* saved or configured page over the suggested one lives there with the rest
|
|
693
|
+
* of the page machinery. Nothing registered means nothing changes.
|
|
694
|
+
*/
|
|
695
|
+
let pageResolver = null;
|
|
696
|
+
|
|
697
|
+
export function setPageResolver(resolve) {
|
|
698
|
+
pageResolver = resolve;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Open one record, in whichever view the walk is already in.
|
|
703
|
+
*
|
|
704
|
+
* Every route into a record goes through here, so the mode is sticky by
|
|
705
|
+
* default and a call site that wants the record view specifically still says
|
|
706
|
+
* `go(rowView(...))` — which is what the table grid and the record view's own
|
|
707
|
+
* related lists do, since neither is in page view to begin with.
|
|
708
|
+
*/
|
|
709
|
+
export async function openRecord(tableId, key, label, how = 'push') {
|
|
710
|
+
if (currentEntry()?.kind === 'page' && pageResolver) {
|
|
711
|
+
try {
|
|
712
|
+
const pageId = await pageResolver(tableId);
|
|
713
|
+
if (pageId) {
|
|
714
|
+
go(pageView(pageId, tableId, key, label), how);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
} catch {
|
|
718
|
+
/* A page that cannot be resolved is not a reason to go nowhere. */
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
go(rowView(tableId, key, label), how);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* ---------- chrome shared between views ---------- */
|
|
725
|
+
|
|
726
|
+
/* Whether the compiled SQL is on show. Held here rather than read off a
|
|
727
|
+
button, because the control that sets it moved into a menu and the pane
|
|
728
|
+
should not care which widget last toggled it. */
|
|
729
|
+
let explaining = false;
|
|
730
|
+
|
|
731
|
+
export const explainVisible = () => explaining;
|
|
732
|
+
|
|
733
|
+
export function setExplaining(on) {
|
|
734
|
+
explaining = on;
|
|
735
|
+
showExplain();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
export function setExplain(sql) {
|
|
739
|
+
$('explain').textContent = sql ?? '';
|
|
740
|
+
lastExplained = sql ?? '';
|
|
741
|
+
showExplain();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/* The pane is hidden as a whole rather than the <pre> alone, so the control
|
|
745
|
+
that edits the statement cannot outlive the statement it edits. */
|
|
746
|
+
function showExplain() {
|
|
747
|
+
const pane = $('explain-pane');
|
|
748
|
+
if (pane) pane.hidden = !explaining || !$('explain').textContent;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The statement the current view compiled to, for anything that wants to run it. */
|
|
752
|
+
let lastExplained = '';
|
|
753
|
+
export const explainedSql = () => lastExplained;
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
export function showErrors(errors) {
|
|
757
|
+
const box = $('errors');
|
|
758
|
+
box.replaceChildren(...errors.map((e) => el('p', { text: e.message })));
|
|
759
|
+
box.hidden = !errors.length;
|
|
760
|
+
|
|
761
|
+
/* A query that did not run must not be saveable as a filter.
|
|
762
|
+
|
|
763
|
+
The banner said `"quantitee" is not a column on invoice_line`, correctly
|
|
764
|
+
and helpfully — and beside it "+ Save this filter" stayed live, so a typo
|
|
765
|
+
could be given a name and kept. The clause chips stayed ordinary too,
|
|
766
|
+
which is the deeper misdescription: when the query fails *none* of them
|
|
767
|
+
are in effect, so a row of chips reading as the current filter is
|
|
768
|
+
describing something that is not happening.
|
|
769
|
+
|
|
770
|
+
Done here rather than where the bar is built because this is the only
|
|
771
|
+
moment the app knows: the bar is drawn from the query text, before the
|
|
772
|
+
answer comes back. */
|
|
773
|
+
const failed = errors.length > 0;
|
|
774
|
+
for (const save of document.querySelectorAll('.view-save')) {
|
|
775
|
+
save.disabled = failed;
|
|
776
|
+
save.title = failed ? 'Fix the query before saving it as a filter.' : '';
|
|
777
|
+
}
|
|
778
|
+
document.querySelector('.views')?.classList.toggle('not-in-effect', failed);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Quote a value for the query bar, only when it needs it.
|
|
783
|
+
*
|
|
784
|
+
* The one place the client writes a value into the grammar. There used to be
|
|
785
|
+
* three, disagreeing: two escaped an embedded `"` as `\"` and one deleted it
|
|
786
|
+
* outright, so a customer called `Harbour "HQ"` produced a different filter
|
|
787
|
+
* depending on which button you pressed — and neither matched, because the
|
|
788
|
+
* server stripped the quotes without unescaping. The server's `unquoteValue`
|
|
789
|
+
* now undoes exactly what this does; the pair is asserted as a round trip in
|
|
790
|
+
* mirrors.test.ts.
|
|
791
|
+
*
|
|
792
|
+
* The backslash is escaped before the quote, or a value ending in one would
|
|
793
|
+
* escape its own closing quote and swallow the rest of the query.
|
|
794
|
+
*/
|
|
795
|
+
export function quote(value) {
|
|
796
|
+
const text = String(value);
|
|
797
|
+
if (/^[\w.-]+$/.test(text)) return text;
|
|
798
|
+
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** A short-lived message at the top of the content area. */
|
|
802
|
+
/**
|
|
803
|
+
* A passing message, optionally with one thing to do about it.
|
|
804
|
+
*
|
|
805
|
+
* The action exists for the case where the message *is* the moment to act —
|
|
806
|
+
* "Updated 2 columns" is exactly when someone realises it was the wrong row,
|
|
807
|
+
* and sending them to a menu to find Undo wastes the seconds in which they
|
|
808
|
+
* still remember what they did. Anything that needs more than one verb belongs
|
|
809
|
+
* in a dialog rather than here.
|
|
810
|
+
*/
|
|
811
|
+
export function toast(message, tone = 'info', action) {
|
|
812
|
+
const box = $('toast');
|
|
813
|
+
box.className = `toast toast-${tone}`;
|
|
814
|
+
box.replaceChildren(el('span', { text: message }));
|
|
815
|
+
if (action) {
|
|
816
|
+
box.append(el('button', {
|
|
817
|
+
type: 'button',
|
|
818
|
+
class: 'toast-action',
|
|
819
|
+
text: action.label,
|
|
820
|
+
onclick: () => { box.hidden = true; action.run(); },
|
|
821
|
+
}));
|
|
822
|
+
}
|
|
823
|
+
box.hidden = false;
|
|
824
|
+
clearTimeout(toast.timer);
|
|
825
|
+
toast.timer = setTimeout(() => {
|
|
826
|
+
box.hidden = true;
|
|
827
|
+
// Longer when there is something to do: four seconds is enough to read a
|
|
828
|
+
// message and not enough to decide to act on one.
|
|
829
|
+
}, action ? 9000 : 4000);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/* ---------- persistence ----------
|
|
833
|
+
|
|
834
|
+
Every localStorage call is wrapped. It throws in Safari's private mode, it
|
|
835
|
+
throws when the quota is exceeded, and it is absent entirely in some
|
|
836
|
+
embedded webviews. A browser that cannot remember your saved filters should
|
|
837
|
+
still let you browse the database — losing a preference is not a reason to
|
|
838
|
+
lose the tool. */
|
|
839
|
+
|
|
840
|
+
export function loadJson(key, fallback) {
|
|
841
|
+
try {
|
|
842
|
+
migrateNamespace();
|
|
843
|
+
const raw = window.localStorage?.getItem(key);
|
|
844
|
+
if (!raw) return fallback;
|
|
845
|
+
const parsed = JSON.parse(raw);
|
|
846
|
+
return parsed ?? fallback;
|
|
847
|
+
} catch {
|
|
848
|
+
// Corrupt or unreadable: start fresh rather than breaking the page.
|
|
849
|
+
return fallback;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Carry everything over from the previous name's namespace, once.
|
|
855
|
+
*
|
|
856
|
+
* The project was tablewalk, then datawalk, and is tablewalk again. The
|
|
857
|
+
* first rename copied lazily and left the old keys in place — so a lazy copy
|
|
858
|
+
* back would find those ancient `tablewalk.*` values first and quietly
|
|
859
|
+
* resurrect them over the `datawalk.*` ones that received every write since.
|
|
860
|
+
* One sweep instead: every `datawalk.*` key overwrites its `tablewalk.*`
|
|
861
|
+
* counterpart, newest namespace wins, and a marker stops it running twice.
|
|
862
|
+
* The old keys are left alone — a migration that also deletes has no way
|
|
863
|
+
* back if it turns out to be wrong.
|
|
864
|
+
*/
|
|
865
|
+
const MIGRATION_MARK = 'tablewalk.migrated-from-datawalk';
|
|
866
|
+
function migrateNamespace() {
|
|
867
|
+
try {
|
|
868
|
+
const store = window.localStorage;
|
|
869
|
+
if (!store || store.getItem(MIGRATION_MARK)) return;
|
|
870
|
+
const carried = [];
|
|
871
|
+
for (let i = 0; i < store.length; i += 1) {
|
|
872
|
+
const key = store.key(i);
|
|
873
|
+
if (key?.startsWith('datawalk.')) carried.push(key);
|
|
874
|
+
}
|
|
875
|
+
// Collected before writing: setItem during iteration reorders the store.
|
|
876
|
+
for (const key of carried) {
|
|
877
|
+
const value = store.getItem(key);
|
|
878
|
+
if (value !== null) store.setItem(`tablewalk.${key.slice('datawalk.'.length)}`, value);
|
|
879
|
+
}
|
|
880
|
+
store.setItem(MIGRATION_MARK, '1');
|
|
881
|
+
} catch {
|
|
882
|
+
// Storage unavailable; nothing to migrate from either.
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export function saveJson(key, value) {
|
|
887
|
+
try {
|
|
888
|
+
window.localStorage?.setItem(key, JSON.stringify(value));
|
|
889
|
+
return true;
|
|
890
|
+
} catch {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
}
|