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,1035 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table view: tabs, results, sorting and paging.
|
|
3
|
+
*
|
|
4
|
+
* Sorting works by editing the query text rather than holding UI state, so
|
|
5
|
+
* the sort is visible in the language and survives being walked back to.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
$, PAGE_SIZES, pageSize, setPageSize, api, currentEntry, el, findTable, go, keyOf,
|
|
9
|
+
looksLikeDateColumn, pageView, primaryKey, quote, render, rowLabel, rowView, setExplain,
|
|
10
|
+
showErrors, state, tableView, whenRemoved,
|
|
11
|
+
} from './core.js';
|
|
12
|
+
import { addSort, explicitLimit, nextSort, sortCount, sortDirection } from './clauses.js';
|
|
13
|
+
import { SORT_SCAN_BELOW, planColumn, sortableFromHeader } from './columns.js';
|
|
14
|
+
import { composeFilter } from './compose.js';
|
|
15
|
+
import { promptFor, promptForRange } from './prompt.js';
|
|
16
|
+
import { dropdown } from './dropdown.js';
|
|
17
|
+
import { VIRTUAL_THRESHOLD, virtualise } from './virtual.js';
|
|
18
|
+
import { diagram } from './diagram.js';
|
|
19
|
+
import { exportControl } from './export.js';
|
|
20
|
+
import { newRowButton } from './insert.js';
|
|
21
|
+
import { recordQuery } from './history.js';
|
|
22
|
+
import { applyPins, pinnedFor, togglePin } from './pins.js';
|
|
23
|
+
import { initGrid } from './grid.js';
|
|
24
|
+
import { peekable } from './peek.js';
|
|
25
|
+
import { pagesFor } from './page.js';
|
|
26
|
+
import { expandAllControl, expander, hasChildren, resetNested } from './nested.js';
|
|
27
|
+
import { gridSkeleton, placeholder } from './skeleton.js';
|
|
28
|
+
|
|
29
|
+
function tabs(entry) {
|
|
30
|
+
const bar = el('div', { class: 'tabs', role: 'tablist' });
|
|
31
|
+
for (const [id, label] of [['rows', 'Rows'], ['ddl', 'Definition'], ['diagram', 'Diagram']]) {
|
|
32
|
+
bar.append(el('button', {
|
|
33
|
+
type: 'button',
|
|
34
|
+
role: 'tab',
|
|
35
|
+
class: 'tab',
|
|
36
|
+
'aria-selected': String((entry.tab ?? 'rows') === id),
|
|
37
|
+
text: label,
|
|
38
|
+
onclick: () => go({ ...entry, tab: id }, 'replace'),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* The way into a page from a table.
|
|
43
|
+
|
|
44
|
+
Not a tab of this table — a page is its own kind of entry, with its own
|
|
45
|
+
root — but this is where someone looks for it, and a feature reachable
|
|
46
|
+
only by editing a config file is a feature most people never meet. Every
|
|
47
|
+
table has a page: the one someone wrote, or the one built from its
|
|
48
|
+
schema. */
|
|
49
|
+
bar.append(el('button', {
|
|
50
|
+
type: 'button',
|
|
51
|
+
class: 'tab tab-page',
|
|
52
|
+
title: 'A page about one record of this table',
|
|
53
|
+
text: 'Page \u2192',
|
|
54
|
+
onclick: () => void openPageFor(entry.table),
|
|
55
|
+
}));
|
|
56
|
+
return bar;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Open this table's page: the configured one if there is one, else the suggested. */
|
|
60
|
+
async function openPageFor(tableId) {
|
|
61
|
+
let pageId = `suggested:${tableId}`;
|
|
62
|
+
try {
|
|
63
|
+
const [configured] = await pagesFor(tableId);
|
|
64
|
+
if (configured && !configured.error) pageId = configured.id;
|
|
65
|
+
} catch {
|
|
66
|
+
// No config pages to read is not a reason to refuse the suggested one.
|
|
67
|
+
}
|
|
68
|
+
go(pageView(pageId, tableId), 'push');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The active virtualiser, torn down before another view replaces it. */
|
|
72
|
+
let teardownVirtual = null;
|
|
73
|
+
|
|
74
|
+
/** Bumped per run, so a reply that outlived its request paints nothing. */
|
|
75
|
+
let runToken = 0;
|
|
76
|
+
|
|
77
|
+
export async function renderTable(entry, content) {
|
|
78
|
+
// Any previous grid is gone the moment a new view renders; its scroll
|
|
79
|
+
// listener must go with it rather than outliving the DOM it watched.
|
|
80
|
+
teardownVirtual?.();
|
|
81
|
+
teardownVirtual = null;
|
|
82
|
+
$('query').value = entry.query;
|
|
83
|
+
const body = el('div');
|
|
84
|
+
content.append(tabs(entry), body);
|
|
85
|
+
|
|
86
|
+
if (entry.tab === 'diagram') {
|
|
87
|
+
body.replaceChildren(el('div', { class: 'panel-pad' }, diagram(entry.table)));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (entry.tab === 'ddl') {
|
|
91
|
+
const reading = placeholder(body, () => gridSkeleton(2, 6));
|
|
92
|
+
try {
|
|
93
|
+
const { sql, exact } = await api(`/api/ddl?table=${encodeURIComponent(entry.table)}`);
|
|
94
|
+
reading();
|
|
95
|
+
body.replaceChildren(
|
|
96
|
+
el('p', { class: 'ddl-note' }, [
|
|
97
|
+
el('strong', { text: exact ? 'Stored definition. ' : 'Reconstructed. ' }),
|
|
98
|
+
document.createTextNode(
|
|
99
|
+
exact
|
|
100
|
+
? 'This is the statement the database itself keeps, verbatim.'
|
|
101
|
+
: 'Rebuilt from the catalog. Indexes, checks and triggers are not shown.',
|
|
102
|
+
),
|
|
103
|
+
]),
|
|
104
|
+
el('pre', { class: 'ddl', text: sql }),
|
|
105
|
+
);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
reading();
|
|
108
|
+
body.replaceChildren(el('p', { class: 'note', text: err.message }));
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Shaped like the grid that is about to replace it, and only if the query
|
|
114
|
+
takes long enough to be worth acknowledging. */
|
|
115
|
+
const table = findTable(entry.table);
|
|
116
|
+
/* Claimed before the request goes out, checked when it comes back.
|
|
117
|
+
|
|
118
|
+
Without this the *last response* won rather than the last request. Skim
|
|
119
|
+
the sidebar with the arrow keys and runs for two tables overlap; if the
|
|
120
|
+
slower one lands second it repaints the rows — but it also resets the
|
|
121
|
+
nested cache, replaces the error banner, rewrites the SQL pane and adds a
|
|
122
|
+
history entry, all belonging to a table you are no longer on. The grid
|
|
123
|
+
showed B's data under A's SQL and A's errors.
|
|
124
|
+
|
|
125
|
+
Same shape as `runToken` in composer.js, which solved this for the
|
|
126
|
+
preview first. */
|
|
127
|
+
const mine = ++runToken;
|
|
128
|
+
const done = placeholder(body, () =>
|
|
129
|
+
gridSkeleton(table?.columns.length ?? 5, explicitLimit(entry.query) ?? pageSize()));
|
|
130
|
+
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
/* An explicit `limit` in the query is a deliberate statement and beats
|
|
134
|
+
the page size. Without this the client's default silently overrode it,
|
|
135
|
+
so `limit 40000` returned twenty rows and the language quietly lied. */
|
|
136
|
+
const asked = explicitLimit(entry.query);
|
|
137
|
+
data = await api('/api/run', {
|
|
138
|
+
q: entry.query,
|
|
139
|
+
limit: asked ?? pageSize(),
|
|
140
|
+
offset: asked ? 0 : state.page * pageSize(),
|
|
141
|
+
// Set only by "count them", so the expensive path is asked for rather
|
|
142
|
+
// than arrived at.
|
|
143
|
+
exactCount: entry.exactCount === true,
|
|
144
|
+
});
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (mine !== runToken) return;
|
|
147
|
+
done();
|
|
148
|
+
body.replaceChildren(el('p', { class: 'note', text: err.message }));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
/* Nothing below this line touches only the body — `resetNested`,
|
|
152
|
+
`showErrors`, `setExplain` and `recordQuery` are all global — so a stale
|
|
153
|
+
reply has to stop here, before any of them. */
|
|
154
|
+
if (mine !== runToken) return;
|
|
155
|
+
done();
|
|
156
|
+
/* A new result means new rows; answers cached against the old ones are
|
|
157
|
+
answers to a question nobody is asking any more. */
|
|
158
|
+
resetNested();
|
|
159
|
+
showErrors(data.errors ?? []);
|
|
160
|
+
setExplain(data.explain);
|
|
161
|
+
if (!data.columns) {
|
|
162
|
+
body.replaceChildren(el('p', { class: 'note', text: 'Fix the query above to see results.' }));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* Recorded here rather than at submit: this is the point where the query is
|
|
167
|
+
known to have compiled and run, and a history full of typos you never got
|
|
168
|
+
an answer from is a history nobody opens. */
|
|
169
|
+
recordQuery(entry.query);
|
|
170
|
+
|
|
171
|
+
const shown = state.page * pageSize() + data.rows.length;
|
|
172
|
+
const head = el('div', { class: 'result-head' }, [
|
|
173
|
+
el('h2', { text: data.table }),
|
|
174
|
+
/* An estimate says so, and offers the real number.
|
|
175
|
+
|
|
176
|
+
`COUNT(*)` is a scan, so on a large table the header stat — which nobody
|
|
177
|
+
asked for — was the most expensive thing on the page. Above a threshold
|
|
178
|
+
it now comes from the catalog's own statistics instead, and the
|
|
179
|
+
interface has to be honest about which it is showing: "2,400,113" when
|
|
180
|
+
the truth is 2,411,908 is worse than "~2.4M", because one of them is
|
|
181
|
+
wrong and the other is not pretending. */
|
|
182
|
+
data.exactTotal === false
|
|
183
|
+
? el('button', {
|
|
184
|
+
type: 'button',
|
|
185
|
+
class: 'stat stat-estimate',
|
|
186
|
+
title: 'Roughly, from the database’s own statistics — no rows were counted. Click to count them.',
|
|
187
|
+
onclick: () => void countExactly(entry, body),
|
|
188
|
+
text: data.total > shown
|
|
189
|
+
? `${shown.toLocaleString()} of ~${approx(data.total)} rows`
|
|
190
|
+
: `~${approx(data.total)} rows`,
|
|
191
|
+
})
|
|
192
|
+
: el('span', {
|
|
193
|
+
class: 'stat',
|
|
194
|
+
text: data.total > shown
|
|
195
|
+
? `${shown.toLocaleString()} of ${data.total.toLocaleString()} rows`
|
|
196
|
+
: `${data.total.toLocaleString()} row${data.total === 1 ? '' : 's'}`,
|
|
197
|
+
}),
|
|
198
|
+
el('span', { class: 'stat', text: `${data.ms} ms` }),
|
|
199
|
+
/* Named in the header as well as marked in the grid, because the column
|
|
200
|
+
it sorted by may not be one of the columns on screen — a `show name`
|
|
201
|
+
projection still gets ordered by the key. Clicking writes it into the
|
|
202
|
+
query, which turns a default into a decision. */
|
|
203
|
+
data.order?.implicit && data.order.order.length
|
|
204
|
+
? el('button', {
|
|
205
|
+
type: 'button',
|
|
206
|
+
class: 'stat stat-order',
|
|
207
|
+
title: 'Rows have to come back in some order. Click to make this one explicit.',
|
|
208
|
+
onclick: () => sortBy(data.order.order[0].column, data.order.order[0].direction),
|
|
209
|
+
text: `sorted by ${data.order.order[0].column}${data.order.order[0].direction === 'desc' ? ' ↓' : ' ↑'}`,
|
|
210
|
+
})
|
|
211
|
+
: null,
|
|
212
|
+
/* Beside Export rather than among the stats: both are actions on the
|
|
213
|
+
table itself, and the gate (writable, write mode) already decided
|
|
214
|
+
whether the button exists at all. */
|
|
215
|
+
newRowButton(findTable(data.table)),
|
|
216
|
+
exportControl(entry, data),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
body.replaceChildren(head, resultTable(findTable(data.table), data), pager(entry, data, shown));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A rounded figure, because an estimate written out in full reads as a count.
|
|
224
|
+
*
|
|
225
|
+
* "2,411,908" claims a precision the number does not have. "2.4M" says about
|
|
226
|
+
* as much as is known, which is the point.
|
|
227
|
+
*/
|
|
228
|
+
function approx(n) {
|
|
229
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)}M`;
|
|
230
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
231
|
+
return String(n);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Re-run the query asking for the true count this time. */
|
|
235
|
+
async function countExactly(entry, body) {
|
|
236
|
+
const stat = body.querySelector('.stat-estimate');
|
|
237
|
+
if (stat) stat.textContent = 'counting…';
|
|
238
|
+
/* The whole query again rather than a count on its own: the rows are already
|
|
239
|
+
cached by the browser and this keeps one code path, where a second one
|
|
240
|
+
would be a second place for the count and the page to disagree. */
|
|
241
|
+
go({ ...entry, exactCount: true }, 'replace');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A result grid, for anything that has rows and columns.
|
|
246
|
+
*
|
|
247
|
+
* Exported so the record page's embedded tables render exactly like the main
|
|
248
|
+
* grid — same type-aware cells, same alignment, same null and boolean
|
|
249
|
+
* treatment. Two renderers would drift, and a work order's parts showing
|
|
250
|
+
* `1`/`0` where the table above shows ✓/✗ is the kind of small inconsistency
|
|
251
|
+
* that makes a tool feel assembled rather than built.
|
|
252
|
+
*/
|
|
253
|
+
export function renderGrid(table, data, { onRowClick, compact = false, emptyText } = {}) {
|
|
254
|
+
const wrap = resultTable(table, data, { onRowClick, virtual: false, emptyText });
|
|
255
|
+
if (compact) wrap.classList.add('grid-compact');
|
|
256
|
+
return wrap;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function resultTable(table, data, options = {}) {
|
|
260
|
+
const wrap = el('div', { class: 'table-wrap' });
|
|
261
|
+
/* A caller that knows what it was asking for gets the shape of the answer
|
|
262
|
+
even when the answer is nothing: the headers say which columns had
|
|
263
|
+
nothing in them, which is the same argument the empty query result has
|
|
264
|
+
always made, and an empty related list is where it matters most — the
|
|
265
|
+
sentence alone leaves you unable to tell an empty list from a broken
|
|
266
|
+
one. */
|
|
267
|
+
if (!data.rows.length && !(options.emptyText && data.columns?.length)) {
|
|
268
|
+
/* "No rows match" claims a filter did it. On `customer` with no
|
|
269
|
+
conditions, nothing was excluded — the table is empty, which is a fact
|
|
270
|
+
about the database and a different thing to be told. Saying the first
|
|
271
|
+
when the second is true sends someone looking for a filter that is not
|
|
272
|
+
there. */
|
|
273
|
+
const entry = currentEntry();
|
|
274
|
+
const filtered = !entry || entry.kind !== 'table'
|
|
275
|
+
|| entry.query.trim() !== entry.table
|
|
276
|
+
|| Boolean(data.errors?.length);
|
|
277
|
+
wrap.append(el('p', {
|
|
278
|
+
class: 'note',
|
|
279
|
+
text: filtered ? 'No rows match.' : 'This table is empty.',
|
|
280
|
+
}));
|
|
281
|
+
return wrap;
|
|
282
|
+
}
|
|
283
|
+
const byName = new Map((table?.columns ?? []).map((c) => [c.name, c]));
|
|
284
|
+
const plans = new Map(data.columns.map((name) => [name, planColumn(byName.get(name), name, data.rows)]));
|
|
285
|
+
const empty = !data.rows.length;
|
|
286
|
+
|
|
287
|
+
const interactive = options.virtual !== false;
|
|
288
|
+
/* Only where the header controls apply: an embedded child table is a handful
|
|
289
|
+
of columns in a panel, and freezing one of them buys nothing. */
|
|
290
|
+
const pinned = interactive ? pinnedFor(table, data.columns) : [];
|
|
291
|
+
|
|
292
|
+
/* Expanders are offered only on a page the virtualiser is not managing.
|
|
293
|
+
|
|
294
|
+
An opened detail row is many times the height of an ordinary one, and the
|
|
295
|
+
virtualiser's whole model is that every row is the same height — it
|
|
296
|
+
computes each offset as `index * rowHeight`. Mixing the two would make the
|
|
297
|
+
scrollbar disagree with the content by a screenful. The default page is
|
|
298
|
+
twenty rows and is never virtualised, so the affordance is there when it is
|
|
299
|
+
wanted and absent on the forty-thousand-row result where it could not
|
|
300
|
+
work. */
|
|
301
|
+
// Built before the header, which holds a control that watches it.
|
|
302
|
+
const tbody = el('tbody');
|
|
303
|
+
|
|
304
|
+
/* Decided here rather than beside the loop that uses it, because the header
|
|
305
|
+
has to know: expanding every row is meaningless on a table whose rows are
|
|
306
|
+
a moving window over the result. */
|
|
307
|
+
const virtual = options.virtual !== false && data.rows.length > VIRTUAL_THRESHOLD;
|
|
308
|
+
const expandable = interactive
|
|
309
|
+
&& data.rows.length <= VIRTUAL_THRESHOLD
|
|
310
|
+
&& hasChildren(table);
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Whether the header's controls have anything to act on.
|
|
314
|
+
*
|
|
315
|
+
* Sorting and filtering here work by rewriting the query text — that is the
|
|
316
|
+
* design, and it is why a filter built by clicking can be edited by typing.
|
|
317
|
+
* It also means both are meaningless for a result that did not come from a
|
|
318
|
+
* tablewalk query: a view fall-through, or a SQL statement. `table` being
|
|
319
|
+
* undefined is exactly that case.
|
|
320
|
+
*
|
|
321
|
+
* Left alone, the degradation was silent and wrong in three ways at once.
|
|
322
|
+
* `!table?.indexed` is `true` when there is no table, so *every* column
|
|
323
|
+
* became sortable and the unindexed-sort guard disappeared; clicking one
|
|
324
|
+
* rewrote text that was not a query; and the filter menu fabricated
|
|
325
|
+
* `${table} show ${column}` and re-ran it, which for a SQL result would
|
|
326
|
+
* destroy the statement.
|
|
327
|
+
*/
|
|
328
|
+
const language = interactive && Boolean(table);
|
|
329
|
+
|
|
330
|
+
/* Whether a row click can go anywhere. A uniform property of the result, not
|
|
331
|
+
of each row: either the key columns are present or they are not. */
|
|
332
|
+
const navigable = Boolean(options.onRowClick)
|
|
333
|
+
|| Boolean(table && primaryKey(table).length
|
|
334
|
+
&& primaryKey(table).every((c) => data.columns.includes(c)));
|
|
335
|
+
const headRow = el('tr');
|
|
336
|
+
// An empty header over the expander column, so the cells below line up.
|
|
337
|
+
/* The one control that belongs above the expander column, and costs no row
|
|
338
|
+
of its own. Not offered on a virtualised table: half those rows do not
|
|
339
|
+
exist, and the ones that do are replaced as you scroll. */
|
|
340
|
+
if (expandable) {
|
|
341
|
+
headRow.append(el('th', { class: 'expander-cell' },
|
|
342
|
+
virtual ? null : expandAllControl(tbody)));
|
|
343
|
+
}
|
|
344
|
+
for (const name of data.columns) {
|
|
345
|
+
const meta = byName.get(name);
|
|
346
|
+
const plan = plans.get(name);
|
|
347
|
+
// An embedded table has no query behind it to sort or filter, so its
|
|
348
|
+
// header is a label rather than a set of controls that would do nothing.
|
|
349
|
+
/* Three states, and the difference between the last two matters:
|
|
350
|
+
sorted because the query says so, sorted because nothing did and the
|
|
351
|
+
rows had to come back in *some* order, and not sorted at all. The
|
|
352
|
+
implicit one is drawn faintly and says so on hover — it is a fact about
|
|
353
|
+
the result, not a choice the reader made. */
|
|
354
|
+
/* Sorting from the header is offered only on columns the database can
|
|
355
|
+
sort cheaply.
|
|
356
|
+
|
|
357
|
+
A header click is a one-click action with no visible cost, and on an
|
|
358
|
+
unindexed column of a large table it is a full scan and a sort of
|
|
359
|
+
everything to show twenty rows. The language still allows it — typing
|
|
360
|
+
`sort status` is an explicit request, made by someone who has said what
|
|
361
|
+
they want — but the interface does not hand it to you as though it were
|
|
362
|
+
free. */
|
|
363
|
+
/* Indexed, or small enough that it does not matter. `approxRows` unknown
|
|
364
|
+
is not treated as small: a table nobody could count is not one to
|
|
365
|
+
assume is cheap. */
|
|
366
|
+
const sortable = table
|
|
367
|
+
? !table.indexed || table.indexed.includes(name) || sortableFromHeader(table)
|
|
368
|
+
: false;
|
|
369
|
+
const explicit = language ? sortStateFor(name) : null;
|
|
370
|
+
const implied = !explicit && data.order?.implicit
|
|
371
|
+
? data.order.order.find((o) => o.column === name)?.direction ?? null
|
|
372
|
+
: null;
|
|
373
|
+
const sorted = explicit ?? implied;
|
|
374
|
+
headRow.append(
|
|
375
|
+
el(
|
|
376
|
+
'th',
|
|
377
|
+
{
|
|
378
|
+
// Headers take the column's alignment, so the label sits over its
|
|
379
|
+
// own values instead of drifting away from a right-aligned number.
|
|
380
|
+
class: `align-${plan.align}${explicit ? ' sorted' : ''}${implied ? ' sorted-default' : ''}`,
|
|
381
|
+
title: implied
|
|
382
|
+
? `${meta ? `${meta.type} · ` : ''}default order — rows have to come back in some order, and this is the one tablewalk chose`
|
|
383
|
+
/* A view's computed column has no declared type, and now says so
|
|
384
|
+
rather than claiming BLOB — so there may be nothing to put here,
|
|
385
|
+
and an empty tooltip is worse than the column's own name. */
|
|
386
|
+
: meta?.type ? `${meta.type}${meta.nullable ? '' : ' NOT NULL'}` : name,
|
|
387
|
+
},
|
|
388
|
+
[
|
|
389
|
+
el('button', {
|
|
390
|
+
class: `th-sort${language && !sortable ? ' not-sortable' : ''}`,
|
|
391
|
+
type: 'button',
|
|
392
|
+
/* `aria-disabled`, not `disabled`.
|
|
393
|
+
|
|
394
|
+
A disabled button is not focusable, so the sentence explaining
|
|
395
|
+
*why* a column cannot be sorted — the most useful thing on this
|
|
396
|
+
header — was reachable by hovering and by nothing else. Keyboard
|
|
397
|
+
and screen-reader users got a control that was simply absent,
|
|
398
|
+
with no reason given. Marked unavailable instead, and the click
|
|
399
|
+
does nothing. */
|
|
400
|
+
'aria-disabled': String(!language || !sortable),
|
|
401
|
+
title: !language
|
|
402
|
+
? `${name} — sorting a result like this one means editing the statement that produced it`
|
|
403
|
+
: sortable
|
|
404
|
+
? `Sort by ${name}`
|
|
405
|
+
: `${name} is not indexed — sorting it would scan the whole table. Type "sort ${name}" in the query bar if you mean it.`,
|
|
406
|
+
// Sorting edits the query text, so the sort is visible in the
|
|
407
|
+
// language rather than hidden in UI state.
|
|
408
|
+
/* Shift-click adds a level rather than replacing the clause, so
|
|
409
|
+
a two-column sort can be built by clicking. Without a modifier
|
|
410
|
+
it still replaces: reversing one column of `sort a, b` is not
|
|
411
|
+
something a plain click can express. */
|
|
412
|
+
onclick: (e) => {
|
|
413
|
+
if (!language || !sortable) return;
|
|
414
|
+
if (e.shiftKey) sortBy(name, sortDirection(entry.query, name) === 'asc' ? 'desc' : 'asc', true);
|
|
415
|
+
else sortBy(name);
|
|
416
|
+
},
|
|
417
|
+
}, [
|
|
418
|
+
document.createTextNode(name),
|
|
419
|
+
meta?.primaryKey ? el('span', { class: 'pk', text: ' key' }) : null,
|
|
420
|
+
meta?.references ? el('span', { class: 'fk', text: ' →' }) : null,
|
|
421
|
+
sorted ? el('span', { class: 'th-arrow', text: sorted === 'desc' ? ' ↓' : ' ↑' }) : null,
|
|
422
|
+
]),
|
|
423
|
+
language ? el('button', {
|
|
424
|
+
class: 'th-filter',
|
|
425
|
+
type: 'button',
|
|
426
|
+
title: `Filter on ${name}`,
|
|
427
|
+
'aria-label': `Filter on ${name}`,
|
|
428
|
+
text: '⌄',
|
|
429
|
+
onclick: (e) => { e.stopPropagation(); openFilterMenu(e.currentTarget, meta, name, data.rows, plan); },
|
|
430
|
+
}) : null,
|
|
431
|
+
].filter(Boolean),
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const openRow = options.onRowClick
|
|
437
|
+
?? ((row) => {
|
|
438
|
+
const key = table && keyOf(table, row);
|
|
439
|
+
// A projection that leaves out the key produces rows with nothing to
|
|
440
|
+
// navigate by. Better to do nothing than to open a row that is not there.
|
|
441
|
+
if (!key) return;
|
|
442
|
+
go(rowView(table.id, key, rowLabel(table, row)));
|
|
443
|
+
});
|
|
444
|
+
const buildRow = (row, index) => {
|
|
445
|
+
/* No handler at all when there is nowhere to go, so the row is inert in the
|
|
446
|
+
DOM as well as in effect. It used to keep its `onclick` and its pointer
|
|
447
|
+
cursor and simply do nothing, which is the worst of both: it advertises
|
|
448
|
+
an action it does not have. */
|
|
449
|
+
const tr = navigable
|
|
450
|
+
? el('tr', { class: 'clickable', onclick: () => openRow(row) })
|
|
451
|
+
: el('tr');
|
|
452
|
+
/* The row's place in the result, not in the DOM. Above the threshold the
|
|
453
|
+
rows in `tbody` are a moving window over `data.rows`, so `tr` position
|
|
454
|
+
and row number are different numbers; the keyboard cursor navigates by
|
|
455
|
+
this one. */
|
|
456
|
+
if (index !== undefined) tr.dataset.row = String(index);
|
|
457
|
+
if (expandable) tr.append(expander(table, row, tr) ?? el('td', { class: 'expander-cell' }));
|
|
458
|
+
for (const name of data.columns) tr.append(cell(byName.get(name), plans.get(name), row[name]));
|
|
459
|
+
return tr;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/* Beyond a few hundred rows the DOM, not the database, is the bottleneck:
|
|
463
|
+
forty-two thousand rows arrive in about 100ms and take seconds to put on
|
|
464
|
+
the page, after which every scroll and hover is slow. Below the threshold
|
|
465
|
+
windowing costs more than it saves. */
|
|
466
|
+
if (!virtual) {
|
|
467
|
+
data.rows.forEach((row, i) => tbody.append(buildRow(row, i)));
|
|
468
|
+
}
|
|
469
|
+
wrap.append(el('table', {}, [el('thead', {}, headRow), tbody]));
|
|
470
|
+
|
|
471
|
+
if (virtual) {
|
|
472
|
+
/* Started after the table is in the document, because the window is
|
|
473
|
+
measured from a real rendered row and from the scroller's actual
|
|
474
|
+
height — neither of which exists until it is attached. */
|
|
475
|
+
queueMicrotask(() => {
|
|
476
|
+
const scroller = wrap.closest('.content') ?? document.scrollingElement;
|
|
477
|
+
if (!wrap.isConnected) return;
|
|
478
|
+
teardownVirtual?.();
|
|
479
|
+
teardownVirtual = virtualise({
|
|
480
|
+
scroller,
|
|
481
|
+
tbody,
|
|
482
|
+
rows: data.rows,
|
|
483
|
+
renderRow: buildRow,
|
|
484
|
+
columns: data.columns.length,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
wrap.append(el('p', {
|
|
488
|
+
class: 'note virtual-note',
|
|
489
|
+
text: `${data.rows.length.toLocaleString()} rows, drawn as you scroll.`,
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (pinned.length) {
|
|
494
|
+
/* After layout, because the offsets are the widths of the columns to the
|
|
495
|
+
left and those are not known until the browser has measured them. The
|
|
496
|
+
observer re-measures on resize *and* after a virtualised repaint swaps
|
|
497
|
+
the rows out from under the ones already frozen. */
|
|
498
|
+
const freeze = () => applyPins(wrap, data.columns, pinned);
|
|
499
|
+
requestAnimationFrame(freeze);
|
|
500
|
+
const size = new ResizeObserver(freeze);
|
|
501
|
+
size.observe(wrap);
|
|
502
|
+
const rows = new MutationObserver(freeze);
|
|
503
|
+
rows.observe(tbody, { childList: true });
|
|
504
|
+
|
|
505
|
+
/* Stopped when the table they watch leaves the document. Without this,
|
|
506
|
+
two observers were created on every render and never stopped — each one
|
|
507
|
+
re-measuring pinned columns on a table nobody can see. */
|
|
508
|
+
whenRemoved(wrap, () => {
|
|
509
|
+
size.disconnect();
|
|
510
|
+
rows.disconnect();
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* After the table exists and after virtualisation is set up, so the cursor
|
|
515
|
+
is measuring rows that are really there. */
|
|
516
|
+
initGrid(wrap, {
|
|
517
|
+
tbody,
|
|
518
|
+
rowCount: data.rows.length,
|
|
519
|
+
columnCount: data.columns.length,
|
|
520
|
+
offset: expandable ? 1 : 0,
|
|
521
|
+
onOpen: navigable ? (index) => openRow(data.rows[index]) : undefined,
|
|
522
|
+
/* So a screen reader hears "of 63,010" rather than "of 20". An estimate is
|
|
523
|
+
not passed: announcing a guess as the count is worse than announcing
|
|
524
|
+
nothing, because nothing at least sounds like nothing. */
|
|
525
|
+
totalRows: data.exactTotal === false ? undefined : data.total,
|
|
526
|
+
firstRow: explicitLimit(currentEntry()?.query ?? '') ? 0 : state.page * pageSize(),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
/* The reason there are no rows, under the headers that say what would have
|
|
530
|
+
been in them. */
|
|
531
|
+
if (empty) wrap.append(el('p', { class: 'note grid-empty', text: options.emptyText }));
|
|
532
|
+
|
|
533
|
+
return wrap;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* One cell, rendered by what it holds.
|
|
538
|
+
*
|
|
539
|
+
* A reference is marked on the way out rather than in each branch: a foreign
|
|
540
|
+
* key can be a number, a string or a code, so the branch that draws it is
|
|
541
|
+
* decided by its *type* while whether it points somewhere is decided by the
|
|
542
|
+
* schema. Marking it in one place is what stopped numeric keys — the most
|
|
543
|
+
* common kind there is — from being the one sort that could not be peeked at.
|
|
544
|
+
*/
|
|
545
|
+
function cell(meta, plan, value) {
|
|
546
|
+
return peekable(paint(meta, plan, value), meta?.references, value);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Thousands separators on a numeric string too big to be a JS number.
|
|
551
|
+
*
|
|
552
|
+
* `toLocaleString` is the usual way to group digits and it cannot be used
|
|
553
|
+
* here: it takes a number, and turning this back into one is the rounding
|
|
554
|
+
* being avoided. So the grouping is done on the digits directly, which is all
|
|
555
|
+
* `useGrouping` was ever doing to the integer part anyway.
|
|
556
|
+
*/
|
|
557
|
+
function groupDigits(text) {
|
|
558
|
+
const neg = text.startsWith('-');
|
|
559
|
+
const digits = neg ? text.slice(1) : text;
|
|
560
|
+
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
561
|
+
return neg ? `-${grouped}` : grouped;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function paint(meta, plan, value) {
|
|
565
|
+
if (value === null || value === undefined) {
|
|
566
|
+
return el('td', { class: `null align-${plan.align}`, text: 'null' });
|
|
567
|
+
}
|
|
568
|
+
const raw = String(value);
|
|
569
|
+
const classes = [`align-${plan.align}`];
|
|
570
|
+
if (plan.mono || plan.kind === 'number') classes.push('mono');
|
|
571
|
+
|
|
572
|
+
if (plan.kind === 'boolean') {
|
|
573
|
+
const on = value === true || value === 1 || value === '1' || value === 't';
|
|
574
|
+
return el('td', { class: `${classes.join(' ')} bool`, title: raw },
|
|
575
|
+
el('span', { class: on ? 'yes' : 'no', text: on ? '✓' : '✗' }));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (plan.kind === 'number') {
|
|
579
|
+
const n = Number(value);
|
|
580
|
+
/* A value the server sent as a string because it is past 2^53 must not be
|
|
581
|
+
run back through Number() here — that is the exact rounding the server
|
|
582
|
+
fix avoided, undone one layer later. `9223372036854775807` came over the
|
|
583
|
+
wire intact and `Number(...).toLocaleString()` renders it
|
|
584
|
+
`9223372036854776000`: a different number, off by 193, that looks
|
|
585
|
+
perfectly valid. So a raw string that is not a safe integer is shown as
|
|
586
|
+
it arrived, grouped by hand rather than by a lossy round trip. */
|
|
587
|
+
const overflow = typeof value === 'string' && /^-?\d+$/.test(value)
|
|
588
|
+
&& !Number.isSafeInteger(n);
|
|
589
|
+
const shown = overflow
|
|
590
|
+
? groupDigits(value)
|
|
591
|
+
: Number.isFinite(n)
|
|
592
|
+
? n.toLocaleString(undefined, {
|
|
593
|
+
minimumFractionDigits: plan.decimals,
|
|
594
|
+
maximumFractionDigits: plan.decimals,
|
|
595
|
+
useGrouping: plan.group,
|
|
596
|
+
})
|
|
597
|
+
: raw;
|
|
598
|
+
const td = el('td', { class: `${classes.join(' ')} num`, text: shown, title: shown === raw ? undefined : raw });
|
|
599
|
+
// A numeric foreign key is still a link into another table.
|
|
600
|
+
if (meta?.references) td.classList.add('reflink');
|
|
601
|
+
return td;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (plan.kind === 'date') {
|
|
605
|
+
const shown = plan.showTime ? raw.replace('T', ' ') : raw.slice(0, 10);
|
|
606
|
+
return el('td', { class: classes.join(' '), text: shown, title: shown === raw ? undefined : raw });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (meta?.references) {
|
|
610
|
+
return el('td', { class: classes.join(' ') },
|
|
611
|
+
el('span', { class: 'ref', text: raw, title: `→ ${meta.references.table}` }));
|
|
612
|
+
}
|
|
613
|
+
/* A grid cell shows one line, clipped at 30rem with an ellipsis, so nothing
|
|
614
|
+
past a couple of hundred characters is ever visible in it — and a `TEXT`
|
|
615
|
+
column can hold a whole document. Putting the raw value into the DOM and
|
|
616
|
+
into the `title` regardless meant a page of a hundred rows carried
|
|
617
|
+
megabytes nobody could read, and a native tooltip that spanned the
|
|
618
|
+
screen. The cell shows a bounded prefix and says how much more there is;
|
|
619
|
+
the record page, which is where a long value is actually read, still gets
|
|
620
|
+
it whole. */
|
|
621
|
+
const cell = clampCell(raw);
|
|
622
|
+
return el('td', { class: classes.join(' '), title: cell.title, text: cell.text });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The most a single grid cell will put in the DOM. Well past what 30rem shows. */
|
|
626
|
+
const CELL_MAX = 300;
|
|
627
|
+
|
|
628
|
+
function clampCell(raw) {
|
|
629
|
+
if (raw.length <= CELL_MAX) {
|
|
630
|
+
return { text: raw, title: raw.length > 40 ? raw : undefined };
|
|
631
|
+
}
|
|
632
|
+
/* A count rather than a bare ellipsis, because "there is more" and "there
|
|
633
|
+
are forty thousand more characters" are different facts about a row, and
|
|
634
|
+
the second is the one worth knowing before you open it. */
|
|
635
|
+
const more = raw.length - CELL_MAX;
|
|
636
|
+
return {
|
|
637
|
+
text: `${raw.slice(0, CELL_MAX)}…`,
|
|
638
|
+
title: `${more.toLocaleString()} more character${more === 1 ? '' : 's'} — open the row to read it`,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** 'asc' | 'desc' when the current query sorts by this column, else null. */
|
|
643
|
+
function sortStateFor(column) {
|
|
644
|
+
const entry = currentEntry();
|
|
645
|
+
if (!entry || entry.kind !== 'table') return null;
|
|
646
|
+
return sortDirection(entry.query, column);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* The per-column filter menu.
|
|
651
|
+
*
|
|
652
|
+
* Every option writes a condition into the query bar rather than holding
|
|
653
|
+
* hidden state. That keeps one source of truth — the text — so a filter built
|
|
654
|
+
* by clicking can be edited by typing, saved as a view, shared as a link and
|
|
655
|
+
* read back later. A filter you cannot see is a filter you cannot debug.
|
|
656
|
+
*/
|
|
657
|
+
async function openFilterMenu(anchor, meta, name, rows, plan) {
|
|
658
|
+
document.querySelector('.th-menu')?.remove();
|
|
659
|
+
|
|
660
|
+
/* Values are sampled from the *unfiltered* table, not the visible page.
|
|
661
|
+
Once you have filtered to status = scheduled the page contains only
|
|
662
|
+
scheduled rows, so a menu built from it offers exactly the filter you
|
|
663
|
+
already applied — useless precisely when you want to widen. */
|
|
664
|
+
const entryNow = currentEntry();
|
|
665
|
+
let sample = rows;
|
|
666
|
+
if (entryNow && entryNow.kind === 'table' && entryNow.query.trim() !== entryNow.table) {
|
|
667
|
+
try {
|
|
668
|
+
const unfiltered = await api('/api/run', {
|
|
669
|
+
q: `${entryNow.table} show ${name}`,
|
|
670
|
+
limit: 400,
|
|
671
|
+
});
|
|
672
|
+
if (unfiltered.rows?.length) sample = unfiltered.rows;
|
|
673
|
+
} catch {
|
|
674
|
+
// Sampling is a nicety; the rest of the menu still works without it.
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
rows = sample;
|
|
678
|
+
|
|
679
|
+
/* The header this menu belongs to may be gone.
|
|
680
|
+
|
|
681
|
+
The sample takes a round trip, and navigating during it leaves the
|
|
682
|
+
anchor detached — `getBoundingClientRect` on a node that is no longer in
|
|
683
|
+
the document is all zeroes, so the menu opened in the top-left corner of
|
|
684
|
+
the window, attached to nothing, listing values from the table you just
|
|
685
|
+
left. */
|
|
686
|
+
if (!anchor.isConnected) return;
|
|
687
|
+
|
|
688
|
+
const add = (condition) => {
|
|
689
|
+
const entry = currentEntry();
|
|
690
|
+
if (!entry || entry.kind !== 'table') return;
|
|
691
|
+
go(tableView(entry.table, composeFilter(entry.query, entry.table, name, condition)), 'replace');
|
|
692
|
+
menu.remove();
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const options = [];
|
|
696
|
+
const kind = plan.kind;
|
|
697
|
+
|
|
698
|
+
if (kind === 'boolean') {
|
|
699
|
+
options.push(['is true', `${name} = true`], ['is false', `${name} = false`]);
|
|
700
|
+
} else if (kind === 'date' || dateish(meta, name)) {
|
|
701
|
+
/* Dates get ranges, not equality. `= 2026-08-22` on a timestamp matches
|
|
702
|
+
an exact instant and almost never what was meant. */
|
|
703
|
+
for (const phrase of ['today', 'last 7 days', 'last 30 days', 'this month', 'last month', 'this year']) {
|
|
704
|
+
options.push([phrase, `${name} = ${phrase}`]);
|
|
705
|
+
}
|
|
706
|
+
} else if (kind === 'number') {
|
|
707
|
+
const values = rows.map((r) => Number(r[name])).filter((n) => Number.isFinite(n));
|
|
708
|
+
if (values.length) {
|
|
709
|
+
const max = Math.max(...values);
|
|
710
|
+
const min = Math.min(...values);
|
|
711
|
+
const mid = Math.round((min + max) / 2);
|
|
712
|
+
options.push([`greater than ${mid.toLocaleString()}`, `${name} > ${mid}`]);
|
|
713
|
+
options.push([`less than ${mid.toLocaleString()}`, `${name} < ${mid}`]);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/* Values present on this page, most frequent first. Only offered when the
|
|
718
|
+
column repeats — a column where every value is distinct is a key, and a
|
|
719
|
+
list of forty unique values is a worse way to pick one than typing it. */
|
|
720
|
+
const counts = new Map();
|
|
721
|
+
for (const row of rows) {
|
|
722
|
+
const value = row[name];
|
|
723
|
+
if (value === null || value === undefined) continue;
|
|
724
|
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
725
|
+
}
|
|
726
|
+
const distinct = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
727
|
+
if (distinct.length && distinct.length <= Math.max(3, rows.length / 2)) {
|
|
728
|
+
for (const [value, count] of distinct.slice(0, 8)) {
|
|
729
|
+
const text = String(value);
|
|
730
|
+
const quoted = /^[\w.-]+$/.test(text) ? text : `"${text.replace(/"/g, '')}"`;
|
|
731
|
+
options.push([`${text} · ${count}`, `${name} = ${quoted}`]);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
options.push(['is empty', `${name} is empty`], ['is not empty', `${name} is not empty`]);
|
|
736
|
+
|
|
737
|
+
/* Typed conditions, always offered.
|
|
738
|
+
|
|
739
|
+
Sampled values only help on a column that repeats. On a key or a
|
|
740
|
+
high-cardinality reference — invoice_id across forty thousand rows — the
|
|
741
|
+
sample is a list of arbitrary values that happen to be near the top, and
|
|
742
|
+
the thing you actually want is to name the one you are looking for. */
|
|
743
|
+
/* Typed because the checker otherwise reads the tuples as a union and
|
|
744
|
+
decides `build` might be a string. Each entry is a label, the function
|
|
745
|
+
that turns a typed value into a condition, and the placeholder. */
|
|
746
|
+
/* A timestamp gets a time in its picker and a date does not. Read off the
|
|
747
|
+
declared type rather than the values: the answer has to be the same
|
|
748
|
+
before and after the table has any rows in it. */
|
|
749
|
+
const hasTime = /time|timestamp/i.test(meta?.type ?? '');
|
|
750
|
+
const dateInput = hasTime ? 'datetime-local' : 'date';
|
|
751
|
+
/* `2026-01-01T09:30` is what the control hands back and not what any of
|
|
752
|
+
these databases write. The separator is the only difference. */
|
|
753
|
+
const asStamp = (v) => String(v).replace('T', ' ');
|
|
754
|
+
|
|
755
|
+
/** @type {Array<[string, (value: string) => string, string, string]>} */
|
|
756
|
+
const typed = [];
|
|
757
|
+
if (kind === 'number') {
|
|
758
|
+
typed.push(
|
|
759
|
+
['equals a value…', (v) => `${name} = ${v}`, 'Value', 'text'],
|
|
760
|
+
['greater than…', (v) => `${name} > ${v}`, 'Minimum', 'text'],
|
|
761
|
+
['less than…', (v) => `${name} < ${v}`, 'Maximum', 'text'],
|
|
762
|
+
);
|
|
763
|
+
} else if (kind === 'date') {
|
|
764
|
+
typed.push(
|
|
765
|
+
['on or after…', (v) => `${name} >= ${quoteValue(asStamp(v))}`, 'Date', dateInput],
|
|
766
|
+
['before…', (v) => `${name} < ${quoteValue(asStamp(v))}`, 'Date', dateInput],
|
|
767
|
+
);
|
|
768
|
+
} else {
|
|
769
|
+
typed.push(
|
|
770
|
+
['equals a value…', (v) => `${name} = ${quoteValue(v)}`, 'Value', 'text'],
|
|
771
|
+
['contains…', (v) => `${name} contains ${quoteValue(v)}`, 'Text to look for', 'text'],
|
|
772
|
+
['starts with…', (v) => `${name} startswith ${quoteValue(v)}`, 'Prefix', 'text'],
|
|
773
|
+
/* The escape hatch from the three above, which all escape `%` so that
|
|
774
|
+
searching for `50%` finds a percent sign. Here the wildcards are the
|
|
775
|
+
question. */
|
|
776
|
+
['matches a pattern…', (v) => `${name} like ${quoteValue(v)}`, 'Pattern, e.g. Wh% or _a%', 'text'],
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/* `between` exists in the language and was reachable only by typing it.
|
|
781
|
+
Offered on the two kinds of column a range means anything on, and asked
|
|
782
|
+
as one question with two fields rather than as two prompts — a range with
|
|
783
|
+
one end filled in is a different filter from the one intended. */
|
|
784
|
+
/** @type {Array<[string, string, string]>} */
|
|
785
|
+
const ranges = [];
|
|
786
|
+
if (kind === 'date') ranges.push(['between…', dateInput, 'Date']);
|
|
787
|
+
else if (kind === 'number') ranges.push(['between…', 'text', 'Value']);
|
|
788
|
+
|
|
789
|
+
const table = findTable(currentEntry()?.table ?? '');
|
|
790
|
+
const columns = [...document.querySelectorAll('.table-wrap thead th')].map(
|
|
791
|
+
(th) => th.querySelector('.th-sort')?.firstChild?.textContent ?? '',
|
|
792
|
+
);
|
|
793
|
+
const pinned = pinnedFor(table, columns);
|
|
794
|
+
|
|
795
|
+
/* Sorting, always offered.
|
|
796
|
+
|
|
797
|
+
It was reachable only by clicking the header, and only on columns an index
|
|
798
|
+
leads on — so on an unindexed column there was no way to sort from the
|
|
799
|
+
interface at all, and nothing said why. The menu is where someone looks
|
|
800
|
+
for what they can do to a column, so both directions live here whatever
|
|
801
|
+
the index says, and the cost is stated rather than the action withheld:
|
|
802
|
+
a scan on a large table is a real cost, and it is the reader's to spend. */
|
|
803
|
+
const indexLeads = !table?.indexed || table.indexed.includes(name);
|
|
804
|
+
const bigTable = table?.approxRows != null && table.approxRows >= SORT_SCAN_BELOW;
|
|
805
|
+
const sortItem = (label, direction, add = false) => (entryNow && entryNow.kind === 'table'
|
|
806
|
+
? el('button', {
|
|
807
|
+
type: 'button', class: 'th-menu-item', role: 'menuitem',
|
|
808
|
+
onclick: () => {
|
|
809
|
+
const rewrite = add ? addSort : nextSort;
|
|
810
|
+
go(tableView(entryNow.table, rewrite(entryNow.query, name, direction)), 'replace');
|
|
811
|
+
menu.remove();
|
|
812
|
+
},
|
|
813
|
+
}, [
|
|
814
|
+
el('span', { class: 'th-menu-label', text: label }),
|
|
815
|
+
el('span', {
|
|
816
|
+
class: 'th-menu-cond',
|
|
817
|
+
text: indexLeads || !bigTable ? `sort ${name}` : 'no index — a full scan',
|
|
818
|
+
}),
|
|
819
|
+
])
|
|
820
|
+
: null);
|
|
821
|
+
|
|
822
|
+
/* "Then by" appears only once there is something to be *after*. Offering it
|
|
823
|
+
on an unsorted table would be offering a second step before the first. */
|
|
824
|
+
const sortsAlready = entryNow && entryNow.kind === 'table'
|
|
825
|
+
&& sortCount(entryNow.query) > 0
|
|
826
|
+
&& sortDirection(entryNow.query, name) === null;
|
|
827
|
+
|
|
828
|
+
const menu = el('div', { class: 'th-menu', role: 'menu' }, [
|
|
829
|
+
/* First, because it is the one item here that is about *reading* the table
|
|
830
|
+
rather than narrowing it — and on the wide tables where the menu is most
|
|
831
|
+
used it is the thing most often wanted. */
|
|
832
|
+
table
|
|
833
|
+
? el('button', {
|
|
834
|
+
type: 'button', class: 'th-menu-item', role: 'menuitem',
|
|
835
|
+
onclick: () => { togglePin(table, columns, name); menu.remove(); render(); },
|
|
836
|
+
}, [
|
|
837
|
+
el('span', { class: 'th-menu-label', text: pinned.includes(name) ? 'Unpin this column' : 'Pin this column' }),
|
|
838
|
+
el('span', { class: 'th-menu-cond', text: pinned.includes(name) ? 'let it scroll away' : 'keep it in view' }),
|
|
839
|
+
])
|
|
840
|
+
: null,
|
|
841
|
+
sortItem('Sort ascending', 'asc'),
|
|
842
|
+
sortItem('Sort descending', 'desc'),
|
|
843
|
+
sortsAlready ? sortItem('Then by, ascending', 'asc', true) : null,
|
|
844
|
+
sortsAlready ? sortItem('Then by, descending', 'desc', true) : null,
|
|
845
|
+
table ? el('div', { class: 'th-menu-sep', role: 'separator' }) : null,
|
|
846
|
+
...typed.map(([label, build, placeholder, inputType]) =>
|
|
847
|
+
el('button', {
|
|
848
|
+
type: 'button', class: 'th-menu-item th-menu-typed', role: 'menuitem',
|
|
849
|
+
onclick: async () => {
|
|
850
|
+
menu.remove();
|
|
851
|
+
const value = await promptFor({
|
|
852
|
+
title: `Filter ${name}`,
|
|
853
|
+
label,
|
|
854
|
+
placeholder,
|
|
855
|
+
type: inputType,
|
|
856
|
+
confirmLabel: 'Apply',
|
|
857
|
+
hint: 'Adds a condition to the query, which you can edit afterwards.',
|
|
858
|
+
});
|
|
859
|
+
if (value) add(build(value));
|
|
860
|
+
},
|
|
861
|
+
}, [el('span', { class: 'th-menu-label', text: label })])),
|
|
862
|
+
...ranges.map(([label, inputType, fieldLabel]) =>
|
|
863
|
+
el('button', {
|
|
864
|
+
type: 'button', class: 'th-menu-item th-menu-typed', role: 'menuitem',
|
|
865
|
+
onclick: async () => {
|
|
866
|
+
menu.remove();
|
|
867
|
+
const range = await promptForRange({
|
|
868
|
+
title: `Filter ${name}`,
|
|
869
|
+
fromLabel: `${fieldLabel} from`,
|
|
870
|
+
toLabel: `${fieldLabel} to`,
|
|
871
|
+
type: inputType,
|
|
872
|
+
hint: 'Both ends are included. Adds a condition you can edit afterwards.',
|
|
873
|
+
});
|
|
874
|
+
if (range) {
|
|
875
|
+
add(`${name} between ${quoteValue(asStamp(range.from))} and ${quoteValue(asStamp(range.to))}`);
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
}, [el('span', { class: 'th-menu-label', text: label })])),
|
|
879
|
+
typed.length ? el('div', { class: 'th-menu-sep', role: 'separator' }) : null,
|
|
880
|
+
...options.map(([label, condition]) =>
|
|
881
|
+
el('button', { type: 'button', class: 'th-menu-item', role: 'menuitem', onclick: () => add(condition) }, [
|
|
882
|
+
el('span', { class: 'th-menu-label', text: label }),
|
|
883
|
+
el('span', { class: 'th-menu-cond', text: condition }),
|
|
884
|
+
])),
|
|
885
|
+
].filter(Boolean));
|
|
886
|
+
|
|
887
|
+
document.body.append(menu);
|
|
888
|
+
const box = anchor.getBoundingClientRect();
|
|
889
|
+
// Flipped to the left when it would run off the right edge, which happens
|
|
890
|
+
// constantly on a wide table scrolled to the end.
|
|
891
|
+
const width = menu.offsetWidth;
|
|
892
|
+
menu.style.top = `${box.bottom + 4}px`;
|
|
893
|
+
menu.style.left = `${Math.min(box.left, window.innerWidth - width - 12)}px`;
|
|
894
|
+
|
|
895
|
+
const dismiss = (e) => {
|
|
896
|
+
if (menu.contains(e.target)) return;
|
|
897
|
+
menu.remove();
|
|
898
|
+
document.removeEventListener('pointerdown', dismiss, true);
|
|
899
|
+
document.removeEventListener('keydown', onEsc, true);
|
|
900
|
+
};
|
|
901
|
+
const onEsc = (e) => { if (e.key === 'Escape') dismiss(e); };
|
|
902
|
+
document.addEventListener('pointerdown', dismiss, true);
|
|
903
|
+
document.addEventListener('keydown', onEsc, true);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Quote a typed value only when the grammar needs it. */
|
|
907
|
+
/* Trims, then hands the value to the one quoter — this used to *delete* an
|
|
908
|
+
embedded quote, so `Harbour "HQ"` silently became `Harbour HQ` and the
|
|
909
|
+
filter matched a different row, or none. */
|
|
910
|
+
function quoteValue(text) {
|
|
911
|
+
return quote(String(text).trim());
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* The date test from core.js, applied to the name the *result set* used.
|
|
916
|
+
*
|
|
917
|
+
* A joined column arrives under its path — `customer_id.opened_on` — and the
|
|
918
|
+
* column meta beside it still calls itself `opened_on`, so the name has to be
|
|
919
|
+
* passed in rather than read off the meta.
|
|
920
|
+
*
|
|
921
|
+
* The copy that used to be written out here had lost `timestamp`, `datetime`
|
|
922
|
+
* and `deleted` from the name pattern, so the filter menu offered `=` on an
|
|
923
|
+
* `event_timestamp TEXT` column and hid the relative-date phrases that are
|
|
924
|
+
* the only sensible thing to say about a date.
|
|
925
|
+
*/
|
|
926
|
+
const dateish = (meta, name) => Boolean(meta) && looksLikeDateColumn({ type: meta.type, name });
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* `append` adds a level to the sort instead of replacing it — shift-click,
|
|
930
|
+
* which is the gesture every grid uses for exactly this.
|
|
931
|
+
*/
|
|
932
|
+
function sortBy(column, direction, append = false) {
|
|
933
|
+
const entry = state.stack[state.stack.length - 1];
|
|
934
|
+
if (!entry || entry.kind !== 'table') return;
|
|
935
|
+
/* `nextSort` must keep receiving `undefined` when no direction was named:
|
|
936
|
+
an explicit direction means "write down what is already happening" and
|
|
937
|
+
deliberately skips the ascending → descending cycle. Defaulting it here
|
|
938
|
+
cost the header its second click. `addSort` has no cycle to skip, so it
|
|
939
|
+
takes a direction. */
|
|
940
|
+
go(tableView(entry.table, append
|
|
941
|
+
? addSort(entry.query, column, direction ?? 'asc')
|
|
942
|
+
: nextSort(entry.query, column, direction)), 'replace');
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Paging, for a table somebody might actually reach the end of.
|
|
947
|
+
*
|
|
948
|
+
* This was one `Next →` at twenty rows a page. On the demo's invoice lines —
|
|
949
|
+
* sixty-three thousand of them — that is three thousand clicks to the last
|
|
950
|
+
* page, which means the table was browsable at the front and nowhere else.
|
|
951
|
+
* Every part below exists because that number is absurd.
|
|
952
|
+
*
|
|
953
|
+
* The size control sits on the left whether or not there is a second page: it
|
|
954
|
+
* is a property of how you are reading the table, not of how much is left.
|
|
955
|
+
* "Last" and the page box appear only when the total is a real count — an
|
|
956
|
+
* estimate cannot say which page is last, and a control that jumps to a page
|
|
957
|
+
* that may not exist is worse than no control.
|
|
958
|
+
*/
|
|
959
|
+
function pager(entry, data, shown) {
|
|
960
|
+
const bar = el('div', { class: 'pager' });
|
|
961
|
+
// A query that names its own limit defines the whole result; paging through
|
|
962
|
+
// it would contradict what the user asked for.
|
|
963
|
+
if (explicitLimit(entry.query)) return bar;
|
|
964
|
+
|
|
965
|
+
const size = pageSize();
|
|
966
|
+
/* An estimated total cannot decide whether there is a next page.
|
|
967
|
+
|
|
968
|
+
`total` is a guess above ESTIMATE_ABOVE rows — read from the catalog
|
|
969
|
+
rather than counted — and a guess that comes in low hid `Next` on a table
|
|
970
|
+
that had more, with nothing on screen to say browsing had been cut short.
|
|
971
|
+
A full page returned is the honest signal: it means the database had at
|
|
972
|
+
least that many, whatever the estimate thinks. */
|
|
973
|
+
const full = (data.rows?.length ?? 0) >= size;
|
|
974
|
+
const counted = data.exactTotal !== false && typeof data.total === 'number';
|
|
975
|
+
const more = counted ? data.total > shown : full;
|
|
976
|
+
const lastPage = counted ? Math.max(0, Math.ceil(data.total / size) - 1) : null;
|
|
977
|
+
|
|
978
|
+
const goTo = (page) => {
|
|
979
|
+
const wanted = Math.max(0, lastPage === null ? page : Math.min(page, lastPage));
|
|
980
|
+
if (wanted === state.page) return;
|
|
981
|
+
state.page = wanted;
|
|
982
|
+
render();
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
/* The app's own dropdown, not a native `<select>`. Everywhere else that
|
|
986
|
+
picks between a handful of values — the connection picker most visibly —
|
|
987
|
+
uses this control, and a system popup opening in the middle of a page
|
|
988
|
+
that has its own look reads as something the page did not draw. */
|
|
989
|
+
bar.append(el('div', { class: 'pager-size' }, [
|
|
990
|
+
el('span', { text: 'Rows' }),
|
|
991
|
+
dropdown({
|
|
992
|
+
ariaLabel: 'Rows per page',
|
|
993
|
+
value: size,
|
|
994
|
+
items: PAGE_SIZES.map((n) => ({ value: n, label: String(n) })),
|
|
995
|
+
onChange: (n) => { setPageSize(n); render(); },
|
|
996
|
+
}),
|
|
997
|
+
]));
|
|
998
|
+
|
|
999
|
+
const step = (text, label, to, enabled) => el('button', {
|
|
1000
|
+
class: 'ghost', type: 'button', text, 'aria-label': label,
|
|
1001
|
+
disabled: enabled ? undefined : true,
|
|
1002
|
+
onclick: () => goTo(to),
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
bar.append(el('div', { class: 'pager-steps' }, [
|
|
1006
|
+
step('« First', 'First page', 0, state.page > 0),
|
|
1007
|
+
step('← Previous', 'Previous page', state.page - 1, state.page > 0),
|
|
1008
|
+
/* The page number is an input, not a label. Reaching page 400 by clicking
|
|
1009
|
+
Next four hundred times is the thing this whole function is fixing. */
|
|
1010
|
+
counted
|
|
1011
|
+
? el('span', { class: 'page-label' }, [
|
|
1012
|
+
el('span', { text: 'Page' }),
|
|
1013
|
+
el('input', {
|
|
1014
|
+
class: 'page-input',
|
|
1015
|
+
type: 'number',
|
|
1016
|
+
min: '1',
|
|
1017
|
+
max: String(lastPage + 1),
|
|
1018
|
+
value: String(state.page + 1),
|
|
1019
|
+
'aria-label': 'Page number',
|
|
1020
|
+
onchange: (e) => {
|
|
1021
|
+
const asked = Number(e.target.value);
|
|
1022
|
+
if (!Number.isFinite(asked)) { e.target.value = String(state.page + 1); return; }
|
|
1023
|
+
goTo(Math.round(asked) - 1);
|
|
1024
|
+
},
|
|
1025
|
+
}),
|
|
1026
|
+
el('span', { text: `of ${(lastPage + 1).toLocaleString()}` }),
|
|
1027
|
+
])
|
|
1028
|
+
: el('span', { class: 'page-label', text: `Page ${state.page + 1}` }),
|
|
1029
|
+
step('Next →', 'Next page', state.page + 1, more),
|
|
1030
|
+
/* Absent on an estimate: the last page of a guess is a guess. */
|
|
1031
|
+
counted ? step('Last »', 'Last page', lastPage, state.page < lastPage) : null,
|
|
1032
|
+
].filter(Boolean)));
|
|
1033
|
+
|
|
1034
|
+
return bar;
|
|
1035
|
+
}
|