@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.
- 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 +42 -42
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +63 -80
- 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/authorize.ts +0 -82
- package/src/endpoints.ts +0 -48
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only place this client creates a node.
|
|
3
|
+
*
|
|
4
|
+
* **Nothing here writes `innerHTML`, and `client/safety.test.ts` asserts that
|
|
5
|
+
* no client module does.** Every value the grid renders is a database row —
|
|
6
|
+
* whatever the last writer put there — and the operator's origin owns
|
|
7
|
+
* `/api/_db/rows`, so a string that reached the DOM as markup would be stored
|
|
8
|
+
* XSS to arbitrary row writes. `textContent` is not a style preference; it is
|
|
9
|
+
* the reason this plugin has no XSS surface. The dashboard's grid, which
|
|
10
|
+
* concatenates into `innerHTML` and escapes by hand at each of eight call
|
|
11
|
+
* sites, is the counter-example this one exists not to repeat.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ElOptions {
|
|
15
|
+
class?: string
|
|
16
|
+
text?: string
|
|
17
|
+
title?: string
|
|
18
|
+
id?: string
|
|
19
|
+
/** `aria-*`, `role`, `data-*` — anything set through `setAttribute`. */
|
|
20
|
+
attrs?: Record<string, string | number | boolean | null | undefined>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function el<K extends keyof HTMLElementTagNameMap>(
|
|
24
|
+
tag: K,
|
|
25
|
+
options: ElOptions = {},
|
|
26
|
+
): HTMLElementTagNameMap[K] {
|
|
27
|
+
const node = document.createElement(tag)
|
|
28
|
+
if (options.class) node.className = options.class
|
|
29
|
+
if (options.text !== undefined) node.textContent = options.text
|
|
30
|
+
if (options.title !== undefined) node.title = options.title
|
|
31
|
+
if (options.id) node.id = options.id
|
|
32
|
+
applyAttrs(node, options.attrs)
|
|
33
|
+
return node
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Named so the loop is not an inline body inside `el` (see the module rule). */
|
|
37
|
+
function applyAttrs(node: HTMLElement, attrs: ElOptions['attrs']): void {
|
|
38
|
+
if (!attrs) return
|
|
39
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
40
|
+
if (value === null || value === undefined || value === false) continue
|
|
41
|
+
node.setAttribute(name, String(value))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function button(
|
|
46
|
+
label: string,
|
|
47
|
+
onClick: () => void,
|
|
48
|
+
options: ElOptions = {},
|
|
49
|
+
): HTMLButtonElement {
|
|
50
|
+
const node = el('button', { ...options, text: label })
|
|
51
|
+
node.type = 'button'
|
|
52
|
+
node.addEventListener('click', onClick)
|
|
53
|
+
return node
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `<div class=…>` with children, the shape most of this UI is made of. */
|
|
57
|
+
export function box(cls: string, ...children: (Node | null)[]): HTMLDivElement {
|
|
58
|
+
const node = el('div', { class: cls })
|
|
59
|
+
append(node, children)
|
|
60
|
+
return node
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function append(parent: Node, children: (Node | null)[]): void {
|
|
64
|
+
for (const child of children) {
|
|
65
|
+
if (child) parent.appendChild(child)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render a list through a named factory.
|
|
71
|
+
*
|
|
72
|
+
* Exists so a caller never writes `for (…) { …twenty lines… }` inline: an
|
|
73
|
+
* inline loop body counts against the enclosing function's cognitive
|
|
74
|
+
* complexity, and a named one does not. The rule is mechanical and this is the
|
|
75
|
+
* tool that makes it cheap to follow.
|
|
76
|
+
*/
|
|
77
|
+
export function each<T>(
|
|
78
|
+
parent: Node,
|
|
79
|
+
items: readonly T[],
|
|
80
|
+
make: (item: T, index: number) => Node | null,
|
|
81
|
+
): void {
|
|
82
|
+
items.forEach((item, index) => {
|
|
83
|
+
const node = make(item, index)
|
|
84
|
+
if (node) parent.appendChild(node)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function clear(node: Node): void {
|
|
89
|
+
;(node as Element).replaceChildren()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A pending region: visibly disabled and announced as such. */
|
|
93
|
+
export function setBusy(node: HTMLElement, busy: boolean): void {
|
|
94
|
+
node.setAttribute('aria-busy', busy ? 'true' : 'false')
|
|
95
|
+
node.classList.toggle('busy', busy)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function on<K extends keyof HTMLElementEventMap>(
|
|
99
|
+
node: HTMLElement | Document | Window,
|
|
100
|
+
type: K,
|
|
101
|
+
handler: (event: HTMLElementEventMap[K]) => void,
|
|
102
|
+
): void {
|
|
103
|
+
node.addEventListener(type, handler as EventListener)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A `<select>` over `{value,label}` options, with the current one selected. */
|
|
107
|
+
export function select(
|
|
108
|
+
options: readonly { value: string; label: string }[],
|
|
109
|
+
current: string,
|
|
110
|
+
onChange: (value: string) => void,
|
|
111
|
+
cls = 'sel',
|
|
112
|
+
): HTMLSelectElement {
|
|
113
|
+
const node = el('select', { class: cls })
|
|
114
|
+
each(node, options, option => {
|
|
115
|
+
const item = el('option', { text: option.label })
|
|
116
|
+
item.value = option.value
|
|
117
|
+
item.selected = option.value === current
|
|
118
|
+
return item
|
|
119
|
+
})
|
|
120
|
+
node.addEventListener('change', () => onChange(node.value))
|
|
121
|
+
return node
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Hand the browser a file without a server round trip.
|
|
126
|
+
*
|
|
127
|
+
* An object URL rather than a `data:` one: a rejected-rows export of a 50,000
|
|
128
|
+
* row import is megabytes, and Chrome refuses a `data:` navigation past a size
|
|
129
|
+
* that is not documented anywhere.
|
|
130
|
+
*/
|
|
131
|
+
export function downloadText(
|
|
132
|
+
filename: string,
|
|
133
|
+
text: string,
|
|
134
|
+
type = 'text/csv',
|
|
135
|
+
): void {
|
|
136
|
+
const url = URL.createObjectURL(new Blob([text], { type }))
|
|
137
|
+
const link = el('a')
|
|
138
|
+
link.href = url
|
|
139
|
+
link.download = filename
|
|
140
|
+
link.click()
|
|
141
|
+
// The object URL owns the blob until it is revoked; the click is synchronous
|
|
142
|
+
// but the download is not, so the revoke waits a turn.
|
|
143
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000)
|
|
144
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-row dirty buffer, and the statement it becomes.
|
|
3
|
+
*
|
|
4
|
+
* **Pure — no DOM, no fetch.** This is where the editing model actually lives,
|
|
5
|
+
* so it is the part with tests. The grid is a view over it.
|
|
6
|
+
*
|
|
7
|
+
* Three properties it exists to hold:
|
|
8
|
+
*
|
|
9
|
+
* 1. **A row is one save.** Editing three columns produces one PATCH carrying
|
|
10
|
+
* all three, not three PATCHes. The dashboard's save-on-blur produced the
|
|
11
|
+
* latter, which means a reader can observe the row a third of the way
|
|
12
|
+
* through an edit the user considers atomic.
|
|
13
|
+
* 2. **The pre-image is kept.** `expect` is built from the row as it was
|
|
14
|
+
* *read*, never from a re-read at save time — a re-read would defeat the
|
|
15
|
+
* concurrency check it exists to perform.
|
|
16
|
+
* 3. **Unchanged columns are dropped**, by `updatePlan`, so two people editing
|
|
17
|
+
* different columns of one row do not collide.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type ColumnKind, comparableKind, sameValue } from '../shared/coerce'
|
|
21
|
+
import { updatePlan } from '../shared/plan'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A row's identity, flattened to a string.
|
|
25
|
+
*
|
|
26
|
+
* `JSON.stringify` over the identity columns in their declared order — the
|
|
27
|
+
* order is the server's, from `identity.cols`, so it is stable across pages and
|
|
28
|
+
* across a composite key's spelling. `null` when the row does not carry every
|
|
29
|
+
* identity column, which is the same condition `updatePlan` reports as
|
|
30
|
+
* `missingIdentity` and means the row cannot be addressed at all.
|
|
31
|
+
*/
|
|
32
|
+
export function rowId(
|
|
33
|
+
row: Record<string, unknown>,
|
|
34
|
+
identity: readonly string[],
|
|
35
|
+
): string | null {
|
|
36
|
+
if (!identity.length) return null
|
|
37
|
+
const parts: unknown[] = []
|
|
38
|
+
for (const column of identity) {
|
|
39
|
+
if (!(column in row)) return null
|
|
40
|
+
parts.push(row[column])
|
|
41
|
+
}
|
|
42
|
+
return JSON.stringify(parts)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface RowPlan {
|
|
46
|
+
/** Columns that actually differ, and the values to write. */
|
|
47
|
+
set: Record<string, unknown>
|
|
48
|
+
/** The identity predicate, from the pre-image. */
|
|
49
|
+
where: Record<string, unknown>
|
|
50
|
+
/** The pre-image of every changed column that SQL can compare. */
|
|
51
|
+
expect: Record<string, unknown>
|
|
52
|
+
/**
|
|
53
|
+
* Changed columns SQL cannot compare — `json` and `buffer`. The server
|
|
54
|
+
* refuses these in `expect` and demands `force: true` to write them without
|
|
55
|
+
* a concurrency check, so the UI has to say so rather than retry blindly.
|
|
56
|
+
*/
|
|
57
|
+
unguardable: string[]
|
|
58
|
+
/** Edited keys whose value matched what was already there. */
|
|
59
|
+
unchanged: string[]
|
|
60
|
+
/** Identity columns the pre-image does not carry. Non-empty means unusable. */
|
|
61
|
+
missingIdentity: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type KindLookup = (column: string) => ColumnKind | undefined
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Staged edits for the rows currently on screen.
|
|
68
|
+
*
|
|
69
|
+
* Bounded by construction: an entry exists only for a row the user has typed
|
|
70
|
+
* into, they are dropped on save and on revert, and changing table or page
|
|
71
|
+
* clears the whole session — there is no key here a page of data does not
|
|
72
|
+
* already hold (convention 6).
|
|
73
|
+
*/
|
|
74
|
+
export class EditSession {
|
|
75
|
+
private readonly edits = new Map<string, Map<string, unknown>>()
|
|
76
|
+
private readonly originals = new Map<string, Record<string, unknown>>()
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record an edited value.
|
|
80
|
+
*
|
|
81
|
+
* Staging a value equal to the original **unstages** it, so typing a change
|
|
82
|
+
* and typing it back leaves the row clean rather than dirty-with-no-diff.
|
|
83
|
+
* `sameValue` rather than `===`, for the reason its own comment gives: the
|
|
84
|
+
* original came from a driver and the edit came from an input, and `1` vs
|
|
85
|
+
* `true` is not an edit the user made.
|
|
86
|
+
*/
|
|
87
|
+
stage(
|
|
88
|
+
id: string,
|
|
89
|
+
original: Record<string, unknown>,
|
|
90
|
+
column: string,
|
|
91
|
+
value: unknown,
|
|
92
|
+
): void {
|
|
93
|
+
if (!this.originals.has(id)) this.originals.set(id, original)
|
|
94
|
+
const pre = this.originals.get(id)!
|
|
95
|
+
const row = this.edits.get(id) ?? new Map<string, unknown>()
|
|
96
|
+
if (sameValue(pre[column], value)) row.delete(column)
|
|
97
|
+
else row.set(column, value)
|
|
98
|
+
if (row.size) this.edits.set(id, row)
|
|
99
|
+
else this.drop(id)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The value to show in a cell: the staged one, else the original. */
|
|
103
|
+
value(id: string, column: string, fallback: unknown): unknown {
|
|
104
|
+
const row = this.edits.get(id)
|
|
105
|
+
return row?.has(column) ? row.get(column) : fallback
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
isStaged(id: string, column: string): boolean {
|
|
109
|
+
return this.edits.get(id)?.has(column) ?? false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
changedColumns(id: string): string[] {
|
|
113
|
+
return [...(this.edits.get(id)?.keys() ?? [])]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
isDirty(id: string): boolean {
|
|
117
|
+
return (this.edits.get(id)?.size ?? 0) > 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** How many rows are dirty. What the header badge and the unload guard read. */
|
|
121
|
+
dirtyRows(): number {
|
|
122
|
+
return this.edits.size
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dirtyIds(): string[] {
|
|
126
|
+
return [...this.edits.keys()]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
original(id: string): Record<string, unknown> | undefined {
|
|
130
|
+
return this.originals.get(id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
drop(id: string): void {
|
|
134
|
+
this.edits.delete(id)
|
|
135
|
+
this.originals.delete(id)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
clear(): void {
|
|
139
|
+
this.edits.clear()
|
|
140
|
+
this.originals.clear()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One row's edits as an UPDATE.
|
|
145
|
+
*
|
|
146
|
+
* `where` comes from the pre-image and `set` from the edits, which is what
|
|
147
|
+
* lets a primary key itself be edited — see `updatePlan`'s own note.
|
|
148
|
+
*/
|
|
149
|
+
plan(
|
|
150
|
+
id: string,
|
|
151
|
+
identity: readonly string[],
|
|
152
|
+
kindOf: KindLookup,
|
|
153
|
+
): RowPlan | null {
|
|
154
|
+
const original = this.originals.get(id)
|
|
155
|
+
const edits = this.edits.get(id)
|
|
156
|
+
if (!original || !edits?.size) return null
|
|
157
|
+
|
|
158
|
+
const plan = updatePlan({
|
|
159
|
+
original,
|
|
160
|
+
edits: Object.fromEntries(edits),
|
|
161
|
+
identity,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const expect: Record<string, unknown> = {}
|
|
165
|
+
const unguardable: string[] = []
|
|
166
|
+
for (const column of Object.keys(plan.set)) {
|
|
167
|
+
const kind = kindOf(column)
|
|
168
|
+
// Unknown kind is treated as guardable: the column came out of the
|
|
169
|
+
// server's own schema report, so an absent entry is a bug rather than a
|
|
170
|
+
// JSON column, and including it in `expect` fails loudly at validation
|
|
171
|
+
// instead of silently dropping the concurrency check.
|
|
172
|
+
if (kind && !comparableKind(kind)) unguardable.push(column)
|
|
173
|
+
else expect[column] = original[column]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { ...plan, expect, unguardable }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface UndoEntry {
|
|
181
|
+
label: string
|
|
182
|
+
/** Runs the inverse. Rejects like any other request; the caller reports it. */
|
|
183
|
+
undo: () => Promise<void>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The last twenty things that happened, newest first.
|
|
188
|
+
*
|
|
189
|
+
* Twenty rather than unlimited because each entry closes over a row's
|
|
190
|
+
* pre-image, and an unbounded stack of those is exactly the module-level
|
|
191
|
+
* unbounded cache convention 6 forbids. The pre-image is already being kept for
|
|
192
|
+
* the concurrency check, so undo costs nothing extra to record — only to keep.
|
|
193
|
+
*/
|
|
194
|
+
export class UndoStack {
|
|
195
|
+
private readonly entries: UndoEntry[] = []
|
|
196
|
+
|
|
197
|
+
constructor(readonly limit = 20) {}
|
|
198
|
+
|
|
199
|
+
push(entry: UndoEntry): void {
|
|
200
|
+
this.entries.unshift(entry)
|
|
201
|
+
if (this.entries.length > this.limit) this.entries.length = this.limit
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get size(): number {
|
|
205
|
+
return this.entries.length
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
peek(): UndoEntry | undefined {
|
|
209
|
+
return this.entries[0]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
pop(): UndoEntry | undefined {
|
|
213
|
+
return this.entries.shift()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
clear(): void {
|
|
217
|
+
this.entries.length = 0
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One editor per `WidgetKind`, chosen through a record rather than an if-chain.
|
|
3
|
+
*
|
|
4
|
+
* **The null toggle is the point of this module.** Every editor here carries a
|
|
5
|
+
* `∅` button that is *separate from the input*, because SQL NULL and the empty
|
|
6
|
+
* string are different values, the server enforces the difference
|
|
7
|
+
* (`coerce.ts`'s `empty_string` code), and an editor with only a text box can
|
|
8
|
+
* express one of them. The dashboard's editor read every cell as a string, so
|
|
9
|
+
* clearing a field wrote `''` into a numeric column and got `0`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { coerceValue } from '../shared/coerce'
|
|
13
|
+
import { el, on } from './dom'
|
|
14
|
+
import {
|
|
15
|
+
columnKind,
|
|
16
|
+
columnMeta,
|
|
17
|
+
type SchemaColumn,
|
|
18
|
+
type WidgetKind,
|
|
19
|
+
} from './meta'
|
|
20
|
+
|
|
21
|
+
export interface EditorHooks {
|
|
22
|
+
/** Every keystroke's worth: stage into the row buffer, never save. */
|
|
23
|
+
onInput: (value: unknown) => void
|
|
24
|
+
/** Forwarded to the grid's reducer. The editor decides nothing itself. */
|
|
25
|
+
onKey: (event: KeyboardEvent) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EditorHandle {
|
|
29
|
+
node: HTMLElement
|
|
30
|
+
focus: () => void
|
|
31
|
+
/** The current value, already NULL-aware. Not coerced — that is the caller's. */
|
|
32
|
+
read: () => unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Control {
|
|
36
|
+
/** The focusable element. */
|
|
37
|
+
input: HTMLElement
|
|
38
|
+
read: () => unknown
|
|
39
|
+
/** Put a non-null value back after the null toggle is switched off. */
|
|
40
|
+
write: (value: unknown) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Factory = (column: SchemaColumn, value: unknown) => Control
|
|
44
|
+
|
|
45
|
+
/** `<input type=text>` with the value pre-filled as text, the common case. */
|
|
46
|
+
function textControl(_column: SchemaColumn, value: unknown): Control {
|
|
47
|
+
const input = el('input', { class: 'ed' })
|
|
48
|
+
input.type = 'text'
|
|
49
|
+
input.value = value === null || value === undefined ? '' : String(value)
|
|
50
|
+
return {
|
|
51
|
+
input,
|
|
52
|
+
read: () => input.value,
|
|
53
|
+
write: next => {
|
|
54
|
+
input.value = next === null || next === undefined ? '' : String(next)
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The same text column, in a box you can actually read.
|
|
61
|
+
*
|
|
62
|
+
* Used **only by the row panel**, which is where a description or a rendered
|
|
63
|
+
* template is edited; the grid keeps the one-line input because a textarea in a
|
|
64
|
+
* table cell breaks row height and the Tab-commits-right key model. Opting in
|
|
65
|
+
* rather than switching on the value's current length matters: a column whose
|
|
66
|
+
* first row happens to be short would otherwise get a different editor from the
|
|
67
|
+
* same column two rows down.
|
|
68
|
+
*/
|
|
69
|
+
function longTextControl(_column: SchemaColumn, value: unknown): Control {
|
|
70
|
+
const input = el('textarea', { class: 'ed ed-long' })
|
|
71
|
+
input.rows = 5
|
|
72
|
+
input.value = value === null || value === undefined ? '' : String(value)
|
|
73
|
+
return {
|
|
74
|
+
input,
|
|
75
|
+
read: () => input.value,
|
|
76
|
+
write: next => {
|
|
77
|
+
input.value = next === null || next === undefined ? '' : String(next)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function enumControl(column: SchemaColumn, value: unknown): Control {
|
|
83
|
+
const input = el('select', { class: 'ed' })
|
|
84
|
+
for (const option of column.enum ?? []) {
|
|
85
|
+
const item = el('option', { text: option })
|
|
86
|
+
item.value = option
|
|
87
|
+
item.selected = option === value
|
|
88
|
+
input.appendChild(item)
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
input,
|
|
92
|
+
read: () => input.value,
|
|
93
|
+
write: next => {
|
|
94
|
+
input.value = String(next ?? '')
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A checkbox, and it reads back a real boolean.
|
|
101
|
+
*
|
|
102
|
+
* A driver hands a boolean column back as `1`/`0` on SQLite and MySQL, so the
|
|
103
|
+
* *initial* state has to accept those spellings — but what leaves here is
|
|
104
|
+
* `true`/`false`, which is what `coerceValue` wants and what makes `sameValue`
|
|
105
|
+
* agree that nothing changed when nothing did.
|
|
106
|
+
*/
|
|
107
|
+
function booleanControl(_column: SchemaColumn, value: unknown): Control {
|
|
108
|
+
const input = el('input', { class: 'ed ed-check' })
|
|
109
|
+
input.type = 'checkbox'
|
|
110
|
+
input.checked =
|
|
111
|
+
value === true || value === 1 || value === '1' || value === 'true'
|
|
112
|
+
return {
|
|
113
|
+
input,
|
|
114
|
+
read: () => input.checked,
|
|
115
|
+
write: next => {
|
|
116
|
+
input.checked = next === true || next === 1
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `datetime-local`, fed and read in the format that control insists on.
|
|
123
|
+
*
|
|
124
|
+
* It refuses an ISO string with a `Z` and refuses one carrying milliseconds, so
|
|
125
|
+
* the value is trimmed to `YYYY-MM-DDTHH:mm` going in and handed back as a full
|
|
126
|
+
* ISO string going out — `coerceValue`'s `date` branch parses either, but the
|
|
127
|
+
* round trip has to be lossless in the direction the user sees.
|
|
128
|
+
*/
|
|
129
|
+
function dateControl(_column: SchemaColumn, value: unknown): Control {
|
|
130
|
+
const input = el('input', { class: 'ed' })
|
|
131
|
+
input.type = 'datetime-local'
|
|
132
|
+
input.value = toLocalInput(value)
|
|
133
|
+
return {
|
|
134
|
+
input,
|
|
135
|
+
read: () => (input.value ? new Date(input.value).toISOString() : ''),
|
|
136
|
+
write: next => {
|
|
137
|
+
input.value = toLocalInput(next)
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function toLocalInput(value: unknown): string {
|
|
143
|
+
if (value === null || value === undefined || value === '') return ''
|
|
144
|
+
const date = value instanceof Date ? value : new Date(String(value))
|
|
145
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
146
|
+
// Local, not UTC: the control shows local time, so feeding it a UTC string
|
|
147
|
+
// shifts every timestamp by the viewer's offset the moment it is opened.
|
|
148
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
149
|
+
return (
|
|
150
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
151
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A textarea, because a JSON document does not fit on one line. */
|
|
156
|
+
function jsonControl(_column: SchemaColumn, value: unknown): Control {
|
|
157
|
+
const input = el('textarea', { class: 'ed ed-json' })
|
|
158
|
+
input.rows = 6
|
|
159
|
+
input.value = jsonText(value)
|
|
160
|
+
return {
|
|
161
|
+
input,
|
|
162
|
+
read: () => input.value,
|
|
163
|
+
write: next => {
|
|
164
|
+
input.value = jsonText(next)
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function jsonText(value: unknown): string {
|
|
170
|
+
if (value === null || value === undefined) return ''
|
|
171
|
+
if (typeof value === 'string') return value
|
|
172
|
+
try {
|
|
173
|
+
return JSON.stringify(value, null, 2)
|
|
174
|
+
} catch {
|
|
175
|
+
// Cyclic or BigInt-bearing. The textarea still needs something to show,
|
|
176
|
+
// and the underlying value is not touched by what is displayed.
|
|
177
|
+
return String(value)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const CONTROLS: Record<WidgetKind, Factory> = {
|
|
182
|
+
enum: enumControl,
|
|
183
|
+
boolean: booleanControl,
|
|
184
|
+
date: dateControl,
|
|
185
|
+
json: jsonControl,
|
|
186
|
+
text: textControl,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface EditorOptions {
|
|
190
|
+
/**
|
|
191
|
+
* Render a `text` column as a textarea. The row panel passes this; the grid
|
|
192
|
+
* does not, because a textarea in a table cell breaks the row height and the
|
|
193
|
+
* Tab-commits-right key model.
|
|
194
|
+
*/
|
|
195
|
+
multiline?: boolean
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* An editor for one cell.
|
|
200
|
+
*
|
|
201
|
+
* The wrapper owns three things the control does not: the null toggle, the
|
|
202
|
+
* validity message, and key forwarding. Validation runs `coerceValue` — the
|
|
203
|
+
* same function the server runs — so the message the user sees before sending
|
|
204
|
+
* is the message they would have got back.
|
|
205
|
+
*/
|
|
206
|
+
export function createEditor(
|
|
207
|
+
column: SchemaColumn,
|
|
208
|
+
value: unknown,
|
|
209
|
+
hooks: EditorHooks,
|
|
210
|
+
options: EditorOptions = {},
|
|
211
|
+
): EditorHandle {
|
|
212
|
+
const meta = columnMeta(column)
|
|
213
|
+
const wrap = el('div', { class: 'editor' })
|
|
214
|
+
const kind = columnKind(column)
|
|
215
|
+
const factory =
|
|
216
|
+
options.multiline && kind === 'text' ? longTextControl : CONTROLS[kind]
|
|
217
|
+
const control = factory(column, value)
|
|
218
|
+
let isNull = value === null || value === undefined
|
|
219
|
+
let lastNonNull: unknown = isNull ? '' : control.read()
|
|
220
|
+
|
|
221
|
+
const note = el('div', { class: 'ed-note' })
|
|
222
|
+
const nullToggle = el('button', {
|
|
223
|
+
class: 'ed-null',
|
|
224
|
+
text: '∅',
|
|
225
|
+
title: column.nullable
|
|
226
|
+
? 'set SQL NULL (not the empty string)'
|
|
227
|
+
: 'this column cannot be NULL',
|
|
228
|
+
attrs: { 'aria-pressed': String(isNull), 'aria-label': 'null' },
|
|
229
|
+
})
|
|
230
|
+
nullToggle.type = 'button'
|
|
231
|
+
nullToggle.disabled = !column.nullable
|
|
232
|
+
|
|
233
|
+
const read = () => (isNull ? null : control.read())
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `touched` is why the initial paint does not call `onInput`.
|
|
237
|
+
*
|
|
238
|
+
* Two consumers break if merely *opening* an editor counts as input. The
|
|
239
|
+
* insert dialog decides whether to send a column at all from whether it was
|
|
240
|
+
* given a value, so an opening notification would put every column in the
|
|
241
|
+
* record as null and defeat every default in the schema. And a real `DATE`
|
|
242
|
+
* column round trips through `datetime-local` as a different string than the
|
|
243
|
+
* driver returned, so the row panel would mark a row dirty for opening it.
|
|
244
|
+
*/
|
|
245
|
+
const refresh = (touched: boolean) => {
|
|
246
|
+
;(control.input as HTMLInputElement).disabled = isNull
|
|
247
|
+
nullToggle.setAttribute('aria-pressed', String(isNull))
|
|
248
|
+
wrap.classList.toggle('is-null', isNull)
|
|
249
|
+
const current = read()
|
|
250
|
+
const result = coerceValue(current, meta)
|
|
251
|
+
note.textContent = result.ok ? '' : result.message
|
|
252
|
+
wrap.classList.toggle('invalid', !result.ok)
|
|
253
|
+
control.input.setAttribute('aria-invalid', String(!result.ok))
|
|
254
|
+
if (touched) hooks.onInput(current)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
on(nullToggle, 'click', () => {
|
|
258
|
+
if (!isNull) lastNonNull = control.read()
|
|
259
|
+
isNull = !isNull
|
|
260
|
+
if (!isNull) control.write(lastNonNull)
|
|
261
|
+
refresh(true)
|
|
262
|
+
control.input.focus()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
control.input.addEventListener('input', () => refresh(true))
|
|
266
|
+
control.input.addEventListener('change', () => refresh(true))
|
|
267
|
+
control.input.addEventListener('keydown', event => {
|
|
268
|
+
hooks.onKey(event as KeyboardEvent)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
wrap.append(control.input, nullToggle, note)
|
|
272
|
+
// Paints the initial validity only. See the note on `touched`.
|
|
273
|
+
refresh(false)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
node: wrap,
|
|
277
|
+
focus: () => {
|
|
278
|
+
control.input.focus()
|
|
279
|
+
if (control.input instanceof HTMLInputElement) control.input.select()
|
|
280
|
+
},
|
|
281
|
+
read,
|
|
282
|
+
}
|
|
283
|
+
}
|