@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.6
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 +261 -0
- package/src/client/bulk.ts +361 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +201 -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 +91 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +144 -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 +242 -0
- package/src/client/grid-body.ts +103 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +466 -0
- package/src/client/meta.ts +188 -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 +209 -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 +231 -0
- package/src/client/tabs.ts +224 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +374 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +0 -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 +235 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +164 -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,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editable grid: which node holds which value, and where focus is.
|
|
3
|
+
*
|
|
4
|
+
* Two rules keep this file's functions under biome's complexity ceiling, and
|
|
5
|
+
* they are worth stating because the old `renderTable` broke both and scored
|
|
6
|
+
* 34 with a fraction of the behaviour:
|
|
7
|
+
*
|
|
8
|
+
* 1. **No function both fetches and renders.** Everything here takes rows it
|
|
9
|
+
* was handed. `api.ts` does the asking.
|
|
10
|
+
* 2. **Every loop body is a named function.** `each()` from `dom.ts` takes the
|
|
11
|
+
* factory; an inline body would fold its branches into the caller's score.
|
|
12
|
+
*
|
|
13
|
+
* Three neighbours carry the parts that do not need the cursor:
|
|
14
|
+
* `grid-header.ts` builds the heading row, `grid-body.ts` paints a cell's
|
|
15
|
+
* contents, and `grid-rowbar.ts` owns the per-row save strip. The editing model
|
|
16
|
+
* itself is in `edit-session.ts` and the keyboard model in `cell.ts`, both pure
|
|
17
|
+
* and both tested. What is left here genuinely needs a DOM.
|
|
18
|
+
*
|
|
19
|
+
* `focus` used to live here too — a row identity carried alongside the filters,
|
|
20
|
+
* because a substring `LIKE` could not name a row. With `eq` in the filter
|
|
21
|
+
* vocabulary a foreign-key jump is an ordinary filter, and the highlight, the
|
|
22
|
+
* `focusedMissing()` note and the whole concept are gone.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterCommit, type CellAction, type CellState, keyOnCell } from './cell'
|
|
26
|
+
import { box, each, el, on, setBusy } from './dom'
|
|
27
|
+
import type { EditSession } from './edit-session'
|
|
28
|
+
import { rowId } from './edit-session'
|
|
29
|
+
import { createEditor, type EditorHandle } from './editors'
|
|
30
|
+
import type { FkResolver, FkTarget } from './fk'
|
|
31
|
+
import { type CellPaintContext, paintCell } from './grid-body'
|
|
32
|
+
import { buildHead } from './grid-header'
|
|
33
|
+
import {
|
|
34
|
+
buildRowBar,
|
|
35
|
+
paintRowBar,
|
|
36
|
+
type RowBar,
|
|
37
|
+
setRowBarMessage,
|
|
38
|
+
} from './grid-rowbar'
|
|
39
|
+
import type { SchemaColumn, SchemaGraph, SchemaTable } from './meta'
|
|
40
|
+
|
|
41
|
+
export interface GridContext {
|
|
42
|
+
table: SchemaTable
|
|
43
|
+
columns: SchemaColumn[]
|
|
44
|
+
rows: Record<string, unknown>[]
|
|
45
|
+
editable: boolean
|
|
46
|
+
graph: SchemaGraph | null
|
|
47
|
+
session: EditSession
|
|
48
|
+
resolver: FkResolver
|
|
49
|
+
sortBy: string | null
|
|
50
|
+
sortOrder: 'ASC' | 'DESC'
|
|
51
|
+
onSort: (column: string) => void
|
|
52
|
+
onSaveRow: (id: string) => void
|
|
53
|
+
onOpenRow: (row: Record<string, unknown>) => void
|
|
54
|
+
onFollowFk: (target: FkTarget, key: Record<string, unknown>) => void
|
|
55
|
+
onSelectionChange: (selected: number) => void
|
|
56
|
+
onDirtyChange: () => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface RowSlot {
|
|
60
|
+
id: string | null
|
|
61
|
+
row: Record<string, unknown>
|
|
62
|
+
tr: HTMLTableRowElement
|
|
63
|
+
cells: HTMLTableCellElement[]
|
|
64
|
+
bar: RowBar
|
|
65
|
+
check: HTMLInputElement | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class Grid {
|
|
69
|
+
readonly node: HTMLElement
|
|
70
|
+
private readonly slots: RowSlot[] = []
|
|
71
|
+
private readonly body: HTMLTableSectionElement
|
|
72
|
+
private readonly paintCtx: CellPaintContext
|
|
73
|
+
private cursor = { row: 0, col: 0 }
|
|
74
|
+
private editor: EditorHandle | null = null
|
|
75
|
+
private editing: { row: number; col: number } | null = null
|
|
76
|
+
readonly selected = new Set<string>()
|
|
77
|
+
|
|
78
|
+
constructor(private readonly ctx: GridContext) {
|
|
79
|
+
this.paintCtx = {
|
|
80
|
+
table: ctx.table.name,
|
|
81
|
+
graph: ctx.graph,
|
|
82
|
+
resolver: ctx.resolver,
|
|
83
|
+
onFollowFk: ctx.onFollowFk,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const table = el('table', { class: 'grid' })
|
|
87
|
+
table.appendChild(
|
|
88
|
+
buildHead({
|
|
89
|
+
columns: ctx.columns,
|
|
90
|
+
editable: ctx.editable,
|
|
91
|
+
sortBy: ctx.sortBy,
|
|
92
|
+
sortOrder: ctx.sortOrder,
|
|
93
|
+
onSort: ctx.onSort,
|
|
94
|
+
onToggleAll: checked => this.setAllSelected(checked),
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
this.body = el('tbody')
|
|
98
|
+
table.appendChild(this.body)
|
|
99
|
+
each(this.body, ctx.rows, (row, index) => this.buildRow(row, index))
|
|
100
|
+
this.node = box('scroll', table)
|
|
101
|
+
on(this.node, 'keydown', event => this.onKeyDown(event as KeyboardEvent))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -------------------------------------------------------------------- rows
|
|
105
|
+
|
|
106
|
+
private buildRow(row: Record<string, unknown>, index: number): Node {
|
|
107
|
+
const id = rowId(row, this.ctx.table.identity.cols)
|
|
108
|
+
const tr = el('tr')
|
|
109
|
+
const check = this.buildPick(tr, row, id)
|
|
110
|
+
const cells: HTMLTableCellElement[] = []
|
|
111
|
+
each(tr, this.ctx.columns, (column, col) => {
|
|
112
|
+
const td = this.buildCell(row, index, column, col)
|
|
113
|
+
cells.push(td)
|
|
114
|
+
return td
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const bar = buildRowBar(this.ctx.columns.length + 1, {
|
|
118
|
+
onSave: () => this.saveRow(index),
|
|
119
|
+
onRevert: () => this.revertRow(index),
|
|
120
|
+
})
|
|
121
|
+
this.slots[index] = { id, row, tr, cells, bar, check }
|
|
122
|
+
|
|
123
|
+
// A fragment so one `each` callback can add two rows: the data row and the
|
|
124
|
+
// save/revert bar that lives under it.
|
|
125
|
+
const fragment = document.createDocumentFragment()
|
|
126
|
+
fragment.append(tr, bar.tr)
|
|
127
|
+
return fragment
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Selection checkbox (when editable) and the panel opener (always). */
|
|
131
|
+
private buildPick(
|
|
132
|
+
tr: HTMLTableRowElement,
|
|
133
|
+
row: Record<string, unknown>,
|
|
134
|
+
id: string | null,
|
|
135
|
+
): HTMLInputElement | null {
|
|
136
|
+
const td = el('td', { class: 'pick' })
|
|
137
|
+
let check: HTMLInputElement | null = null
|
|
138
|
+
if (this.ctx.editable) {
|
|
139
|
+
check = el('input', { attrs: { 'aria-label': 'select row' } })
|
|
140
|
+
check.type = 'checkbox'
|
|
141
|
+
check.disabled = id === null
|
|
142
|
+
on(check, 'change', () => this.setSelected(id, check!.checked))
|
|
143
|
+
td.appendChild(check)
|
|
144
|
+
}
|
|
145
|
+
const open = el('button', {
|
|
146
|
+
class: 'btn',
|
|
147
|
+
text: '⋯',
|
|
148
|
+
title: 'open this row',
|
|
149
|
+
})
|
|
150
|
+
open.type = 'button'
|
|
151
|
+
on(open, 'click', () => this.ctx.onOpenRow(row))
|
|
152
|
+
td.appendChild(open)
|
|
153
|
+
tr.appendChild(td)
|
|
154
|
+
return check
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------------------------------- cells
|
|
158
|
+
|
|
159
|
+
private buildCell(
|
|
160
|
+
row: Record<string, unknown>,
|
|
161
|
+
rowIndex: number,
|
|
162
|
+
column: SchemaColumn,
|
|
163
|
+
col: number,
|
|
164
|
+
): HTMLTableCellElement {
|
|
165
|
+
const td = el('td', { attrs: { tabindex: -1 } })
|
|
166
|
+
on(td, 'click', () => this.focusCell(rowIndex, col))
|
|
167
|
+
on(td, 'dblclick', () => this.requestEdit(rowIndex, col))
|
|
168
|
+
this.repaintCell(rowIndex, col, td, row, column)
|
|
169
|
+
return td
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render one cell from the session's current view of it.
|
|
174
|
+
*
|
|
175
|
+
* Called on build, after every stage, and after an editor closes — so a
|
|
176
|
+
* staged value and a saved value take exactly the same path.
|
|
177
|
+
*/
|
|
178
|
+
private repaintCell(
|
|
179
|
+
rowIndex: number,
|
|
180
|
+
col: number,
|
|
181
|
+
node?: HTMLTableCellElement,
|
|
182
|
+
row?: Record<string, unknown>,
|
|
183
|
+
column?: SchemaColumn,
|
|
184
|
+
): void {
|
|
185
|
+
const slot = this.slots[rowIndex]
|
|
186
|
+
const td = node ?? slot?.cells[col]
|
|
187
|
+
const source = row ?? slot?.row
|
|
188
|
+
const meta = column ?? this.ctx.columns[col]
|
|
189
|
+
if (!td || !source || !meta) return
|
|
190
|
+
|
|
191
|
+
const id = slot?.id ?? rowId(source, this.ctx.table.identity.cols)
|
|
192
|
+
const value = id
|
|
193
|
+
? this.ctx.session.value(id, meta.name, source[meta.name])
|
|
194
|
+
: source[meta.name]
|
|
195
|
+
const staged = id ? this.ctx.session.isStaged(id, meta.name) : false
|
|
196
|
+
paintCell(td, this.paintCtx, source, meta, value, staged)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ----------------------------------------------------------------- cursor
|
|
200
|
+
|
|
201
|
+
private focusCell(row: number, col: number): void {
|
|
202
|
+
if (this.editing) this.commitEditor('none')
|
|
203
|
+
this.cursor = { row, col }
|
|
204
|
+
const td = this.slots[row]?.cells[col]
|
|
205
|
+
if (!td) return
|
|
206
|
+
for (const other of this.body.querySelectorAll('td.at')) {
|
|
207
|
+
other.classList.remove('at')
|
|
208
|
+
}
|
|
209
|
+
td.classList.add('at')
|
|
210
|
+
td.focus()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private cellState(): CellState {
|
|
214
|
+
return {
|
|
215
|
+
row: this.cursor.row,
|
|
216
|
+
col: this.cursor.col,
|
|
217
|
+
rows: this.slots.length,
|
|
218
|
+
cols: this.ctx.columns.length,
|
|
219
|
+
editing: this.editing !== null,
|
|
220
|
+
editable: this.ctx.editable,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private onKeyDown(event: KeyboardEvent): void {
|
|
225
|
+
const action = keyOnCell(event, this.cellState())
|
|
226
|
+
if (action.type === 'none') return
|
|
227
|
+
event.preventDefault()
|
|
228
|
+
this.apply(action)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private apply(action: CellAction): void {
|
|
232
|
+
const handlers: Record<CellAction['type'], () => void> = {
|
|
233
|
+
none: () => {},
|
|
234
|
+
move: () => {
|
|
235
|
+
const move = action as { row: number; col: number }
|
|
236
|
+
this.focusCell(move.row, move.col)
|
|
237
|
+
},
|
|
238
|
+
edit: () => this.requestEdit(this.cursor.row, this.cursor.col),
|
|
239
|
+
cancel: () => this.cancelEditor(),
|
|
240
|
+
null: () => this.stageNull(),
|
|
241
|
+
commit: () => this.commitEditor((action as { move: 'down' }).move),
|
|
242
|
+
}
|
|
243
|
+
handlers[action.type]()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------- editing
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Open an editor — or explain why not.
|
|
250
|
+
*
|
|
251
|
+
* A double-click on a read-only table says what is wrong *here*, at the
|
|
252
|
+
* moment the user asked. The alternative is a grid that looks editable and
|
|
253
|
+
* refuses at save time, after the typing.
|
|
254
|
+
*/
|
|
255
|
+
private requestEdit(rowIndex: number, col: number): void {
|
|
256
|
+
this.focusCell(rowIndex, col)
|
|
257
|
+
if (!this.ctx.editable) {
|
|
258
|
+
this.setRowMessage(
|
|
259
|
+
rowIndex,
|
|
260
|
+
this.ctx.table.reason ?? 'this table is read-only',
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
const slot = this.slots[rowIndex]
|
|
265
|
+
if (!slot?.id) {
|
|
266
|
+
this.setRowMessage(
|
|
267
|
+
rowIndex,
|
|
268
|
+
'this row carries no identity, so it cannot be addressed',
|
|
269
|
+
)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
this.openEditor(rowIndex, col, slot)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private openEditor(rowIndex: number, col: number, slot: RowSlot): void {
|
|
276
|
+
const column = this.ctx.columns[col]
|
|
277
|
+
if (!column || !slot.id) return
|
|
278
|
+
const current = this.ctx.session.value(
|
|
279
|
+
slot.id,
|
|
280
|
+
column.name,
|
|
281
|
+
slot.row[column.name],
|
|
282
|
+
)
|
|
283
|
+
const handle = createEditor(column, current, {
|
|
284
|
+
// The grid reads the editor at commit time instead of tracking every
|
|
285
|
+
// keystroke, which is what makes Escape a true revert: an uncommitted
|
|
286
|
+
// value was never anywhere the session could see it.
|
|
287
|
+
onInput: () => {},
|
|
288
|
+
onKey: event => this.onKeyDown(event),
|
|
289
|
+
})
|
|
290
|
+
// Blur stages; it never saves. A click into another cell must not be a
|
|
291
|
+
// write, and a multi-column edit must stay one statement.
|
|
292
|
+
//
|
|
293
|
+
// `relatedTarget` is checked because `focusout` also fires for movement
|
|
294
|
+
// *inside* the editor — tabbing from the input to the null toggle is one —
|
|
295
|
+
// and committing there would close the editor the user is still in.
|
|
296
|
+
handle.node.addEventListener('focusout', event => {
|
|
297
|
+
const next = (event as FocusEvent).relatedTarget
|
|
298
|
+
if (next instanceof Node && handle.node.contains(next)) return
|
|
299
|
+
this.stageOnBlur(rowIndex, col)
|
|
300
|
+
})
|
|
301
|
+
const td = slot.cells[col]
|
|
302
|
+
if (!td) return
|
|
303
|
+
td.replaceChildren(handle.node)
|
|
304
|
+
td.classList.add('editing')
|
|
305
|
+
this.editor = handle
|
|
306
|
+
this.editing = { row: rowIndex, col }
|
|
307
|
+
handle.focus()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private stageOnBlur(rowIndex: number, col: number): void {
|
|
311
|
+
if (
|
|
312
|
+
!this.editing ||
|
|
313
|
+
this.editing.row !== rowIndex ||
|
|
314
|
+
this.editing.col !== col
|
|
315
|
+
)
|
|
316
|
+
return
|
|
317
|
+
this.commitEditor('none')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private commitEditor(move: 'down' | 'right' | 'left' | 'none'): void {
|
|
321
|
+
const at = this.editing
|
|
322
|
+
if (!at) return
|
|
323
|
+
const slot = this.slots[at.row]
|
|
324
|
+
const column = this.ctx.columns[at.col]
|
|
325
|
+
// Read before closing: the editor is the only holder of the typed value.
|
|
326
|
+
const value = this.editor?.read()
|
|
327
|
+
this.closeEditor()
|
|
328
|
+
if (slot?.id && column) {
|
|
329
|
+
this.ctx.session.stage(slot.id, slot.row, column.name, value)
|
|
330
|
+
this.repaintCell(at.row, at.col)
|
|
331
|
+
this.refreshBar(at.row)
|
|
332
|
+
this.ctx.onDirtyChange()
|
|
333
|
+
}
|
|
334
|
+
const next = afterCommit(
|
|
335
|
+
{ ...this.cellState(), ...at, editing: false },
|
|
336
|
+
move,
|
|
337
|
+
)
|
|
338
|
+
if (next.type === 'move') this.focusCell(next.row, next.col)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Escape: the editor is dropped unread and the cell repaints from the buffer. */
|
|
342
|
+
private cancelEditor(): void {
|
|
343
|
+
const at = this.editing
|
|
344
|
+
if (!at) return
|
|
345
|
+
this.closeEditor()
|
|
346
|
+
this.repaintCell(at.row, at.col)
|
|
347
|
+
this.focusCell(at.row, at.col)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private closeEditor(): void {
|
|
351
|
+
const at = this.editing
|
|
352
|
+
this.editing = null
|
|
353
|
+
this.editor = null
|
|
354
|
+
if (at) this.slots[at.row]?.cells[at.col]?.classList.remove('editing')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Delete on a selected cell: SQL NULL, not the empty string. */
|
|
358
|
+
private stageNull(): void {
|
|
359
|
+
const slot = this.slots[this.cursor.row]
|
|
360
|
+
const column = this.ctx.columns[this.cursor.col]
|
|
361
|
+
if (!slot?.id || !column) return
|
|
362
|
+
this.ctx.session.stage(slot.id, slot.row, column.name, null)
|
|
363
|
+
this.repaintCell(this.cursor.row, this.cursor.col)
|
|
364
|
+
this.refreshBar(this.cursor.row)
|
|
365
|
+
this.ctx.onDirtyChange()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ------------------------------------------------------------- the row bar
|
|
369
|
+
|
|
370
|
+
refreshBar(rowIndex: number): void {
|
|
371
|
+
const slot = this.slots[rowIndex]
|
|
372
|
+
if (!slot) return
|
|
373
|
+
const changed = slot.id ? this.ctx.session.changedColumns(slot.id) : []
|
|
374
|
+
paintRowBar(slot.bar, changed)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private saveRow(rowIndex: number): void {
|
|
378
|
+
const slot = this.slots[rowIndex]
|
|
379
|
+
if (slot?.id) this.ctx.onSaveRow(slot.id)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private revertRow(rowIndex: number): void {
|
|
383
|
+
const slot = this.slots[rowIndex]
|
|
384
|
+
if (!slot?.id) return
|
|
385
|
+
this.ctx.session.drop(slot.id)
|
|
386
|
+
this.repaintRow(rowIndex)
|
|
387
|
+
this.ctx.onDirtyChange()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
repaintRow(rowIndex: number): void {
|
|
391
|
+
const slot = this.slots[rowIndex]
|
|
392
|
+
if (!slot) return
|
|
393
|
+
this.ctx.columns.forEach((_column, col) => {
|
|
394
|
+
this.repaintCell(rowIndex, col)
|
|
395
|
+
})
|
|
396
|
+
this.refreshBar(rowIndex)
|
|
397
|
+
slot.bar.message.textContent = ''
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Where a save's failure is reported: under the row it belongs to. */
|
|
401
|
+
setRowMessage(rowIndex: number, message: string): void {
|
|
402
|
+
const slot = this.slots[rowIndex]
|
|
403
|
+
if (!slot) return
|
|
404
|
+
setRowBarMessage(slot.bar, message)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
setRowBusy(rowIndex: number, busy: boolean): void {
|
|
408
|
+
const slot = this.slots[rowIndex]
|
|
409
|
+
if (!slot) return
|
|
410
|
+
setBusy(slot.tr, busy)
|
|
411
|
+
for (const input of slot.tr.querySelectorAll(
|
|
412
|
+
'input,button,select,textarea',
|
|
413
|
+
)) {
|
|
414
|
+
;(input as HTMLInputElement).disabled = busy
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
indexOfRow(id: string): number {
|
|
419
|
+
return this.slots.findIndex(slot => slot?.id === id)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
rowAt(index: number): Record<string, unknown> | undefined {
|
|
423
|
+
return this.slots[index]?.row
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The row under the cursor — what a keyboard-only user means by "this row". */
|
|
427
|
+
focusedRow(): Record<string, unknown> | undefined {
|
|
428
|
+
return this.slots[this.cursor.row]?.row
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The rows the checkboxes name, as identity objects for a write. */
|
|
432
|
+
selectedKeys(): Record<string, unknown>[] {
|
|
433
|
+
const cols = this.ctx.table.identity.cols
|
|
434
|
+
const keys: Record<string, unknown>[] = []
|
|
435
|
+
for (const slot of this.slots) {
|
|
436
|
+
if (!slot?.id || !this.selected.has(slot.id)) continue
|
|
437
|
+
const key: Record<string, unknown> = {}
|
|
438
|
+
for (const column of cols) key[column] = slot.row[column]
|
|
439
|
+
keys.push(key)
|
|
440
|
+
}
|
|
441
|
+
return keys
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
selectedRows(): Record<string, unknown>[] {
|
|
445
|
+
return this.slots
|
|
446
|
+
.filter(slot => slot?.id && this.selected.has(slot.id))
|
|
447
|
+
.map(slot => slot.row)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private setSelected(id: string | null, on: boolean): void {
|
|
451
|
+
if (!id) return
|
|
452
|
+
if (on) this.selected.add(id)
|
|
453
|
+
else this.selected.delete(id)
|
|
454
|
+
this.ctx.onSelectionChange(this.selected.size)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private setAllSelected(on: boolean): void {
|
|
458
|
+
for (const slot of this.slots) {
|
|
459
|
+
if (!slot?.id || !slot.check) continue
|
|
460
|
+
slot.check.checked = on
|
|
461
|
+
if (on) this.selected.add(slot.id)
|
|
462
|
+
else this.selected.delete(slot.id)
|
|
463
|
+
}
|
|
464
|
+
this.ctx.onSelectionChange(this.selected.size)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the server said about a column, and the four decisions the grid makes
|
|
3
|
+
* from it.
|
|
4
|
+
*
|
|
5
|
+
* **Pure — no DOM.** Every branch the editor would otherwise take on a column's
|
|
6
|
+
* type is resolved here, once, into a `WidgetKind`, and every consumer
|
|
7
|
+
* dispatches through a record of small functions rather than an if-chain. A
|
|
8
|
+
* record of five functions has no cognitive complexity; five `else if`s have
|
|
9
|
+
* one arm each, and that is the whole reason `renderTable` in the old client
|
|
10
|
+
* scored 34.
|
|
11
|
+
*
|
|
12
|
+
* The shapes below mirror `endpoints/read.ts` exactly. They are re-declared
|
|
13
|
+
* rather than imported because that module reaches for the ORM and for
|
|
14
|
+
* `node:async_hooks` through its neighbours, and this one is compiled into the
|
|
15
|
+
* browser bundle.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ColumnKind, ColumnMeta } from '../shared/coerce'
|
|
19
|
+
|
|
20
|
+
export interface SchemaColumn {
|
|
21
|
+
name: string
|
|
22
|
+
type: string
|
|
23
|
+
notnull: boolean
|
|
24
|
+
pk: boolean
|
|
25
|
+
kind: string
|
|
26
|
+
nullable: boolean
|
|
27
|
+
length?: number
|
|
28
|
+
enum?: readonly string[]
|
|
29
|
+
hasDefault: boolean
|
|
30
|
+
autoIncrement?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Identity {
|
|
34
|
+
mode: 'pk' | 'unique' | 'none'
|
|
35
|
+
cols: string[]
|
|
36
|
+
reason?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SchemaIndex {
|
|
40
|
+
name: string
|
|
41
|
+
type: string
|
|
42
|
+
cols: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SchemaTable {
|
|
46
|
+
name: string
|
|
47
|
+
rowCount: number
|
|
48
|
+
columns: SchemaColumn[]
|
|
49
|
+
identity: Identity
|
|
50
|
+
/** Optional so a fixture — and an older server — need not carry them. */
|
|
51
|
+
indexes?: SchemaIndex[]
|
|
52
|
+
isView?: boolean
|
|
53
|
+
writable: boolean
|
|
54
|
+
reason?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SchemaReport {
|
|
58
|
+
access: 'read' | 'write' | false
|
|
59
|
+
tables: SchemaTable[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TablePage {
|
|
63
|
+
rows: Record<string, unknown>[]
|
|
64
|
+
totalRows?: number
|
|
65
|
+
totalPages?: number
|
|
66
|
+
page?: number
|
|
67
|
+
pageSize?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ForeignKeyInfo {
|
|
71
|
+
table: string
|
|
72
|
+
cols: string[]
|
|
73
|
+
refTable: string
|
|
74
|
+
refCols: string[]
|
|
75
|
+
name?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SchemaGraph {
|
|
79
|
+
foreignKeys: Record<string, ForeignKeyInfo>
|
|
80
|
+
identity: Record<string, Identity>
|
|
81
|
+
labels: Record<string, string | null>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Which editor a column gets. The discriminant every dispatch keys on. */
|
|
85
|
+
export type WidgetKind = 'enum' | 'boolean' | 'date' | 'json' | 'text'
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `enum` first, because it is a constraint on a `string` column rather than a
|
|
89
|
+
* kind of its own — the server reports `kind: 'string'` with an `enum` list,
|
|
90
|
+
* and a free-text box over a checked list is a guaranteed round trip to a
|
|
91
|
+
* `not_in_enum` error.
|
|
92
|
+
*/
|
|
93
|
+
export function columnKind(column: SchemaColumn): WidgetKind {
|
|
94
|
+
if (column.enum?.length) return 'enum'
|
|
95
|
+
if (column.kind === 'boolean') return 'boolean'
|
|
96
|
+
if (column.kind === 'date') return 'date'
|
|
97
|
+
if (column.kind === 'json') return 'json'
|
|
98
|
+
return 'text'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The `ColumnMeta` `coerceValue` takes.
|
|
103
|
+
*
|
|
104
|
+
* The wire shape and the coercer's shape are nearly the same object and are
|
|
105
|
+
* deliberately not the same type: `kind` crosses the wire as a plain `string`
|
|
106
|
+
* because JSON has no unions, and this is the one place it is narrowed back.
|
|
107
|
+
*/
|
|
108
|
+
export function columnMeta(column: SchemaColumn): ColumnMeta {
|
|
109
|
+
return {
|
|
110
|
+
kind: column.kind as ColumnKind,
|
|
111
|
+
nullable: column.nullable,
|
|
112
|
+
length: column.length,
|
|
113
|
+
enum: column.enum,
|
|
114
|
+
hasDefault: column.hasDefault,
|
|
115
|
+
primary: column.pk,
|
|
116
|
+
autoIncrement: column.autoIncrement,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A cell's text.
|
|
122
|
+
*
|
|
123
|
+
* `NULL` is rendered as the word and styled as absent, which is the
|
|
124
|
+
* distinction the whole editor turns on: an empty string is a value and a
|
|
125
|
+
* blank cell would make the two look identical. Binary is summarised rather
|
|
126
|
+
* than dumped — a blob rendered as its bytes is thousands of characters of
|
|
127
|
+
* noise that also makes the row unselectable.
|
|
128
|
+
*/
|
|
129
|
+
export function cellText(value: unknown): string {
|
|
130
|
+
if (value === null || value === undefined) return 'NULL'
|
|
131
|
+
if (value instanceof Uint8Array) return `${value.byteLength} bytes`
|
|
132
|
+
if (typeof value === 'object') return safeJson(value)
|
|
133
|
+
return String(value)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function safeJson(value: unknown): string {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.stringify(value) ?? String(value)
|
|
139
|
+
} catch {
|
|
140
|
+
// A cycle or a BigInt. The cell still has to render something, and the
|
|
141
|
+
// failure is cosmetic — the value itself is untouched.
|
|
142
|
+
return String(value)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Class names for a rendered cell: the base, plus what the value is. */
|
|
147
|
+
export function cellClass(value: unknown, column: SchemaColumn): string {
|
|
148
|
+
const classes = ['cell', `k-${columnKind(column)}`]
|
|
149
|
+
if (value === null || value === undefined) classes.push('null')
|
|
150
|
+
if (column.pk) classes.push('pk')
|
|
151
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
152
|
+
classes.push('num')
|
|
153
|
+
}
|
|
154
|
+
return classes.join(' ')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface CellProps {
|
|
158
|
+
text: string
|
|
159
|
+
className: string
|
|
160
|
+
/** The untruncated value, when the cell shows less than all of it. */
|
|
161
|
+
title?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Everything a cell needs, as data. The DOM half only assigns these three. */
|
|
165
|
+
export function cellProps(value: unknown, column: SchemaColumn): CellProps {
|
|
166
|
+
const text = cellText(value)
|
|
167
|
+
return {
|
|
168
|
+
text,
|
|
169
|
+
className: cellClass(value, column),
|
|
170
|
+
title: text.length > 60 ? text : undefined,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Table names as the two halves of the system spell them.
|
|
176
|
+
*
|
|
177
|
+
* `getForeignKeys()` reports whatever the database calls a table while
|
|
178
|
+
* `getConstraints()` is camel-keyed, and the graph endpoint passes both
|
|
179
|
+
* through untouched. Comparing them literally makes every foreign key on a
|
|
180
|
+
* `snake_case` schema invisible, which is the entire feature silently absent.
|
|
181
|
+
*/
|
|
182
|
+
export function sameTable(a: string, b: string): boolean {
|
|
183
|
+
return a === b || flatten(a) === flatten(b)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function flatten(name: string): string {
|
|
187
|
+
return name.toLowerCase().replace(/[\s_-]+/g, '')
|
|
188
|
+
}
|