@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.
- 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 +40 -45
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +64 -81
- 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/credential.ts +0 -26
- 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
|
+
}
|
package/src/client/fk.ts
ADDED
|
@@ -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
|
+
}
|