@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.7
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/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +279 -0
- package/src/client/bulk.ts +357 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +203 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +89 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +164 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +269 -0
- package/src/client/grid-body.ts +106 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +468 -0
- package/src/client/meta.ts +185 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +205 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +234 -0
- package/src/client/tabs.ts +219 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +376 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +148 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +173 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +391 -0
- package/src/index.ts +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +186 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +173 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
- package/src/credential.ts +0 -26
- package/src/endpoints.ts +0 -48
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actions over a selection: set one column across it, delete it, or add rows.
|
|
3
|
+
*
|
|
4
|
+
* **Every count that reaches a dialog comes from a `dryRun`.** The server
|
|
5
|
+
* executes the statements inside a transaction and rolls back, so the number is
|
|
6
|
+
* what would actually happen — not the number of checkboxes, which is the same
|
|
7
|
+
* number only when nothing has changed underneath and no key has gone missing.
|
|
8
|
+
* `confirm.ts` turns that count into the right amount of ceremony.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { coerceValue, omittableOnInsert } from '../shared/coerce'
|
|
12
|
+
import { bulkEdit, deleteRows, insertRows, messageOf } from './api'
|
|
13
|
+
import { confirmDanger, notify, offerUndo } from './confirm'
|
|
14
|
+
import { append, box, button, each, el, select } from './dom'
|
|
15
|
+
import type { UndoStack } from './edit-session'
|
|
16
|
+
import { createEditor } from './editors'
|
|
17
|
+
import { columnMeta, type SchemaColumn, type SchemaTable } from './meta'
|
|
18
|
+
|
|
19
|
+
export interface BulkContext {
|
|
20
|
+
table: SchemaTable
|
|
21
|
+
columns: SchemaColumn[]
|
|
22
|
+
editable: boolean
|
|
23
|
+
selectedKeys: () => Record<string, unknown>[]
|
|
24
|
+
selectedRows: () => Record<string, unknown>[]
|
|
25
|
+
reload: () => Promise<void>
|
|
26
|
+
undo: UndoStack
|
|
27
|
+
openImport: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BulkToolbar {
|
|
31
|
+
node: HTMLElement
|
|
32
|
+
setCount: (count: number) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function bulkToolbar(ctx: BulkContext): BulkToolbar {
|
|
36
|
+
const node = box('toolbar')
|
|
37
|
+
const count = el('span', { class: 'note' })
|
|
38
|
+
|
|
39
|
+
if (!ctx.editable) {
|
|
40
|
+
node.appendChild(
|
|
41
|
+
el('span', { class: 'note', text: ctx.table.reason ?? 'read-only' }),
|
|
42
|
+
)
|
|
43
|
+
return { node, setCount: () => {} }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const setColumn = button('Set column…', () => void openSetColumn(ctx), {
|
|
47
|
+
class: 'btn',
|
|
48
|
+
})
|
|
49
|
+
const remove = button('Delete', () => void runDelete(ctx), {
|
|
50
|
+
class: 'btn danger-btn',
|
|
51
|
+
})
|
|
52
|
+
const add = button('Add rows…', () => openInsert(ctx), { class: 'btn' })
|
|
53
|
+
const importer = button('Import CSV…', ctx.openImport, { class: 'btn' })
|
|
54
|
+
|
|
55
|
+
append(node, [add, importer, setColumn, remove, count])
|
|
56
|
+
|
|
57
|
+
const setCount = (selected: number) => {
|
|
58
|
+
count.textContent = selected ? `${selected} selected` : ''
|
|
59
|
+
setColumn.disabled = selected === 0
|
|
60
|
+
remove.disabled = selected === 0
|
|
61
|
+
}
|
|
62
|
+
setCount(0)
|
|
63
|
+
return { node, setCount }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ------------------------------------------------------------ set one column
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One column, one value, across the selection.
|
|
70
|
+
*
|
|
71
|
+
* The value goes through the same editor the grid uses, so an enum is a select
|
|
72
|
+
* and a nullable column can be set to NULL — the operation people actually
|
|
73
|
+
* reach for and the one a plain text prompt cannot express.
|
|
74
|
+
*/
|
|
75
|
+
async function openSetColumn(ctx: BulkContext): Promise<void> {
|
|
76
|
+
const editable = ctx.columns.filter(column => !column.autoIncrement)
|
|
77
|
+
const first = editable[0]
|
|
78
|
+
if (!first) return
|
|
79
|
+
|
|
80
|
+
const dialog = el('dialog', { class: 'danger' })
|
|
81
|
+
dialog.appendChild(el('h3', { text: 'Set a column across the selection' }))
|
|
82
|
+
|
|
83
|
+
let column = first
|
|
84
|
+
let value: unknown = null
|
|
85
|
+
const slot = box('panel-field')
|
|
86
|
+
|
|
87
|
+
const paint = () => {
|
|
88
|
+
slot.replaceChildren()
|
|
89
|
+
const editor = createEditor(column, null, {
|
|
90
|
+
onInput: next => {
|
|
91
|
+
value = next
|
|
92
|
+
},
|
|
93
|
+
onKey: () => {},
|
|
94
|
+
})
|
|
95
|
+
slot.appendChild(editor.node)
|
|
96
|
+
value = editor.read()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const picker = select(
|
|
100
|
+
editable.map(c => ({ value: c.name, label: `${c.name} — ${c.type}` })),
|
|
101
|
+
column.name,
|
|
102
|
+
name => {
|
|
103
|
+
column = editable.find(c => c.name === name) ?? column
|
|
104
|
+
paint()
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
paint()
|
|
108
|
+
|
|
109
|
+
const apply = button(
|
|
110
|
+
'Apply',
|
|
111
|
+
() => {
|
|
112
|
+
dialog.close()
|
|
113
|
+
void applySetColumn(ctx, column, value)
|
|
114
|
+
},
|
|
115
|
+
{ class: 'btn primary' },
|
|
116
|
+
)
|
|
117
|
+
const cancel = button('Cancel', () => dialog.close(), { class: 'btn' })
|
|
118
|
+
const bar = box('row-bar', cancel, apply)
|
|
119
|
+
append(dialog, [picker, slot, bar])
|
|
120
|
+
|
|
121
|
+
document.body.appendChild(dialog)
|
|
122
|
+
dialog.addEventListener('close', () => dialog.remove())
|
|
123
|
+
dialog.showModal()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function applySetColumn(
|
|
127
|
+
ctx: BulkContext,
|
|
128
|
+
column: SchemaColumn,
|
|
129
|
+
value: unknown,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const check = coerceValue(value, columnMeta(column))
|
|
132
|
+
if (!check.ok) {
|
|
133
|
+
notify(`${column.name}: ${check.message}`, 'error')
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const edits = ctx
|
|
138
|
+
.selectedKeys()
|
|
139
|
+
.map(key => ({ key, set: { [column.name]: check.value } }))
|
|
140
|
+
if (!edits.length) return
|
|
141
|
+
|
|
142
|
+
const dry = await run(() =>
|
|
143
|
+
bulkEdit({ table: ctx.table.name, edits, dryRun: true }),
|
|
144
|
+
)
|
|
145
|
+
if (!dry) return
|
|
146
|
+
|
|
147
|
+
const decision = await confirmDanger({
|
|
148
|
+
verb: `set ${column.name}`,
|
|
149
|
+
count: dry.changed,
|
|
150
|
+
table: ctx.table.name,
|
|
151
|
+
detail: `to ${describe(check.value)}`,
|
|
152
|
+
})
|
|
153
|
+
if (decision.refusal) return notify(decision.refusal, 'error')
|
|
154
|
+
if (!decision.ok) return
|
|
155
|
+
|
|
156
|
+
await run(async () => {
|
|
157
|
+
const result = await bulkEdit({ table: ctx.table.name, edits })
|
|
158
|
+
notify(`${result.changed} rows updated`)
|
|
159
|
+
await ctx.reload()
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function describe(value: unknown): string {
|
|
164
|
+
if (value === null) return 'NULL'
|
|
165
|
+
if (typeof value === 'string') return JSON.stringify(value)
|
|
166
|
+
return String(value)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// -------------------------------------------------------------------- delete
|
|
170
|
+
|
|
171
|
+
async function runDelete(ctx: BulkContext): Promise<void> {
|
|
172
|
+
const keys = ctx.selectedKeys()
|
|
173
|
+
const rows = ctx.selectedRows()
|
|
174
|
+
if (!keys.length) return
|
|
175
|
+
|
|
176
|
+
const dry = await run(() =>
|
|
177
|
+
deleteRows({ table: ctx.table.name, keys, dryRun: true }),
|
|
178
|
+
)
|
|
179
|
+
if (!dry) return
|
|
180
|
+
|
|
181
|
+
const decision = await confirmDanger({
|
|
182
|
+
verb: 'delete',
|
|
183
|
+
count: dry.deleted,
|
|
184
|
+
table: ctx.table.name,
|
|
185
|
+
detail: dry.conflicts.length
|
|
186
|
+
? `${dry.conflicts.length} of the selected rows no longer match`
|
|
187
|
+
: undefined,
|
|
188
|
+
})
|
|
189
|
+
if (decision.refusal) return notify(decision.refusal, 'error')
|
|
190
|
+
if (!decision.ok) return
|
|
191
|
+
|
|
192
|
+
await run(async () => {
|
|
193
|
+
const result = await deleteRows({ table: ctx.table.name, keys })
|
|
194
|
+
await ctx.reload()
|
|
195
|
+
offerDeleteUndo(ctx, rows, result.deleted)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A ten-second offer to put the row back.
|
|
201
|
+
*
|
|
202
|
+
* Only for the immediate tier — anything above one row went through a dialog,
|
|
203
|
+
* and re-inserting a hundred rows from the browser's memory is a second bulk
|
|
204
|
+
* write dressed up as a safety net. The pre-image is already in hand because
|
|
205
|
+
* the grid holds the rows it rendered.
|
|
206
|
+
*/
|
|
207
|
+
function offerDeleteUndo(
|
|
208
|
+
ctx: BulkContext,
|
|
209
|
+
rows: Record<string, unknown>[],
|
|
210
|
+
deleted: number,
|
|
211
|
+
): void {
|
|
212
|
+
if (deleted !== 1 || rows.length !== 1) {
|
|
213
|
+
notify(`${deleted} rows deleted`)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const row = rows[0]!
|
|
217
|
+
const restore = async () => {
|
|
218
|
+
await run(async () => {
|
|
219
|
+
await insertRows({ table: ctx.table.name, rows: [row] })
|
|
220
|
+
await ctx.reload()
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
ctx.undo.push({ label: `delete from ${ctx.table.name}`, undo: restore })
|
|
224
|
+
offerUndo('1 row deleted', restore)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// -------------------------------------------------------------------- insert
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Add rows by hand.
|
|
231
|
+
*
|
|
232
|
+
* A column is sent only when the user gave it a value or when the database
|
|
233
|
+
* cannot supply one — `omittableOnInsert` is the same predicate the importer's
|
|
234
|
+
* blocking check uses. Sending every column would defeat every default in the
|
|
235
|
+
* schema and would make an auto-increment key impossible to leave alone.
|
|
236
|
+
*/
|
|
237
|
+
function openInsert(ctx: BulkContext): void {
|
|
238
|
+
const columns = ctx.columns.filter(column => !column.autoIncrement)
|
|
239
|
+
const dialog = el('dialog', { class: 'danger wide' })
|
|
240
|
+
dialog.appendChild(el('h3', { text: `Add rows to ${ctx.table.name}` }))
|
|
241
|
+
|
|
242
|
+
const list = box('insert-rows')
|
|
243
|
+
const drafts: Map<string, unknown>[] = []
|
|
244
|
+
|
|
245
|
+
const addRow = () => {
|
|
246
|
+
const draft = new Map<string, unknown>()
|
|
247
|
+
drafts.push(draft)
|
|
248
|
+
const row = box('insert-row')
|
|
249
|
+
row.appendChild(el('span', { class: 'note', text: `row ${drafts.length}` }))
|
|
250
|
+
each(row, columns, column => insertField(column, draft))
|
|
251
|
+
list.appendChild(row)
|
|
252
|
+
}
|
|
253
|
+
addRow()
|
|
254
|
+
|
|
255
|
+
const message = el('p', { class: 'row-error' })
|
|
256
|
+
const submit = button(
|
|
257
|
+
'Insert',
|
|
258
|
+
() => {
|
|
259
|
+
const built = buildInsertRows(columns, drafts)
|
|
260
|
+
if (built.errors.length) {
|
|
261
|
+
message.textContent = built.errors.join('; ')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
dialog.close()
|
|
265
|
+
void run(async () => {
|
|
266
|
+
const result = await insertRows({
|
|
267
|
+
table: ctx.table.name,
|
|
268
|
+
rows: built.rows,
|
|
269
|
+
})
|
|
270
|
+
notify(`${result.inserted} rows inserted`)
|
|
271
|
+
await ctx.reload()
|
|
272
|
+
})
|
|
273
|
+
},
|
|
274
|
+
{ class: 'btn primary' },
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const bar = box(
|
|
278
|
+
'row-bar',
|
|
279
|
+
button('+ row', addRow, { class: 'btn' }),
|
|
280
|
+
button('Cancel', () => dialog.close(), { class: 'btn' }),
|
|
281
|
+
submit,
|
|
282
|
+
)
|
|
283
|
+
append(dialog, [list, message, bar])
|
|
284
|
+
document.body.appendChild(dialog)
|
|
285
|
+
dialog.addEventListener('close', () => dialog.remove())
|
|
286
|
+
dialog.showModal()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function insertField(column: SchemaColumn, draft: Map<string, unknown>): Node {
|
|
290
|
+
const wrap = box('panel-field')
|
|
291
|
+
wrap.appendChild(el('label', { class: 'panel-label', text: column.name }))
|
|
292
|
+
const editor = createEditor(column, null, {
|
|
293
|
+
onInput: value => draft.set(column.name, value),
|
|
294
|
+
onKey: () => {},
|
|
295
|
+
})
|
|
296
|
+
wrap.appendChild(editor.node)
|
|
297
|
+
return wrap
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface BuiltRows {
|
|
301
|
+
rows: Record<string, unknown>[]
|
|
302
|
+
errors: string[]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Drafts to records, with the same coercion the server will apply.
|
|
307
|
+
*
|
|
308
|
+
* Named rather than inlined into the submit handler because it is the decision
|
|
309
|
+
* in this file worth stating: an untouched omittable column is *absent* from
|
|
310
|
+
* the record, and an untouched required one is an error named here rather than
|
|
311
|
+
* a database exception later.
|
|
312
|
+
*/
|
|
313
|
+
function buildInsertRows(
|
|
314
|
+
columns: readonly SchemaColumn[],
|
|
315
|
+
drafts: readonly Map<string, unknown>[],
|
|
316
|
+
): BuiltRows {
|
|
317
|
+
const rows: Record<string, unknown>[] = []
|
|
318
|
+
const errors: string[] = []
|
|
319
|
+
|
|
320
|
+
drafts.forEach((draft, index) => {
|
|
321
|
+
const record: Record<string, unknown> = {}
|
|
322
|
+
for (const column of columns) {
|
|
323
|
+
const meta = columnMeta(column)
|
|
324
|
+
const given = draft.has(column.name)
|
|
325
|
+
if (!given && omittableOnInsert(meta)) continue
|
|
326
|
+
const result = coerceValue(draft.get(column.name) ?? null, meta)
|
|
327
|
+
if (!result.ok) {
|
|
328
|
+
errors.push(`row ${index + 1} · ${column.name}: ${result.message}`)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
record[column.name] = result.value
|
|
332
|
+
}
|
|
333
|
+
rows.push(record)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
return { rows, errors }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ------------------------------------------------------------------ plumbing
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Run a call, reporting its failure rather than throwing at the click handler.
|
|
343
|
+
*
|
|
344
|
+
* A rejected promise from an event listener is an unhandled rejection and a
|
|
345
|
+
* silent no-op on screen, which is the one outcome a destructive action must
|
|
346
|
+
* not have. `null` is the failure, so a dry run's caller checks the result and
|
|
347
|
+
* an action's caller ignores it — they were two identically-bodied functions
|
|
348
|
+
* until the second was noticed to be the first with the value discarded.
|
|
349
|
+
*/
|
|
350
|
+
async function run<T>(call: () => Promise<T>): Promise<T | null> {
|
|
351
|
+
try {
|
|
352
|
+
return await call()
|
|
353
|
+
} catch (error) {
|
|
354
|
+
notify(messageOf(error), 'error')
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard navigation, as a reducer.
|
|
3
|
+
*
|
|
4
|
+
* **Pure, and the reason it exists apart from the grid.** A key handler that
|
|
5
|
+
* reads the DOM to decide what to do can only be tested by building a DOM;
|
|
6
|
+
* this one takes a plain `{key, shiftKey}` and a plain cursor and answers with
|
|
7
|
+
* an action, so `cell.test.ts` pins the whole interaction model — including the
|
|
8
|
+
* two rules that are easy to regress, *Enter commits and moves down* and
|
|
9
|
+
* *blur is not in this table at all* — without happy-dom.
|
|
10
|
+
*
|
|
11
|
+
* **Blur is deliberately absent.** The dashboard saves on blur, so a stray
|
|
12
|
+
* click into another cell is a write, and a three-column edit is three
|
|
13
|
+
* statements with two moments in between where the row is half-updated. Here
|
|
14
|
+
* blur only stages into the row's dirty buffer; the only things that commit are
|
|
15
|
+
* Enter, Tab, and the row's own Save button.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The parts of a `KeyboardEvent` this reducer reads. Nothing DOM-shaped. */
|
|
19
|
+
export interface KeyEvent {
|
|
20
|
+
key: string
|
|
21
|
+
shiftKey?: boolean
|
|
22
|
+
ctrlKey?: boolean
|
|
23
|
+
metaKey?: boolean
|
|
24
|
+
altKey?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CellState {
|
|
28
|
+
row: number
|
|
29
|
+
col: number
|
|
30
|
+
/** Rows on the current page. */
|
|
31
|
+
rows: number
|
|
32
|
+
/** Columns in the grid. */
|
|
33
|
+
cols: number
|
|
34
|
+
/** Is an editor open on the focused cell? */
|
|
35
|
+
editing: boolean
|
|
36
|
+
/** May this table be edited at all? Decided once, before first paint. */
|
|
37
|
+
editable: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CellAction =
|
|
41
|
+
| { type: 'none' }
|
|
42
|
+
| { type: 'move'; row: number; col: number }
|
|
43
|
+
| { type: 'edit' }
|
|
44
|
+
/** Write the editor's value into the row buffer, then move. */
|
|
45
|
+
| { type: 'commit'; move: 'down' | 'right' | 'left' | 'none' }
|
|
46
|
+
/** Throw the editor's value away and restore the cell. */
|
|
47
|
+
| { type: 'cancel' }
|
|
48
|
+
/** Stage SQL NULL — distinct from staging the empty string. */
|
|
49
|
+
| { type: 'null' }
|
|
50
|
+
|
|
51
|
+
const NONE: CellAction = { type: 'none' }
|
|
52
|
+
|
|
53
|
+
type Rule = (event: KeyEvent, state: CellState) => CellAction
|
|
54
|
+
|
|
55
|
+
function clamp(value: number, max: number): number {
|
|
56
|
+
if (value < 0) return 0
|
|
57
|
+
return value > max ? max : value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A move, or nothing when the cursor is already against that edge. */
|
|
61
|
+
function moveBy(
|
|
62
|
+
state: CellState,
|
|
63
|
+
rowStep: number,
|
|
64
|
+
colStep: number,
|
|
65
|
+
): CellAction {
|
|
66
|
+
const row = clamp(state.row + rowStep, state.rows - 1)
|
|
67
|
+
const col = clamp(state.col + colStep, state.cols - 1)
|
|
68
|
+
if (row === state.row && col === state.col) return NONE
|
|
69
|
+
return { type: 'move', row, col }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function moveTo(state: CellState, row: number, col: number): CellAction {
|
|
73
|
+
const next = {
|
|
74
|
+
row: clamp(row, state.rows - 1),
|
|
75
|
+
col: clamp(col, state.cols - 1),
|
|
76
|
+
}
|
|
77
|
+
if (next.row === state.row && next.col === state.col) return NONE
|
|
78
|
+
return { type: 'move', ...next }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Only when the table is editable — a read-only grid still navigates. */
|
|
82
|
+
function whenEditable(action: CellAction): Rule {
|
|
83
|
+
return (_event, state) => (state.editable ? action : NONE)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* While an editor is open. Four keys, and everything else belongs to the input
|
|
88
|
+
* — a `default` that swallowed keys here would make the editor unable to
|
|
89
|
+
* receive a literal `a`.
|
|
90
|
+
*/
|
|
91
|
+
const EDITING: Record<string, Rule> = {
|
|
92
|
+
Enter: () => ({ type: 'commit', move: 'down' }),
|
|
93
|
+
Tab: event => ({ type: 'commit', move: event.shiftKey ? 'left' : 'right' }),
|
|
94
|
+
Escape: () => ({ type: 'cancel' }),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** While a cell is merely selected. */
|
|
98
|
+
const BROWSING: Record<string, Rule> = {
|
|
99
|
+
ArrowUp: (_e, state) => moveBy(state, -1, 0),
|
|
100
|
+
ArrowDown: (_e, state) => moveBy(state, 1, 0),
|
|
101
|
+
ArrowLeft: (_e, state) => moveBy(state, 0, -1),
|
|
102
|
+
ArrowRight: (_e, state) => moveBy(state, 0, 1),
|
|
103
|
+
Tab: (event, state) => moveBy(state, 0, event.shiftKey ? -1 : 1),
|
|
104
|
+
Home: (event, state) => moveTo(state, event.ctrlKey ? 0 : state.row, 0),
|
|
105
|
+
End: (event, state) =>
|
|
106
|
+
moveTo(state, event.ctrlKey ? state.rows - 1 : state.row, state.cols - 1),
|
|
107
|
+
PageUp: (_e, state) => moveBy(state, -10, 0),
|
|
108
|
+
PageDown: (_e, state) => moveBy(state, 10, 0),
|
|
109
|
+
Enter: whenEditable({ type: 'edit' }),
|
|
110
|
+
F2: whenEditable({ type: 'edit' }),
|
|
111
|
+
// Delete stages NULL rather than clearing to `''`. They are different
|
|
112
|
+
// values, the server enforces the difference, and a grid that offers only
|
|
113
|
+
// one of them cannot express the other.
|
|
114
|
+
Delete: whenEditable({ type: 'null' }),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One key, one action.
|
|
119
|
+
*
|
|
120
|
+
* An empty page answers `none` for everything: a cursor over zero rows has no
|
|
121
|
+
* valid position, and returning a move to `-1` would be a subscript the caller
|
|
122
|
+
* has to guard at every call site instead of here once.
|
|
123
|
+
*/
|
|
124
|
+
export function keyOnCell(event: KeyEvent, state: CellState): CellAction {
|
|
125
|
+
if (state.rows <= 0 || state.cols <= 0) return NONE
|
|
126
|
+
const rule = (state.editing ? EDITING : BROWSING)[event.key]
|
|
127
|
+
return rule ? rule(event, state) : NONE
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Where a `commit` leaves the cursor. Same clamping as a bare move. */
|
|
131
|
+
export function afterCommit(
|
|
132
|
+
state: CellState,
|
|
133
|
+
move: 'down' | 'right' | 'left' | 'none',
|
|
134
|
+
): CellAction {
|
|
135
|
+
if (move === 'down') return moveBy(state, 1, 0)
|
|
136
|
+
if (move === 'right') return moveBy(state, 0, 1)
|
|
137
|
+
if (move === 'left') return moveBy(state, 0, -1)
|
|
138
|
+
return NONE
|
|
139
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friction proportionate to blast radius, in the spirit of `db:sync`'s DANGER
|
|
3
|
+
* ZONE.
|
|
4
|
+
*
|
|
5
|
+
* `frictionFor` is **pure and tested**; the dialog below is the DOM half. The
|
|
6
|
+
* split matters because the ladder is policy — the thresholds are the answer to
|
|
7
|
+
* "how bad is this if it was a mis-click" — and policy that lives inside a
|
|
8
|
+
* click handler cannot be asserted.
|
|
9
|
+
*
|
|
10
|
+
* **The count fed to this must come from a `dryRun`, never from the page.** A
|
|
11
|
+
* selection of eight checkboxes on a filtered page can delete eight rows or,
|
|
12
|
+
* if the caller built the keys from something wider, rather more; the server's
|
|
13
|
+
* own preview is the only number that is the number.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { button, el } from './dom'
|
|
17
|
+
|
|
18
|
+
export type Friction = 'immediate' | 'confirm' | 'typed' | 'refuse'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How much ceremony `count` rows deserve.
|
|
22
|
+
*
|
|
23
|
+
* - **≤ 1** — immediate, with an undo. One row is recoverable and a dialog per
|
|
24
|
+
* row makes the tool unusable for the thing it is for.
|
|
25
|
+
* - **2–100** — a dialog naming the count and what changes.
|
|
26
|
+
* - **101–10 000** — the same, plus typing the table name. At this size the
|
|
27
|
+
* user is doing something deliberate and should have to prove it.
|
|
28
|
+
* - **> 10 000** — refused. Not a dialog: there is no phrasing of "are you
|
|
29
|
+
* sure" that makes a ten-thousand-row unreviewed write a good idea, and the
|
|
30
|
+
* honest answer is to narrow it with a filter.
|
|
31
|
+
*/
|
|
32
|
+
export function frictionFor(count: number): Friction {
|
|
33
|
+
if (!Number.isFinite(count) || count <= 1) return 'immediate'
|
|
34
|
+
if (count <= 100) return 'confirm'
|
|
35
|
+
if (count <= 10_000) return 'typed'
|
|
36
|
+
return 'refuse'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DangerRequest {
|
|
40
|
+
/** What is about to happen, in the user's words: `delete`, `set status`. */
|
|
41
|
+
verb: string
|
|
42
|
+
/** The exact count, from a dry run. */
|
|
43
|
+
count: number
|
|
44
|
+
table: string
|
|
45
|
+
/** A second line: which column, which filter, whatever narrows it. */
|
|
46
|
+
detail?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DangerOutcome {
|
|
50
|
+
ok: boolean
|
|
51
|
+
/** Present when refused by the ladder rather than by the user. */
|
|
52
|
+
refusal?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ask, at the level `frictionFor` decided.
|
|
57
|
+
*
|
|
58
|
+
* Returns rather than throws, and returns `{ok: false}` for a dismissal and for
|
|
59
|
+
* a refusal alike — the caller does one check. The refusal carries its reason
|
|
60
|
+
* so the message names the ceiling instead of failing silently.
|
|
61
|
+
*/
|
|
62
|
+
export async function confirmDanger(
|
|
63
|
+
request: DangerRequest,
|
|
64
|
+
): Promise<DangerOutcome> {
|
|
65
|
+
const level = frictionFor(request.count)
|
|
66
|
+
if (level === 'immediate') return { ok: true }
|
|
67
|
+
if (level === 'refuse') {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
refusal:
|
|
71
|
+
`${request.count} rows is past the ${(10_000).toLocaleString()} row ` +
|
|
72
|
+
'ceiling for one action — narrow it with a filter',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const ok = await openDialog(request, level === 'typed')
|
|
76
|
+
return { ok }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ask, unconditionally.
|
|
81
|
+
*
|
|
82
|
+
* For the questions that are not about blast radius. Discarding one row of
|
|
83
|
+
* unsaved typing deserves a prompt even though `frictionFor(1)` is `immediate`
|
|
84
|
+
* — the ladder is about how much damage a mis-click does to the *database*,
|
|
85
|
+
* and this one is about work the user has done and the database has not seen.
|
|
86
|
+
*/
|
|
87
|
+
export async function confirmChoice(request: DangerRequest): Promise<boolean> {
|
|
88
|
+
return await openDialog(request, false)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A modal `<dialog>`.
|
|
93
|
+
*
|
|
94
|
+
* The native element rather than a hand-rolled overlay: it traps focus, closes
|
|
95
|
+
* on Escape, and is inert to the page behind it without a single line of
|
|
96
|
+
* script. Resolved through a `close` listener so a dismissal by any route —
|
|
97
|
+
* Escape, the backdrop, the button — lands in one place.
|
|
98
|
+
*/
|
|
99
|
+
function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
|
|
100
|
+
const dialog = el('dialog', { class: 'danger' })
|
|
101
|
+
const title = el('h3', {
|
|
102
|
+
text: `${request.verb} ${request.count.toLocaleString()} row${
|
|
103
|
+
request.count === 1 ? '' : 's'
|
|
104
|
+
}`,
|
|
105
|
+
})
|
|
106
|
+
const where = el('p', {
|
|
107
|
+
class: 'note',
|
|
108
|
+
text: `in ${request.table}${request.detail ? ` · ${request.detail}` : ''}`,
|
|
109
|
+
})
|
|
110
|
+
dialog.append(title, where)
|
|
111
|
+
|
|
112
|
+
let accepted = false
|
|
113
|
+
const confirm = button(
|
|
114
|
+
request.verb,
|
|
115
|
+
() => {
|
|
116
|
+
accepted = true
|
|
117
|
+
dialog.close()
|
|
118
|
+
},
|
|
119
|
+
{ class: 'btn danger-btn' },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if (typed) {
|
|
123
|
+
const prompt = el('p', {
|
|
124
|
+
class: 'note',
|
|
125
|
+
text: `Type ${request.table} to confirm.`,
|
|
126
|
+
})
|
|
127
|
+
const input = el('input', { class: 'ed' })
|
|
128
|
+
input.type = 'text'
|
|
129
|
+
input.setAttribute('aria-label', 'table name')
|
|
130
|
+
confirm.disabled = true
|
|
131
|
+
input.addEventListener('input', () => {
|
|
132
|
+
confirm.disabled = input.value.trim() !== request.table
|
|
133
|
+
})
|
|
134
|
+
dialog.append(prompt, input)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const cancel = button('Cancel', () => dialog.close(), { class: 'btn' })
|
|
138
|
+
const bar = el('div', { class: 'row-bar' })
|
|
139
|
+
bar.append(cancel, confirm)
|
|
140
|
+
dialog.appendChild(bar)
|
|
141
|
+
|
|
142
|
+
// `settle` is narrowed to `(value: boolean) => void` rather than used as the
|
|
143
|
+
// executor's own `resolve`, whose parameter is `boolean | PromiseLike<boolean>`
|
|
144
|
+
// — calling that reads as an unhandled promise to the floating-promise rule.
|
|
145
|
+
let settle: (value: boolean) => void = () => {}
|
|
146
|
+
const answered = new Promise<boolean>(resolve => {
|
|
147
|
+
settle = resolve
|
|
148
|
+
})
|
|
149
|
+
dialog.addEventListener('close', () => {
|
|
150
|
+
dialog.remove()
|
|
151
|
+
settle(accepted)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
document.body.appendChild(dialog)
|
|
155
|
+
dialog.showModal()
|
|
156
|
+
return answered
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A short-lived bar offering to put one thing back.
|
|
161
|
+
*
|
|
162
|
+
* Ten seconds, and the timer is the undo window rather than a toast animation:
|
|
163
|
+
* the action already happened. Returns a disposer so a second action can
|
|
164
|
+
* retire the first offer instead of stacking two.
|
|
165
|
+
*/
|
|
166
|
+
export function offerUndo(
|
|
167
|
+
message: string,
|
|
168
|
+
undo: () => Promise<void>,
|
|
169
|
+
seconds = 10,
|
|
170
|
+
): () => void {
|
|
171
|
+
const bar = el('div', { class: 'undo-bar', attrs: { role: 'status' } })
|
|
172
|
+
bar.appendChild(el('span', { text: message }))
|
|
173
|
+
|
|
174
|
+
const remove = () => {
|
|
175
|
+
clearTimeout(timer)
|
|
176
|
+
bar.remove()
|
|
177
|
+
}
|
|
178
|
+
bar.appendChild(
|
|
179
|
+
button(
|
|
180
|
+
'Undo',
|
|
181
|
+
() => {
|
|
182
|
+
remove()
|
|
183
|
+
void undo()
|
|
184
|
+
},
|
|
185
|
+
{ class: 'btn' },
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const timer = setTimeout(remove, seconds * 1000)
|
|
190
|
+
document.body.appendChild(bar)
|
|
191
|
+
return remove
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A message that is not a question. Used for a refusal and for a failure. */
|
|
195
|
+
export function notify(message: string, kind: 'info' | 'error' = 'info'): void {
|
|
196
|
+
const bar = el('div', {
|
|
197
|
+
class: `undo-bar ${kind}`,
|
|
198
|
+
text: message,
|
|
199
|
+
attrs: { role: kind === 'error' ? 'alert' : 'status' },
|
|
200
|
+
})
|
|
201
|
+
document.body.appendChild(bar)
|
|
202
|
+
setTimeout(() => bar.remove(), 6000)
|
|
203
|
+
}
|