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,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep links: the pure half.
|
|
3
|
+
*
|
|
4
|
+
* No DOM and no state, so it can be tested directly. The stateful half —
|
|
5
|
+
* reading `window.location`, deciding when to write it — lives in url.js.
|
|
6
|
+
*
|
|
7
|
+
* The design decision that matters: **the trail goes in the URL, not just the
|
|
8
|
+
* current position.** A link to a row you reached by walking should arrive
|
|
9
|
+
* with the walk intact, so whoever you send it to can go back the way you
|
|
10
|
+
* came. That is the tool's whole idea, and a link that dropped it would hand
|
|
11
|
+
* them a dead end.
|
|
12
|
+
*
|
|
13
|
+
* The hash is used rather than the path because tablewalk serves a static
|
|
14
|
+
* client from a small server with no router. A path-based URL would need the
|
|
15
|
+
* server to know every route; a hash never reaches it.
|
|
16
|
+
*
|
|
17
|
+
* Compactness matters, because these get pasted into chat. A table view is
|
|
18
|
+
* its query text, already short and human-readable; a row is its key:
|
|
19
|
+
*
|
|
20
|
+
* /demo/customer/~4/invoice%20customer_id%20=%204/~1004
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A table view. Mirrors core.js — kept here so this module imports nothing. */
|
|
24
|
+
/* The tabs a table view can be on — see the tab strip in table.js. A segment
|
|
25
|
+
ends in one of these or it does not end in a tab at all. */
|
|
26
|
+
const TABS = new Set(['rows', 'ddl', 'diagram']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A query, safe to put after a `!` separator.
|
|
30
|
+
*
|
|
31
|
+
* `encodeURIComponent` leaves `!` alone — it is an unreserved character — so
|
|
32
|
+
* a query containing `!=` wrote a second separator into its own segment.
|
|
33
|
+
*/
|
|
34
|
+
function encodeQuery(query) {
|
|
35
|
+
return encodeURIComponent(query).replace(/!/g, '%21');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A key, as it appears in a link.
|
|
40
|
+
*
|
|
41
|
+
* Single-column keys — nearly all of them — are just the value, so the common
|
|
42
|
+
* case stays readable; composite keys name their columns, because position
|
|
43
|
+
* alone would not survive a column being added.
|
|
44
|
+
*
|
|
45
|
+
* Shared by rows and pages so the two cannot spell the same key differently.
|
|
46
|
+
*/
|
|
47
|
+
function keyBody(key) {
|
|
48
|
+
const columns = Object.keys(key);
|
|
49
|
+
return columns.length === 1
|
|
50
|
+
? String(key[columns[0]])
|
|
51
|
+
: columns.map((c) => `${c}=${key[c]}`).join(',');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tableView(tableId, query, tab) {
|
|
55
|
+
return { kind: 'table', table: tableId, query: query ?? tableId, tab: tab ?? 'rows' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Serialise a connection id and a stack into a hash.
|
|
60
|
+
*
|
|
61
|
+
* `pageToken` turns a page's id into the text a link should carry — its name,
|
|
62
|
+
* where that names it unambiguously. Passed in rather than imported: this
|
|
63
|
+
* module deliberately depends on nothing, which is what lets the link format
|
|
64
|
+
* be tested without a browser, and the page list it would need to consult
|
|
65
|
+
* lives three modules away. The default writes the id, which is what every
|
|
66
|
+
* link did before and still resolves.
|
|
67
|
+
*/
|
|
68
|
+
export function hashFor(connectionId, stack, { pageToken = (id) => id } = {}) {
|
|
69
|
+
const parts = [encodeURIComponent(connectionId ?? '')];
|
|
70
|
+
stack.forEach((entry, index) => {
|
|
71
|
+
if (entry.kind === 'page') {
|
|
72
|
+
/* `@page-id` for the page, and the root — when one has been chosen —
|
|
73
|
+
written exactly as a row's key is, so the two agree about composite
|
|
74
|
+
keys instead of inventing a second spelling for them. A page with no
|
|
75
|
+
root is a link to the front door, which is a real place to send
|
|
76
|
+
someone: "open the customer page and pick one". */
|
|
77
|
+
/* `rootBody` is the unresolved form a link arrives with, and it is
|
|
78
|
+
written back when the key has not been resolved yet. Boot renders —
|
|
79
|
+
and stamps the hash — before the page has looked up which column a
|
|
80
|
+
bare key belongs to, so without this the first paint dropped the root
|
|
81
|
+
out of the address bar and the reader lost it by reloading. */
|
|
82
|
+
const body = entry.key ? keyBody(entry.key) : (entry.rootBody ?? '');
|
|
83
|
+
/* The open tab rides with the page rather than after the key: a key is
|
|
84
|
+
arbitrary text and may contain a `!`, while a page token is a slug or
|
|
85
|
+
an id and never does. "Look at the parts tab" is a thing people send
|
|
86
|
+
each other, and a link that always lands on the first tab loses the
|
|
87
|
+
point of sending it. */
|
|
88
|
+
const tab = entry.tab ? `!${encodeURIComponent(entry.tab)}` : '';
|
|
89
|
+
parts.push(`@${encodeURIComponent(pageToken(entry.pageId))}${tab}${body ? `~${encodeURIComponent(body)}` : ''}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (entry.kind === 'table' || entry.kind === 'breakdown') {
|
|
93
|
+
/* A breakdown carries no tab — it has none — and is otherwise the same
|
|
94
|
+
segment as a table query, because it is the same language. Which kind
|
|
95
|
+
it comes back as is decided by `isBreakdown` in `fromHash`. */
|
|
96
|
+
const tab = entry.kind === 'table' && entry.tab && entry.tab !== 'rows' ? `!${entry.tab}` : '';
|
|
97
|
+
parts.push(encodeQuery(entry.query) + tab);
|
|
98
|
+
} else {
|
|
99
|
+
/* A row is `~` plus its key. Single-column keys — nearly all of them —
|
|
100
|
+
are just the value, so the common case stays readable; composite keys
|
|
101
|
+
name their columns because position alone would not survive a schema
|
|
102
|
+
change.
|
|
103
|
+
|
|
104
|
+
The table is named whenever it differs from the entry before, which
|
|
105
|
+
is exactly what following a foreign key from one row to another does.
|
|
106
|
+
Without it `work_order/~8/~636` claims 636 is a work_order when it is
|
|
107
|
+
a customer, and the link restores the wrong row — or no row. */
|
|
108
|
+
const body = keyBody(entry.key);
|
|
109
|
+
const previous = stack[index - 1];
|
|
110
|
+
const prefix = previous && previous.table === entry.table ? '' : `${entry.table}:`;
|
|
111
|
+
/* And which related table is open, if one is — the record view's rail
|
|
112
|
+
is a tablist, and "look at its notes" is as worth linking to on a
|
|
113
|
+
record as it is on a page. `!` is escaped inside the key for the
|
|
114
|
+
same reason the query encoder escapes it: it is an unreserved
|
|
115
|
+
character that `encodeURIComponent` leaves alone, so a value
|
|
116
|
+
containing one would otherwise write a second separator. */
|
|
117
|
+
const tab = entry.tab ? `!${encodeURIComponent(entry.tab)}` : '';
|
|
118
|
+
parts.push(`~${encodeQuery(prefix + body)}${tab}`);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return `/${parts.join('/')}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The connection a hash names, without parsing the rest of it.
|
|
126
|
+
*
|
|
127
|
+
* Needed because parsing the trail requires the *right* schema: a link into
|
|
128
|
+
* another database mentions tables the current one has never heard of, so
|
|
129
|
+
* every row segment fails to resolve and is dropped. The connection has to be
|
|
130
|
+
* switched before the rest can be read.
|
|
131
|
+
*/
|
|
132
|
+
/**
|
|
133
|
+
* Take the leading `/` off a link — or the leading `#/` off one written
|
|
134
|
+
* before these were paths.
|
|
135
|
+
*
|
|
136
|
+
* Both spellings are read for as long as it costs one regex to do so. A link
|
|
137
|
+
* is the thing people paste into a ticket and come back to a year later, and
|
|
138
|
+
* the day the old ones stop resolving should not be the day this changed.
|
|
139
|
+
*/
|
|
140
|
+
function stripPrefix(link) {
|
|
141
|
+
return String(link ?? '').replace(/^#/, '').replace(/^\//, '');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function connectionFromHash(hash) {
|
|
145
|
+
const raw = stripPrefix(hash);
|
|
146
|
+
if (!raw) return null;
|
|
147
|
+
const first = raw.split('/').filter(Boolean)[0];
|
|
148
|
+
return first ? decodeURIComponent(first) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse a hash into a stack. Returns null when there is nothing to restore.
|
|
153
|
+
*
|
|
154
|
+
* Rows need the table they belong to, which is the entry before them — so a
|
|
155
|
+
* row segment that opens the trail is dropped rather than guessed at.
|
|
156
|
+
*/
|
|
157
|
+
export function fromHash(hash, findTableById, options = {}) {
|
|
158
|
+
/* Read off `options` rather than destructured with a default in the
|
|
159
|
+
signature: `check-client-imports` cannot see a parameter list once a
|
|
160
|
+
default inside it has parentheses of its own, and reads every name in it
|
|
161
|
+
as undefined. The tool is right that this file must import nothing, so it
|
|
162
|
+
is the signature that gives way. */
|
|
163
|
+
const isBreakdown = options.isBreakdown ?? (() => false);
|
|
164
|
+
const raw = stripPrefix(hash);
|
|
165
|
+
if (!raw) return null;
|
|
166
|
+
const segments = raw.split('/').filter(Boolean);
|
|
167
|
+
if (!segments.length) return null;
|
|
168
|
+
|
|
169
|
+
const connection = decodeURIComponent(segments[0]);
|
|
170
|
+
const stack = [];
|
|
171
|
+
|
|
172
|
+
for (const segment of segments.slice(1)) {
|
|
173
|
+
if (segment.startsWith('@')) {
|
|
174
|
+
/* A page, and its root if the link has one. The base table is not in
|
|
175
|
+
the link: a page names it, and putting it in the URL as well would be
|
|
176
|
+
a second copy to disagree with the config file the day someone
|
|
177
|
+
re-points a page at another table. It is filled in when the page
|
|
178
|
+
loads. */
|
|
179
|
+
const [head, keyPart] = segment.slice(1).split('~');
|
|
180
|
+
/* Page first, then the tab: split on the first `!`, which a page token
|
|
181
|
+
cannot contain and a tab name is encoded past. */
|
|
182
|
+
const bang = head.indexOf('!');
|
|
183
|
+
const pageId = decodeURIComponent(bang === -1 ? head : head.slice(0, bang));
|
|
184
|
+
const tab = bang === -1 ? undefined : decodeURIComponent(head.slice(bang + 1)) || undefined;
|
|
185
|
+
if (!pageId) continue;
|
|
186
|
+
const body = keyPart === undefined ? undefined : decodeURIComponent(keyPart);
|
|
187
|
+
/* Carried as text, not resolved here. A single-column key is written as
|
|
188
|
+
a bare value, and turning `1181` back into `{ id: 1181 }` needs the
|
|
189
|
+
page's base table — which the link deliberately does not name, since
|
|
190
|
+
the page already does and two copies would disagree the day someone
|
|
191
|
+
re-points it. The page resolves it when it knows its own base. */
|
|
192
|
+
stack.push({ kind: 'page', pageId, table: undefined, rootBody: body, label: body, tab });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (segment.startsWith('~')) {
|
|
196
|
+
const rest = segment.slice(1);
|
|
197
|
+
const bang = rest.indexOf('!');
|
|
198
|
+
const tab = bang === -1 ? undefined : decodeURIComponent(rest.slice(bang + 1)) || undefined;
|
|
199
|
+
let body = decodeURIComponent(bang === -1 ? rest : rest.slice(0, bang));
|
|
200
|
+
/* An explicit `table:` prefix wins. It is only written when the table
|
|
201
|
+
changed, so its absence means "same as the entry before". The colon
|
|
202
|
+
is found from the left and only counts before any `=`, so a composite
|
|
203
|
+
key like `a=1,b=2` is never mistaken for a table prefix. */
|
|
204
|
+
let tableId = null;
|
|
205
|
+
const colon = body.indexOf(':');
|
|
206
|
+
if (colon > 0 && (body.indexOf('=') === -1 || colon < body.indexOf('='))) {
|
|
207
|
+
/* Only if it actually names a table. A key value can contain a colon
|
|
208
|
+
— a time like `12:30`, a URN, a namespaced id — and treating that
|
|
209
|
+
as a table prefix would drop the row entirely. Checking against the
|
|
210
|
+
schema makes the ambiguity decidable instead of guessed. */
|
|
211
|
+
const candidate = body.slice(0, colon);
|
|
212
|
+
if (findTableById(candidate)) {
|
|
213
|
+
tableId = candidate;
|
|
214
|
+
body = body.slice(colon + 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const previous = stack[stack.length - 1];
|
|
218
|
+
if (!tableId && !previous) continue; // a row with no table means nothing
|
|
219
|
+
const resolvedId = tableId ?? previous.table;
|
|
220
|
+
const table = findTableById(resolvedId);
|
|
221
|
+
if (!table) continue;
|
|
222
|
+
const key = {};
|
|
223
|
+
if (body.includes('=')) {
|
|
224
|
+
for (const pair of body.split(',')) {
|
|
225
|
+
const [column, ...rest] = pair.split('=');
|
|
226
|
+
key[column] = rest.join('=');
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
const pk = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
230
|
+
if (!pk.length) continue;
|
|
231
|
+
key[pk[0]] = body;
|
|
232
|
+
}
|
|
233
|
+
stack.push({ kind: 'row', table: table.id, key, label: labelFor(body), tab });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
/* Only a real tab name ends a segment.
|
|
237
|
+
|
|
238
|
+
`lastIndexOf('!')` alone read the `!` of `!=` as the separator, so
|
|
239
|
+
`invoice total != 0` came back as `invoice total ` with a tab of
|
|
240
|
+
`%3D 0` — the condition silently gone, and a shared "unpaid invoices"
|
|
241
|
+
link showing every invoice. Encoding fixes it going forward; checking
|
|
242
|
+
the suffix against the tabs that exist also rescues every link already
|
|
243
|
+
written and every one this app did not write. */
|
|
244
|
+
const bang = segment.lastIndexOf('!');
|
|
245
|
+
const suffix = bang > 0 ? segment.slice(bang + 1) : '';
|
|
246
|
+
const hasTab = TABS.has(suffix);
|
|
247
|
+
const tab = hasTab ? suffix : 'rows';
|
|
248
|
+
const query = decodeURIComponent(hasTab ? segment.slice(0, bang) : segment);
|
|
249
|
+
const name = query.trim().split(/\s+/)[0];
|
|
250
|
+
const table = findTableById(name);
|
|
251
|
+
/* A breakdown is written into the link exactly as a table query is — it is
|
|
252
|
+
the same language and the same text — so which kind it is on the way back
|
|
253
|
+
is read off the text itself. The rule is supplied rather than imported:
|
|
254
|
+
this module deliberately depends on nothing, which is what lets the link
|
|
255
|
+
format be tested without a browser, and it is the same arrangement
|
|
256
|
+
`pageToken` uses on the way out. */
|
|
257
|
+
stack.push(isBreakdown(query)
|
|
258
|
+
? { kind: 'breakdown', table: table ? table.id : name, query }
|
|
259
|
+
: tableView(table ? table.id : name, query, tab));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { connection, stack };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function labelFor(body) {
|
|
266
|
+
// The real label needs the row, which has not been fetched yet. The key is
|
|
267
|
+
// a truthful placeholder and gets replaced by resolveLabels below.
|
|
268
|
+
return body.length > 24 ? `${body.slice(0, 23)}…` : body;
|
|
269
|
+
}
|
|
270
|
+
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deleting a row, having first said what that would do.
|
|
3
|
+
*
|
|
4
|
+
* This is the one destructive action in tablewalk, and it is also the one place
|
|
5
|
+
* the tool has a genuine advantage over every other database client — not
|
|
6
|
+
* through cleverness, but because of what it already knows. It counts what
|
|
7
|
+
* points at a row in order to draw the Related panel; the same counts answer
|
|
8
|
+
* "what breaks if this goes" *before* the delete rather than as a constraint
|
|
9
|
+
* violation afterwards.
|
|
10
|
+
*
|
|
11
|
+
* The count alone is not the answer, which is the part most tools would get
|
|
12
|
+
* wrong if they tried. Three rows pointing at this one means three rows
|
|
13
|
+
* deleted under `cascade`, three rows left holding a null under `set null`, and
|
|
14
|
+
* a delete that simply fails under `restrict`. Three outcomes behind one
|
|
15
|
+
* number. So the dialog leads with the *rule*, and a rule the adapter could not
|
|
16
|
+
* determine says so rather than being quietly rendered as harmless.
|
|
17
|
+
*/
|
|
18
|
+
import { api, el, go, state, toast } from './core.js';
|
|
19
|
+
|
|
20
|
+
/** How a rule reads to someone deciding whether to press the button. */
|
|
21
|
+
const CONSEQUENCE = {
|
|
22
|
+
cascade: { tone: 'bad', says: (n, t) => `${n} in ${t} will be deleted too` },
|
|
23
|
+
'set null': { tone: 'warn', says: (n, t) => `${n} in ${t} will be kept, with the reference emptied` },
|
|
24
|
+
'set default': { tone: 'warn', says: (n, t) => `${n} in ${t} will be kept, with the reference reset` },
|
|
25
|
+
restrict: { tone: 'stop', says: (n, t) => `${n} in ${t} will block this — the database will refuse it` },
|
|
26
|
+
'no action': { tone: 'stop', says: (n, t) => `${n} in ${t} will block this — the database will refuse it` },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const rows = (n) => `${n.toLocaleString()} row${n === 1 ? '' : 's'}`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether this connection will accept a delete at all.
|
|
33
|
+
*
|
|
34
|
+
* Asked of the server's own answer rather than inferred: `writable` is on the
|
|
35
|
+
* connection list precisely so the interface does not offer a button the server
|
|
36
|
+
* would refuse.
|
|
37
|
+
*/
|
|
38
|
+
export function canDelete(table) {
|
|
39
|
+
if (!state.canWrite || !table || table.isView) return false;
|
|
40
|
+
return Boolean(state.connections?.find((c) => c.id === state.activeConnection)?.writable);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function deleteButton(table, key, label) {
|
|
44
|
+
if (!canDelete(table)) return null;
|
|
45
|
+
return el('button', {
|
|
46
|
+
type: 'button',
|
|
47
|
+
class: 'ghost record-delete',
|
|
48
|
+
title: `Delete this ${table.name} row`,
|
|
49
|
+
text: 'Delete…',
|
|
50
|
+
onclick: () => void confirmDelete(table, key, label),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function confirmDelete(table, key, label) {
|
|
55
|
+
let report;
|
|
56
|
+
try {
|
|
57
|
+
report = await api('/api/delete', { table: table.id, key });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
toast(err.message, 'error');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const impact = report.impact ?? [];
|
|
64
|
+
const blocked = impact.some((i) => i.onDelete === 'restrict' || i.onDelete === 'no action');
|
|
65
|
+
|
|
66
|
+
const dialog = el('dialog', { class: 'confirm delete-confirm' }, [
|
|
67
|
+
el('h2', { text: `Delete ${table.name} ${label}?` }),
|
|
68
|
+
impact.length
|
|
69
|
+
? el('div', {}, [
|
|
70
|
+
el('p', { text: 'This row is referenced. Here is what the database will do:' }),
|
|
71
|
+
el('ul', { class: 'delete-impact' }, impact.map((i) => {
|
|
72
|
+
const rule = CONSEQUENCE[i.onDelete];
|
|
73
|
+
return el('li', { class: `impact-${rule?.tone ?? 'unknown'}` }, [
|
|
74
|
+
el('span', { class: 'impact-what' }, [
|
|
75
|
+
document.createTextNode(
|
|
76
|
+
i.count < 0
|
|
77
|
+
? `${i.table} could not be counted`
|
|
78
|
+
: rule
|
|
79
|
+
? rule.says(rows(i.count), i.table)
|
|
80
|
+
: `${rows(i.count)} in ${i.table} — this constraint's rule is unknown`,
|
|
81
|
+
),
|
|
82
|
+
]),
|
|
83
|
+
el('span', { class: 'impact-rule', text: i.onDelete ? `on delete ${i.onDelete}` : 'rule unknown' }),
|
|
84
|
+
]);
|
|
85
|
+
})),
|
|
86
|
+
/* Said plainly rather than left to be inferred from the list. A
|
|
87
|
+
cascade that itself cascades is not counted here, and implying a
|
|
88
|
+
total that stopped one level down would be worse than saying so. */
|
|
89
|
+
impact.some((i) => i.onDelete === 'cascade')
|
|
90
|
+
? el('p', {
|
|
91
|
+
class: 'delete-note',
|
|
92
|
+
text: 'Cascades are counted one level deep. Rows deleted by a cascade may take their own children with them.',
|
|
93
|
+
})
|
|
94
|
+
: null,
|
|
95
|
+
].filter(Boolean))
|
|
96
|
+
: el('p', { text: 'Nothing points at this row, so nothing else is affected.' }),
|
|
97
|
+
|
|
98
|
+
blocked
|
|
99
|
+
? el('p', {
|
|
100
|
+
class: 'delete-blocked',
|
|
101
|
+
text: 'The database will refuse this while those references exist. You can try anyway — its answer is the authority, not ours.',
|
|
102
|
+
})
|
|
103
|
+
: null,
|
|
104
|
+
|
|
105
|
+
el('div', { class: 'confirm-actions' }, [
|
|
106
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
|
|
107
|
+
el('button', {
|
|
108
|
+
type: 'button',
|
|
109
|
+
class: 'danger',
|
|
110
|
+
text: blocked ? 'Try anyway' : 'Delete',
|
|
111
|
+
onclick: () => { dialog.close(); void doDelete(table, key, label); },
|
|
112
|
+
}),
|
|
113
|
+
]),
|
|
114
|
+
].filter(Boolean));
|
|
115
|
+
|
|
116
|
+
document.body.append(dialog);
|
|
117
|
+
dialog.addEventListener('close', () => dialog.remove());
|
|
118
|
+
dialog.showModal();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function doDelete(table, key, label) {
|
|
122
|
+
let result;
|
|
123
|
+
try {
|
|
124
|
+
result = await api('/api/delete', { table: table.id, key, confirm: true });
|
|
125
|
+
} catch (err) {
|
|
126
|
+
toast(err.message, 'error');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!result.deleted) {
|
|
131
|
+
/* The database's own words. A constraint violation names the constraint,
|
|
132
|
+
which is more use than any paraphrase — and re-deriving the judgement
|
|
133
|
+
here would be a second implementation of a rule the database already
|
|
134
|
+
enforces. */
|
|
135
|
+
toast(result.errors?.[0]?.message ?? 'The row was not deleted.', 'error');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
toast(`Deleted ${table.name} ${label}.`, 'ok');
|
|
140
|
+
/* Back to the table, not to the record that no longer exists. Staying would
|
|
141
|
+
re-fetch a row that is gone and report it as missing, which reads as a
|
|
142
|
+
failure rather than as the thing that was just asked for. */
|
|
143
|
+
go({ kind: 'table', table: table.id, query: table.id, tab: 'rows' }, 'reset');
|
|
144
|
+
}
|