@bakery-framework/plugin-db-explorer 2.0.0-alpha.6 → 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 +3 -3
- package/src/client/api.ts +25 -7
- package/src/client/bulk.ts +19 -23
- package/src/client/confirm.ts +21 -19
- package/src/client/csv-commit.ts +2 -2
- package/src/client/csv-map.ts +4 -4
- package/src/client/csv-preview.ts +3 -5
- package/src/client/dom.ts +20 -0
- package/src/client/fk.ts +28 -1
- package/src/client/grid-body.ts +14 -11
- package/src/client/grid.ts +3 -1
- package/src/client/meta.ts +2 -5
- package/src/client/save.ts +1 -5
- package/src/client/structure.ts +35 -32
- package/src/client/tabs.ts +2 -7
- package/src/client.ts +7 -5
- package/src/endpoints/graph.ts +0 -0
- package/src/endpoints/import.ts +4 -4
- package/src/shared/csv.ts +8 -57
- package/src/shared/plan.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/plugin-db-explorer",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.7",
|
|
4
4
|
"description": "Bakery database explorer plugin — browse and edit rows — no raw SQL, no DDL.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bakery",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"!src/tests"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@bakery-framework/core": "^2.0.0-alpha.
|
|
37
|
-
"@bakery-framework/orm": "^2.0.0-alpha.
|
|
36
|
+
"@bakery-framework/core": "^2.0.0-alpha.7",
|
|
37
|
+
"@bakery-framework/orm": "^2.0.0-alpha.7"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
40
|
"bun": ">=1.3.14"
|
package/src/client/api.ts
CHANGED
|
@@ -30,6 +30,24 @@ export class ApiError extends Error {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* What to show a user when a call threw.
|
|
35
|
+
*
|
|
36
|
+
* Here rather than in `dom.ts` because the thing it knows about is `ApiError`,
|
|
37
|
+
* whose `message` is the server's own sentence from the envelope — the reason
|
|
38
|
+
* a failed write reads as "row was modified by someone else" instead of
|
|
39
|
+
* "Request failed (409)". `String(error)` is the fallback for the two throws
|
|
40
|
+
* that are not ours: an abort and a network failure.
|
|
41
|
+
*
|
|
42
|
+
* There used to be three of these — one exported from `bulk.ts`, a private
|
|
43
|
+
* near-copy in `save.ts`, and a third inlined at the `notify` call in
|
|
44
|
+
* `csv-commit.ts` — so improving the wording in one left the other two alone.
|
|
45
|
+
*/
|
|
46
|
+
export function messageOf(error: unknown): string {
|
|
47
|
+
const api = error as Partial<ApiError>
|
|
48
|
+
return api?.message ?? String(error)
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
const KEY_STORAGE = '__db_key'
|
|
34
52
|
const KEY_PARAM = 'db-key'
|
|
35
53
|
|
|
@@ -79,7 +97,7 @@ interface Envelope {
|
|
|
79
97
|
* available for "why is this slow": a filter that cannot use an index shows up
|
|
80
98
|
* here immediately.
|
|
81
99
|
*/
|
|
82
|
-
|
|
100
|
+
interface Timed<T> {
|
|
83
101
|
data: T
|
|
84
102
|
ms: number
|
|
85
103
|
}
|
|
@@ -101,7 +119,7 @@ async function unwrap<T>(res: Response): Promise<T> {
|
|
|
101
119
|
return (await unwrapEnvelope<T>(res)).data
|
|
102
120
|
}
|
|
103
121
|
|
|
104
|
-
|
|
122
|
+
async function apiGet<T>(
|
|
105
123
|
path: string,
|
|
106
124
|
params?: Record<string, string>,
|
|
107
125
|
signal?: AbortSignal,
|
|
@@ -114,7 +132,7 @@ export async function apiGet<T>(
|
|
|
114
132
|
return await unwrap<T>(res)
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
|
|
135
|
+
async function apiSend<T>(
|
|
118
136
|
method: 'POST' | 'PATCH' | 'DELETE',
|
|
119
137
|
path: string,
|
|
120
138
|
body: unknown,
|
|
@@ -192,7 +210,7 @@ export async function lookupRefs(
|
|
|
192
210
|
return data.rows ?? []
|
|
193
211
|
}
|
|
194
212
|
|
|
195
|
-
|
|
213
|
+
interface UpdateResult {
|
|
196
214
|
changed: number
|
|
197
215
|
row: Record<string, unknown> | null
|
|
198
216
|
}
|
|
@@ -207,7 +225,7 @@ export async function patchRow(payload: {
|
|
|
207
225
|
return await apiSend<UpdateResult>('PATCH', 'row', payload)
|
|
208
226
|
}
|
|
209
227
|
|
|
210
|
-
|
|
228
|
+
interface BulkResult {
|
|
211
229
|
changed: number
|
|
212
230
|
conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
|
|
213
231
|
}
|
|
@@ -220,7 +238,7 @@ export async function bulkEdit(payload: {
|
|
|
220
238
|
return await apiSend<BulkResult>('POST', 'rows/bulk', payload)
|
|
221
239
|
}
|
|
222
240
|
|
|
223
|
-
|
|
241
|
+
interface DeleteResult {
|
|
224
242
|
deleted: number
|
|
225
243
|
conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
|
|
226
244
|
}
|
|
@@ -233,7 +251,7 @@ export async function deleteRows(payload: {
|
|
|
233
251
|
return await apiSend<DeleteResult>('DELETE', 'rows', payload)
|
|
234
252
|
}
|
|
235
253
|
|
|
236
|
-
|
|
254
|
+
interface InsertResult {
|
|
237
255
|
inserted: number
|
|
238
256
|
rows?: Record<string, unknown>[]
|
|
239
257
|
}
|
package/src/client/bulk.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { coerceValue, omittableOnInsert } from '../shared/coerce'
|
|
12
|
-
import {
|
|
12
|
+
import { bulkEdit, deleteRows, insertRows, messageOf } from './api'
|
|
13
13
|
import { confirmDanger, notify, offerUndo } from './confirm'
|
|
14
14
|
import { append, box, button, each, el, select } from './dom'
|
|
15
15
|
import type { UndoStack } from './edit-session'
|
|
@@ -139,7 +139,7 @@ async function applySetColumn(
|
|
|
139
139
|
.map(key => ({ key, set: { [column.name]: check.value } }))
|
|
140
140
|
if (!edits.length) return
|
|
141
141
|
|
|
142
|
-
const dry = await
|
|
142
|
+
const dry = await run(() =>
|
|
143
143
|
bulkEdit({ table: ctx.table.name, edits, dryRun: true }),
|
|
144
144
|
)
|
|
145
145
|
if (!dry) return
|
|
@@ -173,7 +173,7 @@ async function runDelete(ctx: BulkContext): Promise<void> {
|
|
|
173
173
|
const rows = ctx.selectedRows()
|
|
174
174
|
if (!keys.length) return
|
|
175
175
|
|
|
176
|
-
const dry = await
|
|
176
|
+
const dry = await run(() =>
|
|
177
177
|
deleteRows({ table: ctx.table.name, keys, dryRun: true }),
|
|
178
178
|
)
|
|
179
179
|
if (!dry) return
|
|
@@ -297,7 +297,7 @@ function insertField(column: SchemaColumn, draft: Map<string, unknown>): Node {
|
|
|
297
297
|
return wrap
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
|
|
300
|
+
interface BuiltRows {
|
|
301
301
|
rows: Record<string, unknown>[]
|
|
302
302
|
errors: string[]
|
|
303
303
|
}
|
|
@@ -305,11 +305,12 @@ export interface BuiltRows {
|
|
|
305
305
|
/**
|
|
306
306
|
* Drafts to records, with the same coercion the server will apply.
|
|
307
307
|
*
|
|
308
|
-
*
|
|
309
|
-
* untouched omittable column is *absent* from
|
|
310
|
-
* required one is an error named here rather than
|
|
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.
|
|
311
312
|
*/
|
|
312
|
-
|
|
313
|
+
function buildInsertRows(
|
|
313
314
|
columns: readonly SchemaColumn[],
|
|
314
315
|
drafts: readonly Map<string, unknown>[],
|
|
315
316
|
): BuiltRows {
|
|
@@ -337,8 +338,16 @@ export function buildInsertRows(
|
|
|
337
338
|
|
|
338
339
|
// ------------------------------------------------------------------ plumbing
|
|
339
340
|
|
|
340
|
-
/**
|
|
341
|
-
|
|
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> {
|
|
342
351
|
try {
|
|
343
352
|
return await call()
|
|
344
353
|
} catch (error) {
|
|
@@ -346,16 +355,3 @@ async function dryRun<T>(call: () => Promise<T>): Promise<T | null> {
|
|
|
346
355
|
return null
|
|
347
356
|
}
|
|
348
357
|
}
|
|
349
|
-
|
|
350
|
-
async function run(action: () => Promise<void>): Promise<void> {
|
|
351
|
-
try {
|
|
352
|
-
await action()
|
|
353
|
-
} catch (error) {
|
|
354
|
-
notify(messageOf(error), 'error')
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
export function messageOf(error: unknown): string {
|
|
359
|
-
const api = error as Partial<ApiError>
|
|
360
|
-
return api?.message ?? String(error)
|
|
361
|
-
}
|
package/src/client/confirm.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* own preview is the only number that is the number.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { button, el } from './dom'
|
|
17
17
|
|
|
18
18
|
export type Friction = 'immediate' | 'confirm' | 'typed' | 'refuse'
|
|
19
19
|
|
|
@@ -109,8 +109,15 @@ function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
|
|
|
109
109
|
})
|
|
110
110
|
dialog.append(title, where)
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
confirm
|
|
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
|
+
)
|
|
114
121
|
|
|
115
122
|
if (typed) {
|
|
116
123
|
const prompt = el('p', {
|
|
@@ -127,19 +134,11 @@ function openDialog(request: DangerRequest, typed: boolean): Promise<boolean> {
|
|
|
127
134
|
dialog.append(prompt, input)
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
const cancel =
|
|
131
|
-
cancel.type = 'button'
|
|
137
|
+
const cancel = button('Cancel', () => dialog.close(), { class: 'btn' })
|
|
132
138
|
const bar = el('div', { class: 'row-bar' })
|
|
133
139
|
bar.append(cancel, confirm)
|
|
134
140
|
dialog.appendChild(bar)
|
|
135
141
|
|
|
136
|
-
let accepted = false
|
|
137
|
-
on(confirm, 'click', () => {
|
|
138
|
-
accepted = true
|
|
139
|
-
dialog.close()
|
|
140
|
-
})
|
|
141
|
-
on(cancel, 'click', () => dialog.close())
|
|
142
|
-
|
|
143
142
|
// `settle` is narrowed to `(value: boolean) => void` rather than used as the
|
|
144
143
|
// executor's own `resolve`, whose parameter is `boolean | PromiseLike<boolean>`
|
|
145
144
|
// — calling that reads as an unhandled promise to the floating-promise rule.
|
|
@@ -172,17 +171,20 @@ export function offerUndo(
|
|
|
172
171
|
const bar = el('div', { class: 'undo-bar', attrs: { role: 'status' } })
|
|
173
172
|
bar.appendChild(el('span', { text: message }))
|
|
174
173
|
|
|
175
|
-
const action = el('button', { class: 'btn', text: 'Undo' })
|
|
176
|
-
action.type = 'button'
|
|
177
174
|
const remove = () => {
|
|
178
175
|
clearTimeout(timer)
|
|
179
176
|
bar.remove()
|
|
180
177
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
178
|
+
bar.appendChild(
|
|
179
|
+
button(
|
|
180
|
+
'Undo',
|
|
181
|
+
() => {
|
|
182
|
+
remove()
|
|
183
|
+
void undo()
|
|
184
|
+
},
|
|
185
|
+
{ class: 'btn' },
|
|
186
|
+
),
|
|
187
|
+
)
|
|
186
188
|
|
|
187
189
|
const timer = setTimeout(remove, seconds * 1000)
|
|
188
190
|
document.body.appendChild(bar)
|
package/src/client/csv-commit.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* are in.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { type ImportResult, importRows } from './api'
|
|
11
|
+
import { type ImportResult, importRows, messageOf } from './api'
|
|
12
12
|
import { notify } from './confirm'
|
|
13
13
|
import {
|
|
14
14
|
type BadRowPolicy,
|
|
@@ -171,7 +171,7 @@ async function sendBatch(
|
|
|
171
171
|
try {
|
|
172
172
|
return await importRows({ table: ctx.table.name, rows, onBadRow })
|
|
173
173
|
} catch (error) {
|
|
174
|
-
notify((error
|
|
174
|
+
notify(messageOf(error), 'error')
|
|
175
175
|
return null
|
|
176
176
|
}
|
|
177
177
|
}
|
package/src/client/csv-map.ts
CHANGED
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
import { append, box, each, el, on, select } from './dom'
|
|
17
17
|
import type { SchemaColumn } from './meta'
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const SKIP_VALUE = ' skip'
|
|
20
|
+
const CONST_VALUE = ' const'
|
|
21
21
|
|
|
22
22
|
export interface MapContext {
|
|
23
23
|
columns: SchemaColumn[]
|
|
@@ -95,7 +95,7 @@ function summaryText(model: ImportModel): string {
|
|
|
95
95
|
* itself contains the *new* delimiter survives the round trip. This is why the
|
|
96
96
|
* wizard can offer a delimiter override at all without holding the file.
|
|
97
97
|
*/
|
|
98
|
-
|
|
98
|
+
function rebuildSource(model: ImportModel): string {
|
|
99
99
|
const quote = (field: string) =>
|
|
100
100
|
/["\n\r]/.test(field) || field.includes(model.delimiter)
|
|
101
101
|
? `"${field.replace(/"/g, '""')}"`
|
|
@@ -175,7 +175,7 @@ function currentValue(assignment: Assignment): string {
|
|
|
175
175
|
return assignment.kind === 'constant' ? CONST_VALUE : SKIP_VALUE
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
function assignmentFor(value: string, previous: Assignment): Assignment {
|
|
179
179
|
if (value === SKIP_VALUE) return { kind: 'skip' }
|
|
180
180
|
if (value === CONST_VALUE) {
|
|
181
181
|
return {
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
type RowPreview,
|
|
18
18
|
setEmptyToNull,
|
|
19
19
|
} from './csv-model'
|
|
20
|
-
import { box, each, el, on } from './dom'
|
|
20
|
+
import { box, each, el, gridTable, on } from './dom'
|
|
21
21
|
import type { SchemaColumn } from './meta'
|
|
22
22
|
|
|
23
23
|
export function paintPreview(
|
|
@@ -34,11 +34,9 @@ export function paintPreview(
|
|
|
34
34
|
return
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const table = el('table', { class: 'grid' })
|
|
38
|
-
const head = el('tr')
|
|
39
37
|
const names = rows[0]!.cells.map(cell => cell.column)
|
|
40
|
-
|
|
41
|
-
table.
|
|
38
|
+
const table = gridTable(names)
|
|
39
|
+
table.appendChild(nullToggleRow(model, names, update))
|
|
42
40
|
each(table, rows, row => previewRow(row))
|
|
43
41
|
node.appendChild(box('scroll', table))
|
|
44
42
|
}
|
package/src/client/dom.ts
CHANGED
|
@@ -85,6 +85,26 @@ export function each<T>(
|
|
|
85
85
|
})
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A `<table class="grid">` with its heading row already in it.
|
|
90
|
+
*
|
|
91
|
+
* The two lines this replaces — build the table, loop `<th>` into a `<tr>` —
|
|
92
|
+
* had been written out three times, twice in `structure.ts` and once in
|
|
93
|
+
* `csv-preview.ts`. Rows are appended by the caller, because the three differ
|
|
94
|
+
* in what a row is: `structure.ts` has plain text cells, and `csv-preview.ts`
|
|
95
|
+
* puts a checkbox row directly under the headings.
|
|
96
|
+
*/
|
|
97
|
+
export function gridTable(
|
|
98
|
+
headings: readonly string[],
|
|
99
|
+
cls = 'grid',
|
|
100
|
+
): HTMLTableElement {
|
|
101
|
+
const table = el('table', { class: cls })
|
|
102
|
+
const head = el('tr')
|
|
103
|
+
each(head, headings, heading => el('th', { text: heading }))
|
|
104
|
+
table.appendChild(head)
|
|
105
|
+
return table
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
export function clear(node: Node): void {
|
|
89
109
|
;(node as Element).replaceChildren()
|
|
90
110
|
}
|
package/src/client/fk.ts
CHANGED
|
@@ -64,7 +64,6 @@ class BoundedCache<V> {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
67
|
export interface FkTarget {
|
|
69
68
|
/** Columns in *this* table that make up the key. */
|
|
70
69
|
cols: string[]
|
|
@@ -98,6 +97,34 @@ export function fkForColumn(
|
|
|
98
97
|
return null
|
|
99
98
|
}
|
|
100
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Every foreign-key column of one table, resolved once.
|
|
102
|
+
*
|
|
103
|
+
* `fkForColumn` is a linear scan of the whole graph, and the grid asked it per
|
|
104
|
+
* cell, per paint — a fifty-row page of twenty columns against forty foreign
|
|
105
|
+
* keys is forty thousand comparisons, each of which had `sameTable` allocate
|
|
106
|
+
* two lowercased strings, and `repaintRow`/`repaintCell` run it again. The scan
|
|
107
|
+
* does not depend on the row, so it happens once when the grid is built.
|
|
108
|
+
*
|
|
109
|
+
* First declaration wins, exactly as the scan's early `return` does, so a
|
|
110
|
+
* column named by two keys resolves to the same one either way.
|
|
111
|
+
*/
|
|
112
|
+
export function fkMapFor(
|
|
113
|
+
graph: SchemaGraph | null,
|
|
114
|
+
table: string,
|
|
115
|
+
): Map<string, FkTarget> {
|
|
116
|
+
const map = new Map<string, FkTarget>()
|
|
117
|
+
if (!graph) return map
|
|
118
|
+
for (const fk of Object.values(graph.foreignKeys)) {
|
|
119
|
+
if (!sameTable(fk.table, table)) continue
|
|
120
|
+
const target = toTarget(fk)
|
|
121
|
+
for (const column of fk.cols) {
|
|
122
|
+
if (!map.has(column)) map.set(column, target)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return map
|
|
126
|
+
}
|
|
127
|
+
|
|
101
128
|
/** Every key pointing *at* this table. The row panel's "referenced by" list. */
|
|
102
129
|
export function reverseFks(
|
|
103
130
|
graph: SchemaGraph | null,
|
package/src/client/grid-body.ts
CHANGED
|
@@ -11,18 +11,20 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { button } from './dom'
|
|
14
|
-
import {
|
|
15
|
-
type FkResolver,
|
|
16
|
-
type FkTarget,
|
|
17
|
-
fkForColumn,
|
|
18
|
-
fkKeyOf,
|
|
19
|
-
fkLabel,
|
|
20
|
-
} from './fk'
|
|
14
|
+
import { type FkResolver, type FkTarget, fkKeyOf, fkLabel } from './fk'
|
|
21
15
|
import { cellProps, type SchemaColumn, type SchemaGraph } from './meta'
|
|
22
16
|
|
|
23
17
|
export interface CellPaintContext {
|
|
24
18
|
table: string
|
|
25
19
|
graph: SchemaGraph | null
|
|
20
|
+
/**
|
|
21
|
+
* Which columns of `table` are foreign keys, resolved once by `fkMapFor`.
|
|
22
|
+
*
|
|
23
|
+
* Built by the grid rather than looked up here: the answer depends on the
|
|
24
|
+
* table and not on the row, and asking per cell per paint made a page repaint
|
|
25
|
+
* quadratic in the size of the schema graph.
|
|
26
|
+
*/
|
|
27
|
+
fks: ReadonlyMap<string, FkTarget>
|
|
26
28
|
resolver: FkResolver
|
|
27
29
|
onFollowFk: (target: FkTarget, key: Record<string, unknown>) => void
|
|
28
30
|
}
|
|
@@ -47,18 +49,19 @@ export function paintCell(
|
|
|
47
49
|
td.className = props.className
|
|
48
50
|
if (staged) td.classList.add('staged')
|
|
49
51
|
if (props.title) td.title = props.title
|
|
50
|
-
|
|
52
|
+
// `props.text` is passed down rather than recomputed: `cellBody` wants the
|
|
53
|
+
// same string, and `cellProps` was being run twice for every cell.
|
|
54
|
+
td.appendChild(cellBody(ctx, row, column, props.text))
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
function cellBody(
|
|
54
58
|
ctx: CellPaintContext,
|
|
55
59
|
row: Record<string, unknown>,
|
|
56
60
|
column: SchemaColumn,
|
|
57
|
-
|
|
61
|
+
text: string,
|
|
58
62
|
): Node {
|
|
59
|
-
const target =
|
|
63
|
+
const target = ctx.fks.get(column.name)
|
|
60
64
|
const key = target ? fkKeyOf(target, row) : null
|
|
61
|
-
const text = cellProps(value, column).text
|
|
62
65
|
if (!target || !key) return document.createTextNode(text)
|
|
63
66
|
return fkButton(ctx, target, key, text)
|
|
64
67
|
}
|
package/src/client/grid.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { box, each, el, on, setBusy } from './dom'
|
|
|
27
27
|
import type { EditSession } from './edit-session'
|
|
28
28
|
import { rowId } from './edit-session'
|
|
29
29
|
import { createEditor, type EditorHandle } from './editors'
|
|
30
|
-
import type
|
|
30
|
+
import { type FkResolver, type FkTarget, fkMapFor } from './fk'
|
|
31
31
|
import { type CellPaintContext, paintCell } from './grid-body'
|
|
32
32
|
import { buildHead } from './grid-header'
|
|
33
33
|
import {
|
|
@@ -79,6 +79,8 @@ export class Grid {
|
|
|
79
79
|
this.paintCtx = {
|
|
80
80
|
table: ctx.table.name,
|
|
81
81
|
graph: ctx.graph,
|
|
82
|
+
// Once per grid, not once per cell per paint — see `fkMapFor`.
|
|
83
|
+
fks: fkMapFor(ctx.graph, ctx.table.name),
|
|
82
84
|
resolver: ctx.resolver,
|
|
83
85
|
onFollowFk: ctx.onFollowFk,
|
|
84
86
|
}
|
package/src/client/meta.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ColumnKind, ColumnMeta } from '../shared/coerce'
|
|
19
|
+
import { normalize } from '../shared/plan'
|
|
19
20
|
|
|
20
21
|
export interface SchemaColumn {
|
|
21
22
|
name: string
|
|
@@ -180,9 +181,5 @@ export function cellProps(value: unknown, column: SchemaColumn): CellProps {
|
|
|
180
181
|
* `snake_case` schema invisible, which is the entire feature silently absent.
|
|
181
182
|
*/
|
|
182
183
|
export function sameTable(a: string, b: string): boolean {
|
|
183
|
-
return a === b ||
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function flatten(name: string): string {
|
|
187
|
-
return name.toLowerCase().replace(/[\s_-]+/g, '')
|
|
184
|
+
return a === b || normalize(a) === normalize(b)
|
|
188
185
|
}
|
package/src/client/save.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ColumnKind } from '../shared/coerce'
|
|
11
|
-
import { type ApiError, patchRow } from './api'
|
|
11
|
+
import { type ApiError, messageOf, patchRow } from './api'
|
|
12
12
|
import { box, button, el } from './dom'
|
|
13
13
|
import type { EditSession, RowPlan } from './edit-session'
|
|
14
14
|
import type { SchemaTable } from './meta'
|
|
@@ -38,10 +38,6 @@ function kindLookup(
|
|
|
38
38
|
return column => kinds.get(column)
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function messageOf(error: unknown): string {
|
|
42
|
-
return (error as Error)?.message ?? String(error)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
41
|
/**
|
|
46
42
|
* One row, one PATCH, carrying every changed column and its pre-image.
|
|
47
43
|
*
|
package/src/client/structure.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* is said to be is asserted rather than eyeballed.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { box, el } from './dom'
|
|
17
|
+
import { append, box, each, el, gridTable } from './dom'
|
|
18
18
|
import type { SchemaColumn, SchemaIndex, SchemaTable } from './meta'
|
|
19
19
|
|
|
20
20
|
/** One row of the columns table, already worded. */
|
|
@@ -109,6 +109,29 @@ const HEADINGS = [
|
|
|
109
109
|
'identity',
|
|
110
110
|
] as const
|
|
111
111
|
|
|
112
|
+
const INDEX_HEADINGS = ['index', 'type', 'columns'] as const
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A scrolling table of plain text cells.
|
|
116
|
+
*
|
|
117
|
+
* Columns and indexes are the same table twice — headings, then a `<tr>` of
|
|
118
|
+
* `<td class="cell">` per row — so they are one builder and two `string[][]`.
|
|
119
|
+
*/
|
|
120
|
+
function dataTable(
|
|
121
|
+
headings: readonly string[],
|
|
122
|
+
rows: readonly (readonly string[])[],
|
|
123
|
+
): HTMLElement {
|
|
124
|
+
const grid = gridTable(headings, 'grid structure-grid')
|
|
125
|
+
each(grid, rows, cells => dataRow(cells))
|
|
126
|
+
return box('scroll', grid)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function dataRow(cells: readonly string[]): HTMLElement {
|
|
130
|
+
const tr = el('tr')
|
|
131
|
+
each(tr, cells, text => el('td', { class: 'cell', text }))
|
|
132
|
+
return tr
|
|
133
|
+
}
|
|
134
|
+
|
|
112
135
|
export interface StructureContext {
|
|
113
136
|
table: SchemaTable
|
|
114
137
|
editable: boolean
|
|
@@ -118,12 +141,11 @@ export interface StructureContext {
|
|
|
118
141
|
|
|
119
142
|
export function renderStructure(ctx: StructureContext): HTMLElement {
|
|
120
143
|
const node = box('structure')
|
|
121
|
-
|
|
144
|
+
append(node, [
|
|
122
145
|
identitySection(ctx),
|
|
123
146
|
columnsSection(ctx.table),
|
|
124
147
|
indexesSection(ctx.table),
|
|
125
|
-
]
|
|
126
|
-
for (const part of parts) if (part) node.appendChild(part)
|
|
148
|
+
])
|
|
127
149
|
return node
|
|
128
150
|
}
|
|
129
151
|
|
|
@@ -166,20 +188,15 @@ function identitySection(ctx: StructureContext): HTMLElement {
|
|
|
166
188
|
function columnsSection(table: SchemaTable): HTMLElement {
|
|
167
189
|
const section = box('structure-section')
|
|
168
190
|
section.appendChild(el('h3', { text: `Columns (${table.columns.length})` }))
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
for (const heading of HEADINGS) head.appendChild(el('th', { text: heading }))
|
|
173
|
-
grid.appendChild(head)
|
|
174
|
-
for (const row of structureRows(table)) grid.appendChild(columnRow(row))
|
|
175
|
-
|
|
176
|
-
section.appendChild(box('scroll', grid))
|
|
191
|
+
section.appendChild(
|
|
192
|
+
dataTable(HEADINGS, structureRows(table).map(columnCells)),
|
|
193
|
+
)
|
|
177
194
|
return section
|
|
178
195
|
}
|
|
179
196
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
197
|
+
/** A `StructureRow` in `HEADINGS` order. The only place the two are paired. */
|
|
198
|
+
function columnCells(row: StructureRow): string[] {
|
|
199
|
+
return [
|
|
183
200
|
row.name,
|
|
184
201
|
row.type,
|
|
185
202
|
row.nullable,
|
|
@@ -188,8 +205,6 @@ function columnRow(row: StructureRow): HTMLElement {
|
|
|
188
205
|
row.values,
|
|
189
206
|
row.identity ? '✓' : '',
|
|
190
207
|
]
|
|
191
|
-
for (const text of cells) tr.appendChild(el('td', { class: 'cell', text }))
|
|
192
|
-
return tr
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
/**
|
|
@@ -210,22 +225,10 @@ function indexesSection(table: SchemaTable): HTMLElement {
|
|
|
210
225
|
return section
|
|
211
226
|
}
|
|
212
227
|
|
|
213
|
-
|
|
214
|
-
const head = el('tr')
|
|
215
|
-
for (const heading of ['index', 'type', 'columns']) {
|
|
216
|
-
head.appendChild(el('th', { text: heading }))
|
|
217
|
-
}
|
|
218
|
-
grid.appendChild(head)
|
|
219
|
-
for (const index of indexes) grid.appendChild(indexRow(index))
|
|
220
|
-
|
|
221
|
-
section.appendChild(box('scroll', grid))
|
|
228
|
+
section.appendChild(dataTable(INDEX_HEADINGS, indexes.map(indexCells)))
|
|
222
229
|
return section
|
|
223
230
|
}
|
|
224
231
|
|
|
225
|
-
function
|
|
226
|
-
|
|
227
|
-
for (const text of [index.name, index.type, index.cols.join(', ')]) {
|
|
228
|
-
tr.appendChild(el('td', { class: 'cell', text }))
|
|
229
|
-
}
|
|
230
|
-
return tr
|
|
232
|
+
function indexCells(index: SchemaIndex): string[] {
|
|
233
|
+
return [index.name, index.type, index.cols.join(', ')]
|
|
231
234
|
}
|
package/src/client/tabs.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { ViewState } from './state'
|
|
24
|
-
import { decodeView,
|
|
24
|
+
import { decodeView, encodeView } from './state'
|
|
25
25
|
|
|
26
26
|
export interface Tab {
|
|
27
27
|
view: ViewState
|
|
@@ -47,7 +47,7 @@ export function activeView(state: TabsState): ViewState | null {
|
|
|
47
47
|
return activeTab(state)?.view ?? null
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
function indexOfTable(state: TabsState, table: string): number {
|
|
51
51
|
return state.tabs.findIndex(tab => tab.view.table === table)
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -217,8 +217,3 @@ function readIndex(raw: string | null, length: number): number {
|
|
|
217
217
|
const value = Number.parseInt(raw, 10)
|
|
218
218
|
return Number.isFinite(value) && value >= 0 && value < length ? value : -1
|
|
219
219
|
}
|
|
220
|
-
|
|
221
|
-
/** A fresh tab for a table, as the sidebar and the relations view make one. */
|
|
222
|
-
export function tabView(table: string): ViewState {
|
|
223
|
-
return defaultView(table)
|
|
224
|
-
}
|
package/src/client.ts
CHANGED
|
@@ -21,7 +21,13 @@
|
|
|
21
21
|
* editor is for.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
adoptUrlKey,
|
|
26
|
+
fetchGraph,
|
|
27
|
+
fetchPage,
|
|
28
|
+
fetchSchema,
|
|
29
|
+
messageOf,
|
|
30
|
+
} from './client/api'
|
|
25
31
|
import { confirmChoice, notify } from './client/confirm'
|
|
26
32
|
import { el } from './client/dom'
|
|
27
33
|
import { EditSession, UndoStack } from './client/edit-session'
|
|
@@ -347,10 +353,6 @@ async function followFk(
|
|
|
347
353
|
|
|
348
354
|
// ------------------------------------------------------------------ plumbing
|
|
349
355
|
|
|
350
|
-
function messageOf(error: unknown): string {
|
|
351
|
-
return (error as Error)?.message ?? String(error)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
356
|
/**
|
|
355
357
|
* The unload guard.
|
|
356
358
|
*
|
package/src/endpoints/graph.ts
CHANGED
|
Binary file
|
package/src/endpoints/import.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CSV import — the rows, not the file.
|
|
3
3
|
*
|
|
4
|
-
* The parse happens in `shared/csv.ts`,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The parse happens in the browser, in `shared/csv.ts`, and stays there. This
|
|
5
|
+
* endpoint takes rows that are already records, so the mapping the user
|
|
6
|
+
* confirmed in the dialog is what arrives, rather than a file the server
|
|
7
|
+
* re-guesses the columns of. No CSV text reaches the server at all.
|
|
8
8
|
*
|
|
9
9
|
* Two things separate it from `POST /api/_db/rows`, and both are about scale:
|
|
10
10
|
* the bound is a spreadsheet's worth of rows rather than an edit's, and a bad
|
package/src/shared/csv.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CSV, RFC 4180-ish, with the delimiter sniffed rather than assumed.
|
|
3
3
|
*
|
|
4
|
-
* **Pure** — see the note at the top of `coerce.ts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **Pure** — see the note at the top of `coerce.ts` — and, unlike the rest of
|
|
5
|
+
* `shared/`, **browser-only**. It was written when the server re-parsed the
|
|
6
|
+
* text it was sent; `endpoints/import.ts` takes records that are already mapped
|
|
7
|
+
* and coerced, so no CSV text ever reaches it. `client/csv-model.ts` is the
|
|
8
|
+
* only caller. The file stays here because `client/safety.test.ts` holds
|
|
9
|
+
* `shared/` to the same no-framework-imports rule as `client/`, and because a
|
|
10
|
+
* server-side importer would want exactly this parser back.
|
|
7
11
|
*
|
|
8
12
|
* ## Why not `parseCSVRows` from `orm/adapters/base.ts:1071`
|
|
9
13
|
*
|
|
@@ -31,24 +35,10 @@
|
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
37
|
/** The delimiters `sniffDelimiter` will consider, in preference order. */
|
|
34
|
-
|
|
38
|
+
const CANDIDATE_DELIMITERS = [',', ';', '\t', '|'] as const
|
|
35
39
|
|
|
36
40
|
export type Delimiter = (typeof CANDIDATE_DELIMITERS)[number]
|
|
37
41
|
|
|
38
|
-
export interface CSVTable {
|
|
39
|
-
headers: string[]
|
|
40
|
-
rows: string[][]
|
|
41
|
-
delimiter: string
|
|
42
|
-
/**
|
|
43
|
-
* Rows whose field count differs from the header count, as
|
|
44
|
-
* `{row, fields}` — 1-based over the data rows, so it lines up with what a
|
|
45
|
-
* spreadsheet shows. Ragged rows are still returned in `rows`, padded with
|
|
46
|
-
* `''` or truncated; refusing the whole file for one short line is not the
|
|
47
|
-
* importer's call to make.
|
|
48
|
-
*/
|
|
49
|
-
ragged: { row: number; fields: number }[]
|
|
50
|
-
}
|
|
51
|
-
|
|
52
42
|
/** Strip a UTF-8 BOM. Excel writes one; nothing downstream expects it. */
|
|
53
43
|
export function stripBOM(text: string): string {
|
|
54
44
|
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
@@ -194,42 +184,3 @@ export function parseCSVRows(text: string, delimiter = ','): string[][] {
|
|
|
194
184
|
|
|
195
185
|
return rows
|
|
196
186
|
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* A whole file: headers, data rows squared off against them, and what the
|
|
200
|
-
* delimiter turned out to be.
|
|
201
|
-
*/
|
|
202
|
-
export function parseCSV(
|
|
203
|
-
text: string,
|
|
204
|
-
options: { delimiter?: string } = {},
|
|
205
|
-
): CSVTable {
|
|
206
|
-
const delimiter = options.delimiter ?? sniffDelimiter(text)
|
|
207
|
-
const all = parseCSVRows(text, delimiter)
|
|
208
|
-
if (!all.length) return { headers: [], rows: [], delimiter, ragged: [] }
|
|
209
|
-
|
|
210
|
-
// Headers are the one place trimming is right: a header is a name, and
|
|
211
|
-
// `" id "` naming the column `id` is what every spreadsheet means by it.
|
|
212
|
-
const headers = all[0]!.map(h => h.trim())
|
|
213
|
-
const ragged: { row: number; fields: number }[] = []
|
|
214
|
-
const rows = all.slice(1).map((fields, index) => {
|
|
215
|
-
if (fields.length !== headers.length) {
|
|
216
|
-
ragged.push({ row: index + 1, fields: fields.length })
|
|
217
|
-
}
|
|
218
|
-
const squared = fields.slice(0, headers.length)
|
|
219
|
-
while (squared.length < headers.length) squared.push('')
|
|
220
|
-
return squared
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
return { headers, rows, delimiter, ragged }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** A parsed table as records, keyed by header. */
|
|
227
|
-
export function csvRecords(table: CSVTable): Record<string, string>[] {
|
|
228
|
-
return table.rows.map(fields => {
|
|
229
|
-
const record: Record<string, string> = {}
|
|
230
|
-
for (let i = 0; i < table.headers.length; i++) {
|
|
231
|
-
record[table.headers[i]!] = fields[i] ?? ''
|
|
232
|
-
}
|
|
233
|
-
return record
|
|
234
|
-
})
|
|
235
|
-
}
|
package/src/shared/plan.ts
CHANGED
|
@@ -54,7 +54,16 @@ export function autoMap(
|
|
|
54
54
|
return mapping
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* A name with case and separators removed.
|
|
59
|
+
*
|
|
60
|
+
* Exported because `client/meta.ts` compares *table* names the same way and
|
|
61
|
+
* held a byte-identical private copy called `flatten`. Both are in the browser
|
|
62
|
+
* bundle, so it was one rule written twice: a change to what counts as "the
|
|
63
|
+
* same name" would have fixed CSV auto-mapping and left foreign-key visibility
|
|
64
|
+
* on the old one.
|
|
65
|
+
*/
|
|
66
|
+
export function normalize(name: string): string {
|
|
58
67
|
return name.toLowerCase().replace(/[\s_-]+/g, '')
|
|
59
68
|
}
|
|
60
69
|
|