@bakery-framework/plugin-db-explorer 2.0.0-alpha.5 → 2.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +279 -0
  4. package/src/client/bulk.ts +357 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +203 -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 +89 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +164 -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 +269 -0
  18. package/src/client/grid-body.ts +106 -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 +468 -0
  22. package/src/client/meta.ts +185 -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 +205 -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 +234 -0
  31. package/src/client/tabs.ts +219 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +376 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +148 -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 +40 -45
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +64 -81
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +186 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +173 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/credential.ts +0 -26
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The filter builder: a chip per condition, combinable and each removable.
3
+ *
4
+ * This replaces a row of one text box per column, which could only ever mean
5
+ * "contains" and which grew a box for every column whether or not anyone wanted
6
+ * to filter on it. A chip is column + operator + value + remove, and the
7
+ * operator is the part that was missing: `eq` is what lets a foreign-key jump
8
+ * name a row, and `null` / `notnull` are the two questions a substring box
9
+ * cannot ask at all.
10
+ *
11
+ * The vocabulary and the wire shape are in `shared/filters.ts`, shared with the
12
+ * endpoint that validates them. Nothing here decides what an operator means.
13
+ */
14
+
15
+ import type { Filter, FilterOp } from '../shared/filters'
16
+ import {
17
+ duplicateColumns,
18
+ FILTER_OPS,
19
+ filter as makeFilter,
20
+ OP_LABELS,
21
+ opTakesValue,
22
+ } from '../shared/filters'
23
+ import { append, box, button, each, el, on, select } from './dom'
24
+ import type { SchemaColumn } from './meta'
25
+
26
+ export interface FilterBuilderContext {
27
+ columns: readonly SchemaColumn[]
28
+ filters: readonly Filter[]
29
+ /** Called with the whole new list; the caller re-fetches and repaints. */
30
+ onChange: (filters: Filter[]) => void
31
+ }
32
+
33
+ const OPTIONS = FILTER_OPS.map(op => ({ value: op, label: OP_LABELS[op] }))
34
+
35
+ /**
36
+ * The bar.
37
+ *
38
+ * Every control writes a **whole new list** through `onChange` rather than
39
+ * mutating the one it was given — same discipline as the CSV wizard's model,
40
+ * and the reason a chip's state can never disagree with the URL.
41
+ */
42
+ export function filterBar(ctx: FilterBuilderContext): HTMLElement {
43
+ const bar = box('filter-bar')
44
+ each(bar, ctx.filters, (entry, index) => chip(ctx, entry, index))
45
+ bar.appendChild(addButton(ctx))
46
+
47
+ const clashes = duplicateColumns(ctx.filters)
48
+ if (clashes.length) bar.appendChild(clashNote(clashes))
49
+ return bar
50
+ }
51
+
52
+ /**
53
+ * A column named twice, said out loud.
54
+ *
55
+ * The wire shape is a record keyed by column, so only the last filter on a
56
+ * column is sent. Rendering both chips and silently dropping one would be a
57
+ * screen that disagrees with the query — the one failure mode a filter builder
58
+ * must not have.
59
+ */
60
+ function clashNote(columns: readonly string[]): HTMLElement {
61
+ return el('span', {
62
+ class: 'filter-clash',
63
+ text: `only the last filter on ${columns.join(', ')} is applied`,
64
+ title:
65
+ 'the table-data endpoint keys filters by column, so a column can carry ' +
66
+ 'one condition at a time',
67
+ })
68
+ }
69
+
70
+ function addButton(ctx: FilterBuilderContext): HTMLElement {
71
+ const first = ctx.columns[0]
72
+ const add = button(
73
+ '+ filter',
74
+ () => {
75
+ if (!first) return
76
+ ctx.onChange([...ctx.filters, makeFilter(first.name, 'eq', '')])
77
+ },
78
+ { class: 'btn filter-add', title: 'add a condition' },
79
+ )
80
+ add.disabled = !first
81
+ return add
82
+ }
83
+
84
+ function chip(
85
+ ctx: FilterBuilderContext,
86
+ entry: Filter,
87
+ index: number,
88
+ ): HTMLElement {
89
+ const node = box('filter-chip')
90
+ const replace = (next: Filter) =>
91
+ ctx.onChange(withAt(ctx.filters, index, next))
92
+
93
+ append(node, [
94
+ columnPicker(ctx, entry, replace),
95
+ operatorPicker(entry, replace),
96
+ valueInput(entry, replace),
97
+ removeButton(ctx, entry, index),
98
+ ])
99
+ return node
100
+ }
101
+
102
+ function columnPicker(
103
+ ctx: FilterBuilderContext,
104
+ entry: Filter,
105
+ replace: (next: Filter) => void,
106
+ ): HTMLElement {
107
+ const options = ctx.columns.map(column => ({
108
+ value: column.name,
109
+ label: `${column.name} · ${column.type}`,
110
+ }))
111
+ return select(options, entry.column, value =>
112
+ replace({ ...entry, column: value }),
113
+ )
114
+ }
115
+
116
+ function operatorPicker(
117
+ entry: Filter,
118
+ replace: (next: Filter) => void,
119
+ ): HTMLElement {
120
+ return select(OPTIONS, entry.op, value =>
121
+ // Switching to a nullary operator clears the operand rather than keeping
122
+ // it hidden: a value that is invisible and still in the URL is a filter
123
+ // nobody can see and nobody asked for.
124
+ replace(nextOnOp(entry, value as FilterOp)),
125
+ )
126
+ }
127
+
128
+ function nextOnOp(entry: Filter, op: FilterOp): Filter {
129
+ return { ...entry, op, value: opTakesValue(op) ? entry.value : '' }
130
+ }
131
+
132
+ /**
133
+ * The operand — **absent** for `null` and `notnull`.
134
+ *
135
+ * Not disabled, not hidden by CSS: not built at all. A greyed-out box still
136
+ * says "there is a value here", and there is not one.
137
+ */
138
+ function valueInput(
139
+ entry: Filter,
140
+ replace: (next: Filter) => void,
141
+ ): HTMLElement | null {
142
+ if (!opTakesValue(entry.op)) return null
143
+ const input = el('input', {
144
+ class: 'filter-value',
145
+ attrs: {
146
+ 'aria-label': `${entry.column} ${OP_LABELS[entry.op]}`,
147
+ placeholder: 'value',
148
+ },
149
+ })
150
+ input.value = entry.value
151
+ on(input, 'change', () => replace({ ...entry, value: input.value }))
152
+ return input
153
+ }
154
+
155
+ function removeButton(
156
+ ctx: FilterBuilderContext,
157
+ entry: Filter,
158
+ index: number,
159
+ ): HTMLElement {
160
+ return button('×', () => ctx.onChange(withoutAt(ctx.filters, index)), {
161
+ class: 'btn filter-drop',
162
+ title: `remove the filter on ${entry.column}`,
163
+ attrs: { 'aria-label': `remove the filter on ${entry.column}` },
164
+ })
165
+ }
166
+
167
+ /**
168
+ * Immutable list edits.
169
+ *
170
+ * Exported because they are the two operations the chip callbacks are made of,
171
+ * and an off-by-one in either would present as "removing one filter removed a
172
+ * different one" — a bug worth a test rather than a careful read.
173
+ */
174
+ export function withAt(
175
+ filters: readonly Filter[],
176
+ index: number,
177
+ next: Filter,
178
+ ): Filter[] {
179
+ return filters.map((entry, at) => (at === index ? next : entry))
180
+ }
181
+
182
+ export function withoutAt(filters: readonly Filter[], index: number): Filter[] {
183
+ return filters.filter((_entry, at) => at !== index)
184
+ }
185
+
186
+ /**
187
+ * The filter a foreign-key jump becomes.
188
+ *
189
+ * One `eq` per referenced column, which is exactly a row identity — this is the
190
+ * function that replaced `ViewState.focus`. Values stringify because the wire
191
+ * carries text and the ORM binds it as a parameter; the comparison happens in
192
+ * the database, against the column's own type.
193
+ */
194
+ export function equalityFilters(key: Record<string, unknown>): Filter[] {
195
+ return Object.entries(key)
196
+ .filter(([, value]) => value !== null && value !== undefined)
197
+ .map(([column, value]) => makeFilter(column, 'eq', String(value)))
198
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Foreign keys: what a cell points at, and what it costs to find out.
3
+ *
4
+ * The naive version of this feature is a `fetch` per foreign-key cell on
5
+ * `mouseover`. A fifty-row page with three foreign keys is a hundred and fifty
6
+ * requests, and moving the mouse across the grid fires most of them. Three
7
+ * things prevent that here, and all three are load-bearing:
8
+ *
9
+ * - **A ~350 ms arming delay.** Crossing a cell on the way somewhere else
10
+ * never issues a request.
11
+ * - **An `AbortController`.** `pointerleave` cancels a request already in
12
+ * flight, so a fast sweep costs one aborted socket rather than fifty
13
+ * completed round trips.
14
+ * - **A bounded cache** (convention 6). The key is `table|col|value`, which is
15
+ * per-row and therefore unbounded in principle; the bound is what makes that
16
+ * safe.
17
+ *
18
+ * The pure half — which column points where, and what key a row implies — is
19
+ * separated out so the graph reasoning can be read without the timers.
20
+ */
21
+
22
+ import { type LookupRef, lookupRefs } from './api'
23
+ import { type ForeignKeyInfo, type SchemaGraph, sameTable } from './meta'
24
+
25
+ /**
26
+ * A least-recently-used map, in nine lines.
27
+ *
28
+ * Core has `LRUCache` and this is not a preference for a private copy —
29
+ * `bundleModule` externalises every installed package, so importing it leaves
30
+ * `@bakery-framework/core/cache/lru` as a bare specifier in the emitted bundle
31
+ * and the browser cannot resolve it. Client code imports nothing from
32
+ * `@bakery-framework/*`; see the header of `shared/coerce.ts`.
33
+ *
34
+ * A `Map` iterates in insertion order, so re-inserting on read is the whole
35
+ * recency mechanism and the oldest key is always the first one.
36
+ */
37
+ class BoundedCache<V> {
38
+ private readonly map = new Map<string, V>()
39
+ constructor(private readonly limit: number) {}
40
+
41
+ get(key: string): V | undefined {
42
+ const value = this.map.get(key)
43
+ if (value === undefined) return undefined
44
+ this.map.delete(key)
45
+ this.map.set(key, value)
46
+ return value
47
+ }
48
+
49
+ set(key: string, value: V): void {
50
+ this.map.delete(key)
51
+ this.map.set(key, value)
52
+ if (this.map.size > this.limit) {
53
+ const oldest = this.map.keys().next().value
54
+ if (oldest !== undefined) this.map.delete(oldest)
55
+ }
56
+ }
57
+
58
+ clear(): void {
59
+ this.map.clear()
60
+ }
61
+
62
+ get size(): number {
63
+ return this.map.size
64
+ }
65
+ }
66
+
67
+ export interface FkTarget {
68
+ /** Columns in *this* table that make up the key. */
69
+ cols: string[]
70
+ refTable: string
71
+ /** The matching columns in the referenced table, positionally. */
72
+ refCols: string[]
73
+ }
74
+
75
+ function toTarget(fk: ForeignKeyInfo): FkTarget {
76
+ return { cols: fk.cols, refTable: fk.refTable, refCols: fk.refCols }
77
+ }
78
+
79
+ /**
80
+ * The foreign key `column` participates in, if any.
81
+ *
82
+ * Composite keys are included rather than skipped: a two-column key renders as
83
+ * a button on each of its columns, and both resolve the same referenced row.
84
+ * Skipping them would make the feature quietly absent on exactly the schemas
85
+ * where following a reference by hand is hardest.
86
+ */
87
+ export function fkForColumn(
88
+ graph: SchemaGraph | null,
89
+ table: string,
90
+ column: string,
91
+ ): FkTarget | null {
92
+ if (!graph) return null
93
+ for (const fk of Object.values(graph.foreignKeys)) {
94
+ if (!sameTable(fk.table, table)) continue
95
+ if (fk.cols.includes(column)) return toTarget(fk)
96
+ }
97
+ return null
98
+ }
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
+
128
+ /** Every key pointing *at* this table. The row panel's "referenced by" list. */
129
+ export function reverseFks(
130
+ graph: SchemaGraph | null,
131
+ table: string,
132
+ ): ForeignKeyInfo[] {
133
+ if (!graph) return []
134
+ return Object.values(graph.foreignKeys).filter(fk =>
135
+ sameTable(fk.refTable, table),
136
+ )
137
+ }
138
+
139
+ /**
140
+ * The referenced row's identity, as this row names it.
141
+ *
142
+ * `null` when any participating column is NULL — an optional foreign key with
143
+ * no value points at nothing, and a predicate containing `NULL` matches no row
144
+ * anyway, so issuing the lookup would spend a request to learn that.
145
+ */
146
+ export function fkKeyOf(
147
+ target: FkTarget,
148
+ row: Record<string, unknown>,
149
+ ): Record<string, unknown> | null {
150
+ const key: Record<string, unknown> = {}
151
+ for (let i = 0; i < target.cols.length; i++) {
152
+ const value = row[target.cols[i]!]
153
+ if (value === null || value === undefined) return null
154
+ key[target.refCols[i] ?? target.cols[i]!] = value
155
+ }
156
+ return key
157
+ }
158
+
159
+ /** `table|col|value` — stable across pages, which is what makes caching pay. */
160
+ export function cacheKey(
161
+ refTable: string,
162
+ key: Record<string, unknown>,
163
+ ): string {
164
+ const parts = Object.keys(key)
165
+ .sort()
166
+ .map(column => `${column}=${String(key[column])}`)
167
+ return `${refTable}|${parts.join('|')}`
168
+ }
169
+
170
+ /**
171
+ * What to show for a resolved row: the label column if the table has one, the
172
+ * identity otherwise.
173
+ *
174
+ * `graph.labels` is the server's pick — the first text column that is not part
175
+ * of the identity — so a `users` row reads as `ada` rather than `41`.
176
+ */
177
+ export function fkLabel(
178
+ graph: SchemaGraph | null,
179
+ refTable: string,
180
+ row: Record<string, unknown> | null,
181
+ ): string | null {
182
+ if (!row) return null
183
+ const label = graph?.labels?.[refTable]
184
+ if (label && row[label] !== null && row[label] !== undefined) {
185
+ return String(row[label])
186
+ }
187
+ const identity = graph?.identity?.[refTable]?.cols ?? Object.keys(row)
188
+ return identity.map(column => String(row[column])).join(' / ')
189
+ }
190
+
191
+ export type Resolved = Record<string, unknown> | null
192
+
193
+ /**
194
+ * Hover-driven resolution, with the cache and the cancellation.
195
+ *
196
+ * One instance per page load, cleared when the schema graph is refetched.
197
+ */
198
+ export class FkResolver {
199
+ /**
200
+ * 500 entries — ten pages of a fifty-row grid with a foreign key on every
201
+ * row. Past that the earliest are the ones the user has scrolled away from.
202
+ */
203
+ private readonly cache = new BoundedCache<Resolved>(500)
204
+ private timer: ReturnType<typeof setTimeout> | null = null
205
+ private inflight: AbortController | null = null
206
+
207
+ cached(refTable: string, key: Record<string, unknown>): Resolved | undefined {
208
+ return this.cache.get(cacheKey(refTable, key))
209
+ }
210
+
211
+ /**
212
+ * Arm the delay. Returns a canceller for `pointerleave`.
213
+ *
214
+ * The canceller aborts an in-flight request as well as clearing an unfired
215
+ * timer, because the expensive case is the one that already left.
216
+ */
217
+ arm(
218
+ refTable: string,
219
+ key: Record<string, unknown>,
220
+ onResolved: (row: Resolved) => void,
221
+ delay = 350,
222
+ ): () => void {
223
+ const hit = this.cached(refTable, key)
224
+ if (hit !== undefined) {
225
+ onResolved(hit)
226
+ return () => {}
227
+ }
228
+ this.cancel()
229
+ this.timer = setTimeout(() => {
230
+ void this.resolve(refTable, key).then(onResolved, () => {
231
+ // An abort or a failed lookup leaves the cell showing its raw value,
232
+ // which is the state it was already in. Nothing to report: the user
233
+ // moved the pointer away, or the row is simply not resolvable.
234
+ })
235
+ }, delay)
236
+ return () => this.cancel()
237
+ }
238
+
239
+ cancel(): void {
240
+ if (this.timer !== null) clearTimeout(this.timer)
241
+ this.timer = null
242
+ this.inflight?.abort()
243
+ this.inflight = null
244
+ }
245
+
246
+ /** One batched lookup. Public so the panel can resolve without hovering. */
247
+ async resolve(
248
+ refTable: string,
249
+ key: Record<string, unknown>,
250
+ ): Promise<Resolved> {
251
+ const hit = this.cached(refTable, key)
252
+ if (hit !== undefined) return hit
253
+
254
+ const controller = new AbortController()
255
+ this.inflight = controller
256
+ const refs: LookupRef[] = [{ table: refTable, key }]
257
+ const rows = await lookupRefs(refs, controller.signal)
258
+ this.inflight = null
259
+
260
+ const row = rows[0]?.row ?? null
261
+ this.cache.set(cacheKey(refTable, key), row)
262
+ return row
263
+ }
264
+
265
+ clear(): void {
266
+ this.cancel()
267
+ this.cache.clear()
268
+ }
269
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * What goes *inside* a cell: the text, the classes, and the foreign-key button.
3
+ *
4
+ * Split out of `grid.ts` alongside the header and the row bar. This half is the
5
+ * one with no knowledge of the cursor or the editor — it is handed a value and
6
+ * returns a node — which is why it could leave.
7
+ *
8
+ * The `<button>` for a foreign key is a real button rather than a styled span,
9
+ * because it is activated by keyboard and read as an action by a screen reader.
10
+ * A `div` with a click handler is neither.
11
+ */
12
+
13
+ import { button } from './dom'
14
+ import { type FkResolver, type FkTarget, fkKeyOf, fkLabel } from './fk'
15
+ import { cellProps, type SchemaColumn, type SchemaGraph } from './meta'
16
+
17
+ export interface CellPaintContext {
18
+ table: string
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>
28
+ resolver: FkResolver
29
+ onFollowFk: (target: FkTarget, key: Record<string, unknown>) => void
30
+ }
31
+
32
+ /**
33
+ * Write one cell's value into its `<td>`.
34
+ *
35
+ * The staged flag is passed in rather than read from a session here, so this
36
+ * module needs no `EditSession` — a staged value and a stored value take
37
+ * exactly the same path and cannot diverge in how they look.
38
+ */
39
+ export function paintCell(
40
+ td: HTMLTableCellElement,
41
+ ctx: CellPaintContext,
42
+ row: Record<string, unknown>,
43
+ column: SchemaColumn,
44
+ value: unknown,
45
+ staged: boolean,
46
+ ): void {
47
+ const props = cellProps(value, column)
48
+ td.replaceChildren()
49
+ td.className = props.className
50
+ if (staged) td.classList.add('staged')
51
+ if (props.title) td.title = props.title
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))
55
+ }
56
+
57
+ function cellBody(
58
+ ctx: CellPaintContext,
59
+ row: Record<string, unknown>,
60
+ column: SchemaColumn,
61
+ text: string,
62
+ ): Node {
63
+ const target = ctx.fks.get(column.name)
64
+ const key = target ? fkKeyOf(target, row) : null
65
+ if (!target || !key) return document.createTextNode(text)
66
+ return fkButton(ctx, target, key, text)
67
+ }
68
+
69
+ /**
70
+ * A foreign key as a link, resolving its label on hover.
71
+ *
72
+ * The resolver owns the delay, the cache and the cancellation — see `fk.ts`.
73
+ * All that happens here is arming it on `pointerenter` and disarming it on
74
+ * `pointerleave`, which is what keeps a sweep across the grid from costing
75
+ * fifty round trips.
76
+ */
77
+ function fkButton(
78
+ ctx: CellPaintContext,
79
+ target: FkTarget,
80
+ key: Record<string, unknown>,
81
+ text: string,
82
+ ): HTMLButtonElement {
83
+ const node = button(text, () => ctx.onFollowFk(target, key), {
84
+ class: 'fk',
85
+ title: `→ ${target.refTable}`,
86
+ })
87
+
88
+ const show = (resolved: Record<string, unknown> | null) => {
89
+ const label = fkLabel(ctx.graph, target.refTable, resolved)
90
+ node.title = label ? `${target.refTable}: ${label}` : `→ ${target.refTable}`
91
+ if (label) node.textContent = `${text} · ${label}`
92
+ }
93
+
94
+ const cached = ctx.resolver.cached(target.refTable, key)
95
+ if (cached !== undefined) show(cached)
96
+
97
+ let disarm: (() => void) | null = null
98
+ node.addEventListener('pointerenter', () => {
99
+ disarm = ctx.resolver.arm(target.refTable, key, show)
100
+ })
101
+ node.addEventListener('pointerleave', () => {
102
+ disarm?.()
103
+ disarm = null
104
+ })
105
+ return node
106
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The grid's header row: the select-all box and the sortable column headings.
3
+ *
4
+ * Split out of `grid.ts` when that file passed six hundred lines. It is a clean
5
+ * seam because the header depends on nothing the grid mutates — it is built
6
+ * once from the columns and the current sort, and never repainted. Cursor,
7
+ * editor and selection state all live on the other side of it.
8
+ */
9
+
10
+ import { each, el, on } from './dom'
11
+ import type { SchemaColumn } from './meta'
12
+
13
+ export interface HeaderContext {
14
+ columns: readonly SchemaColumn[]
15
+ editable: boolean
16
+ sortBy: string | null
17
+ sortOrder: 'ASC' | 'DESC'
18
+ onSort: (column: string) => void
19
+ onToggleAll: (checked: boolean) => void
20
+ }
21
+
22
+ export function buildHead(ctx: HeaderContext): HTMLTableSectionElement {
23
+ const head = el('thead')
24
+ const tr = el('tr')
25
+ tr.appendChild(buildSelectAll(ctx))
26
+ each(tr, ctx.columns, column => buildHeaderCell(ctx, column))
27
+ head.appendChild(tr)
28
+ return head
29
+ }
30
+
31
+ /**
32
+ * The leading column exists on every table, editable or not: it carries the row
33
+ * panel's opener, which a read-only table needs just as much — a forty-column
34
+ * row is unreadable in a grid whether or not it can be changed. The select-all
35
+ * checkbox joins it only when there is something to select *for*.
36
+ */
37
+ function buildSelectAll(ctx: HeaderContext): HTMLTableCellElement {
38
+ const th = el('th', { class: 'pick' })
39
+ if (!ctx.editable) return th
40
+ const check = el('input', { attrs: { 'aria-label': 'select all rows' } })
41
+ check.type = 'checkbox'
42
+ on(check, 'change', () => ctx.onToggleAll(check.checked))
43
+ th.appendChild(check)
44
+ return th
45
+ }
46
+
47
+ function buildHeaderCell(
48
+ ctx: HeaderContext,
49
+ column: SchemaColumn,
50
+ ): HTMLTableCellElement {
51
+ const th = el('th', {
52
+ text: column.name + sortArrow(ctx, column.name),
53
+ title: `${column.type}${column.notnull ? ' NOT NULL' : ''}`,
54
+ attrs: { 'data-kind': column.kind },
55
+ })
56
+ if (column.pk) th.classList.add('pk')
57
+ on(th, 'click', () => ctx.onSort(column.name))
58
+ return th
59
+ }
60
+
61
+ /** Named so the ternary chain is not folded into `buildHeaderCell`'s score. */
62
+ function sortArrow(ctx: HeaderContext, column: string): string {
63
+ if (ctx.sortBy !== column) return ''
64
+ return ctx.sortOrder === 'ASC' ? ' ↑' : ' ↓'
65
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The strip that appears under a row the moment it is dirty.
3
+ *
4
+ * Under the row rather than in a toolbar, because "which row is this Save for"
5
+ * has exactly one unambiguous answer and it is proximity. It carries the
6
+ * changed-column list, the two buttons, and the place a failed save reports
7
+ * itself — a 409 belongs next to the row it is about, not in a toast.
8
+ */
9
+
10
+ import { append, box, button, el } from './dom'
11
+
12
+ export interface RowBar {
13
+ tr: HTMLTableRowElement
14
+ message: HTMLElement
15
+ count: HTMLElement
16
+ }
17
+
18
+ export interface RowBarHooks {
19
+ onSave: () => void
20
+ onRevert: () => void
21
+ }
22
+
23
+ export function buildRowBar(colSpan: number, hooks: RowBarHooks): RowBar {
24
+ const tr = el('tr', { class: 'row-bar-row' })
25
+ const td = el('td')
26
+ td.colSpan = colSpan
27
+
28
+ const bar = box('row-bar')
29
+ const count = el('span', { class: 'note' })
30
+ const message = el('span', { class: 'row-error' })
31
+ append(bar, [
32
+ count,
33
+ button('Save', hooks.onSave, { class: 'btn primary' }),
34
+ button('Revert', hooks.onRevert, { class: 'btn' }),
35
+ message,
36
+ ])
37
+
38
+ td.appendChild(bar)
39
+ tr.appendChild(td)
40
+ return { tr, message, count }
41
+ }
42
+
43
+ /**
44
+ * Show the bar, and say what changed.
45
+ *
46
+ * Naming the columns rather than counting them is the point: "3 columns
47
+ * changed" tells you that you touched something and not what, and the whole
48
+ * reason the buffer exists is that a row is saved as one statement.
49
+ */
50
+ export function paintRowBar(bar: RowBar, changed: readonly string[]): void {
51
+ bar.tr.classList.toggle('open', changed.length > 0)
52
+ bar.count.textContent = changed.length
53
+ ? `${changed.length} column${changed.length === 1 ? '' : 's'} changed: ${changed.join(', ')}`
54
+ : ''
55
+ // A fresh edit clears a stale failure: the message was about the previous
56
+ // attempt, and leaving it up would make a retry look like it had already
57
+ // failed.
58
+ if (changed.length) bar.message.textContent = ''
59
+ }
60
+
61
+ export function setRowBarMessage(bar: RowBar, message: string): void {
62
+ bar.tr.classList.add('open')
63
+ bar.message.textContent = message
64
+ }