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,1972 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view composer: build a multi-table result by picking related tables and
|
|
3
|
+
* their fields, rather than by writing a join.
|
|
4
|
+
*
|
|
5
|
+
* The whole thing is a visual editor for **reference paths** — the dot-walked
|
|
6
|
+
* chains defined in shared/view.ts. `customer_id.name` means "follow
|
|
7
|
+
* invoice.customer_id to customer, then take name", and because the foreign
|
|
8
|
+
* key graph is already in the browser every hop can be proposed rather than
|
|
9
|
+
* typed. Adding a related table adds paths; ticking a field adds a path;
|
|
10
|
+
* filtering across a walk is a condition on a path. One idea, three panels.
|
|
11
|
+
*
|
|
12
|
+
* It is deliberately not a query builder with a JOIN dropdown. Nobody thinks
|
|
13
|
+
* "left join customer on customer.id = invoice.customer_id"; they think "and
|
|
14
|
+
* show me the customer's country". So the suggestions are phrased from the
|
|
15
|
+
* key — *each invoice has one customer* — and the join is a consequence you
|
|
16
|
+
* can go and read in the SQL panel if you want to.
|
|
17
|
+
*
|
|
18
|
+
* ## Self-attaching
|
|
19
|
+
*
|
|
20
|
+
* This module adds its own entry point to the top bar and its own stylesheet,
|
|
21
|
+
* and hooks navigation through the existing hash scheme rather than through
|
|
22
|
+
* app.js. Nothing else in the client imports it, which is what lets the whole
|
|
23
|
+
* feature live in two files.
|
|
24
|
+
*
|
|
25
|
+
* ## Where a view lives in the URL
|
|
26
|
+
*
|
|
27
|
+
* A composer session is a *tab on a table*, exactly like Rows, Definition and
|
|
28
|
+
* Diagram — so `/demo/invoice!view:sales-by-country` needs no second URL
|
|
29
|
+
* format and no change to deeplink.js. The serialisation already carries the
|
|
30
|
+
* tab; the id after the colon names the saved view, and a bare `!view` is a
|
|
31
|
+
* fresh composer on that table.
|
|
32
|
+
*
|
|
33
|
+
* ## Where a view is stored
|
|
34
|
+
*
|
|
35
|
+
* Saved views are per connection in localStorage, like saved filters: they are
|
|
36
|
+
* one person's working set, and `orders` on staging is not `orders` on
|
|
37
|
+
* production. Views defined in `tablewalk.json` come from the server, are
|
|
38
|
+
* marked as such, and cannot be edited from here — a page silently rewriting a
|
|
39
|
+
* file someone checked into a repo is a surprise nobody asked for.
|
|
40
|
+
*/
|
|
41
|
+
import {
|
|
42
|
+
$, api, currentEntry, el, findTable, go, labelColumn, loadJson, looksLikeDateColumn,
|
|
43
|
+
primaryKey, quote, refsFrom, refsTo, saveJson, state, tableView, toast,
|
|
44
|
+
/* Aliased rather than renamed at every call site: `hopsOut(customer)` reads
|
|
45
|
+
as a question about one table, where `hopsFrom` reads as half of a call
|
|
46
|
+
that has lost its schema argument. */
|
|
47
|
+
hopName as hopLabel, hopsFrom as hopsOut, tableAtPrefix as tableAt,
|
|
48
|
+
} from './core.js';
|
|
49
|
+
import { toHash } from './url.js';
|
|
50
|
+
import { dropdown } from './dropdown.js';
|
|
51
|
+
import { addMenuItem } from './menu.js';
|
|
52
|
+
import { promptFor } from './prompt.js';
|
|
53
|
+
|
|
54
|
+
/* Mirrors MAX_PATH_DEPTH in shared/view.ts. The cap is not decoration:
|
|
55
|
+
work_order.parent_id points at work_order, so the path space is infinite
|
|
56
|
+
and any breadth-first walk of it runs forever. */
|
|
57
|
+
export const MAX_DEPTH = 4;
|
|
58
|
+
|
|
59
|
+
const STORE = 'tablewalk.composer.v1';
|
|
60
|
+
|
|
61
|
+
/* ---------- module state ---------- */
|
|
62
|
+
|
|
63
|
+
/** The overlay, or null when the composer is closed. */
|
|
64
|
+
let root = null;
|
|
65
|
+
/**
|
|
66
|
+
* What the overlay is currently showing, as `base|viewId`.
|
|
67
|
+
*
|
|
68
|
+
* The base table is part of the identity and not an afterthought: two fresh
|
|
69
|
+
* composers both have an empty view id, so keying on the id alone means
|
|
70
|
+
* moving from `/db/invoice!view` to `/db/work_order!view` looks like no
|
|
71
|
+
* change at all and the panel keeps showing the old table.
|
|
72
|
+
*/
|
|
73
|
+
let openFor = null;
|
|
74
|
+
/** The view being edited. */
|
|
75
|
+
let draft = null;
|
|
76
|
+
/** Path prefixes whose table has been added — the joins, in tree form. */
|
|
77
|
+
let opened = new Set();
|
|
78
|
+
/** The prefix added by the last click, so the checklist can point at it. */
|
|
79
|
+
let justAdded = null;
|
|
80
|
+
/** Config-defined views for this connection, fetched once per open. */
|
|
81
|
+
let fromConfig = [];
|
|
82
|
+
/** The last preview response, kept so the SQL toggle needs no round trip. */
|
|
83
|
+
let preview = null;
|
|
84
|
+
let previewError = null;
|
|
85
|
+
let showSql = false;
|
|
86
|
+
/** The text panel, and what has been typed into it but not yet applied. */
|
|
87
|
+
let showText = false;
|
|
88
|
+
let textEdit = null;
|
|
89
|
+
let running = false;
|
|
90
|
+
let runToken = 0;
|
|
91
|
+
|
|
92
|
+
/* ---------- paths ----------
|
|
93
|
+
|
|
94
|
+
The mirror of shared/view.ts these are built on lives in core.js, where the
|
|
95
|
+
layout builder can reach it too — it used to walk the same prefixes with a
|
|
96
|
+
copy of its own, and disagreed with this one about whether a constraint
|
|
97
|
+
name is a legal path segment. */
|
|
98
|
+
|
|
99
|
+
/** A path's final column, or null if any segment does not resolve.
|
|
100
|
+
|
|
101
|
+
Deliberately stricter than resolvePath in shared/view.ts, which also
|
|
102
|
+
accepts a case-insensitive column name and refuses a path deeper than
|
|
103
|
+
MAX_PATH_DEPTH. Both differences can only ever withhold a suggestion the
|
|
104
|
+
server would have accepted; neither can propose one it would refuse. */
|
|
105
|
+
export function columnAt(baseId, path) {
|
|
106
|
+
const cut = path.lastIndexOf('.');
|
|
107
|
+
const table = tableAt(baseId, cut === -1 ? '' : path.slice(0, cut));
|
|
108
|
+
if (!table) return null;
|
|
109
|
+
const name = path.slice(cut + 1);
|
|
110
|
+
return table.columns.find((c) => c.name === name) ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const depthOf = (path) => (path ? path.split('.').length : 0);
|
|
114
|
+
|
|
115
|
+
/* ---------- plain language ----------
|
|
116
|
+
|
|
117
|
+
The phrasing is generated from the key, not written per table, and says the
|
|
118
|
+
two things a foreign key actually tells you: the direction, and whether the
|
|
119
|
+
other row has to exist. "may have one" versus "has one" is the difference
|
|
120
|
+
between a column that will show nulls and one that will not. */
|
|
121
|
+
|
|
122
|
+
function phraseOne(fromTable, hop) {
|
|
123
|
+
const target = findTable(hop.target);
|
|
124
|
+
const subject = fromTable.name;
|
|
125
|
+
const object = target ? target.name : hop.target;
|
|
126
|
+
const verb = hop.optional ? 'may have one' : 'has one';
|
|
127
|
+
const self = target && target.id === fromTable.id;
|
|
128
|
+
return self
|
|
129
|
+
? `each ${subject} ${verb} parent ${object}`
|
|
130
|
+
: `each ${subject} ${verb} ${object}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function phraseMany(toTable, fk) {
|
|
134
|
+
const child = findTable(fk.from.table);
|
|
135
|
+
return `each ${toTable.name} has many ${plural(child ? child.name : fk.from.table)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* "each country has many customer" is the kind of almost-English that makes a
|
|
139
|
+
generated sentence read as generated. Three rules cover every table name
|
|
140
|
+
anyone actually writes, and a name they do not cover is still a real word
|
|
141
|
+
with an s on the end. */
|
|
142
|
+
function plural(name) {
|
|
143
|
+
if (/(?:s|x|z|ch|sh)$/i.test(name)) return `${name}es`;
|
|
144
|
+
if (/[^aeiou]y$/i.test(name)) return `${name.slice(0, -1)}ies`;
|
|
145
|
+
return `${name}s`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ---------- storage ---------- */
|
|
149
|
+
|
|
150
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Everything stored for one connection: the views, and which ids are pinned.
|
|
154
|
+
*
|
|
155
|
+
* Pins live beside the views rather than on them because a view defined in
|
|
156
|
+
* `tablewalk.json` can be pinned too, and that view is not ours to write to.
|
|
157
|
+
* A list of ids covers both without the config half needing a copy kept here
|
|
158
|
+
* that could drift from the file.
|
|
159
|
+
*
|
|
160
|
+
* An earlier shape stored the array directly. It is read rather than
|
|
161
|
+
* discarded: someone who saved three views before this existed should not
|
|
162
|
+
* find them gone.
|
|
163
|
+
*/
|
|
164
|
+
function bucket() {
|
|
165
|
+
const stored = loadJson(STORE, {})[connectionKey()];
|
|
166
|
+
if (Array.isArray(stored)) return { views: stored, pinned: [] };
|
|
167
|
+
return { views: stored?.views ?? [], pinned: stored?.pinned ?? [] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function savedViews() {
|
|
171
|
+
return bucket().views;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const pinnedIds = () => bucket().pinned;
|
|
175
|
+
const isPinned = (id) => pinnedIds().includes(id);
|
|
176
|
+
|
|
177
|
+
function writeBucket(next) {
|
|
178
|
+
const all = loadJson(STORE, {});
|
|
179
|
+
all[connectionKey()] = next;
|
|
180
|
+
if (!saveJson(STORE, all)) {
|
|
181
|
+
toast('Could not save — this browser is not allowing local storage.', 'error');
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeSaved(list) {
|
|
188
|
+
return writeBucket({ ...bucket(), views: list });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function togglePin(id) {
|
|
192
|
+
const current = bucket();
|
|
193
|
+
const pinned = current.pinned.includes(id)
|
|
194
|
+
? current.pinned.filter((p) => p !== id)
|
|
195
|
+
: [...current.pinned, id];
|
|
196
|
+
if (writeBucket({ ...current, pinned })) redraw();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function newId() {
|
|
200
|
+
/* Time plus a little noise. Ids end up in URLs, so they stay in the
|
|
201
|
+
character set the hash scheme already treats as opaque. */
|
|
202
|
+
return `v${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ---------- the draft ---------- */
|
|
206
|
+
|
|
207
|
+
function blankView(baseId) {
|
|
208
|
+
const table = findTable(baseId);
|
|
209
|
+
const label = table ? labelColumn(table) : null;
|
|
210
|
+
const key = table ? primaryKey(table) : [];
|
|
211
|
+
/* A brand new view opens with the row's key and its readable column ticked.
|
|
212
|
+
An empty grid is a worse starting point than a small correct one: it
|
|
213
|
+
gives you nothing to recognise your data by while you build. */
|
|
214
|
+
const columns = [...new Set([...key, label].filter(Boolean))].map((path) => ({ path }));
|
|
215
|
+
return { id: '', name: '', base: baseId, columns, aggregates: [], query: '', orderBy: [], limit: 20 };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const selected = () => draft.columns.map((c) => c.path);
|
|
219
|
+
const isSelected = (path) => draft.columns.some((c) => c.path === path);
|
|
220
|
+
|
|
221
|
+
function toggleField(path) {
|
|
222
|
+
draft.columns = isSelected(path)
|
|
223
|
+
? draft.columns.filter((c) => c.path !== path)
|
|
224
|
+
: [...draft.columns, { path }];
|
|
225
|
+
changed();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function addJoin(prefix) {
|
|
229
|
+
opened.add(prefix);
|
|
230
|
+
/* A join with nothing showing through it is a join you cannot see: the SQL
|
|
231
|
+
does not even emit it, because no path uses it, so "Add" appears to do
|
|
232
|
+
nothing at all. One field comes along — the readable one where there is
|
|
233
|
+
one — so the click has a visible consequence in the grid as well as in
|
|
234
|
+
the checklist. */
|
|
235
|
+
const table = tableAt(draft.base, prefix);
|
|
236
|
+
const field = table ? defaultField(table, prefix) : null;
|
|
237
|
+
if (field) draft.columns.push({ path: `${prefix}.${field}` });
|
|
238
|
+
justAdded = prefix;
|
|
239
|
+
changed();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The one field worth showing by default from a newly joined table. */
|
|
243
|
+
function defaultField(table, prefix) {
|
|
244
|
+
const free = (name) => name && !isSelected(`${prefix}.${name}`);
|
|
245
|
+
const label = labelColumn(table);
|
|
246
|
+
if (free(label)) return label;
|
|
247
|
+
const text = table.columns.find((c) => !c.primaryKey && /char|text|varchar|string/i.test(c.type));
|
|
248
|
+
if (free(text?.name)) return text.name;
|
|
249
|
+
const key = primaryKey(table)[0];
|
|
250
|
+
if (free(key)) return key;
|
|
251
|
+
return table.columns.map((c) => c.name).find(free) ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function removeJoin(prefix) {
|
|
255
|
+
// Everything reached *through* this table goes with it, or the view keeps
|
|
256
|
+
// paths whose join has just been taken away.
|
|
257
|
+
for (const open of [...opened]) {
|
|
258
|
+
if (open === prefix || open.startsWith(`${prefix}.`)) opened.delete(open);
|
|
259
|
+
}
|
|
260
|
+
draft.columns = draft.columns.filter((c) => !c.path.startsWith(`${prefix}.`));
|
|
261
|
+
draft.orderBy = (draft.orderBy ?? []).filter((o) => !o.path.startsWith(`${prefix}.`));
|
|
262
|
+
changed();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Rebase onto another table, discarding paths that no longer mean anything. */
|
|
266
|
+
function setBase(baseId) {
|
|
267
|
+
draft = { ...blankView(baseId), id: draft.id, name: draft.name };
|
|
268
|
+
opened = new Set();
|
|
269
|
+
claim();
|
|
270
|
+
changed();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Record what is on screen and make the address bar agree with it. */
|
|
274
|
+
function claim() {
|
|
275
|
+
const tab = tabForDraft();
|
|
276
|
+
openFor = `${draft.base}|${viewTabOf({ kind: 'table', tab })}`;
|
|
277
|
+
linkTo(tab);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* One edit: redraw the panels, then re-run the preview.
|
|
282
|
+
*
|
|
283
|
+
* Kept as a pair rather than folded into `redraw`, because `redraw` also runs
|
|
284
|
+
* when nothing about the *view* changed — opening the overlay, toggling the
|
|
285
|
+
* SQL panel — and re-querying for those would put a round trip behind a
|
|
286
|
+
* button that only reveals text.
|
|
287
|
+
*/
|
|
288
|
+
function changed() {
|
|
289
|
+
redraw();
|
|
290
|
+
schedulePreview();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ---------- the view sent to the server ---------- */
|
|
294
|
+
|
|
295
|
+
function payload() {
|
|
296
|
+
return {
|
|
297
|
+
id: draft.id,
|
|
298
|
+
name: draft.name,
|
|
299
|
+
base: draft.base,
|
|
300
|
+
columns: draft.columns,
|
|
301
|
+
aggregates: draft.aggregates ?? [],
|
|
302
|
+
/* Carried, and not only when the box is empty. A view defined in
|
|
303
|
+
`tablewalk.json` holds its filter as a structure, and leaving it out
|
|
304
|
+
here was a bug with the worst possible shape: the view ran, returned
|
|
305
|
+
rows, and returned the wrong ones — an "Australian work orders" view
|
|
306
|
+
quietly listing New Zealand. */
|
|
307
|
+
filter: draft.filter,
|
|
308
|
+
orderBy: draft.orderBy ?? [],
|
|
309
|
+
limit: draft.limit,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* ---------- a filter, written back as text ----------
|
|
314
|
+
|
|
315
|
+
A structural filter that does not appear in the box is a filter you cannot
|
|
316
|
+
see, edit or argue with — the composer would be applying a condition the
|
|
317
|
+
screen denies exists. Rendering it back into the language closes that gap
|
|
318
|
+
and makes the box the single thing the view's filter *is*. */
|
|
319
|
+
|
|
320
|
+
const OP_TEXT = {
|
|
321
|
+
'=': '=', '!=': '!=', '<': '<', '<=': '<=', '>': '>', '>=': '>=',
|
|
322
|
+
contains: 'contains', startsWith: 'startswith', endsWith: 'endswith', like: 'like',
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function filterToText(filter) {
|
|
326
|
+
if (!filter?.groups?.length) return '';
|
|
327
|
+
return filter.groups
|
|
328
|
+
.map((group) => group.map(conditionToText).filter(Boolean).join(' and '))
|
|
329
|
+
.filter(Boolean)
|
|
330
|
+
.map((text, _, all) => (all.length > 1 ? `(${text})` : text))
|
|
331
|
+
.join(' or ');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function conditionToText(condition) {
|
|
335
|
+
const { path, op, value } = condition;
|
|
336
|
+
if (op === 'isNull') return `${path} is empty`;
|
|
337
|
+
if (op === 'isNotNull') return `${path} is not empty`;
|
|
338
|
+
if (op === 'in') {
|
|
339
|
+
const list = (Array.isArray(value) ? value : [value]).map(valueToText).join(', ');
|
|
340
|
+
return `${path} in (${list})`;
|
|
341
|
+
}
|
|
342
|
+
const word = OP_TEXT[op];
|
|
343
|
+
return word ? `${path} ${word} ${valueToText(value)}` : '';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function valueToText(value) {
|
|
347
|
+
/* Quoted whenever a bare word would not survive the round trip. The parser
|
|
348
|
+
treats an explicit quote as "this is text", which is exactly right for a
|
|
349
|
+
value that came out of a config file already typed.
|
|
350
|
+
|
|
351
|
+
This kept its own copy with a slightly wider bare-word set (`$`), so the
|
|
352
|
+
same value written here and in the query bar could come out differently.
|
|
353
|
+
A `$` now gets quoted instead of passing bare, which the grammar reads
|
|
354
|
+
identically — one rule beats two nearly-identical ones. */
|
|
355
|
+
return quote(String(value ?? ''));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ---------- preview ----------
|
|
359
|
+
|
|
360
|
+
Debounced, and every response carries the token of the request that asked
|
|
361
|
+
for it. Without that, a slow query started three keystrokes ago lands after
|
|
362
|
+
a fast one and the grid shows an answer to a question nobody is asking any
|
|
363
|
+
more — the classic race, and invisible when it happens. */
|
|
364
|
+
|
|
365
|
+
let previewTimer = null;
|
|
366
|
+
|
|
367
|
+
function schedulePreview() {
|
|
368
|
+
clearTimeout(previewTimer);
|
|
369
|
+
previewTimer = setTimeout(() => void runPreview(), 220);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function runPreview() {
|
|
373
|
+
if (!draft || (!draft.columns.length && !aggregates().length)) {
|
|
374
|
+
preview = null;
|
|
375
|
+
previewError = null;
|
|
376
|
+
paintPreview();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const token = ++runToken;
|
|
380
|
+
running = true;
|
|
381
|
+
paintPreview();
|
|
382
|
+
try {
|
|
383
|
+
const data = await api('/api/view/run', {
|
|
384
|
+
view: payload(),
|
|
385
|
+
query: draft.query,
|
|
386
|
+
limit: draft.limit,
|
|
387
|
+
});
|
|
388
|
+
if (token !== runToken) return;
|
|
389
|
+
if (data.errors && data.errors.length) {
|
|
390
|
+
preview = null;
|
|
391
|
+
previewError = data.errors;
|
|
392
|
+
} else {
|
|
393
|
+
preview = data;
|
|
394
|
+
previewError = null;
|
|
395
|
+
/* The text form only exists once the view has run, so this is where an
|
|
396
|
+
unsaved composer becomes a link worth copying. Stamped rather than
|
|
397
|
+
navigated: `go()` would repaint the whole app underneath the overlay,
|
|
398
|
+
and a network round trip per keystroke is exactly what a live preview
|
|
399
|
+
must not cost. */
|
|
400
|
+
stampHash();
|
|
401
|
+
if (showText) textEdit = null;
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (token !== runToken) return;
|
|
405
|
+
preview = null;
|
|
406
|
+
previewError = [{ message: err.message }];
|
|
407
|
+
} finally {
|
|
408
|
+
if (token === runToken) {
|
|
409
|
+
running = false;
|
|
410
|
+
paintPreview();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* ---------- open / close ---------- */
|
|
416
|
+
|
|
417
|
+
/** The view id encoded in a stack entry's tab, or null if it is not a view. */
|
|
418
|
+
function viewTabOf(entry) {
|
|
419
|
+
if (!entry || entry.kind !== 'table') return null;
|
|
420
|
+
const tab = entry.tab ?? 'rows';
|
|
421
|
+
if (tab === 'view') return '';
|
|
422
|
+
/* Two forms, and the leading character says which. `view:<id>` names
|
|
423
|
+
something saved; `view+<text>` carries the view itself, so a composer
|
|
424
|
+
session that was never saved is still a link you can send someone. */
|
|
425
|
+
if (tab.startsWith('view:')) return tab.slice(5);
|
|
426
|
+
if (tab.startsWith('view+')) return `+${tab.slice(5)}`;
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* The hash is one segment of a `/`-separated path, and deeplink.js finds the
|
|
431
|
+
tab by the last `!` in it. Neither may appear in the payload, and
|
|
432
|
+
encodeURIComponent leaves `!` alone. */
|
|
433
|
+
const encodeText = (text) => encodeURIComponent(text).replace(/!/g, '%21');
|
|
434
|
+
|
|
435
|
+
/** Above this the URL stops being pasteable, so the link degrades to the base. */
|
|
436
|
+
const MAX_LINK_TEXT = 1200;
|
|
437
|
+
|
|
438
|
+
function tabForDraft() {
|
|
439
|
+
if (draft.id) return `view:${draft.id}`;
|
|
440
|
+
const text = preview?.text;
|
|
441
|
+
if (text) {
|
|
442
|
+
const encoded = encodeText(text);
|
|
443
|
+
if (encoded.length <= MAX_LINK_TEXT) return `view+${encoded}`;
|
|
444
|
+
}
|
|
445
|
+
return 'view';
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function closeComposer() {
|
|
449
|
+
if (!root) return;
|
|
450
|
+
root.remove();
|
|
451
|
+
root = null;
|
|
452
|
+
openFor = null;
|
|
453
|
+
draft = null;
|
|
454
|
+
preview = null;
|
|
455
|
+
previewError = null;
|
|
456
|
+
document.body.classList.remove('composing');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function openComposer(baseId, viewId) {
|
|
460
|
+
const base = findTable(baseId) ? baseId : likeliestBase();
|
|
461
|
+
if (!base) return;
|
|
462
|
+
|
|
463
|
+
openFor = `${base}|${viewId}`;
|
|
464
|
+
showSql = false;
|
|
465
|
+
draft = blankView(base);
|
|
466
|
+
opened = new Set();
|
|
467
|
+
|
|
468
|
+
if (!root) {
|
|
469
|
+
root = el('div', { class: 'composer', role: 'dialog', 'aria-modal': 'false', 'aria-label': 'View composer' });
|
|
470
|
+
document.body.append(root);
|
|
471
|
+
document.body.classList.add('composing');
|
|
472
|
+
}
|
|
473
|
+
redraw();
|
|
474
|
+
|
|
475
|
+
/* Config views are fetched rather than cached across opens: they can change
|
|
476
|
+
when the connection does, and the request is one small GET. */
|
|
477
|
+
try {
|
|
478
|
+
const data = await api('/api/views');
|
|
479
|
+
fromConfig = data.views ?? [];
|
|
480
|
+
} catch {
|
|
481
|
+
// A server that cannot list config views is not a reason to refuse to
|
|
482
|
+
// compose one. The saved list still works.
|
|
483
|
+
fromConfig = [];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (viewId && viewId.startsWith('+')) {
|
|
487
|
+
/* The link carries the view itself. Parsed by the server, because the
|
|
488
|
+
grammar lives there — the same call the text panel makes. */
|
|
489
|
+
try {
|
|
490
|
+
const data = await api('/api/view/run', { text: decodeURIComponent(viewId.slice(1)) });
|
|
491
|
+
if (data.errors?.length) {
|
|
492
|
+
previewError = data.errors;
|
|
493
|
+
} else {
|
|
494
|
+
loadView({ ...data.view, id: '', name: draft.name });
|
|
495
|
+
preview = data;
|
|
496
|
+
}
|
|
497
|
+
} catch (err) {
|
|
498
|
+
previewError = [{ message: err.message }];
|
|
499
|
+
}
|
|
500
|
+
redraw();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (viewId) {
|
|
505
|
+
const found = [...savedViews(), ...fromConfig].find((v) => v.id === viewId);
|
|
506
|
+
if (found) {
|
|
507
|
+
loadView(found);
|
|
508
|
+
/* A saved view carries its own base table, which need not be the one
|
|
509
|
+
named in the link that opened it. Correcting the address bar keeps a
|
|
510
|
+
copied URL truthful rather than merely functional. */
|
|
511
|
+
claim();
|
|
512
|
+
} else {
|
|
513
|
+
toast(`No saved view called "${viewId}" — starting a new one.`, 'error');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
redraw();
|
|
517
|
+
schedulePreview();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function loadView(view) {
|
|
521
|
+
draft = {
|
|
522
|
+
id: view.id,
|
|
523
|
+
name: view.name ?? '',
|
|
524
|
+
base: view.base,
|
|
525
|
+
columns: (view.columns ?? []).map((c) => (typeof c === 'string' ? { path: c } : { ...c })),
|
|
526
|
+
aggregates: (view.aggregates ?? []).map((a) => ({ ...a })),
|
|
527
|
+
/* Shown in the box, so the condition a config view applies is visible and
|
|
528
|
+
editable rather than invisible and authoritative. The structure is kept
|
|
529
|
+
alongside as the authority until the box is touched, so a filter that
|
|
530
|
+
does not survive the round trip exactly still runs exactly. */
|
|
531
|
+
query: view.query ?? filterToText(view.filter),
|
|
532
|
+
filter: view.filter,
|
|
533
|
+
orderBy: view.orderBy ?? [],
|
|
534
|
+
limit: view.limit ?? 20,
|
|
535
|
+
source: view.source,
|
|
536
|
+
};
|
|
537
|
+
/* Every prefix a path walks through is a join that must be open, or the
|
|
538
|
+
tables panel would show a view whose own columns are not in it. */
|
|
539
|
+
opened = new Set();
|
|
540
|
+
for (const col of draft.columns) {
|
|
541
|
+
const parts = col.path.split('.');
|
|
542
|
+
for (let i = 1; i < parts.length; i++) opened.add(parts.slice(0, i).join('.'));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* ---------- rendering ---------- */
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Sit below the app's own top bar rather than over it.
|
|
550
|
+
*
|
|
551
|
+
* Covering the whole window would be simpler and would also hide the
|
|
552
|
+
* connection label and the schema button — the two things that tell you
|
|
553
|
+
* *which* database you are composing against. Measuring is a line; guessing a
|
|
554
|
+
* height in the stylesheet is a line that goes wrong the first time the bar
|
|
555
|
+
* wraps on a narrow window.
|
|
556
|
+
*/
|
|
557
|
+
function positionRoot() {
|
|
558
|
+
if (!root) return;
|
|
559
|
+
const bar = document.querySelector('.topbar');
|
|
560
|
+
root.style.top = bar ? `${Math.round(bar.getBoundingClientRect().bottom)}px` : '0px';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function redraw() {
|
|
564
|
+
if (!root || !draft) return;
|
|
565
|
+
positionRoot();
|
|
566
|
+
const focused = document.activeElement;
|
|
567
|
+
const keepFilter = focused && focused.classList.contains('composer-query');
|
|
568
|
+
const keepText = focused && focused.classList.contains('composer-text');
|
|
569
|
+
const caret = keepFilter || keepText ? focused.selectionStart : null;
|
|
570
|
+
|
|
571
|
+
root.replaceChildren(topBar(), body());
|
|
572
|
+
|
|
573
|
+
if (keepFilter || keepText) {
|
|
574
|
+
const input = root.querySelector(keepFilter ? '.composer-query' : '.composer-text');
|
|
575
|
+
if (input) {
|
|
576
|
+
input.focus();
|
|
577
|
+
input.setSelectionRange(caret, caret);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/* Bring a freshly added table's fields into sight. Twenty new checkboxes
|
|
582
|
+
below the fold is indistinguishable from nothing having happened, which
|
|
583
|
+
is precisely how "Add" read before this. */
|
|
584
|
+
if (justAdded) {
|
|
585
|
+
const block = root.querySelector(`.composer-group[data-prefix="${cssEscape(justAdded)}"]`);
|
|
586
|
+
if (block) block.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
587
|
+
justAdded = null;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function topBar() {
|
|
592
|
+
const name = el('input', {
|
|
593
|
+
class: 'composer-name',
|
|
594
|
+
type: 'text',
|
|
595
|
+
value: draft.name,
|
|
596
|
+
placeholder: 'Untitled view',
|
|
597
|
+
'aria-label': 'View name',
|
|
598
|
+
oninput: (e) => {
|
|
599
|
+
draft.name = e.target.value;
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
return el('header', { class: 'composer-bar' }, [
|
|
604
|
+
el('span', { class: 'composer-eyebrow', text: 'View' }),
|
|
605
|
+
name,
|
|
606
|
+
el('span', { class: 'composer-on', text: `from ${draft.base}` }),
|
|
607
|
+
el('div', { class: 'composer-bar-actions' }, [
|
|
608
|
+
/* Text first, then SQL: one is the view in the language you can edit,
|
|
609
|
+
the other is what it became. Reading left to right, that is also the
|
|
610
|
+
order the translation happens in. */
|
|
611
|
+
el('button', {
|
|
612
|
+
type: 'button',
|
|
613
|
+
class: 'ghost',
|
|
614
|
+
'aria-pressed': String(showText),
|
|
615
|
+
title: 'The view written down — editable, and the same language as the bar',
|
|
616
|
+
text: 'Text',
|
|
617
|
+
onclick: () => {
|
|
618
|
+
showText = !showText;
|
|
619
|
+
textEdit = null;
|
|
620
|
+
redraw();
|
|
621
|
+
},
|
|
622
|
+
}),
|
|
623
|
+
el('button', {
|
|
624
|
+
type: 'button',
|
|
625
|
+
class: 'ghost',
|
|
626
|
+
'aria-pressed': String(showSql),
|
|
627
|
+
title: 'The statement this compiles to',
|
|
628
|
+
text: 'SQL',
|
|
629
|
+
onclick: () => {
|
|
630
|
+
showSql = !showSql;
|
|
631
|
+
redraw();
|
|
632
|
+
},
|
|
633
|
+
}),
|
|
634
|
+
el('button', { type: 'button', class: 'ghost', text: 'Save as…', onclick: () => void saveAs() }),
|
|
635
|
+
el('button', { type: 'button', text: 'Save', onclick: () => void saveDraft() }),
|
|
636
|
+
el('button', {
|
|
637
|
+
type: 'button',
|
|
638
|
+
class: 'ghost composer-close',
|
|
639
|
+
'aria-label': 'Close the composer',
|
|
640
|
+
title: 'Close (Esc)',
|
|
641
|
+
text: '×',
|
|
642
|
+
onclick: () => leave(),
|
|
643
|
+
}),
|
|
644
|
+
]),
|
|
645
|
+
]);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function body() {
|
|
649
|
+
return el('div', { class: 'composer-body' }, [
|
|
650
|
+
/* Saved views above the relationship list, not below it. The list of
|
|
651
|
+
relationships is long and scrolls; anything under it is, in practice,
|
|
652
|
+
not on the page. The thing you might want *before* building — the view
|
|
653
|
+
you already built — cannot be the thing you have to scroll to find. */
|
|
654
|
+
el('aside', { class: 'composer-side' }, [startPanel(), savedPanel(), relatedPanel()]),
|
|
655
|
+
el('div', { class: 'composer-main' }, [
|
|
656
|
+
fieldsPanel(),
|
|
657
|
+
filterPanel(),
|
|
658
|
+
summaryLine(),
|
|
659
|
+
showText ? textPanel() : null,
|
|
660
|
+
showSql ? sqlPanel() : null,
|
|
661
|
+
el('div', { class: 'composer-preview', id: 'composer-preview' }, previewBody()),
|
|
662
|
+
]),
|
|
663
|
+
]);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* What the result *is*, in one sentence, above the grid that shows it.
|
|
668
|
+
*
|
|
669
|
+
* This is the question the panels do not answer. They show ingredients —
|
|
670
|
+
* relationships, checkboxes, a WHERE box — and leave the shape of the answer
|
|
671
|
+
* to be inferred. The two facts worth stating are the ones people get wrong
|
|
672
|
+
* about joins: how many rows there are, and what happens when the other row
|
|
673
|
+
* is missing.
|
|
674
|
+
*/
|
|
675
|
+
function summaryLine() {
|
|
676
|
+
const base = findTable(draft.base);
|
|
677
|
+
const name = base ? base.name : draft.base;
|
|
678
|
+
|
|
679
|
+
const sources = [...opened].sort().map((prefix) => {
|
|
680
|
+
const table = tableAt(draft.base, prefix);
|
|
681
|
+
return { prefix, name: table ? table.name : prefix };
|
|
682
|
+
});
|
|
683
|
+
/* Two hops onto the same table are two different things — `raised_by` and
|
|
684
|
+
`assigned_to` are both "employee" — so the path is added when the bare
|
|
685
|
+
name would be ambiguous, and left off when it would just be noise. */
|
|
686
|
+
const counts = new Map();
|
|
687
|
+
for (const s of sources) counts.set(s.name, (counts.get(s.name) ?? 0) + 1);
|
|
688
|
+
const labels = sources.map((s) => (counts.get(s.name) > 1 ? `${s.name} (${s.prefix})` : s.name));
|
|
689
|
+
|
|
690
|
+
const rolled = (draft.aggregates ?? []).map((a) => aggregateLabel(a));
|
|
691
|
+
|
|
692
|
+
const parts = [`One row per ${name}`];
|
|
693
|
+
if (labels.length) parts.push(`, with columns from ${andList(labels)}`);
|
|
694
|
+
if (rolled.length) parts.push(`${labels.length ? ', and' : ', with'} ${andList(rolled)}`);
|
|
695
|
+
parts.push('.');
|
|
696
|
+
|
|
697
|
+
const sentence = parts.join('');
|
|
698
|
+
const promise = labels.length
|
|
699
|
+
? ` Every ${name} appears exactly once, even where the related row is missing — those cells read null rather than dropping the row.`
|
|
700
|
+
: '';
|
|
701
|
+
|
|
702
|
+
return el('p', { class: 'composer-summary' }, [
|
|
703
|
+
el('strong', { text: sentence }),
|
|
704
|
+
promise ? document.createTextNode(promise) : null,
|
|
705
|
+
]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function andList(items) {
|
|
709
|
+
if (items.length <= 1) return items[0] ?? '';
|
|
710
|
+
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* ---------- panel: start from ---------- */
|
|
714
|
+
|
|
715
|
+
function startPanel() {
|
|
716
|
+
const items = state.schema.tables.map((t) => ({
|
|
717
|
+
value: t.id,
|
|
718
|
+
label: t.id,
|
|
719
|
+
detail: `${t.columns.length} columns${t.approxRows != null ? ` · ${t.approxRows.toLocaleString()} rows` : ''}`,
|
|
720
|
+
badge: t.isView ? 'view' : undefined,
|
|
721
|
+
}));
|
|
722
|
+
return el('section', { class: 'panel composer-panel' }, [
|
|
723
|
+
el('h3', { text: 'Start from' }),
|
|
724
|
+
el('div', { class: 'composer-pad' }, dropdown({
|
|
725
|
+
items,
|
|
726
|
+
value: draft.base,
|
|
727
|
+
ariaLabel: 'Base table',
|
|
728
|
+
onChange: (id) => setBase(id),
|
|
729
|
+
})),
|
|
730
|
+
]);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* ---------- panel: related tables ---------- */
|
|
734
|
+
|
|
735
|
+
function relatedPanel() {
|
|
736
|
+
const panel = el('section', { class: 'panel composer-panel composer-related' }, el('h3', { text: 'Related tables' }));
|
|
737
|
+
const list = el('ul', { class: 'composer-rel' });
|
|
738
|
+
outgoing(list, draft.base, '', 0);
|
|
739
|
+
|
|
740
|
+
/* The other direction, and the honest treatment of it.
|
|
741
|
+
|
|
742
|
+
A has-many cannot be joined into this statement without multiplying: one
|
|
743
|
+
work order with four parts and three notes becomes twelve rows, each
|
|
744
|
+
repeating the work order's own columns, and "20 rows" stops meaning 20
|
|
745
|
+
work orders. Almost nobody asking for "work orders with their parts"
|
|
746
|
+
wants that, and a composer that produces it silently is worse than one
|
|
747
|
+
that refuses — the duplication reads as a bug in the data.
|
|
748
|
+
|
|
749
|
+
So the many side is offered as the two things that *are* one row per
|
|
750
|
+
record: a summary of the children, or a view starting from them. */
|
|
751
|
+
const incoming = refsTo(draft.base);
|
|
752
|
+
if (incoming.length) {
|
|
753
|
+
const base = findTable(draft.base);
|
|
754
|
+
list.append(el('li', { class: 'composer-rel-head', text: 'Pointing back at it' }));
|
|
755
|
+
for (const fk of incoming) {
|
|
756
|
+
const child = findTable(fk.from.table);
|
|
757
|
+
if (!child) continue;
|
|
758
|
+
list.append(el('li', { class: 'composer-rel-item many' }, [
|
|
759
|
+
el('span', { class: 'composer-phrase', text: phraseMany(base, fk) }),
|
|
760
|
+
el('span', {
|
|
761
|
+
class: 'composer-via',
|
|
762
|
+
text: `via ${fk.from.columns.join(', ')} · joining it would repeat each ${base.name} once per ${child.name}`,
|
|
763
|
+
}),
|
|
764
|
+
el('span', { class: 'composer-many-actions' }, [
|
|
765
|
+
countButton(fk, child, base),
|
|
766
|
+
summariseMenu(fk, child),
|
|
767
|
+
el('button', {
|
|
768
|
+
type: 'button',
|
|
769
|
+
class: 'composer-add',
|
|
770
|
+
title: `Start a view from ${child.name}, showing this ${base.name}`,
|
|
771
|
+
text: 'Start here',
|
|
772
|
+
onclick: () => startFromChild(fk),
|
|
773
|
+
}),
|
|
774
|
+
]),
|
|
775
|
+
]));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
panel.append(list);
|
|
780
|
+
return panel;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ---------- summaries of the many side ---------- */
|
|
784
|
+
|
|
785
|
+
const aggregates = () => draft.aggregates ?? [];
|
|
786
|
+
|
|
787
|
+
const sameAggregate = (a, b) =>
|
|
788
|
+
a.fn === b.fn && a.via === b.via && (a.on ?? '') === (b.on ?? '') && (a.column ?? '') === (b.column ?? '');
|
|
789
|
+
|
|
790
|
+
function addAggregate(next) {
|
|
791
|
+
if (aggregates().some((a) => sameAggregate(a, next))) return;
|
|
792
|
+
draft.aggregates = [...aggregates(), next];
|
|
793
|
+
changed();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function removeAggregate(target) {
|
|
797
|
+
draft.aggregates = aggregates().filter((a) => !sameAggregate(a, target));
|
|
798
|
+
changed();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function countButton(fk, child, base) {
|
|
802
|
+
const spec = { fn: 'count', via: fk.name };
|
|
803
|
+
const on = aggregates().some((a) => sameAggregate(a, spec));
|
|
804
|
+
return el('button', {
|
|
805
|
+
type: 'button',
|
|
806
|
+
class: `composer-add${on ? ' on' : ''}`,
|
|
807
|
+
'aria-pressed': String(on),
|
|
808
|
+
title: on
|
|
809
|
+
? `Remove the ${child.name} count`
|
|
810
|
+
: `Add a column counting each ${base.name}'s ${plural(child.name)} — one row per ${base.name}, no repeats`,
|
|
811
|
+
text: on ? 'Counted' : 'Count',
|
|
812
|
+
onclick: () => (on ? removeAggregate(spec) : addAggregate(spec)),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Sum, min, max and average over a child column.
|
|
818
|
+
*
|
|
819
|
+
* A themed listbox rather than a row of buttons, because the useful
|
|
820
|
+
* combinations are (four functions × every number column) and that is a menu,
|
|
821
|
+
* not a toolbar. Dates get min and max only: an average of two timestamps is
|
|
822
|
+
* a number nobody asked for.
|
|
823
|
+
*/
|
|
824
|
+
/**
|
|
825
|
+
* Plain language for a summary, rather than function syntax.
|
|
826
|
+
*
|
|
827
|
+
* `sum(quantity)` asks the reader to translate; "Total quantity" is the thing
|
|
828
|
+
* they were already trying to say. Every tool that does this well — Metabase's
|
|
829
|
+
* "Count of rows", Airtable's rollups — leads with the question rather than
|
|
830
|
+
* the function, and the function name is recoverable from the result column
|
|
831
|
+
* anyway.
|
|
832
|
+
*/
|
|
833
|
+
function summaryLabel(fn, column, isDate) {
|
|
834
|
+
switch (fn) {
|
|
835
|
+
case 'sum': return `Total ${column}`;
|
|
836
|
+
case 'avg': return `Average ${column}`;
|
|
837
|
+
case 'min': return isDate ? `Earliest ${column}` : `Smallest ${column}`;
|
|
838
|
+
case 'max': return isDate ? `Latest ${column}` : `Largest ${column}`;
|
|
839
|
+
default: return `${fn} ${column}`;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function summariseMenu(fk, child) {
|
|
844
|
+
const items = [{ value: '', label: 'Summarise…', detail: 'a total, an average, a range' }];
|
|
845
|
+
for (const column of child.columns) {
|
|
846
|
+
if (column.primaryKey) continue;
|
|
847
|
+
/* Foreign keys are excluded. They are numeric, so a type check alone lets
|
|
848
|
+
them through, and the menu offered `sum(customer_id)` and
|
|
849
|
+
`avg(sold_by)` — adding up identifiers, which is never a question
|
|
850
|
+
anyone has. A key is a name that happens to be spelled with digits. */
|
|
851
|
+
if (column.references) continue;
|
|
852
|
+
const type = column.type.toLowerCase();
|
|
853
|
+
const numeric = /int|numeric|decimal|real|double|float|money|serial/.test(type);
|
|
854
|
+
const dateish = /date|time/.test(type);
|
|
855
|
+
if (!numeric && !dateish) continue;
|
|
856
|
+
/* A flag is numeric in SQLite and averaging it is meaningless. */
|
|
857
|
+
if (numeric && /^(is|has|can|should)_|^(active|enabled|urgent|billable)/.test(column.name)) continue;
|
|
858
|
+
const fns = numeric ? ['sum', 'avg', 'min', 'max'] : ['min', 'max'];
|
|
859
|
+
for (const fn of fns) {
|
|
860
|
+
items.push({
|
|
861
|
+
value: `${fn}:${column.name}`,
|
|
862
|
+
label: summaryLabel(fn, column.name, dateish),
|
|
863
|
+
// What the resulting column will be called, so the trade between a
|
|
864
|
+
// readable label and a namable column is visible before committing.
|
|
865
|
+
detail: `${fn}(${column.name})`,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (items.length === 1) return null;
|
|
870
|
+
|
|
871
|
+
return el('span', { class: 'composer-menu' }, dropdown({
|
|
872
|
+
items,
|
|
873
|
+
value: '',
|
|
874
|
+
ariaLabel: `Summarise ${child.name}`,
|
|
875
|
+
onChange: (value) => {
|
|
876
|
+
if (!value) return;
|
|
877
|
+
const [fn, column] = value.split(':');
|
|
878
|
+
addAggregate({ fn, via: fk.name, column });
|
|
879
|
+
},
|
|
880
|
+
}));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** How a summary reads in a sentence: "a count of work_order_parts". */
|
|
884
|
+
function aggregateLabel(spec) {
|
|
885
|
+
const fk = state.schema.foreignKeys.find((f) => f.name === spec.via);
|
|
886
|
+
const child = fk ? findTable(fk.from.table) : null;
|
|
887
|
+
const name = child ? child.name : spec.via;
|
|
888
|
+
const where = spec.on ? ` on its ${spec.on}` : '';
|
|
889
|
+
return spec.fn === 'count'
|
|
890
|
+
? `a count of ${plural(name)}${where}`
|
|
891
|
+
: `the ${spec.fn} of ${name}.${spec.column}${where}`;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* One level of the join tree, and recursively the levels below it.
|
|
896
|
+
*
|
|
897
|
+
* Hops are grouped by the table they land on. `work_order` reaches `employee`
|
|
898
|
+
* three ways — raised_by, assigned_to, approved_by — and listing them as three
|
|
899
|
+
* sentences that differ only in a trailing column name is a list you have to
|
|
900
|
+
* read word by word rather than scan. Grouped, the question becomes the one
|
|
901
|
+
* actually being asked: *which* employee.
|
|
902
|
+
*
|
|
903
|
+
* A table reachable exactly one way keeps its full sentence, because a header
|
|
904
|
+
* plus a single row underneath is ceremony around one fact.
|
|
905
|
+
*/
|
|
906
|
+
function outgoing(list, tableId, prefix, depth) {
|
|
907
|
+
const table = findTable(tableId);
|
|
908
|
+
if (!table) return;
|
|
909
|
+
|
|
910
|
+
const groups = new Map();
|
|
911
|
+
for (const hop of hopsOut(tableId)) {
|
|
912
|
+
const list_ = groups.get(hop.target) ?? [];
|
|
913
|
+
list_.push(hop);
|
|
914
|
+
groups.set(hop.target, list_);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
for (const [targetId, hops] of groups) {
|
|
918
|
+
const target = findTable(targetId);
|
|
919
|
+
const rows = target && target.approxRows != null
|
|
920
|
+
? ` · ${target.approxRows.toLocaleString()} rows`
|
|
921
|
+
: '';
|
|
922
|
+
|
|
923
|
+
if (hops.length === 1) {
|
|
924
|
+
list.append(hopRow(table, hops[0], prefix, depth, {
|
|
925
|
+
phrase: phraseOne(table, hops[0]),
|
|
926
|
+
detail: `via ${hops[0].name}${rows}`,
|
|
927
|
+
}));
|
|
928
|
+
if (opened.has(pathOf(prefix, hops[0]))) {
|
|
929
|
+
outgoing(list, targetId, pathOf(prefix, hops[0]), depth + 1);
|
|
930
|
+
}
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/* The header states the relationship once; "may have one" is used when
|
|
935
|
+
any of the ways is optional, since the group as a whole then cannot
|
|
936
|
+
promise a row. Which particular way is optional is said on its own
|
|
937
|
+
line, where it is actionable. */
|
|
938
|
+
const anyOptional = hops.some((h) => h.optional);
|
|
939
|
+
const self = targetId === table.id;
|
|
940
|
+
list.append(el('li', {
|
|
941
|
+
class: 'composer-rel-group',
|
|
942
|
+
style: depth ? `padding-left:${10 + depth * 14}px` : undefined,
|
|
943
|
+
}, [
|
|
944
|
+
el('span', {
|
|
945
|
+
class: 'composer-phrase',
|
|
946
|
+
text: `each ${table.name} ${anyOptional ? 'may have one' : 'has one'} `
|
|
947
|
+
+ `${self ? 'related ' : ''}${target ? target.name : targetId}`,
|
|
948
|
+
}),
|
|
949
|
+
el('span', { class: 'composer-ways', text: `${hops.length} ways${rows}` }),
|
|
950
|
+
]));
|
|
951
|
+
|
|
952
|
+
for (const hop of hops) {
|
|
953
|
+
list.append(hopRow(table, hop, prefix, depth, {
|
|
954
|
+
detail: `via ${hop.name}${hop.optional ? ' · may be empty' : ''}`,
|
|
955
|
+
inGroup: true,
|
|
956
|
+
}));
|
|
957
|
+
if (opened.has(pathOf(prefix, hop))) outgoing(list, targetId, pathOf(prefix, hop), depth + 1);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const pathOf = (prefix, hop) => (prefix ? `${prefix}.${hop.name}` : hop.name);
|
|
963
|
+
|
|
964
|
+
function hopRow(table, hop, prefix, depth, { phrase, detail, inGroup }) {
|
|
965
|
+
const path = pathOf(prefix, hop);
|
|
966
|
+
const isOpen = opened.has(path);
|
|
967
|
+
const tooDeep = depth >= MAX_DEPTH;
|
|
968
|
+
const indent = 10 + depth * 14 + (inGroup ? 12 : 0);
|
|
969
|
+
|
|
970
|
+
return el('li', {
|
|
971
|
+
class: `composer-rel-item${isOpen ? ' open' : ''}${inGroup ? ' in-group' : ''}`,
|
|
972
|
+
style: indent === 10 ? undefined : `padding-left:${indent}px`,
|
|
973
|
+
}, [
|
|
974
|
+
phrase ? el('span', { class: 'composer-phrase', text: phrase }) : null,
|
|
975
|
+
el('span', { class: 'composer-via', text: detail }),
|
|
976
|
+
tooDeep && !isOpen
|
|
977
|
+
? el('span', { class: 'composer-cap', title: `A path may follow at most ${MAX_DEPTH} references.`, text: 'too deep' })
|
|
978
|
+
: el('button', {
|
|
979
|
+
type: 'button',
|
|
980
|
+
class: `composer-add${isOpen ? ' on' : ''}`,
|
|
981
|
+
'aria-pressed': String(isOpen),
|
|
982
|
+
title: isOpen
|
|
983
|
+
? `Remove ${path} and every field taken through it`
|
|
984
|
+
: `Add ${path} — its fields join the checklist above`,
|
|
985
|
+
text: isOpen ? 'Added' : 'Add',
|
|
986
|
+
onclick: () => (isOpen ? removeJoin(path) : addJoin(path)),
|
|
987
|
+
}),
|
|
988
|
+
]);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Turn "each customer has many invoices" into a view of invoices.
|
|
993
|
+
*
|
|
994
|
+
* The reverse of a to-many is a to-one, so the child's own key becomes the
|
|
995
|
+
* first hop and the thing you were looking at is a column on it. That is the
|
|
996
|
+
* same result the user wanted, expressed as something a path can say.
|
|
997
|
+
*/
|
|
998
|
+
function startFromChild(fk) {
|
|
999
|
+
const child = findTable(fk.from.table);
|
|
1000
|
+
if (!child) return;
|
|
1001
|
+
draft = { ...blankView(child.id), id: draft.id, name: draft.name };
|
|
1002
|
+
opened = new Set();
|
|
1003
|
+
claim();
|
|
1004
|
+
const hop = hopsOut(child.id).find((h) => h.fk.name === fk.name);
|
|
1005
|
+
if (hop) addJoin(hop.name);
|
|
1006
|
+
else changed();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/* ---------- panel: fields ---------- */
|
|
1010
|
+
|
|
1011
|
+
function fieldsPanel() {
|
|
1012
|
+
const panel = el('section', { class: 'panel composer-panel composer-fields' }, [
|
|
1013
|
+
el('h3', {}, [
|
|
1014
|
+
document.createTextNode('Fields'),
|
|
1015
|
+
el('span', { class: 'composer-count', text: `${draft.columns.length} shown` }),
|
|
1016
|
+
]),
|
|
1017
|
+
]);
|
|
1018
|
+
|
|
1019
|
+
const groups = [{ prefix: '', tableId: draft.base }];
|
|
1020
|
+
for (const prefix of [...opened].sort()) {
|
|
1021
|
+
const table = tableAt(draft.base, prefix);
|
|
1022
|
+
if (table) groups.push({ prefix, tableId: table.id });
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/* One sentence, once, and only until it has been acted on. A view is an
|
|
1026
|
+
unfamiliar idea and the panel below is just a wall of checkboxes until
|
|
1027
|
+
someone tells you what the wall is for; after the first Add the sentence
|
|
1028
|
+
is describing something you can already see, so it goes away. */
|
|
1029
|
+
if (groups.length === 1) {
|
|
1030
|
+
panel.append(el('p', { class: 'composer-empty' }, [
|
|
1031
|
+
el('strong', { text: `A view is one row per ${findTable(draft.base)?.name ?? draft.base}, ` }),
|
|
1032
|
+
document.createTextNode(
|
|
1033
|
+
'with any field from any table it points at. Tick fields here; press '
|
|
1034
|
+
+ '“Add” beside a related table on the left to bring its fields into this list too.',
|
|
1035
|
+
),
|
|
1036
|
+
]));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
for (const group of groups) {
|
|
1040
|
+
const table = findTable(group.tableId);
|
|
1041
|
+
if (!table) continue;
|
|
1042
|
+
/* The group that just arrived is marked and scrolled to. Adding a related
|
|
1043
|
+
table put twenty new checkboxes on screen; without this they appear
|
|
1044
|
+
below the fold and the click reads as having done nothing. */
|
|
1045
|
+
const fresh = group.prefix && group.prefix === justAdded;
|
|
1046
|
+
const block = el('div', {
|
|
1047
|
+
class: `composer-group${fresh ? ' fresh' : ''}`,
|
|
1048
|
+
'data-prefix': group.prefix || undefined,
|
|
1049
|
+
}, [
|
|
1050
|
+
el('div', { class: 'composer-group-head' }, [
|
|
1051
|
+
el('span', { class: 'composer-group-name', text: table.name }),
|
|
1052
|
+
group.prefix
|
|
1053
|
+
? el('span', { class: 'composer-group-path', text: `via ${group.prefix}` })
|
|
1054
|
+
: el('span', { class: 'composer-group-path', text: 'base table' }),
|
|
1055
|
+
fresh ? el('span', { class: 'composer-group-new', text: 'just added' }) : null,
|
|
1056
|
+
]),
|
|
1057
|
+
]);
|
|
1058
|
+
const grid = el('div', { class: 'composer-checks' });
|
|
1059
|
+
for (const column of table.columns) {
|
|
1060
|
+
const path = group.prefix ? `${group.prefix}.${column.name}` : column.name;
|
|
1061
|
+
if (depthOf(path) > MAX_DEPTH + 1) continue;
|
|
1062
|
+
const on = isSelected(path);
|
|
1063
|
+
grid.append(el('label', { class: `composer-check${on ? ' on' : ''}`, title: path }, [
|
|
1064
|
+
el('input', {
|
|
1065
|
+
type: 'checkbox',
|
|
1066
|
+
checked: on || undefined,
|
|
1067
|
+
onchange: () => toggleField(path),
|
|
1068
|
+
}),
|
|
1069
|
+
el('span', { class: 'composer-field-name', text: column.name }),
|
|
1070
|
+
el('span', { class: 'composer-field-type', text: column.type.toLowerCase() }),
|
|
1071
|
+
column.primaryKey ? el('span', { class: 'composer-mark pk', text: 'key' }) : null,
|
|
1072
|
+
column.references ? el('span', { class: 'composer-mark fk', title: `→ ${column.references.table}`, text: '→' }) : null,
|
|
1073
|
+
]));
|
|
1074
|
+
}
|
|
1075
|
+
block.append(grid);
|
|
1076
|
+
panel.append(block);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/* Summaries live in the checklist rather than only where they were added,
|
|
1080
|
+
because that is where you look to find out what the result contains — and
|
|
1081
|
+
a column you cannot find is a column you cannot remove. */
|
|
1082
|
+
if (aggregates().length) {
|
|
1083
|
+
const block = el('div', { class: 'composer-group' }, [
|
|
1084
|
+
el('div', { class: 'composer-group-head' }, [
|
|
1085
|
+
el('span', { class: 'composer-group-name', text: 'summaries' }),
|
|
1086
|
+
el('span', { class: 'composer-group-path', text: 'one value per row, from many rows' }),
|
|
1087
|
+
]),
|
|
1088
|
+
]);
|
|
1089
|
+
const grid = el('div', { class: 'composer-checks' });
|
|
1090
|
+
for (const spec of aggregates()) {
|
|
1091
|
+
grid.append(el('label', { class: 'composer-check on', title: aggregateLabel(spec) }, [
|
|
1092
|
+
el('input', { type: 'checkbox', checked: true, onchange: () => removeAggregate(spec) }),
|
|
1093
|
+
el('span', { class: 'composer-field-name', text: shortAggregate(spec) }),
|
|
1094
|
+
el('span', { class: 'composer-field-type', text: spec.fn }),
|
|
1095
|
+
]));
|
|
1096
|
+
}
|
|
1097
|
+
block.append(grid);
|
|
1098
|
+
panel.append(block);
|
|
1099
|
+
}
|
|
1100
|
+
return panel;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** The chip's text: `count(work_order_part)`, matching the column header. */
|
|
1104
|
+
function shortAggregate(spec) {
|
|
1105
|
+
const fk = state.schema.foreignKeys.find((f) => f.name === spec.via);
|
|
1106
|
+
const child = fk ? findTable(fk.from.table) : null;
|
|
1107
|
+
const name = `${spec.on ? `${spec.on}.` : ''}${child ? child.name : spec.via}`;
|
|
1108
|
+
return `${spec.fn}(${name}${spec.column ? `.${spec.column}` : ''})`;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/* ---------- panel: filter ---------- */
|
|
1112
|
+
|
|
1113
|
+
function filterPanel() {
|
|
1114
|
+
const input = el('input', {
|
|
1115
|
+
class: 'composer-query',
|
|
1116
|
+
type: 'text',
|
|
1117
|
+
spellcheck: 'false',
|
|
1118
|
+
value: draft.query,
|
|
1119
|
+
'aria-label': 'Filter',
|
|
1120
|
+
placeholder: 'customer_id.country_code.name = Australia and total > 500',
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
const box = el('div', { class: 'ac composer-ac', hidden: true, role: 'listbox' });
|
|
1124
|
+
attachComplete(input, box);
|
|
1125
|
+
|
|
1126
|
+
input.addEventListener('input', () => {
|
|
1127
|
+
draft.query = input.value;
|
|
1128
|
+
/* The moment the box is edited it becomes the filter, entirely. Keeping
|
|
1129
|
+
the loaded structure as a fallback would mean clearing the box quietly
|
|
1130
|
+
restores a condition you just deleted — which reads as the tool
|
|
1131
|
+
ignoring you. */
|
|
1132
|
+
draft.filter = undefined;
|
|
1133
|
+
schedulePreview();
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
const rows = el('input', {
|
|
1137
|
+
class: 'composer-limit',
|
|
1138
|
+
type: 'number',
|
|
1139
|
+
min: '1',
|
|
1140
|
+
max: '1000',
|
|
1141
|
+
value: String(draft.limit),
|
|
1142
|
+
'aria-label': 'Rows',
|
|
1143
|
+
onchange: (e) => {
|
|
1144
|
+
const n = Number(e.target.value);
|
|
1145
|
+
draft.limit = Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 1000) : 20;
|
|
1146
|
+
schedulePreview();
|
|
1147
|
+
},
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
return el('section', { class: 'composer-filter' }, [
|
|
1151
|
+
el('div', { class: 'composer-filter-row' }, [
|
|
1152
|
+
el('span', { class: 'composer-filter-label', text: 'where' }),
|
|
1153
|
+
el('span', { class: 'composer-query-wrap' }, [input, box]),
|
|
1154
|
+
el('span', { class: 'composer-filter-label', text: 'rows' }),
|
|
1155
|
+
rows,
|
|
1156
|
+
]),
|
|
1157
|
+
el('p', {
|
|
1158
|
+
class: 'composer-hint',
|
|
1159
|
+
text: 'Same language as the bar at the top, over paths: '
|
|
1160
|
+
+ 'a dot follows a reference, so customer_id.name is the customer’s name.',
|
|
1161
|
+
}),
|
|
1162
|
+
]);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/* ---------- path-aware autocomplete ----------
|
|
1166
|
+
|
|
1167
|
+
complete.js does this for the query bar and is left alone: it completes
|
|
1168
|
+
*columns*, and a path is a different shape — the useful suggestion after
|
|
1169
|
+
`customer_id.` is the next table's columns, which needs the key graph
|
|
1170
|
+
walked. Same keyboard contract, different candidate set. */
|
|
1171
|
+
|
|
1172
|
+
const CLAUSES = ['sort', 'show', 'limit'];
|
|
1173
|
+
const JOINERS = ['and', 'or'];
|
|
1174
|
+
const OPERATORS = ['=', '!=', '>', '>=', '<', '<=', 'contains', 'startswith', 'endswith', 'like', 'in'];
|
|
1175
|
+
const NULL_CHECKS = ['is empty', 'is not empty'];
|
|
1176
|
+
const DATE_VALUES = ['today', 'yesterday', 'this week', 'last week', 'this month', 'last month', 'last 7 days', 'last 30 days'];
|
|
1177
|
+
const EXPECTS_FIELD = new Set(['and', 'or', 'where', 'filter', 'show', 'select', 'sort', 'by', '']);
|
|
1178
|
+
|
|
1179
|
+
function wordAt(text, caret) {
|
|
1180
|
+
const before = text.slice(0, caret);
|
|
1181
|
+
const start = Math.max(before.lastIndexOf(' '), before.lastIndexOf(',')) + 1;
|
|
1182
|
+
return { word: before.slice(start), start };
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** Paths reachable from a typed prefix: this table's columns, and its hops. */
|
|
1186
|
+
function pathCandidates(word) {
|
|
1187
|
+
const cut = word.lastIndexOf('.');
|
|
1188
|
+
const prefix = cut === -1 ? '' : word.slice(0, cut);
|
|
1189
|
+
const fragment = (cut === -1 ? word : word.slice(cut + 1)).toLowerCase();
|
|
1190
|
+
const table = tableAt(draft.base, prefix);
|
|
1191
|
+
if (!table) return [];
|
|
1192
|
+
const lead = prefix ? `${prefix}.` : '';
|
|
1193
|
+
const out = [];
|
|
1194
|
+
|
|
1195
|
+
for (const column of table.columns) {
|
|
1196
|
+
if (fragment && !column.name.toLowerCase().startsWith(fragment)) continue;
|
|
1197
|
+
out.push({
|
|
1198
|
+
value: `${lead}${column.name}`,
|
|
1199
|
+
label: `${lead}${column.name}`,
|
|
1200
|
+
detail: `${column.type.toLowerCase()}${column.primaryKey ? ' · key' : ''}`,
|
|
1201
|
+
kind: 'value',
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
/* Hops end in a dot and do not close the token, so accepting one leaves the
|
|
1205
|
+
caret mid-path with the next table's columns already listed. That chaining
|
|
1206
|
+
is the whole reason dot-walking is pleasant to type. */
|
|
1207
|
+
if (depthOf(prefix) < MAX_DEPTH) {
|
|
1208
|
+
for (const hop of hopsOut(table.id)) {
|
|
1209
|
+
if (fragment && !hop.name.toLowerCase().startsWith(fragment)) continue;
|
|
1210
|
+
const target = findTable(hop.target);
|
|
1211
|
+
out.push({
|
|
1212
|
+
value: `${lead}${hop.name}.`,
|
|
1213
|
+
label: `${lead}${hop.name}.`,
|
|
1214
|
+
detail: `→ ${target ? target.name : hop.target}`,
|
|
1215
|
+
kind: 'table',
|
|
1216
|
+
partial: true,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return out;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function completionsFor(text, caret) {
|
|
1224
|
+
const { word, start } = wordAt(text, caret);
|
|
1225
|
+
const previous = text.slice(0, start).trim().split(/\s+/).pop()?.toLowerCase() ?? '';
|
|
1226
|
+
const lower = word.toLowerCase();
|
|
1227
|
+
const matches = (list) => list.filter((s) => !lower || s.value.toLowerCase().startsWith(lower));
|
|
1228
|
+
|
|
1229
|
+
if (word.includes('.')) return pathCandidates(word);
|
|
1230
|
+
|
|
1231
|
+
const previousColumn = previous ? columnAt(draft.base, previous) : null;
|
|
1232
|
+
if (previousColumn) {
|
|
1233
|
+
/* Was a fourth copy of the date test, and the narrowest: its name pattern
|
|
1234
|
+
had lost `datetime`, `timestamp` and `deleted`, so `event_timestamp`
|
|
1235
|
+
got `=` and nothing else in the composer while the query bar offered it
|
|
1236
|
+
the relative-date phrases — the language's best feature, missing on the
|
|
1237
|
+
columns it exists for. */
|
|
1238
|
+
const isDate = looksLikeDateColumn(previousColumn);
|
|
1239
|
+
const dates = isDate
|
|
1240
|
+
? DATE_VALUES.map((v) => ({ value: `= ${v}`, label: `= ${v}`, detail: 'date range', kind: 'value' }))
|
|
1241
|
+
: [];
|
|
1242
|
+
return matches([
|
|
1243
|
+
...dates,
|
|
1244
|
+
...OPERATORS.map((o) => ({ value: o, label: o, detail: 'operator', kind: 'op' })),
|
|
1245
|
+
...NULL_CHECKS.map((o) => ({ value: o, label: o, detail: 'null check', kind: 'op' })),
|
|
1246
|
+
]);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (EXPECTS_FIELD.has(previous)) return pathCandidates(word);
|
|
1250
|
+
|
|
1251
|
+
return matches([
|
|
1252
|
+
...JOINERS.map((j) => ({ value: j, label: j, detail: 'combine', kind: 'kw' })),
|
|
1253
|
+
...CLAUSES.map((c) => ({ value: c, label: c, detail: 'clause', kind: 'kw' })),
|
|
1254
|
+
...pathCandidates(word),
|
|
1255
|
+
]);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function attachComplete(input, box) {
|
|
1259
|
+
let items = [];
|
|
1260
|
+
let index = -1;
|
|
1261
|
+
|
|
1262
|
+
const shut = () => {
|
|
1263
|
+
box.hidden = true;
|
|
1264
|
+
items = [];
|
|
1265
|
+
index = -1;
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
const accept = (item) => {
|
|
1269
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
1270
|
+
const { start } = wordAt(input.value, caret);
|
|
1271
|
+
const after = input.value.slice(caret);
|
|
1272
|
+
// A hop keeps the caret against the dot; a finished token gets a space.
|
|
1273
|
+
const insert = item.partial ? item.value : item.value + (after.startsWith(' ') ? '' : ' ');
|
|
1274
|
+
input.value = input.value.slice(0, start) + insert + after;
|
|
1275
|
+
const next = start + insert.length;
|
|
1276
|
+
input.setSelectionRange(next, next);
|
|
1277
|
+
draft.query = input.value;
|
|
1278
|
+
shut();
|
|
1279
|
+
schedulePreview();
|
|
1280
|
+
show();
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
const highlight = (next) => {
|
|
1284
|
+
if (!items.length) return;
|
|
1285
|
+
index = (next + items.length) % items.length;
|
|
1286
|
+
[...box.children].forEach((node, i) => {
|
|
1287
|
+
node.setAttribute('aria-selected', String(i === index));
|
|
1288
|
+
node.classList.toggle('active', i === index);
|
|
1289
|
+
});
|
|
1290
|
+
box.children[index]?.scrollIntoView({ block: 'nearest' });
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
function show() {
|
|
1294
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
1295
|
+
items = completionsFor(input.value, caret).slice(0, 40);
|
|
1296
|
+
if (!items.length) return shut();
|
|
1297
|
+
box.replaceChildren();
|
|
1298
|
+
for (const item of items) {
|
|
1299
|
+
box.append(el('div', {
|
|
1300
|
+
class: `ac-item kind-${item.kind}`,
|
|
1301
|
+
role: 'option',
|
|
1302
|
+
// mousedown, not click: blur fires first on a click and would close
|
|
1303
|
+
// the list before the selection landed.
|
|
1304
|
+
onmousedown: (e) => {
|
|
1305
|
+
e.preventDefault();
|
|
1306
|
+
accept(item);
|
|
1307
|
+
},
|
|
1308
|
+
}, [
|
|
1309
|
+
el('span', { class: 'ac-label', text: item.label }),
|
|
1310
|
+
el('span', { class: 'ac-detail', text: item.detail }),
|
|
1311
|
+
]));
|
|
1312
|
+
}
|
|
1313
|
+
index = -1;
|
|
1314
|
+
box.hidden = false;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
input.addEventListener('input', show);
|
|
1318
|
+
input.addEventListener('click', show);
|
|
1319
|
+
input.addEventListener('blur', () => setTimeout(shut, 120));
|
|
1320
|
+
input.addEventListener('keydown', (e) => {
|
|
1321
|
+
if (box.hidden) {
|
|
1322
|
+
if (e.key === ' ' && (e.ctrlKey || e.metaKey)) {
|
|
1323
|
+
e.preventDefault();
|
|
1324
|
+
show();
|
|
1325
|
+
}
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (e.key === 'ArrowDown') {
|
|
1329
|
+
e.preventDefault();
|
|
1330
|
+
highlight(index + 1);
|
|
1331
|
+
} else if (e.key === 'ArrowUp') {
|
|
1332
|
+
e.preventDefault();
|
|
1333
|
+
highlight(index - 1);
|
|
1334
|
+
} else if (e.key === 'Escape') {
|
|
1335
|
+
e.preventDefault();
|
|
1336
|
+
e.stopPropagation();
|
|
1337
|
+
shut();
|
|
1338
|
+
} else if (e.key === 'Tab') {
|
|
1339
|
+
e.preventDefault();
|
|
1340
|
+
accept(items[index >= 0 ? index : 0]);
|
|
1341
|
+
} else if (e.key === 'Enter') {
|
|
1342
|
+
if (index >= 0) {
|
|
1343
|
+
e.preventDefault();
|
|
1344
|
+
accept(items[index]);
|
|
1345
|
+
} else {
|
|
1346
|
+
shut();
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/* ---------- panel: the view, written down ----------
|
|
1353
|
+
|
|
1354
|
+
A view built by clicking is UI state: it can be saved and loaded, and that
|
|
1355
|
+
is all. Everything else in tablewalk holds that the *text* is the source of
|
|
1356
|
+
truth — it is why sorting edits the query, why a link carries the walk, why
|
|
1357
|
+
a filter composes into the bar. A view that could only be clicked could not
|
|
1358
|
+
be hand-edited, diffed in a config file, or pasted to someone who does not
|
|
1359
|
+
have the composer open.
|
|
1360
|
+
|
|
1361
|
+
So the panel is editable, and applying it replaces the draft. The grammar
|
|
1362
|
+
is `viewtext.ts` on the server: one language, parsed once, rather than a
|
|
1363
|
+
second one here that would drift. */
|
|
1364
|
+
|
|
1365
|
+
function textPanel() {
|
|
1366
|
+
const value = textEdit ?? preview?.text ?? '';
|
|
1367
|
+
const area = el('textarea', {
|
|
1368
|
+
class: 'composer-text',
|
|
1369
|
+
spellcheck: 'false',
|
|
1370
|
+
rows: String(Math.min(10, Math.max(3, value.split('\n').length + 1))),
|
|
1371
|
+
'aria-label': 'The view as text',
|
|
1372
|
+
placeholder: 'invoice\n show id, customer_id.name as customer\n where total > 500',
|
|
1373
|
+
});
|
|
1374
|
+
area.value = value;
|
|
1375
|
+
area.addEventListener('input', () => {
|
|
1376
|
+
textEdit = area.value;
|
|
1377
|
+
const apply = root.querySelector('.composer-text-apply');
|
|
1378
|
+
if (apply) apply.disabled = area.value.trim() === (preview?.text ?? '').trim();
|
|
1379
|
+
});
|
|
1380
|
+
/* Applied on demand rather than on every keystroke. A half-typed clause is
|
|
1381
|
+
the normal state of a multi-line editor, and re-running the view at each
|
|
1382
|
+
character would spend the round trip on text nobody has finished. */
|
|
1383
|
+
area.addEventListener('keydown', (e) => {
|
|
1384
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
1385
|
+
e.preventDefault();
|
|
1386
|
+
void applyText(area.value);
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
return el('section', { class: 'composer-textpanel' }, [
|
|
1391
|
+
area,
|
|
1392
|
+
el('div', { class: 'composer-text-actions' }, [
|
|
1393
|
+
el('span', {
|
|
1394
|
+
class: 'composer-hint',
|
|
1395
|
+
text: 'Edit and apply, or paste one someone sent you. ⌘↵ applies.',
|
|
1396
|
+
}),
|
|
1397
|
+
el('button', {
|
|
1398
|
+
type: 'button',
|
|
1399
|
+
class: 'ghost composer-text-apply',
|
|
1400
|
+
disabled: textEdit === null || textEdit.trim() === (preview?.text ?? '').trim() ? true : undefined,
|
|
1401
|
+
text: 'Apply',
|
|
1402
|
+
onclick: () => void applyText(area.value),
|
|
1403
|
+
}),
|
|
1404
|
+
]),
|
|
1405
|
+
]);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* Replace the draft with a written view.
|
|
1410
|
+
*
|
|
1411
|
+
* Parsed on the server, because that is where the grammar lives. What comes
|
|
1412
|
+
* back is a real `ViewDef`, so the panels rebuild themselves from it exactly
|
|
1413
|
+
* as they would from a saved view — the text form is not a second model, it
|
|
1414
|
+
* is another way of writing the one that exists.
|
|
1415
|
+
*/
|
|
1416
|
+
async function applyText(value) {
|
|
1417
|
+
const text = String(value ?? '').trim();
|
|
1418
|
+
if (!text) return;
|
|
1419
|
+
running = true;
|
|
1420
|
+
paintPreview();
|
|
1421
|
+
try {
|
|
1422
|
+
const data = await api('/api/view/run', { text, limit: draft.limit });
|
|
1423
|
+
if (data.errors?.length) {
|
|
1424
|
+
previewError = data.errors;
|
|
1425
|
+
running = false;
|
|
1426
|
+
paintPreview();
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
// The name and the identity are the composer's, not the text's: retyping
|
|
1430
|
+
// the body of a saved view should still be that saved view.
|
|
1431
|
+
loadView({ ...data.view, id: draft.id, name: draft.name });
|
|
1432
|
+
preview = data;
|
|
1433
|
+
previewError = null;
|
|
1434
|
+
textEdit = null;
|
|
1435
|
+
running = false;
|
|
1436
|
+
claim();
|
|
1437
|
+
redraw();
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
previewError = [{ message: err.message }];
|
|
1440
|
+
running = false;
|
|
1441
|
+
paintPreview();
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Give a column its own name.
|
|
1447
|
+
*
|
|
1448
|
+
* Applies to summaries as well as fields — `count(work_order_part)` is a
|
|
1449
|
+
* worse column heading than `parts` in exactly the same way.
|
|
1450
|
+
*
|
|
1451
|
+
* Matching is by output name rather than by index. The result's column order
|
|
1452
|
+
* is produced by the compiler, and tying a rename to a position would break
|
|
1453
|
+
* the moment a field is ticked above it.
|
|
1454
|
+
*/
|
|
1455
|
+
async function renameColumn(meta, fallbackName) {
|
|
1456
|
+
const current = meta?.name ?? fallbackName;
|
|
1457
|
+
const chosen = await promptFor({
|
|
1458
|
+
title: 'Rename column',
|
|
1459
|
+
label: 'Shown as',
|
|
1460
|
+
value: current,
|
|
1461
|
+
hint: meta?.aggregate
|
|
1462
|
+
? `${meta.aggregate} over ${shortTable(meta.table)}`
|
|
1463
|
+
: meta?.path ?? fallbackName,
|
|
1464
|
+
confirmLabel: 'Rename',
|
|
1465
|
+
});
|
|
1466
|
+
if (chosen === null) return;
|
|
1467
|
+
const alias = chosen.trim();
|
|
1468
|
+
|
|
1469
|
+
if (meta?.aggregate) {
|
|
1470
|
+
const list = aggregates();
|
|
1471
|
+
const hit = list.find((a) => (a.alias ?? '') === current || aggregateOutputName(a) === current);
|
|
1472
|
+
if (hit) hit.alias = alias || undefined;
|
|
1473
|
+
} else if (meta?.path) {
|
|
1474
|
+
const hit = draft.columns.find((c) => c.path === meta.path);
|
|
1475
|
+
// Clearing the box removes the alias rather than setting an empty one,
|
|
1476
|
+
// so the column goes back to naming itself.
|
|
1477
|
+
if (hit) hit.alias = alias && alias !== meta.path ? alias : undefined;
|
|
1478
|
+
}
|
|
1479
|
+
claim();
|
|
1480
|
+
void runPreview();
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** What an aggregate is called when it has no alias of its own. */
|
|
1484
|
+
function aggregateOutputName(spec) {
|
|
1485
|
+
const fk = state.schema.foreignKeys.find((f) => f.name === spec.via);
|
|
1486
|
+
const child = fk ? shortTable(fk.from.table) : spec.via;
|
|
1487
|
+
return spec.alias ?? `${spec.fn}(${child}${spec.column ? `.${spec.column}` : ''})`;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/* ---------- panel: SQL ---------- */
|
|
1491
|
+
|
|
1492
|
+
function sqlPanel() {
|
|
1493
|
+
const text = preview?.explain
|
|
1494
|
+
?? (previewError ? 'The view does not compile yet — fix the errors above.' : 'Pick a field to see the statement.');
|
|
1495
|
+
return el('pre', { class: 'explain composer-explain', text });
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/* ---------- panel: preview ---------- */
|
|
1499
|
+
|
|
1500
|
+
function paintPreview() {
|
|
1501
|
+
if (!root) return;
|
|
1502
|
+
const host = root.querySelector('#composer-preview');
|
|
1503
|
+
if (host) host.replaceChildren(...[].concat(previewBody()));
|
|
1504
|
+
const sql = root.querySelector('.composer-explain');
|
|
1505
|
+
if (sql && showSql) sql.replaceWith(sqlPanel());
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function previewBody() {
|
|
1509
|
+
if (previewError) {
|
|
1510
|
+
return el('div', { class: 'errors composer-errors' }, previewError.map((e) => el('p', { text: e.message })));
|
|
1511
|
+
}
|
|
1512
|
+
if (!draft.columns.length && !aggregates().length) {
|
|
1513
|
+
return el('p', { class: 'note', text: 'Tick a field to see the rows.' });
|
|
1514
|
+
}
|
|
1515
|
+
if (!preview) {
|
|
1516
|
+
return el('p', { class: 'loading', text: running ? 'Running…' : 'Building…' });
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const head = el('div', { class: 'result-head' }, [
|
|
1520
|
+
el('h2', { text: draft.name || draft.base }),
|
|
1521
|
+
el('span', {
|
|
1522
|
+
class: 'stat',
|
|
1523
|
+
/* An exact total, not "first 20 rows". It is affordable *and* correct
|
|
1524
|
+
only because a view never multiplies: every join is to-one, so
|
|
1525
|
+
counting the view is counting the table it started from. */
|
|
1526
|
+
text: preview.total !== undefined && preview.total > preview.rows.length
|
|
1527
|
+
? `${preview.rows.length.toLocaleString()} of ${preview.total.toLocaleString()} rows`
|
|
1528
|
+
: `${(preview.total ?? preview.rows.length).toLocaleString()} row${(preview.total ?? preview.rows.length) === 1 ? '' : 's'}`,
|
|
1529
|
+
}),
|
|
1530
|
+
el('span', { class: 'stat', text: `${preview.ms} ms` }),
|
|
1531
|
+
el('span', { class: 'stat', text: `${preview.joins.length} join${preview.joins.length === 1 ? '' : 's'}` }),
|
|
1532
|
+
running ? el('span', { class: 'stat', text: '…' }) : null,
|
|
1533
|
+
]);
|
|
1534
|
+
|
|
1535
|
+
const wrap = el('div', { class: 'table-wrap' });
|
|
1536
|
+
if (!preview.rows.length) {
|
|
1537
|
+
wrap.append(el('p', { class: 'note', text: 'No rows match.' }));
|
|
1538
|
+
return [head, wrap];
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const byName = new Map(preview.resolved.map((c) => [c.name, c]));
|
|
1542
|
+
const headRow = el('tr');
|
|
1543
|
+
for (const name of preview.columns) {
|
|
1544
|
+
const meta = byName.get(name);
|
|
1545
|
+
const sorted = (draft.orderBy ?? []).find((o) => o.path === (meta ? meta.path : name));
|
|
1546
|
+
const summary = meta?.aggregate;
|
|
1547
|
+
headRow.append(el('th', {
|
|
1548
|
+
title: summary
|
|
1549
|
+
? `${meta.aggregate} over ${shortTable(meta.table)} — one value per row, computed from many`
|
|
1550
|
+
: meta ? `${meta.path} · ${meta.type}` : name,
|
|
1551
|
+
class: `${sorted ? `sorted-${sorted.direction}` : ''}${summary ? ' is-summary' : ''}`.trim() || undefined,
|
|
1552
|
+
// A summary is a subquery, not a column of a joined table, and sorting
|
|
1553
|
+
// by one would need it repeated in ORDER BY. Left unsortable rather
|
|
1554
|
+
// than sortable-but-broken.
|
|
1555
|
+
onclick: summary ? undefined : () => sortByPath(meta ? meta.path : name),
|
|
1556
|
+
}, [
|
|
1557
|
+
// The table qualifier, then the column. A view of thirty fields drawn
|
|
1558
|
+
// from five tables is unreadable if every header is just "name".
|
|
1559
|
+
meta && (meta.hops || summary)
|
|
1560
|
+
? el('span', {
|
|
1561
|
+
class: 'composer-th-table',
|
|
1562
|
+
text: `${summary ? `${meta.aggregate} ` : ''}${shortTable(meta.table)} `,
|
|
1563
|
+
})
|
|
1564
|
+
: null,
|
|
1565
|
+
/* An explicit alias wins: a view that named a column `owner` should say
|
|
1566
|
+
`owner`, not re-derive `last_name` from where the value came from. */
|
|
1567
|
+
document.createTextNode(
|
|
1568
|
+
meta && meta.name !== meta.path
|
|
1569
|
+
? meta.name
|
|
1570
|
+
: summary ? (meta.column === '*' ? '' : meta.column) : (meta ? meta.column : name),
|
|
1571
|
+
),
|
|
1572
|
+
sorted ? el('span', { class: 'composer-th-sort', text: sorted.direction === 'desc' ? ' ↓' : ' ↑' }) : null,
|
|
1573
|
+
/* Renaming lives on the header because that is where the problem is
|
|
1574
|
+
visible: a manager's name arrives as `employee first_name`, which is
|
|
1575
|
+
true and useless. The alias already existed in the model and in the
|
|
1576
|
+
text form — there was simply no way to set one by clicking. */
|
|
1577
|
+
el('button', {
|
|
1578
|
+
class: 'composer-th-rename',
|
|
1579
|
+
type: 'button',
|
|
1580
|
+
title: 'Rename this column',
|
|
1581
|
+
'aria-label': `Rename ${name}`,
|
|
1582
|
+
text: '✎',
|
|
1583
|
+
onclick: (e) => { e.stopPropagation(); void renameColumn(meta, name); },
|
|
1584
|
+
}),
|
|
1585
|
+
]));
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const tbody = el('tbody');
|
|
1589
|
+
for (const row of preview.rows) {
|
|
1590
|
+
const tr = el('tr');
|
|
1591
|
+
for (const name of preview.columns) {
|
|
1592
|
+
const value = row[name];
|
|
1593
|
+
tr.append(value === null || value === undefined
|
|
1594
|
+
? el('td', { class: 'null', text: 'null' })
|
|
1595
|
+
: el('td', { class: 'mono', title: String(value).length > 40 ? String(value) : undefined, text: String(value) }));
|
|
1596
|
+
}
|
|
1597
|
+
tbody.append(tr);
|
|
1598
|
+
}
|
|
1599
|
+
wrap.append(el('table', {}, [el('thead', {}, headRow), tbody]));
|
|
1600
|
+
if (preview.more) {
|
|
1601
|
+
wrap.append(el('p', {
|
|
1602
|
+
class: 'note',
|
|
1603
|
+
text: `More rows match than are shown. Raise "rows", or narrow the filter.`,
|
|
1604
|
+
}));
|
|
1605
|
+
}
|
|
1606
|
+
return [head, wrap];
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const shortTable = (id) => (findTable(id)?.name ?? id);
|
|
1610
|
+
|
|
1611
|
+
/* Quotes and backslashes, for an attribute selector. Path segments cannot
|
|
1612
|
+
contain either, but a selector built from data is a selector worth escaping
|
|
1613
|
+
whether or not today's data needs it. */
|
|
1614
|
+
const cssEscape = (text) => String(text).replace(/["\\]/g, '\\$&');
|
|
1615
|
+
|
|
1616
|
+
function sortByPath(path) {
|
|
1617
|
+
const current = (draft.orderBy ?? []).find((o) => o.path === path);
|
|
1618
|
+
// Click sorts ascending, click again reverses, a third clears — the same
|
|
1619
|
+
// three states the results grid has, so the gesture transfers.
|
|
1620
|
+
if (!current) draft.orderBy = [{ path, direction: 'asc' }];
|
|
1621
|
+
else if (current.direction === 'asc') draft.orderBy = [{ path, direction: 'desc' }];
|
|
1622
|
+
else draft.orderBy = [];
|
|
1623
|
+
changed();
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/* ---------- panel: saved views ---------- */
|
|
1627
|
+
|
|
1628
|
+
function savedPanel() {
|
|
1629
|
+
const panel = el('section', { class: 'panel composer-panel' }, el('h3', { text: 'Saved views' }));
|
|
1630
|
+
const list = el('ul', { class: 'composer-saved' });
|
|
1631
|
+
|
|
1632
|
+
const all = [...savedViews(), ...fromConfig];
|
|
1633
|
+
if (!all.length) {
|
|
1634
|
+
panel.append(el('p', { class: 'note', text: 'Nothing saved yet. Build a view and press Save.' }));
|
|
1635
|
+
return panel;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/* Pinned first, and stably: `sort` on a boolean difference leaves equal
|
|
1639
|
+
items in the order they were already in, so unpinning something puts it
|
|
1640
|
+
back where it was rather than shuffling the list under the cursor. */
|
|
1641
|
+
const pins = pinnedIds();
|
|
1642
|
+
const ordered = [...all].sort(
|
|
1643
|
+
(a, b) => Number(pins.includes(b.id)) - Number(pins.includes(a.id)),
|
|
1644
|
+
);
|
|
1645
|
+
|
|
1646
|
+
for (const view of ordered) {
|
|
1647
|
+
const isConfig = view.source === 'config';
|
|
1648
|
+
const pinned = pins.includes(view.id);
|
|
1649
|
+
list.append(el('li', { class: `composer-saved-item${view.id === draft.id ? ' active' : ''}` }, [
|
|
1650
|
+
el('button', {
|
|
1651
|
+
type: 'button',
|
|
1652
|
+
class: `composer-pin${pinned ? ' on' : ''}`,
|
|
1653
|
+
'aria-pressed': String(pinned),
|
|
1654
|
+
title: pinned ? `Unpin "${view.name}"` : `Pin "${view.name}" to the top`,
|
|
1655
|
+
'aria-label': pinned ? `Unpin ${view.name}` : `Pin ${view.name}`,
|
|
1656
|
+
text: pinned ? '★' : '☆',
|
|
1657
|
+
onclick: () => togglePin(view.id),
|
|
1658
|
+
}),
|
|
1659
|
+
el('button', {
|
|
1660
|
+
type: 'button',
|
|
1661
|
+
class: 'composer-saved-open',
|
|
1662
|
+
title: view.error
|
|
1663
|
+
? view.error
|
|
1664
|
+
: `${view.base} · ${(view.columns ?? []).length} fields`
|
|
1665
|
+
+ `${(view.aggregates ?? []).length ? ` · ${view.aggregates.length} summaries` : ''}`,
|
|
1666
|
+
disabled: view.error ? true : undefined,
|
|
1667
|
+
onclick: () => openSaved(view),
|
|
1668
|
+
}, [
|
|
1669
|
+
el('span', { class: 'composer-saved-name', text: view.name }),
|
|
1670
|
+
el('span', { class: 'composer-saved-base', text: view.base }),
|
|
1671
|
+
]),
|
|
1672
|
+
isConfig
|
|
1673
|
+
? el('span', { class: 'composer-saved-tag', title: 'Defined in tablewalk.json', text: 'config' })
|
|
1674
|
+
: el('button', {
|
|
1675
|
+
type: 'button',
|
|
1676
|
+
class: 'view-remove',
|
|
1677
|
+
title: `Remove "${view.name}"`,
|
|
1678
|
+
'aria-label': `Remove ${view.name}`,
|
|
1679
|
+
text: '×',
|
|
1680
|
+
onclick: () => void removeSaved(view),
|
|
1681
|
+
}),
|
|
1682
|
+
]));
|
|
1683
|
+
}
|
|
1684
|
+
panel.append(list);
|
|
1685
|
+
return panel;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function openSaved(view) {
|
|
1689
|
+
loadView(view);
|
|
1690
|
+
claim();
|
|
1691
|
+
changed();
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
async function removeSaved(view) {
|
|
1695
|
+
const ok = await askConfirm({
|
|
1696
|
+
title: `Remove "${view.name}"?`,
|
|
1697
|
+
body: 'The view is only stored in this browser, so this cannot be undone from here.',
|
|
1698
|
+
confirmLabel: 'Remove',
|
|
1699
|
+
});
|
|
1700
|
+
if (!ok) return;
|
|
1701
|
+
if (!writeSaved(savedViews().filter((v) => v.id !== view.id))) return;
|
|
1702
|
+
if (draft.id === view.id) {
|
|
1703
|
+
/* The draft stays on screen — losing your work because you tidied the
|
|
1704
|
+
list would be a rude surprise — but it is no longer *that* view, and
|
|
1705
|
+
the link has to stop claiming it is or reloading would 404 it. */
|
|
1706
|
+
draft.id = '';
|
|
1707
|
+
claim();
|
|
1708
|
+
}
|
|
1709
|
+
redraw();
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/* ---------- saving ---------- */
|
|
1713
|
+
|
|
1714
|
+
async function saveDraft() {
|
|
1715
|
+
if (!draft.columns.length && !aggregates().length) {
|
|
1716
|
+
toast('Pick at least one field before saving.', 'error');
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
if (draft.source === 'config') {
|
|
1720
|
+
toast('This view comes from tablewalk.json. Use "Save as…" to keep your changes here.', 'error');
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
if (!draft.name.trim() || !draft.id) return saveAs();
|
|
1724
|
+
|
|
1725
|
+
const list = savedViews().map((v) => (v.id === draft.id ? snapshot(draft.id) : v));
|
|
1726
|
+
if (!list.some((v) => v.id === draft.id)) list.push(snapshot(draft.id));
|
|
1727
|
+
if (!writeSaved(list)) return;
|
|
1728
|
+
toast(`Saved "${draft.name.trim()}".`, 'ok');
|
|
1729
|
+
claim();
|
|
1730
|
+
redraw();
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async function saveAs() {
|
|
1734
|
+
if (!draft.columns.length && !aggregates().length) {
|
|
1735
|
+
toast('Pick at least one field before saving.', 'error');
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
const name = await promptFor({
|
|
1739
|
+
title: 'Save this view',
|
|
1740
|
+
label: 'Name',
|
|
1741
|
+
value: draft.name.trim() || suggestName(),
|
|
1742
|
+
hint: `${draft.base} · ${draft.columns.length} fields · ${opened.size} related table${opened.size === 1 ? '' : 's'}`,
|
|
1743
|
+
placeholder: 'e.g. Australian work orders',
|
|
1744
|
+
});
|
|
1745
|
+
if (!name) return;
|
|
1746
|
+
|
|
1747
|
+
draft.name = name;
|
|
1748
|
+
draft.source = undefined;
|
|
1749
|
+
const id = newId();
|
|
1750
|
+
draft.id = id;
|
|
1751
|
+
// A repeated name replaces rather than duplicating: two entries reading
|
|
1752
|
+
// "Open" that do different things is worse than losing the older one.
|
|
1753
|
+
const list = savedViews().filter((v) => v.name !== name);
|
|
1754
|
+
list.push(snapshot(id));
|
|
1755
|
+
if (!writeSaved(list)) return;
|
|
1756
|
+
toast(`Saved "${name}".`, 'ok');
|
|
1757
|
+
claim();
|
|
1758
|
+
redraw();
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function snapshot(id) {
|
|
1762
|
+
return {
|
|
1763
|
+
id,
|
|
1764
|
+
name: draft.name.trim(),
|
|
1765
|
+
base: draft.base,
|
|
1766
|
+
columns: draft.columns.map((c) => ({ path: c.path, alias: c.alias })),
|
|
1767
|
+
aggregates: draft.aggregates ?? [],
|
|
1768
|
+
query: draft.query,
|
|
1769
|
+
filter: draft.filter,
|
|
1770
|
+
orderBy: draft.orderBy ?? [],
|
|
1771
|
+
limit: draft.limit,
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function suggestName() {
|
|
1776
|
+
const table = findTable(draft.base);
|
|
1777
|
+
const extras = [...opened].map((p) => {
|
|
1778
|
+
const joined = tableAt(draft.base, p);
|
|
1779
|
+
return joined ? joined.name : p;
|
|
1780
|
+
});
|
|
1781
|
+
const head = table ? table.name : draft.base;
|
|
1782
|
+
return extras.length ? `${head} with ${extras.slice(0, 2).join(' and ')}` : head;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/* ---------- a themed confirm ---------- */
|
|
1786
|
+
|
|
1787
|
+
function askConfirm({ title, body: text, confirmLabel = 'OK' }) {
|
|
1788
|
+
return new Promise((resolve) => {
|
|
1789
|
+
let settled = false;
|
|
1790
|
+
const finish = (value) => {
|
|
1791
|
+
if (settled) return;
|
|
1792
|
+
settled = true;
|
|
1793
|
+
resolve(value);
|
|
1794
|
+
};
|
|
1795
|
+
const dialog = el('dialog', { class: 'confirm' }, el('form', {
|
|
1796
|
+
method: 'dialog',
|
|
1797
|
+
onsubmit: (e) => {
|
|
1798
|
+
e.preventDefault();
|
|
1799
|
+
dialog.close();
|
|
1800
|
+
finish(true);
|
|
1801
|
+
},
|
|
1802
|
+
}, [
|
|
1803
|
+
el('h2', { text: title }),
|
|
1804
|
+
el('p', { text }),
|
|
1805
|
+
el('div', { class: 'confirm-actions' }, [
|
|
1806
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
|
|
1807
|
+
el('button', { type: 'submit', class: 'danger', text: confirmLabel }),
|
|
1808
|
+
]),
|
|
1809
|
+
]));
|
|
1810
|
+
dialog.addEventListener('close', () => {
|
|
1811
|
+
finish(false);
|
|
1812
|
+
dialog.remove();
|
|
1813
|
+
});
|
|
1814
|
+
document.body.append(dialog);
|
|
1815
|
+
dialog.showModal();
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/* ---------- navigation ----------
|
|
1820
|
+
|
|
1821
|
+
A composer session is a tab on a table, which is why none of this needs a
|
|
1822
|
+
second URL format: the hash already carries a tab, deeplink.js already
|
|
1823
|
+
round-trips it, and the app underneath renders the base table — so closing
|
|
1824
|
+
the composer leaves you somewhere sensible rather than on a blank page. */
|
|
1825
|
+
|
|
1826
|
+
function linkTo(tab) {
|
|
1827
|
+
const entry = currentEntry();
|
|
1828
|
+
if (entry && entry.kind === 'table' && entry.table === draft.base) {
|
|
1829
|
+
go({ ...entry, tab }, 'replace');
|
|
1830
|
+
} else {
|
|
1831
|
+
// Rebasing rewrites the crumb rather than adding one: choosing a
|
|
1832
|
+
// different starting table is editing this view, not a step in a walk.
|
|
1833
|
+
go(tableView(draft.base, draft.base, tab), entry ? 'replace' : 'reset');
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* Update the address bar without re-rendering the app beneath.
|
|
1839
|
+
*
|
|
1840
|
+
* `go()` is the right call when the *position* changes; here only the label
|
|
1841
|
+
* on the position changes, and putting a full render behind every edit would
|
|
1842
|
+
* re-run the base table's query each time a checkbox moved.
|
|
1843
|
+
*/
|
|
1844
|
+
function stampHash() {
|
|
1845
|
+
const entry = currentEntry();
|
|
1846
|
+
const tab = tabForDraft();
|
|
1847
|
+
if (!entry || entry.kind !== 'table' || entry.table !== draft.base) return;
|
|
1848
|
+
if (entry.tab === tab) return;
|
|
1849
|
+
entry.tab = tab;
|
|
1850
|
+
openFor = `${draft.base}|${viewTabOf(entry)}`;
|
|
1851
|
+
window.history.replaceState(null, '', toHash());
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
function leave() {
|
|
1855
|
+
const entry = currentEntry();
|
|
1856
|
+
closeComposer();
|
|
1857
|
+
if (entry && entry.kind === 'table') go({ ...entry, tab: 'rows' }, 'replace');
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Where to start when nothing is selected.
|
|
1862
|
+
*
|
|
1863
|
+
* Not the first table alphabetically: on the demo that is `country`, a
|
|
1864
|
+
* two-column lookup with no outgoing keys, so the composer opens with an
|
|
1865
|
+
* empty "related tables" panel and appears to do nothing. The table with the
|
|
1866
|
+
* most references out of it is the one where the idea explains itself.
|
|
1867
|
+
*/
|
|
1868
|
+
function likeliestBase() {
|
|
1869
|
+
const tables = state.schema.tables.filter((t) => !t.isView);
|
|
1870
|
+
const ranked = (tables.length ? tables : state.schema.tables)
|
|
1871
|
+
.map((t) => ({ id: t.id, out: refsFrom(t.id).length }))
|
|
1872
|
+
.sort((a, b) => b.out - a.out);
|
|
1873
|
+
return ranked[0]?.id;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function openFromTopbar() {
|
|
1877
|
+
const entry = currentEntry();
|
|
1878
|
+
const base = entry?.table ?? likeliestBase();
|
|
1879
|
+
if (!base) {
|
|
1880
|
+
toast('This connection has no tables to compose from.', 'error');
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (entry && entry.kind === 'table') go({ ...entry, tab: 'view' }, 'replace');
|
|
1884
|
+
else go(tableView(base, base, 'view'), 'reset');
|
|
1885
|
+
sync();
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Reconcile the overlay with wherever the app now is.
|
|
1890
|
+
*
|
|
1891
|
+
* Called after anything that could have navigated. It is a no-op unless the
|
|
1892
|
+
* *view identity* changed, so clicking around inside the composer — which
|
|
1893
|
+
* also produces clicks — cannot reset the draft underneath your hands.
|
|
1894
|
+
*/
|
|
1895
|
+
function sync() {
|
|
1896
|
+
if (!state.schema) return;
|
|
1897
|
+
const entry = currentEntry();
|
|
1898
|
+
const id = viewTabOf(entry);
|
|
1899
|
+
if (id === null) {
|
|
1900
|
+
closeComposer();
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (root && openFor === `${entry.table}|${id}`) return;
|
|
1904
|
+
void openComposer(entry.table, id);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/* ---------- attach ---------- */
|
|
1908
|
+
|
|
1909
|
+
function attach() {
|
|
1910
|
+
if ($('composer-button')) return;
|
|
1911
|
+
const actions = document.querySelector('.topbar-actions');
|
|
1912
|
+
if (!actions) return;
|
|
1913
|
+
|
|
1914
|
+
/* Registered rather than inserted. The composer does not need to know what
|
|
1915
|
+
else is in the header, and the header does not need to know the composer
|
|
1916
|
+
exists — which is what let six top-level buttons become one menu without
|
|
1917
|
+
touching this file's reason for existing. */
|
|
1918
|
+
addMenuItem({
|
|
1919
|
+
label: 'Views…',
|
|
1920
|
+
detail: 'compose across tables',
|
|
1921
|
+
onSelect: () => openFromTopbar(),
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
/* The stylesheet is pulled in by the page, but a module that can be dropped
|
|
1925
|
+
into a build without its <link> should still look right rather than
|
|
1926
|
+
silently render as unstyled boxes. Adding it twice is harmless — the
|
|
1927
|
+
browser dedupes identical hrefs — and missing it is not. */
|
|
1928
|
+
if (!document.querySelector('link[href="/composer.css"]')) {
|
|
1929
|
+
document.head.append(el('link', { rel: 'stylesheet', href: '/composer.css' }));
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
window.addEventListener('popstate', () => setTimeout(sync, 0));
|
|
1933
|
+
window.addEventListener('resize', positionRoot);
|
|
1934
|
+
/* go() updates the address bar with pushState, which fires no event, so a
|
|
1935
|
+
tab click elsewhere in the app has to be noticed some other way. A check
|
|
1936
|
+
per click is cheap and, because sync() only acts on a change of view
|
|
1937
|
+
identity, cannot disturb the composer while it is being used. */
|
|
1938
|
+
document.addEventListener('click', () => setTimeout(sync, 0), true);
|
|
1939
|
+
document.addEventListener('keydown', (e) => {
|
|
1940
|
+
if (e.key === 'Escape' && root && !document.querySelector('dialog[open]')) {
|
|
1941
|
+
const inside = root.contains(document.activeElement);
|
|
1942
|
+
if (inside) document.activeElement.blur();
|
|
1943
|
+
else leave();
|
|
1944
|
+
}
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
/* A link pasted into a fresh tab arrives before app.js has finished asking
|
|
1948
|
+
for the schema, and applying a hash raises no event — so the first sync
|
|
1949
|
+
has to wait for the schema rather than for a signal. Polling a boolean
|
|
1950
|
+
ten times a second for a few seconds is not elegant, but the alternative
|
|
1951
|
+
is an import line in app.js, and the module earns its keep by not
|
|
1952
|
+
needing one. */
|
|
1953
|
+
whenReady(() => {
|
|
1954
|
+
sync();
|
|
1955
|
+
setTimeout(sync, 250);
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function whenReady(fn, tries = 0) {
|
|
1960
|
+
if (state.schema) {
|
|
1961
|
+
fn();
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
if (tries > 100) return; // ten seconds; the connection is not coming.
|
|
1965
|
+
setTimeout(() => whenReady(fn, tries + 1), 100);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
if (document.readyState === 'loading') {
|
|
1969
|
+
document.addEventListener('DOMContentLoaded', attach);
|
|
1970
|
+
} else {
|
|
1971
|
+
attach();
|
|
1972
|
+
}
|