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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +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 +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/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,242 @@
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
+
68
+ export interface FkTarget {
69
+ /** Columns in *this* table that make up the key. */
70
+ cols: string[]
71
+ refTable: string
72
+ /** The matching columns in the referenced table, positionally. */
73
+ refCols: string[]
74
+ }
75
+
76
+ function toTarget(fk: ForeignKeyInfo): FkTarget {
77
+ return { cols: fk.cols, refTable: fk.refTable, refCols: fk.refCols }
78
+ }
79
+
80
+ /**
81
+ * The foreign key `column` participates in, if any.
82
+ *
83
+ * Composite keys are included rather than skipped: a two-column key renders as
84
+ * a button on each of its columns, and both resolve the same referenced row.
85
+ * Skipping them would make the feature quietly absent on exactly the schemas
86
+ * where following a reference by hand is hardest.
87
+ */
88
+ export function fkForColumn(
89
+ graph: SchemaGraph | null,
90
+ table: string,
91
+ column: string,
92
+ ): FkTarget | null {
93
+ if (!graph) return null
94
+ for (const fk of Object.values(graph.foreignKeys)) {
95
+ if (!sameTable(fk.table, table)) continue
96
+ if (fk.cols.includes(column)) return toTarget(fk)
97
+ }
98
+ return null
99
+ }
100
+
101
+ /** Every key pointing *at* this table. The row panel's "referenced by" list. */
102
+ export function reverseFks(
103
+ graph: SchemaGraph | null,
104
+ table: string,
105
+ ): ForeignKeyInfo[] {
106
+ if (!graph) return []
107
+ return Object.values(graph.foreignKeys).filter(fk =>
108
+ sameTable(fk.refTable, table),
109
+ )
110
+ }
111
+
112
+ /**
113
+ * The referenced row's identity, as this row names it.
114
+ *
115
+ * `null` when any participating column is NULL — an optional foreign key with
116
+ * no value points at nothing, and a predicate containing `NULL` matches no row
117
+ * anyway, so issuing the lookup would spend a request to learn that.
118
+ */
119
+ export function fkKeyOf(
120
+ target: FkTarget,
121
+ row: Record<string, unknown>,
122
+ ): Record<string, unknown> | null {
123
+ const key: Record<string, unknown> = {}
124
+ for (let i = 0; i < target.cols.length; i++) {
125
+ const value = row[target.cols[i]!]
126
+ if (value === null || value === undefined) return null
127
+ key[target.refCols[i] ?? target.cols[i]!] = value
128
+ }
129
+ return key
130
+ }
131
+
132
+ /** `table|col|value` — stable across pages, which is what makes caching pay. */
133
+ export function cacheKey(
134
+ refTable: string,
135
+ key: Record<string, unknown>,
136
+ ): string {
137
+ const parts = Object.keys(key)
138
+ .sort()
139
+ .map(column => `${column}=${String(key[column])}`)
140
+ return `${refTable}|${parts.join('|')}`
141
+ }
142
+
143
+ /**
144
+ * What to show for a resolved row: the label column if the table has one, the
145
+ * identity otherwise.
146
+ *
147
+ * `graph.labels` is the server's pick — the first text column that is not part
148
+ * of the identity — so a `users` row reads as `ada` rather than `41`.
149
+ */
150
+ export function fkLabel(
151
+ graph: SchemaGraph | null,
152
+ refTable: string,
153
+ row: Record<string, unknown> | null,
154
+ ): string | null {
155
+ if (!row) return null
156
+ const label = graph?.labels?.[refTable]
157
+ if (label && row[label] !== null && row[label] !== undefined) {
158
+ return String(row[label])
159
+ }
160
+ const identity = graph?.identity?.[refTable]?.cols ?? Object.keys(row)
161
+ return identity.map(column => String(row[column])).join(' / ')
162
+ }
163
+
164
+ export type Resolved = Record<string, unknown> | null
165
+
166
+ /**
167
+ * Hover-driven resolution, with the cache and the cancellation.
168
+ *
169
+ * One instance per page load, cleared when the schema graph is refetched.
170
+ */
171
+ export class FkResolver {
172
+ /**
173
+ * 500 entries — ten pages of a fifty-row grid with a foreign key on every
174
+ * row. Past that the earliest are the ones the user has scrolled away from.
175
+ */
176
+ private readonly cache = new BoundedCache<Resolved>(500)
177
+ private timer: ReturnType<typeof setTimeout> | null = null
178
+ private inflight: AbortController | null = null
179
+
180
+ cached(refTable: string, key: Record<string, unknown>): Resolved | undefined {
181
+ return this.cache.get(cacheKey(refTable, key))
182
+ }
183
+
184
+ /**
185
+ * Arm the delay. Returns a canceller for `pointerleave`.
186
+ *
187
+ * The canceller aborts an in-flight request as well as clearing an unfired
188
+ * timer, because the expensive case is the one that already left.
189
+ */
190
+ arm(
191
+ refTable: string,
192
+ key: Record<string, unknown>,
193
+ onResolved: (row: Resolved) => void,
194
+ delay = 350,
195
+ ): () => void {
196
+ const hit = this.cached(refTable, key)
197
+ if (hit !== undefined) {
198
+ onResolved(hit)
199
+ return () => {}
200
+ }
201
+ this.cancel()
202
+ this.timer = setTimeout(() => {
203
+ void this.resolve(refTable, key).then(onResolved, () => {
204
+ // An abort or a failed lookup leaves the cell showing its raw value,
205
+ // which is the state it was already in. Nothing to report: the user
206
+ // moved the pointer away, or the row is simply not resolvable.
207
+ })
208
+ }, delay)
209
+ return () => this.cancel()
210
+ }
211
+
212
+ cancel(): void {
213
+ if (this.timer !== null) clearTimeout(this.timer)
214
+ this.timer = null
215
+ this.inflight?.abort()
216
+ this.inflight = null
217
+ }
218
+
219
+ /** One batched lookup. Public so the panel can resolve without hovering. */
220
+ async resolve(
221
+ refTable: string,
222
+ key: Record<string, unknown>,
223
+ ): Promise<Resolved> {
224
+ const hit = this.cached(refTable, key)
225
+ if (hit !== undefined) return hit
226
+
227
+ const controller = new AbortController()
228
+ this.inflight = controller
229
+ const refs: LookupRef[] = [{ table: refTable, key }]
230
+ const rows = await lookupRefs(refs, controller.signal)
231
+ this.inflight = null
232
+
233
+ const row = rows[0]?.row ?? null
234
+ this.cache.set(cacheKey(refTable, key), row)
235
+ return row
236
+ }
237
+
238
+ clear(): void {
239
+ this.cancel()
240
+ this.cache.clear()
241
+ }
242
+ }
@@ -0,0 +1,103 @@
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 {
15
+ type FkResolver,
16
+ type FkTarget,
17
+ fkForColumn,
18
+ fkKeyOf,
19
+ fkLabel,
20
+ } from './fk'
21
+ import { cellProps, type SchemaColumn, type SchemaGraph } from './meta'
22
+
23
+ export interface CellPaintContext {
24
+ table: string
25
+ graph: SchemaGraph | null
26
+ resolver: FkResolver
27
+ onFollowFk: (target: FkTarget, key: Record<string, unknown>) => void
28
+ }
29
+
30
+ /**
31
+ * Write one cell's value into its `<td>`.
32
+ *
33
+ * The staged flag is passed in rather than read from a session here, so this
34
+ * module needs no `EditSession` — a staged value and a stored value take
35
+ * exactly the same path and cannot diverge in how they look.
36
+ */
37
+ export function paintCell(
38
+ td: HTMLTableCellElement,
39
+ ctx: CellPaintContext,
40
+ row: Record<string, unknown>,
41
+ column: SchemaColumn,
42
+ value: unknown,
43
+ staged: boolean,
44
+ ): void {
45
+ const props = cellProps(value, column)
46
+ td.replaceChildren()
47
+ td.className = props.className
48
+ if (staged) td.classList.add('staged')
49
+ if (props.title) td.title = props.title
50
+ td.appendChild(cellBody(ctx, row, column, value))
51
+ }
52
+
53
+ function cellBody(
54
+ ctx: CellPaintContext,
55
+ row: Record<string, unknown>,
56
+ column: SchemaColumn,
57
+ value: unknown,
58
+ ): Node {
59
+ const target = fkForColumn(ctx.graph, ctx.table, column.name)
60
+ const key = target ? fkKeyOf(target, row) : null
61
+ const text = cellProps(value, column).text
62
+ if (!target || !key) return document.createTextNode(text)
63
+ return fkButton(ctx, target, key, text)
64
+ }
65
+
66
+ /**
67
+ * A foreign key as a link, resolving its label on hover.
68
+ *
69
+ * The resolver owns the delay, the cache and the cancellation — see `fk.ts`.
70
+ * All that happens here is arming it on `pointerenter` and disarming it on
71
+ * `pointerleave`, which is what keeps a sweep across the grid from costing
72
+ * fifty round trips.
73
+ */
74
+ function fkButton(
75
+ ctx: CellPaintContext,
76
+ target: FkTarget,
77
+ key: Record<string, unknown>,
78
+ text: string,
79
+ ): HTMLButtonElement {
80
+ const node = button(text, () => ctx.onFollowFk(target, key), {
81
+ class: 'fk',
82
+ title: `→ ${target.refTable}`,
83
+ })
84
+
85
+ const show = (resolved: Record<string, unknown> | null) => {
86
+ const label = fkLabel(ctx.graph, target.refTable, resolved)
87
+ node.title = label ? `${target.refTable}: ${label}` : `→ ${target.refTable}`
88
+ if (label) node.textContent = `${text} · ${label}`
89
+ }
90
+
91
+ const cached = ctx.resolver.cached(target.refTable, key)
92
+ if (cached !== undefined) show(cached)
93
+
94
+ let disarm: (() => void) | null = null
95
+ node.addEventListener('pointerenter', () => {
96
+ disarm = ctx.resolver.arm(target.refTable, key, show)
97
+ })
98
+ node.addEventListener('pointerleave', () => {
99
+ disarm?.()
100
+ disarm = null
101
+ })
102
+ return node
103
+ }
@@ -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
+ }