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,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One value, rendered by what it is — shared by every view that shows fields.
|
|
3
|
+
*
|
|
4
|
+
* The record view and the page view each grew their own idea of what a value
|
|
5
|
+
* looks like, and the page's was the worse one: `active` rendered as `1`,
|
|
6
|
+
* dates kept their midnights, and a foreign key was a bare integer. Two
|
|
7
|
+
* renderers for the same value will always disagree eventually; this is the
|
|
8
|
+
* one, and the grid's planner is its rule book.
|
|
9
|
+
*/
|
|
10
|
+
import { api, el, findTable, go, labelColumn, openRecord, primaryKey, rowLabel, state } from './core.js';
|
|
11
|
+
import { planColumn } from './columns.js';
|
|
12
|
+
import { peekable } from './peek.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a value by what it is, matching how the result grid renders it.
|
|
16
|
+
*
|
|
17
|
+
* The record view drifted from the table view: the grid renders booleans as
|
|
18
|
+
* ticks, right-aligns numbers and trims midnight off dates, while the record
|
|
19
|
+
* showed everything as a bare string. Two views of the same value should not
|
|
20
|
+
* disagree about what it is.
|
|
21
|
+
*/
|
|
22
|
+
/** Thousands separators on a numeric string too big to survive Number(). */
|
|
23
|
+
function groupDigits(text) {
|
|
24
|
+
const neg = text.startsWith('-');
|
|
25
|
+
const digits = neg ? text.slice(1) : text;
|
|
26
|
+
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
27
|
+
return neg ? `-${grouped}` : grouped;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function valueNode(col, value) {
|
|
31
|
+
const type = (col?.type ?? '').toLowerCase();
|
|
32
|
+
const text = String(value);
|
|
33
|
+
|
|
34
|
+
/* Classified by the same planner the grid uses, on a row of one.
|
|
35
|
+
|
|
36
|
+
This file had its own rule, and it was the looser of the two: an
|
|
37
|
+
unanchored `^(active|enabled|urgent|billable)` with no check on the
|
|
38
|
+
value, so an integer column called `active_connections` was a boolean
|
|
39
|
+
here and a number in the grid — `active_connections = 3` rendered as
|
|
40
|
+
"✗ no" on the record page and as `3` two clicks away. The grid's rule
|
|
41
|
+
wants the name to match a flag *exactly* and every value to be 0 or 1,
|
|
42
|
+
which is the test that makes 0/1 mean true and false rather than being
|
|
43
|
+
two small numbers. */
|
|
44
|
+
const plan = planColumn(col, col?.name ?? '', [{ [col?.name ?? '']: value }]);
|
|
45
|
+
|
|
46
|
+
if (plan.kind === 'boolean') {
|
|
47
|
+
const on = value === true || value === 1 || value === '1' || value === 't';
|
|
48
|
+
return el('span', { class: `bool-value ${on ? 'yes' : 'no'}`, title: text },
|
|
49
|
+
on ? '✓ yes' : '✗ no');
|
|
50
|
+
}
|
|
51
|
+
if (plan.kind === 'number' && Number.isFinite(Number(value))) {
|
|
52
|
+
const n = Number(value);
|
|
53
|
+
/* A big id arrives as a string because it is past 2^53, and running it
|
|
54
|
+
back through Number() here rounds it — the same corruption the adapter
|
|
55
|
+
fix avoided, one layer later, on the page where the value is read most
|
|
56
|
+
carefully. Shown as it came, grouped by hand. `plan.identifier` already
|
|
57
|
+
passed the raw string through; this widens that to any overflow. */
|
|
58
|
+
const overflow = typeof value === 'string' && /^-?\d+$/.test(value)
|
|
59
|
+
&& !Number.isSafeInteger(n);
|
|
60
|
+
const shown = plan.identifier || overflow
|
|
61
|
+
? (overflow ? groupDigits(text) : text)
|
|
62
|
+
: n.toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
63
|
+
return el('span', { class: 'num-value', title: shown === text ? undefined : text, text: shown });
|
|
64
|
+
}
|
|
65
|
+
if (plan.kind === 'date' || /date|time/.test(type)) {
|
|
66
|
+
/* Postgres hands back an ISO wire format — 2025-03-14T09:30:00.000Z —
|
|
67
|
+
which is precise and unreadable. Shown as a date and a time, with the
|
|
68
|
+
original kept in the title because a timezone offset can matter and
|
|
69
|
+
should not be thrown away. */
|
|
70
|
+
const trimmed = text
|
|
71
|
+
.replace(/[ T]00:00:00(\.0+)?(Z|[+-]\d{2}:?\d{2})?$/, '')
|
|
72
|
+
.replace(/T/, ' ')
|
|
73
|
+
.replace(/:\d{2}\.\d+(Z|[+-]\d{2}:?\d{2})?$/, '')
|
|
74
|
+
.replace(/(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:?\d{2})$/, '$1');
|
|
75
|
+
return el('span', { class: 'date-value', title: trimmed === text ? undefined : text, text: trimmed });
|
|
76
|
+
}
|
|
77
|
+
/* Long text gets its own block, and its field spans every column rather
|
|
78
|
+
than wrapping to five lines inside one. The threshold came down when the
|
|
79
|
+
record went multi-column: a column is now roughly half as wide, so text
|
|
80
|
+
that used to fit on one line no longer does. */
|
|
81
|
+
if (text.length > 90) return el('div', { class: 'long-value prose', text });
|
|
82
|
+
|
|
83
|
+
/* Monospace is right for data you compare character by character — ids,
|
|
84
|
+
codes, timestamps, amounts — and wrong for names, which it makes wider
|
|
85
|
+
and harder to read for no benefit. A space is a good enough tell: a value
|
|
86
|
+
with one is prose ("Southerly Logistics", "272 Cuba St"), a value without
|
|
87
|
+
is a token ("on_hold", "SKU-1314", an email). */
|
|
88
|
+
return el('span', { class: /\s/.test(text.trim()) ? 'prose' : '', text });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** How deep the walk may unfold in place before it has to navigate. */
|
|
92
|
+
const MAX_NEST = 3;
|
|
93
|
+
|
|
94
|
+
/** Fields a nested card shows before deferring to its own page. */
|
|
95
|
+
const NEST_FIELDS = 10;
|
|
96
|
+
|
|
97
|
+
/** Referenced rows already unfolded once, keyed like the peek cache is. */
|
|
98
|
+
const nestCache = new Map();
|
|
99
|
+
|
|
100
|
+
async function referencedRow(target, column, value) {
|
|
101
|
+
const id = `${state.activeConnection ?? ''} ${target.id} ${column} ${value}`;
|
|
102
|
+
if (nestCache.has(id)) return nestCache.get(id);
|
|
103
|
+
const data = await api('/api/query', {
|
|
104
|
+
table: target.id,
|
|
105
|
+
filter: { groups: [[{ column, op: '=', value }]] },
|
|
106
|
+
limit: 1,
|
|
107
|
+
offset: 0,
|
|
108
|
+
});
|
|
109
|
+
const row = data.rows?.[0] ?? null;
|
|
110
|
+
nestCache.set(id, row);
|
|
111
|
+
return row;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A foreign key value as the walk it is: the value, the table it points at,
|
|
116
|
+
* a hover that shows the row, a click that goes there — and a caret that
|
|
117
|
+
* unfolds the row *here*.
|
|
118
|
+
*
|
|
119
|
+
* The hover answers "who is 4?" and vanishes; the caret is for when the
|
|
120
|
+
* answer should stay put while you keep reading, and for when the answer has
|
|
121
|
+
* its own references worth one more step. Each unfolded card renders its
|
|
122
|
+
* values through the same functions the record does — booleans as words,
|
|
123
|
+
* references as chips with carets of their own — so the walk continues in
|
|
124
|
+
* place, three levels deep at most. Past the cap the chip still navigates;
|
|
125
|
+
* depth is a courtesy, not a wall.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately not in the URL. An expansion is a look, not a place: the
|
|
128
|
+
* deep link already names any row exactly, and a link that encoded every
|
|
129
|
+
* open caret would break the moment a layout moved — the argument that
|
|
130
|
+
* settled list pagers applies unchanged.
|
|
131
|
+
*/
|
|
132
|
+
export function refJump(references, value, depth = 0) {
|
|
133
|
+
const target = findTable(references.table);
|
|
134
|
+
const chip = peekable(el('button', {
|
|
135
|
+
class: 'ref-jump',
|
|
136
|
+
type: 'button',
|
|
137
|
+
title: `Open ${references.table} ${value}`,
|
|
138
|
+
onclick: () => {
|
|
139
|
+
if (!target) return;
|
|
140
|
+
/* In the view the walk is already in: a reference followed from a page
|
|
141
|
+
opens the referenced record as a page. */
|
|
142
|
+
void openRecord(target.id, { [references.column]: value }, `${references.table} ${value}`);
|
|
143
|
+
},
|
|
144
|
+
}, [
|
|
145
|
+
el('span', { class: 'ref-jump-value', text: String(value) }),
|
|
146
|
+
el('span', { class: 'ref-jump-table', text: references.table }),
|
|
147
|
+
]), references, value);
|
|
148
|
+
|
|
149
|
+
if (!target || depth >= MAX_NEST) return chip;
|
|
150
|
+
|
|
151
|
+
const holder = el('span', { class: 'nest-holder' });
|
|
152
|
+
const caret = el('button', {
|
|
153
|
+
type: 'button',
|
|
154
|
+
class: 'nest-caret',
|
|
155
|
+
'aria-expanded': 'false',
|
|
156
|
+
title: `Show this ${target.name} here`,
|
|
157
|
+
'aria-label': `Show ${references.table} ${value} inline`,
|
|
158
|
+
text: '\u25b8',
|
|
159
|
+
onclick: async () => {
|
|
160
|
+
if (holder.childElementCount) {
|
|
161
|
+
holder.replaceChildren();
|
|
162
|
+
caret.textContent = '\u25b8';
|
|
163
|
+
caret.setAttribute('aria-expanded', 'false');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
caret.textContent = '\u25be';
|
|
167
|
+
caret.setAttribute('aria-expanded', 'true');
|
|
168
|
+
const row = await referencedRow(target, references.column, value);
|
|
169
|
+
/* The caret may have been closed while the row travelled. */
|
|
170
|
+
if (caret.getAttribute('aria-expanded') !== 'true') return;
|
|
171
|
+
holder.replaceChildren(row
|
|
172
|
+
? nestCard(target, row, depth + 1)
|
|
173
|
+
: el('p', { class: 'nest-missing', text: 'That row is no longer there.' }));
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return el('span', { class: 'nest-wrap' }, [chip, caret, holder]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The referenced row, as a card: key and label first, then what fits, each
|
|
182
|
+
* value rendered by what it is — which is what makes the card walkable in
|
|
183
|
+
* turn rather than a dead end of strings.
|
|
184
|
+
*/
|
|
185
|
+
function nestCard(table, row, depth) {
|
|
186
|
+
const key = primaryKey(table);
|
|
187
|
+
const label = labelColumn(table);
|
|
188
|
+
const ordered = [
|
|
189
|
+
...key,
|
|
190
|
+
...(label && !key.includes(label) ? [label] : []),
|
|
191
|
+
...table.columns.map((c) => c.name).filter((n) => !key.includes(n) && n !== label),
|
|
192
|
+
].slice(0, NEST_FIELDS);
|
|
193
|
+
const byName = new Map(table.columns.map((c) => [c.name, c]));
|
|
194
|
+
|
|
195
|
+
return el('div', { class: 'nest-card' }, [
|
|
196
|
+
el('div', { class: 'nest-head' }, [
|
|
197
|
+
el('span', { class: 'nest-table', text: table.name }),
|
|
198
|
+
el('span', { class: 'nest-label', text: rowLabel(table, row) }),
|
|
199
|
+
el('button', {
|
|
200
|
+
type: 'button', class: 'ghost nest-open', text: 'open \u2192',
|
|
201
|
+
onclick: () => {
|
|
202
|
+
const keyValues = Object.fromEntries(key.map((c) => [c, row[c]]));
|
|
203
|
+
void openRecord(table.id, keyValues, rowLabel(table, row));
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
]),
|
|
207
|
+
...ordered.map((name) => {
|
|
208
|
+
const column = byName.get(name);
|
|
209
|
+
const cell = row[name];
|
|
210
|
+
return el('div', { class: 'nest-line' }, [
|
|
211
|
+
el('span', { class: 'nest-name', text: name }),
|
|
212
|
+
cell === null || cell === undefined
|
|
213
|
+
? el('span', { class: 'nest-value null', text: 'null' })
|
|
214
|
+
: column?.references
|
|
215
|
+
? el('span', { class: 'nest-value' }, refJump(column.references, cell, depth))
|
|
216
|
+
: el('span', { class: 'nest-value' }, valueNode(column, cell)),
|
|
217
|
+
]);
|
|
218
|
+
}),
|
|
219
|
+
table.columns.length > NEST_FIELDS
|
|
220
|
+
? el('p', { class: 'nest-more', text: `+ ${table.columns.length - NEST_FIELDS} more on the record` })
|
|
221
|
+
: null,
|
|
222
|
+
].filter(Boolean));
|
|
223
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved filters, as named tabs on a table.
|
|
3
|
+
*
|
|
4
|
+
* A filter you retype every morning is a filter that should have a name. The
|
|
5
|
+
* saved view stores the *query text*, not a compiled filter, for two reasons:
|
|
6
|
+
* it stays readable and editable in the bar, and it keeps working when the
|
|
7
|
+
* schema changes underneath it — a stored filter referencing a dropped column
|
|
8
|
+
* would be a silent empty result, while stored text is at worst a visible
|
|
9
|
+
* error you can fix.
|
|
10
|
+
*
|
|
11
|
+
* Scoped per connection as well as per table, because `orders` on staging and
|
|
12
|
+
* `orders` on production are not the same table and their useful filters are
|
|
13
|
+
* rarely the same either.
|
|
14
|
+
*/
|
|
15
|
+
import { el, go, loadJson, saveJson, state, tableView, toast } from './core.js';
|
|
16
|
+
import { promptFor } from './prompt.js';
|
|
17
|
+
import { activeClauses, withoutClause, withoutCondition } from './clauses.js';
|
|
18
|
+
|
|
19
|
+
const KEY = 'tablewalk.views.v1';
|
|
20
|
+
|
|
21
|
+
function allViews() {
|
|
22
|
+
return loadJson(KEY, {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function viewsFor(connectionId, tableId) {
|
|
26
|
+
const all = allViews();
|
|
27
|
+
return all[connectionId]?.[tableId] ?? [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeViews(connectionId, tableId, list) {
|
|
31
|
+
const all = allViews();
|
|
32
|
+
all[connectionId] = all[connectionId] ?? {};
|
|
33
|
+
all[connectionId][tableId] = list;
|
|
34
|
+
if (!saveJson(KEY, all)) {
|
|
35
|
+
toast('Could not save — this browser is not allowing local storage.', 'error');
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every saved filter for this connection, flattened across tables.
|
|
45
|
+
*
|
|
46
|
+
* The landing page needs them together; the tab strip needs them per table.
|
|
47
|
+
* Both read the same store, so a filter saved on a table is immediately on
|
|
48
|
+
* the front page without anything having to be kept in step.
|
|
49
|
+
*/
|
|
50
|
+
export function allSavedFilters() {
|
|
51
|
+
const byTable = allViews()[connectionKey()] ?? {};
|
|
52
|
+
return Object.entries(byTable).flatMap(([table, list]) =>
|
|
53
|
+
(list ?? []).map((view) => ({ ...view, table })),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The tab strip for a table view. Returns null when there is nothing to show
|
|
59
|
+
* and nothing has been saved yet, so an unused feature costs no screen space.
|
|
60
|
+
*/
|
|
61
|
+
export function savedViewsBar(entry) {
|
|
62
|
+
const conn = connectionKey();
|
|
63
|
+
const views = viewsFor(conn, entry.table);
|
|
64
|
+
const bar = el('div', { class: 'views', role: 'tablist', 'aria-label': 'Saved filters' });
|
|
65
|
+
|
|
66
|
+
/* "All" is the unfiltered table, and is only meaningful once something else
|
|
67
|
+
exists to contrast it with. */
|
|
68
|
+
const isPlain = entry.query.trim() === entry.table;
|
|
69
|
+
if (views.length) {
|
|
70
|
+
bar.append(el('button', {
|
|
71
|
+
type: 'button',
|
|
72
|
+
class: 'view-tab',
|
|
73
|
+
role: 'tab',
|
|
74
|
+
'aria-selected': String(isPlain),
|
|
75
|
+
text: 'All',
|
|
76
|
+
onclick: () => go(tableView(entry.table), 'replace'),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const view of views) {
|
|
81
|
+
const active = view.query.trim() === entry.query.trim();
|
|
82
|
+
const tab = el('span', { class: `view-tab-wrap${active ? ' active' : ''}` }, [
|
|
83
|
+
el('button', {
|
|
84
|
+
type: 'button',
|
|
85
|
+
class: 'view-tab',
|
|
86
|
+
role: 'tab',
|
|
87
|
+
'aria-selected': String(active),
|
|
88
|
+
title: view.query,
|
|
89
|
+
text: view.name,
|
|
90
|
+
onclick: () => go(tableView(entry.table, view.query), 'replace'),
|
|
91
|
+
ondblclick: () => void renameView(conn, entry, view),
|
|
92
|
+
}),
|
|
93
|
+
el('button', {
|
|
94
|
+
type: 'button',
|
|
95
|
+
class: 'view-remove',
|
|
96
|
+
title: `Remove "${view.name}"`,
|
|
97
|
+
'aria-label': `Remove ${view.name}`,
|
|
98
|
+
text: '×',
|
|
99
|
+
onclick: (e) => {
|
|
100
|
+
e.stopPropagation();
|
|
101
|
+
removeView(conn, entry, view);
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
]);
|
|
105
|
+
bar.append(tab);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Saving is offered only when there is a filter worth saving. Naming the
|
|
109
|
+
bare table is a saved view that does nothing. */
|
|
110
|
+
const alreadySaved = views.some((v) => v.query.trim() === entry.query.trim());
|
|
111
|
+
if (!isPlain && !alreadySaved) {
|
|
112
|
+
bar.append(el('button', {
|
|
113
|
+
type: 'button',
|
|
114
|
+
class: 'view-save',
|
|
115
|
+
text: '+ Save this filter',
|
|
116
|
+
onclick: () => void saveCurrent(conn, entry),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* What is currently narrowing the result, each with a way off.
|
|
121
|
+
|
|
122
|
+
The query bar can write these but could not un-write them: having sorted
|
|
123
|
+
by clicking a header, going back to unsorted meant finding `sort
|
|
124
|
+
contract_id` in a line of text and deleting exactly it. */
|
|
125
|
+
for (const clause of activeClauses(entry.query)) {
|
|
126
|
+
bar.append(el('span', { class: `clause clause-${clause.key}` }, [
|
|
127
|
+
el('span', { class: 'clause-label', text: clause.label }),
|
|
128
|
+
el('span', { class: 'clause-text', title: clause.text, text: clause.text }),
|
|
129
|
+
el('button', {
|
|
130
|
+
type: 'button',
|
|
131
|
+
class: 'clause-remove',
|
|
132
|
+
title: `Remove ${clause.label} ${clause.text}`,
|
|
133
|
+
'aria-label': `Remove ${clause.label} ${clause.text}`,
|
|
134
|
+
text: '×',
|
|
135
|
+
/* A filter chip removes its own condition; everything else removes
|
|
136
|
+
the whole clause, because a sort or a limit has only one of it. */
|
|
137
|
+
onclick: () => go(tableView(
|
|
138
|
+
entry.table,
|
|
139
|
+
clause.index === undefined
|
|
140
|
+
? withoutClause(entry.query, clause.key)
|
|
141
|
+
: withoutCondition(entry.query, clause.index),
|
|
142
|
+
), 'replace'),
|
|
143
|
+
}),
|
|
144
|
+
]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return bar.childElementCount ? bar : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function saveCurrent(conn, entry) {
|
|
151
|
+
const trimmed = await promptFor({
|
|
152
|
+
title: 'Save this filter',
|
|
153
|
+
label: 'Name',
|
|
154
|
+
value: suggestName(entry, entry.table),
|
|
155
|
+
hint: entry.query,
|
|
156
|
+
placeholder: 'e.g. Open, high priority',
|
|
157
|
+
});
|
|
158
|
+
if (!trimmed) return;
|
|
159
|
+
|
|
160
|
+
const views = viewsFor(conn, entry.table);
|
|
161
|
+
// A repeated name replaces rather than duplicating: two tabs reading
|
|
162
|
+
// "Open" that do different things is worse than losing the older one.
|
|
163
|
+
const without = views.filter((v) => v.name !== trimmed);
|
|
164
|
+
without.push({ id: `${Date.now()}`, name: trimmed, query: entry.query });
|
|
165
|
+
if (writeViews(conn, entry.table, without)) {
|
|
166
|
+
toast(`Saved "${trimmed}".`, 'ok');
|
|
167
|
+
go({ ...entry }, 'replace');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function renameView(conn, entry, view) {
|
|
172
|
+
const trimmed = await promptFor({
|
|
173
|
+
title: 'Rename filter',
|
|
174
|
+
label: 'Name',
|
|
175
|
+
value: view.name,
|
|
176
|
+
hint: view.query,
|
|
177
|
+
confirmLabel: 'Rename',
|
|
178
|
+
});
|
|
179
|
+
if (!trimmed) return;
|
|
180
|
+
const views = viewsFor(conn, entry.table).map((v) => (v.id === view.id ? { ...v, name: trimmed } : v));
|
|
181
|
+
if (writeViews(conn, entry.table, views)) go({ ...entry }, 'replace');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function removeView(conn, entry, view) {
|
|
185
|
+
const views = viewsFor(conn, entry.table).filter((v) => v.id !== view.id);
|
|
186
|
+
if (writeViews(conn, entry.table, views)) go({ ...entry }, 'replace');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A first guess at a name.
|
|
191
|
+
*
|
|
192
|
+
* `invoice total > 500 sort total desc` suggests "total > 500" — the part
|
|
193
|
+
* that distinguishes it. Stripping the table name and the trailing clauses
|
|
194
|
+
* leaves what the filter is actually about.
|
|
195
|
+
*
|
|
196
|
+
* When the filter was reached by walking, the trail says more than the filter
|
|
197
|
+
* does: `work_order supersedes_id = 5` means little on its own, while
|
|
198
|
+
* "WO-100005 · supersedes_id = 5" says which row it came from. The row you
|
|
199
|
+
* walked through is the context the filter is really about.
|
|
200
|
+
*/
|
|
201
|
+
function suggestName(entry, table) {
|
|
202
|
+
const query = entry.query;
|
|
203
|
+
const previousRow = [...state.stack]
|
|
204
|
+
.slice(0, state.stack.indexOf(entry))
|
|
205
|
+
.reverse()
|
|
206
|
+
.find((e) => e.kind === 'row');
|
|
207
|
+
const withoutTable = query.trim().slice(table.length).trim();
|
|
208
|
+
const withoutClauses = withoutTable
|
|
209
|
+
.replace(/\s+(?:sort|order\s+by|limit|show|select)\s+.*$/i, '')
|
|
210
|
+
.replace(/^(?:filter|where)\s+/i, '')
|
|
211
|
+
.trim();
|
|
212
|
+
const filterPart = withoutClauses || withoutTable;
|
|
213
|
+
const candidate = previousRow?.label ? `${previousRow.label} · ${filterPart}` : filterPart;
|
|
214
|
+
return candidate.length > 48 ? `${candidate.slice(0, 47)}…` : candidate;
|
|
215
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row virtualisation for the result grid.
|
|
3
|
+
*
|
|
4
|
+
* The database is not the bottleneck. On the 87k demo, forty-two thousand
|
|
5
|
+
* rows are fetched in 35ms and arrive in the browser in about 100ms; putting
|
|
6
|
+
* them in the DOM is what takes seconds and then makes every subsequent
|
|
7
|
+
* scroll and hover slow. So the fix belongs in rendering, not in fetching,
|
|
8
|
+
* and the 2,000-row cap this replaces was treating the symptom.
|
|
9
|
+
*
|
|
10
|
+
* The approach is the boring one, which is the point: measure a row, keep a
|
|
11
|
+
* window of rows either side of the viewport, and pad above and below with
|
|
12
|
+
* two spacer rows so the scrollbar is the size it would have been. No
|
|
13
|
+
* library, no absolute positioning, no transform — a `<tr>` with a tall
|
|
14
|
+
* `<td>` is enough, and it keeps the table a real table, so column widths,
|
|
15
|
+
* sticky headers, text selection and Find-in-page all keep working.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately not animated and deliberately not smoothed. Scrolling is the
|
|
18
|
+
* most repeated interaction in the tool; anything that lags the content
|
|
19
|
+
* behind the scrollbar is felt every single time.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Rows kept beyond the viewport on each side, so a flick does not show gaps. */
|
|
23
|
+
const OVERSCAN = 12;
|
|
24
|
+
|
|
25
|
+
/** Below this many rows, windowing costs more than it saves. */
|
|
26
|
+
export const VIRTUAL_THRESHOLD = 300;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Take over a tbody, rendering only what is visible.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {HTMLElement} options.scroller the element that actually scrolls
|
|
33
|
+
* @param {HTMLTableSectionElement} options.tbody
|
|
34
|
+
* @param {Array} options.rows every row, not just the visible ones
|
|
35
|
+
* @param {(row: any, index: number) => HTMLTableRowElement} options.renderRow
|
|
36
|
+
* @param {number} options.columns for the spacers' colspan
|
|
37
|
+
* @returns {() => void} a teardown function
|
|
38
|
+
*/
|
|
39
|
+
export function virtualise({ scroller, tbody, rows, renderRow, columns }) {
|
|
40
|
+
/* Measured from a real rendered row rather than assumed. Row height
|
|
41
|
+
depends on font, zoom and content — a hardcoded guess drifts, and the
|
|
42
|
+
drift shows up as the scrollbar disagreeing with the content. */
|
|
43
|
+
let rowHeight = 0;
|
|
44
|
+
let first = -1;
|
|
45
|
+
let last = -1;
|
|
46
|
+
let frame = 0;
|
|
47
|
+
/** Guards the one repaint a height correction is allowed to cause. */
|
|
48
|
+
let correcting = false;
|
|
49
|
+
|
|
50
|
+
const topSpacer = spacerRow(columns);
|
|
51
|
+
const bottomSpacer = spacerRow(columns);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Height of a row, from rows actually rendered.
|
|
55
|
+
*
|
|
56
|
+
* Sampled from several rows rather than the first, because the first is not
|
|
57
|
+
* a fair sample: a row whose only reference column is null renders a bare
|
|
58
|
+
* `null` where every other row renders a chip with padding, and measuring
|
|
59
|
+
* that one row makes every offset in the table slightly short.
|
|
60
|
+
*
|
|
61
|
+
* Spread through the data rather than taken from the top, so a table whose
|
|
62
|
+
* first screenful is unrepresentative does not set the height for all of it.
|
|
63
|
+
*/
|
|
64
|
+
function measure() {
|
|
65
|
+
const at = [0, Math.floor(rows.length / 3), Math.floor((rows.length * 2) / 3), rows.length - 1]
|
|
66
|
+
.filter((i, n, all) => i >= 0 && all.indexOf(i) === n);
|
|
67
|
+
const samples = at.map((i) => renderRow(rows[i], i));
|
|
68
|
+
tbody.replaceChildren(...samples);
|
|
69
|
+
rowHeight = Math.max(...samples.map((r) => r.getBoundingClientRect().height), 0) || 26;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function windowFor() {
|
|
73
|
+
const viewportTop = scroller.scrollTop;
|
|
74
|
+
const viewportHeight = scroller.clientHeight || 600;
|
|
75
|
+
/* The table does not start at the top of the scroller — there is a
|
|
76
|
+
breadcrumb, tabs and a header above it — so the offset is measured
|
|
77
|
+
rather than assumed to be zero. */
|
|
78
|
+
const tableTop = tbody.parentElement.offsetTop;
|
|
79
|
+
const relative = Math.max(0, viewportTop - tableTop);
|
|
80
|
+
const start = Math.max(0, Math.floor(relative / rowHeight) - OVERSCAN);
|
|
81
|
+
const visible = Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2;
|
|
82
|
+
return { start, end: Math.min(rows.length, start + visible) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function paint() {
|
|
86
|
+
const { start, end } = windowFor();
|
|
87
|
+
// Nothing to do if the window has not moved: scroll fires far more often
|
|
88
|
+
// than the window actually changes.
|
|
89
|
+
if (start === first && end === last) return;
|
|
90
|
+
first = start;
|
|
91
|
+
last = end;
|
|
92
|
+
|
|
93
|
+
topSpacer.firstChild.style.height = `${start * rowHeight}px`;
|
|
94
|
+
bottomSpacer.firstChild.style.height = `${Math.max(0, rows.length - end) * rowHeight}px`;
|
|
95
|
+
|
|
96
|
+
const painted = [topSpacer];
|
|
97
|
+
for (let i = start; i < end; i++) painted.push(renderRow(rows[i], i));
|
|
98
|
+
painted.push(bottomSpacer);
|
|
99
|
+
tbody.replaceChildren(...painted);
|
|
100
|
+
|
|
101
|
+
correct(painted[1]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check the assumption against what was actually drawn, once.
|
|
106
|
+
*
|
|
107
|
+
* Every offset in this file is `index * rowHeight`, so a height that is
|
|
108
|
+
* wrong by a pixel is wrong by a pixel *per row* — at forty thousand rows
|
|
109
|
+
* that is a scrollbar which disagrees with its content by the height of a
|
|
110
|
+
* screenful, and the rows under the cursor are not the rows the scrollbar
|
|
111
|
+
* says.
|
|
112
|
+
*
|
|
113
|
+
* The measurement can be right when taken and wrong later: a webfont
|
|
114
|
+
* arriving after first paint changes every row, and so does a browser zoom
|
|
115
|
+
* that the ResizeObserver sees only after the fact. Rather than enumerate
|
|
116
|
+
* the causes, this compares the number against the thing it describes and
|
|
117
|
+
* corrects it — which covers the causes nobody thought of too.
|
|
118
|
+
*
|
|
119
|
+
* Once per paint, and only when the disagreement is real: a repaint that
|
|
120
|
+
* triggers another correction that triggers another repaint is a loop, and
|
|
121
|
+
* sub-pixel differences are noise from `getBoundingClientRect`.
|
|
122
|
+
*/
|
|
123
|
+
function correct(sample) {
|
|
124
|
+
if (correcting || !sample || sample === bottomSpacer) return;
|
|
125
|
+
const actual = sample.getBoundingClientRect().height;
|
|
126
|
+
if (!actual || Math.abs(actual - rowHeight) < 0.5) return;
|
|
127
|
+
correcting = true;
|
|
128
|
+
rowHeight = actual;
|
|
129
|
+
first = -1;
|
|
130
|
+
last = -1;
|
|
131
|
+
paint();
|
|
132
|
+
correcting = false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onScroll = () => {
|
|
136
|
+
// One paint per frame at most. Scroll can fire many times per frame and
|
|
137
|
+
// rebuilding the window on each is how a virtual list ends up slower
|
|
138
|
+
// than the thing it replaced.
|
|
139
|
+
if (frame) return;
|
|
140
|
+
frame = requestAnimationFrame(() => {
|
|
141
|
+
frame = 0;
|
|
142
|
+
paint();
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
measure();
|
|
147
|
+
paint();
|
|
148
|
+
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
149
|
+
|
|
150
|
+
/* A resize changes how many rows fit, and a zoom changes how tall they are,
|
|
151
|
+
so both invalidate the measurement. */
|
|
152
|
+
const observer = new ResizeObserver(() => {
|
|
153
|
+
first = -1;
|
|
154
|
+
last = -1;
|
|
155
|
+
paint();
|
|
156
|
+
});
|
|
157
|
+
observer.observe(scroller);
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
scroller.removeEventListener('scroll', onScroll);
|
|
161
|
+
observer.disconnect();
|
|
162
|
+
if (frame) cancelAnimationFrame(frame);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function spacerRow(columns) {
|
|
167
|
+
const cell = document.createElement('td');
|
|
168
|
+
cell.colSpan = columns;
|
|
169
|
+
cell.style.padding = '0';
|
|
170
|
+
cell.style.border = 'none';
|
|
171
|
+
const row = document.createElement('tr');
|
|
172
|
+
row.className = 'virtual-spacer';
|
|
173
|
+
row.setAttribute('aria-hidden', 'true');
|
|
174
|
+
row.append(cell);
|
|
175
|
+
return row;
|
|
176
|
+
}
|