@bakery-framework/plugin-db-explorer 2.0.0-alpha.4 → 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.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +261 -0
  4. package/src/client/bulk.ts +361 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +201 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +91 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +144 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +242 -0
  18. package/src/client/grid-body.ts +103 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +466 -0
  22. package/src/client/meta.ts +188 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +209 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +231 -0
  31. package/src/client/tabs.ts +224 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +374 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +0 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +42 -42
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +63 -80
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +235 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +164 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/authorize.ts +0 -82
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,420 @@
1
+ /**
2
+ * The import wizard's state, with no DOM anywhere in it.
3
+ *
4
+ * The wizard has five steps and the interesting behaviour is all in the
5
+ * transitions between them — a re-pick *moves* a database column rather than
6
+ * duplicating it, an unmapped NOT NULL column blocks, `empty → NULL` defaults
7
+ * differently per kind, and the preview recomputes from the mapping every
8
+ * time. None of that is testable through a rendered `<select>`, so none of it
9
+ * lives there. `csv.ts` is the view; this is the machine.
10
+ *
11
+ * Parsing, coercion and mapping are **not reimplemented** — `shared/csv.ts`,
12
+ * `shared/coerce.ts` and `shared/plan.ts` are the same modules the server runs,
13
+ * which is the only way the preview and the outcome agree.
14
+ */
15
+
16
+ import { coerceValue } from '../shared/coerce'
17
+ import { parseCSVRows, sniffDelimiter } from '../shared/csv'
18
+ import {
19
+ autoMap,
20
+ blockingIssues,
21
+ type ColumnMapping,
22
+ type PlanIssue,
23
+ } from '../shared/plan'
24
+ import { columnMeta, type SchemaColumn } from './meta'
25
+
26
+ /** What one CSV column feeds. */
27
+ export type Assignment =
28
+ | { kind: 'skip' }
29
+ | { kind: 'column'; column: string }
30
+ /** Ignore this CSV column; write a literal into `column` on every row. */
31
+ | { kind: 'constant'; column: string | null; text: string }
32
+
33
+ export type BadRowPolicy = 'skip' | 'stop' | 'all'
34
+
35
+ export interface ImportModel {
36
+ headers: string[]
37
+ rows: string[][]
38
+ delimiter: string
39
+ hasHeader: boolean
40
+ /** By CSV header. */
41
+ assign: Record<string, Assignment>
42
+ /** By database column. Defaults on for every kind except text. */
43
+ emptyToNull: Record<string, boolean>
44
+ onBadRow: BadRowPolicy
45
+ /** Rows whose field count differed from the header count. */
46
+ ragged: { row: number; fields: number }[]
47
+ }
48
+
49
+ const SKIP: Assignment = { kind: 'skip' }
50
+
51
+ /** The database column an assignment claims, or `null`. */
52
+ export function targetOf(assignment: Assignment): string | null {
53
+ if (assignment.kind === 'column') return assignment.column
54
+ if (assignment.kind === 'constant') return assignment.column
55
+ return null
56
+ }
57
+
58
+ /**
59
+ * Does row 0 name the columns?
60
+ *
61
+ * Non-empty, unique, and not a number — the three properties a header row has
62
+ * and a data row almost never does. Deliberately conservative in the direction
63
+ * of *yes*: guessing "header" for a data file loses one row in a preview the
64
+ * user is looking at, while guessing "data" for a header file maps nothing and
65
+ * looks like the file is broken.
66
+ */
67
+ export function looksLikeHeader(rows: readonly string[][]): boolean {
68
+ const first = rows[0]
69
+ if (!first?.length) return false
70
+ const seen = new Set<string>()
71
+ for (const field of first) {
72
+ const value = field.trim()
73
+ if (!value) return false
74
+ if (seen.has(value)) return false
75
+ seen.add(value)
76
+ if (Number.isFinite(Number(value))) return false
77
+ }
78
+ return true
79
+ }
80
+
81
+ function synthHeaders(width: number): string[] {
82
+ return Array.from({ length: width }, (_v, i) => `Column ${i + 1}`)
83
+ }
84
+
85
+ function square(fields: string[], width: number): string[] {
86
+ const out = fields.slice(0, width)
87
+ while (out.length < width) out.push('')
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Empty means NULL by default for everything except text.
93
+ *
94
+ * `''` in a numeric or date column is never a value — `coerceValue` refuses it
95
+ * outright with `empty_string` — so defaulting the toggle off there guarantees
96
+ * a failure the user then has to diagnose. In a text column `''` *is* a value,
97
+ * and silently turning every blank cell into NULL would be the dashboard's bug
98
+ * with a checkbox on it.
99
+ */
100
+ function defaultEmptyToNull(
101
+ columns: readonly SchemaColumn[],
102
+ ): Record<string, boolean> {
103
+ const out: Record<string, boolean> = {}
104
+ for (const column of columns) out[column.name] = column.kind !== 'string'
105
+ return out
106
+ }
107
+
108
+ export interface BuildOptions {
109
+ delimiter?: string
110
+ hasHeader?: boolean
111
+ }
112
+
113
+ /** Parse a file into a model with the mapping already guessed. */
114
+ export function buildModel(
115
+ text: string,
116
+ columns: readonly SchemaColumn[],
117
+ options: BuildOptions = {},
118
+ ): ImportModel {
119
+ const delimiter = options.delimiter ?? sniffDelimiter(text)
120
+ const all = parseCSVRows(text, delimiter)
121
+ const hasHeader = options.hasHeader ?? looksLikeHeader(all)
122
+
123
+ const width = all.reduce((max, row) => Math.max(max, row.length), 0)
124
+ const headers = hasHeader
125
+ ? square(all[0] ?? [], width).map(h => h.trim())
126
+ : synthHeaders(width)
127
+ const body = hasHeader ? all.slice(1) : all
128
+
129
+ const ragged: { row: number; fields: number }[] = []
130
+ const rows = body.map((fields, index) => {
131
+ if (fields.length !== headers.length) {
132
+ ragged.push({ row: index + 1, fields: fields.length })
133
+ }
134
+ return square(fields, headers.length)
135
+ })
136
+
137
+ const guessed = autoMap(
138
+ headers,
139
+ columns.map(c => c.name),
140
+ )
141
+ const assign: Record<string, Assignment> = {}
142
+ for (const header of headers) {
143
+ const match = guessed[header]
144
+ assign[header] = match ? { kind: 'column', column: match } : SKIP
145
+ }
146
+
147
+ return {
148
+ headers,
149
+ rows,
150
+ delimiter,
151
+ hasHeader,
152
+ assign,
153
+ emptyToNull: defaultEmptyToNull(columns),
154
+ onBadRow: 'skip',
155
+ ragged,
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Re-assign one CSV column, **moving** the target rather than sharing it.
161
+ *
162
+ * Any other header holding the same database column falls back to skip. That
163
+ * makes a duplicate mapping impossible to express, which is a stronger promise
164
+ * than `blockingIssues`' `duplicate_target` — that check stays as the
165
+ * server-side guarantee, because the server accepts a mapping this UI did not
166
+ * build.
167
+ */
168
+ export function reassign(
169
+ model: ImportModel,
170
+ header: string,
171
+ next: Assignment,
172
+ ): ImportModel {
173
+ const target = targetOf(next)
174
+ const assign: Record<string, Assignment> = {}
175
+ for (const [key, current] of Object.entries(model.assign)) {
176
+ if (key === header) assign[key] = next
177
+ else if (target && targetOf(current) === target) assign[key] = SKIP
178
+ else assign[key] = current
179
+ }
180
+ return { ...model, assign }
181
+ }
182
+
183
+ export function setEmptyToNull(
184
+ model: ImportModel,
185
+ column: string,
186
+ value: boolean,
187
+ ): ImportModel {
188
+ return { ...model, emptyToNull: { ...model.emptyToNull, [column]: value } }
189
+ }
190
+
191
+ export function setBadRowPolicy(
192
+ model: ImportModel,
193
+ onBadRow: BadRowPolicy,
194
+ ): ImportModel {
195
+ return { ...model, onBadRow }
196
+ }
197
+
198
+ /** The mapping in `shared/plan.ts`'s vocabulary, constants included. */
199
+ export function mappingOf(model: ImportModel): ColumnMapping {
200
+ const mapping: ColumnMapping = {}
201
+ for (const [header, assignment] of Object.entries(model.assign)) {
202
+ mapping[header] = targetOf(assignment)
203
+ }
204
+ return mapping
205
+ }
206
+
207
+ export interface ModelIssues {
208
+ /** What stops the import. `blockingIssues` decides, not this module. */
209
+ blocking: PlanIssue[]
210
+ /** CSV columns going nowhere. Fine, but counted and named. */
211
+ unmatched: string[]
212
+ /** Database columns nobody feeds, with why that is or is not a problem. */
213
+ unmapped: { column: string; status: 'required' | 'default' | 'nullable' }[]
214
+ }
215
+
216
+ export function issuesOf(
217
+ model: ImportModel,
218
+ columns: readonly SchemaColumn[],
219
+ ): ModelIssues {
220
+ const mapping = mappingOf(model)
221
+ const blocking = blockingIssues(
222
+ mapping,
223
+ columns.map(c => ({ name: c.name, meta: columnMeta(c) })),
224
+ )
225
+ const claimed = new Set(Object.values(mapping).filter(Boolean) as string[])
226
+ const unmatched = model.headers.filter(header => !mapping[header])
227
+ const unmapped = columns
228
+ .filter(column => !claimed.has(column.name))
229
+ .map(column => ({ column: column.name, status: statusOf(column) }))
230
+ return { blocking, unmatched, unmapped }
231
+ }
232
+
233
+ function statusOf(column: SchemaColumn): 'required' | 'default' | 'nullable' {
234
+ if (column.autoIncrement || column.hasDefault) return 'default'
235
+ if (column.nullable) return 'nullable'
236
+ return 'required'
237
+ }
238
+
239
+ export interface CellPreview {
240
+ column: string
241
+ /** Exactly what the file said, or the constant. */
242
+ raw: string
243
+ ok: boolean
244
+ value?: unknown
245
+ message?: string
246
+ }
247
+
248
+ export interface RowPreview {
249
+ /** 1-based over the data rows, so it matches what a spreadsheet shows. */
250
+ index: number
251
+ cells: CellPreview[]
252
+ ok: boolean
253
+ }
254
+
255
+ /**
256
+ * One CSV field, through the same coercer the server will run.
257
+ *
258
+ * The `empty → NULL` toggle happens *before* coercion, which is the only
259
+ * ordering that works: `coerceValue` refuses `''` on every non-text kind, so a
260
+ * toggle applied afterwards would never be reached.
261
+ */
262
+ function coerceField(
263
+ raw: string,
264
+ column: SchemaColumn,
265
+ emptyToNull: boolean,
266
+ ): CellPreview {
267
+ const input = raw === '' && emptyToNull ? null : raw
268
+ const result = coerceValue(input, columnMeta(column))
269
+ if (result.ok)
270
+ return { column: column.name, raw, ok: true, value: result.value }
271
+ return { column: column.name, raw, ok: false, message: result.message }
272
+ }
273
+
274
+ interface Feed {
275
+ column: SchemaColumn
276
+ /** Where the value comes from: a CSV field index, or a literal. */
277
+ index: number | null
278
+ constant: string
279
+ }
280
+
281
+ /** The assignments resolved to actual sources, once per pass rather than per row. */
282
+ function feedsOf(model: ImportModel, columns: readonly SchemaColumn[]): Feed[] {
283
+ const byName = new Map(columns.map(column => [column.name, column]))
284
+ const feeds: Feed[] = []
285
+ model.headers.forEach((header, index) => {
286
+ const assignment = model.assign[header] ?? SKIP
287
+ const target = targetOf(assignment)
288
+ const column = target ? byName.get(target) : undefined
289
+ if (!column) return
290
+ if (assignment.kind === 'constant') {
291
+ feeds.push({ column, index: null, constant: assignment.text })
292
+ } else {
293
+ feeds.push({ column, index, constant: '' })
294
+ }
295
+ })
296
+ return feeds
297
+ }
298
+
299
+ function previewRow(
300
+ model: ImportModel,
301
+ feeds: readonly Feed[],
302
+ fields: readonly string[],
303
+ index: number,
304
+ ): RowPreview {
305
+ const cells = feeds.map(feed => {
306
+ const raw = feed.index === null ? feed.constant : (fields[feed.index] ?? '')
307
+ return coerceField(
308
+ raw,
309
+ feed.column,
310
+ model.emptyToNull[feed.column.name] ?? false,
311
+ )
312
+ })
313
+ return { index: index + 1, cells, ok: cells.every(cell => cell.ok) }
314
+ }
315
+
316
+ /** The first `limit` rows, coerced. Recomputed on every mapping change. */
317
+ export function previewRows(
318
+ model: ImportModel,
319
+ columns: readonly SchemaColumn[],
320
+ limit = 10,
321
+ ): RowPreview[] {
322
+ const feeds = feedsOf(model, columns)
323
+ return model.rows
324
+ .slice(0, limit)
325
+ .map((fields, index) => previewRow(model, feeds, fields, index))
326
+ }
327
+
328
+ export interface RowFailure {
329
+ /** 1-based over the data rows. */
330
+ index: number
331
+ fields: string[]
332
+ message: string
333
+ }
334
+
335
+ export interface BuildResult {
336
+ records: Record<string, unknown>[]
337
+ failures: RowFailure[]
338
+ }
339
+
340
+ /**
341
+ * Every row, as records to send and rows that could not be built.
342
+ *
343
+ * One pass over the file. The wizard's live bad-row count is
344
+ * `failures.length` from this same call rather than a second walk, because two
345
+ * walks over fifty thousand rows is the difference between a responsive dialog
346
+ * and a frozen tab.
347
+ */
348
+ export function buildRecords(
349
+ model: ImportModel,
350
+ columns: readonly SchemaColumn[],
351
+ ): BuildResult {
352
+ const feeds = feedsOf(model, columns)
353
+ const records: Record<string, unknown>[] = []
354
+ const failures: RowFailure[] = []
355
+
356
+ model.rows.forEach((fields, index) => {
357
+ const preview = previewRow(model, feeds, fields, index)
358
+ if (!preview.ok) {
359
+ const bad = preview.cells.find(cell => !cell.ok)!
360
+ failures.push({
361
+ index: index + 1,
362
+ fields: [...fields],
363
+ message: `${bad.column}: ${bad.message}`,
364
+ })
365
+ return
366
+ }
367
+ const record: Record<string, unknown> = {}
368
+ for (const cell of preview.cells) record[cell.column] = cell.value
369
+ records.push(record)
370
+ })
371
+
372
+ return { records, failures }
373
+ }
374
+
375
+ /** RFC 4180 quoting: only when the field needs it, and `"` doubles. */
376
+ function quote(field: string, delimiter: string): string {
377
+ const needs =
378
+ field.includes(delimiter) ||
379
+ field.includes('"') ||
380
+ field.includes('\n') ||
381
+ field.includes('\r')
382
+ return needs ? `"${field.replace(/"/g, '""')}"` : field
383
+ }
384
+
385
+ export function writeCSV(
386
+ headers: readonly string[],
387
+ rows: readonly (readonly string[])[],
388
+ delimiter = ',',
389
+ ): string {
390
+ const line = (fields: readonly string[]) =>
391
+ fields.map(field => quote(field, delimiter)).join(delimiter)
392
+ return [line(headers), ...rows.map(line)].join('\r\n')
393
+ }
394
+
395
+ /**
396
+ * The rows that did not import, as a file the user can fix and re-drop.
397
+ *
398
+ * The original fields, unmodified, plus a trailing `_error` column. Unmodified
399
+ * matters: a rejected-rows export that had been through the coercer would have
400
+ * lost the very text that explains why it was rejected.
401
+ */
402
+ export function rejectedCSV(
403
+ model: ImportModel,
404
+ failures: readonly RowFailure[],
405
+ ): string {
406
+ return writeCSV(
407
+ [...model.headers, '_error'],
408
+ failures.map(failure => [...failure.fields, failure.message]),
409
+ model.delimiter,
410
+ )
411
+ }
412
+
413
+ /** Send in slices so a fifty-thousand-row file is not one request. */
414
+ export function chunk<T>(items: readonly T[], size: number): T[][] {
415
+ const out: T[][] = []
416
+ for (let i = 0; i < items.length; i += size) {
417
+ out.push(items.slice(i, i + size) as T[])
418
+ }
419
+ return out
420
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Step one of the import wizard: get the text.
3
+ *
4
+ * A file input and a drop zone, and both end in the same `onText` callback —
5
+ * which is the whole reason this is a module rather than two handlers. Nothing
6
+ * downstream knows or cares which way the file arrived.
7
+ */
8
+
9
+ import { append, box, button, el, on } from './dom'
10
+
11
+ export function renderPick(
12
+ stage: HTMLElement,
13
+ onText: (text: string) => void,
14
+ onCancel: () => void,
15
+ ): void {
16
+ stage.replaceChildren()
17
+ const zone = box('drop')
18
+ zone.appendChild(el('p', { text: 'Drop a CSV here, or choose a file.' }))
19
+ zone.appendChild(fileInput(onText))
20
+ wireDrop(zone, onText)
21
+
22
+ append(stage, [
23
+ zone,
24
+ box('row-bar', button('Cancel', onCancel, { class: 'btn' })),
25
+ ])
26
+ }
27
+
28
+ function fileInput(onText: (text: string) => void): HTMLInputElement {
29
+ const input = el('input')
30
+ input.type = 'file'
31
+ input.accept = '.csv,.tsv,.txt,text/csv'
32
+ on(input, 'change', () => {
33
+ const file = input.files?.[0]
34
+ if (file) void file.text().then(onText)
35
+ })
36
+ return input
37
+ }
38
+
39
+ /** Named so the three listeners are not an inline block inside `renderPick`. */
40
+ function wireDrop(zone: HTMLElement, onText: (text: string) => void): void {
41
+ on(zone, 'dragover', event => {
42
+ // Without `preventDefault` the browser navigates to the file instead,
43
+ // which loses the dialog and everything in it.
44
+ event.preventDefault()
45
+ zone.classList.add('over')
46
+ })
47
+ on(zone, 'dragleave', () => zone.classList.remove('over'))
48
+ on(zone, 'drop', event => {
49
+ event.preventDefault()
50
+ zone.classList.remove('over')
51
+ const file = (event as DragEvent).dataTransfer?.files?.[0]
52
+ if (file) void file.text().then(onText)
53
+ })
54
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Step four: the first ten rows exactly as they will be written.
3
+ *
4
+ * The preview recomputes on **every** mapping change, which is the point: a
5
+ * user changing one `<select>` sees immediately that a column now coerces, or
6
+ * now does not. Ten rows keeps that instant on a fifty-thousand-row file.
7
+ *
8
+ * A coerced value that differs from the text carries the text as its `title`,
9
+ * so `"007"` landing in an integer column is visibly `7` and traceably `007`.
10
+ * A failure is ringed and shows the reason `coerceValue` gave — the same
11
+ * sentence the server would have returned.
12
+ */
13
+
14
+ import {
15
+ type ImportModel,
16
+ previewRows,
17
+ type RowPreview,
18
+ setEmptyToNull,
19
+ } from './csv-model'
20
+ import { box, each, el, on } from './dom'
21
+ import type { SchemaColumn } from './meta'
22
+
23
+ export function paintPreview(
24
+ node: HTMLElement,
25
+ columns: SchemaColumn[],
26
+ model: ImportModel,
27
+ update: (next: ImportModel) => void,
28
+ ): void {
29
+ node.replaceChildren()
30
+ const rows = previewRows(model, columns, 10)
31
+ node.appendChild(el('h4', { text: 'Preview' }))
32
+ if (!rows.length) {
33
+ node.appendChild(el('p', { class: 'note', text: 'nothing to import yet' }))
34
+ return
35
+ }
36
+
37
+ const table = el('table', { class: 'grid' })
38
+ const head = el('tr')
39
+ const names = rows[0]!.cells.map(cell => cell.column)
40
+ each(head, names, column => el('th', { text: column }))
41
+ table.append(head, nullToggleRow(model, names, update))
42
+ each(table, rows, row => previewRow(row))
43
+ node.appendChild(box('scroll', table))
44
+ }
45
+
46
+ /**
47
+ * A per-column `empty → NULL` switch, sitting under the header where the
48
+ * column it governs is.
49
+ *
50
+ * On by default for every kind except text, because `''` is a value in a text
51
+ * column and an error in every other one — see `defaultEmptyToNull`.
52
+ */
53
+ function nullToggleRow(
54
+ model: ImportModel,
55
+ columns: readonly string[],
56
+ update: (next: ImportModel) => void,
57
+ ): HTMLElement {
58
+ const tr = el('tr')
59
+ each(tr, columns, column => {
60
+ const td = el('td')
61
+ const check = el('input', {
62
+ attrs: { 'aria-label': `${column}: empty becomes NULL` },
63
+ })
64
+ check.type = 'checkbox'
65
+ check.checked = model.emptyToNull[column] ?? false
66
+ on(check, 'change', () =>
67
+ update(setEmptyToNull(model, column, check.checked)),
68
+ )
69
+ const label = el('label', {
70
+ class: 'note',
71
+ text: ' ∅',
72
+ title: 'empty → NULL',
73
+ })
74
+ label.prepend(check)
75
+ td.appendChild(label)
76
+ return td
77
+ })
78
+ return tr
79
+ }
80
+
81
+ function previewRow(row: RowPreview): HTMLElement {
82
+ const tr = el('tr')
83
+ each(tr, row.cells, cell => {
84
+ const text = cell.ok ? String(cell.value ?? 'NULL') : cell.raw
85
+ const td = el('td', { class: cell.ok ? 'cell' : 'cell bad', text })
86
+ if (!cell.ok) td.title = cell.message ?? ''
87
+ else if (text !== cell.raw) td.title = cell.raw
88
+ return td
89
+ })
90
+ return tr
91
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The CSV import wizard: pick → sniff → map → preview → commit.
3
+ *
4
+ * This file is the composition and nothing else. Each step is its own module —
5
+ * `csv-pick.ts`, `csv-map.ts`, `csv-preview.ts`, `csv-commit.ts` — and
6
+ * everything that *decides* anything is in `csv-model.ts` and is pure. What is
7
+ * left here is one mutable `model` reference and the repaint that follows it.
8
+ *
9
+ * Nothing inspects a `<select>` to work out what the mapping is; the mapping is
10
+ * the model, and every control replaces it wholesale.
11
+ */
12
+
13
+ import { paintFooter } from './csv-commit'
14
+ import {
15
+ paintHead,
16
+ paintMapping,
17
+ paintUnmapped,
18
+ type Reparse,
19
+ reparse,
20
+ } from './csv-map'
21
+ import { buildModel, type ImportModel } from './csv-model'
22
+ import { renderPick } from './csv-pick'
23
+ import { paintPreview } from './csv-preview'
24
+ import { append, box, el } from './dom'
25
+ import type { SchemaColumn, SchemaTable } from './meta'
26
+
27
+ export interface ImportContext {
28
+ table: SchemaTable
29
+ columns: SchemaColumn[]
30
+ reload: () => Promise<void>
31
+ }
32
+
33
+ export function openImport(ctx: ImportContext): void {
34
+ const dialog = el('dialog', { class: 'danger wide import' })
35
+ dialog.appendChild(el('h3', { text: `Import CSV into ${ctx.table.name}` }))
36
+
37
+ const stage = box('import-stage')
38
+ dialog.appendChild(stage)
39
+ dialog.addEventListener('close', () => dialog.remove())
40
+ document.body.appendChild(dialog)
41
+ dialog.showModal()
42
+
43
+ const finish = () => dialog.close()
44
+ const onText = (text: string) =>
45
+ renderMapping(stage, ctx, buildModel(text, ctx.columns), finish)
46
+ renderPick(stage, onText, finish)
47
+ }
48
+
49
+ interface Sections {
50
+ head: HTMLElement
51
+ mapping: HTMLElement
52
+ unmapped: HTMLElement
53
+ preview: HTMLElement
54
+ footer: HTMLElement
55
+ }
56
+
57
+ /**
58
+ * Mapping, preview and footer, repainted together.
59
+ *
60
+ * Together rather than selectively, because they are all functions of the one
61
+ * model: the footer's row count, the preview's coercions and the unmapped
62
+ * list's blocking issues all change when a single `<select>` does, and a
63
+ * partial repaint is how two of them end up disagreeing.
64
+ */
65
+ function renderMapping(
66
+ stage: HTMLElement,
67
+ ctx: ImportContext,
68
+ initial: ImportModel,
69
+ onClose: () => void,
70
+ ): void {
71
+ let model = initial
72
+ stage.replaceChildren()
73
+
74
+ const sections: Sections = {
75
+ head: box('import-head'),
76
+ mapping: box('import-map'),
77
+ unmapped: box('import-unmapped'),
78
+ preview: box('import-preview'),
79
+ footer: box('row-bar'),
80
+ }
81
+ append(stage, [
82
+ sections.head,
83
+ sections.mapping,
84
+ sections.unmapped,
85
+ sections.preview,
86
+ sections.footer,
87
+ ])
88
+
89
+ const update = (next: ImportModel) => {
90
+ model = next
91
+ paint()
92
+ }
93
+ const onReparse = (next: Reparse) => update(reparse(next, ctx.columns))
94
+
95
+ const paint = () => {
96
+ paintHead(sections.head, model, onReparse)
97
+ paintMapping(sections.mapping, ctx, model, update)
98
+ paintUnmapped(sections.unmapped, ctx, model)
99
+ paintPreview(sections.preview, ctx.columns, model, update)
100
+ paintFooter(sections.footer, ctx, model, update, onClose, stage)
101
+ }
102
+
103
+ paint()
104
+ }