@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,332 @@
1
+ /**
2
+ * Everything that paints a page, and nothing that fetches one.
3
+ *
4
+ * The split is the same rule the grid follows: `client.ts` asks the server and
5
+ * hands the answer here, and here it becomes nodes. Keeping the two apart is
6
+ * what stopped the entry module growing back into the 197-line
7
+ * fetch-and-render `renderTable` it replaced.
8
+ *
9
+ * The shape is now three regions rather than two — sidebar, then a column
10
+ * holding the tab strip, the active view and the status bar. The sidebar and
11
+ * the status bar outlive a tab switch; only `#main` is replaced. The painters
12
+ * take callbacks rather than reaching for the entry's state, so the page has no
13
+ * opinion about routing, saving or the panel.
14
+ */
15
+
16
+ import type { Filter } from '../shared/filters'
17
+ import { type BulkToolbar, bulkToolbar } from './bulk'
18
+ import { openImport } from './csv'
19
+ import { append, box, button, el } from './dom'
20
+ import type { EditSession, UndoStack } from './edit-session'
21
+ import { filterBar } from './filter-builder'
22
+ import type { FkResolver, FkTarget } from './fk'
23
+ import { Grid } from './grid'
24
+ import type { SchemaColumn, SchemaTable, TablePage } from './meta'
25
+ import { renderRelations } from './relations'
26
+ import {
27
+ type AppState,
28
+ editableTable,
29
+ PAGE_SIZE,
30
+ readOnlyReason,
31
+ type TableView,
32
+ type ViewState,
33
+ } from './state'
34
+ import { StatusBar } from './statusbar'
35
+ import { renderStructure } from './structure'
36
+
37
+ export interface PageHooks {
38
+ goto: (view: ViewState) => void
39
+ saveRow: (table: SchemaTable, id: string) => void
40
+ openRow: (
41
+ table: SchemaTable,
42
+ columns: SchemaColumn[],
43
+ editable: boolean,
44
+ row: Record<string, unknown>,
45
+ ) => void
46
+ followFk: (target: FkTarget, key: Record<string, unknown>) => void
47
+ reload: () => Promise<void>
48
+ /** Open another table in a tab, filtered. */
49
+ openTable: (table: string, filters: Filter[]) => void
50
+ /**
51
+ * A cell was staged or reverted.
52
+ *
53
+ * The entry module uses it to promote a preview tab: nobody wants the tab
54
+ * they just typed into replaced by the next single click in the sidebar.
55
+ */
56
+ onEdit: () => void
57
+ /** Where a breadcrumb click rewinds the trail to. */
58
+ rewind: (depth: number) => void
59
+ }
60
+
61
+ export class Page {
62
+ grid: Grid | null = null
63
+ readonly status = new StatusBar()
64
+ private toolbar: BulkToolbar | null = null
65
+ private lastMs: number | null = null
66
+
67
+ constructor(
68
+ private readonly state: AppState,
69
+ private readonly session: EditSession,
70
+ private readonly resolver: FkResolver,
71
+ private readonly undo: UndoStack,
72
+ private readonly hooks: PageHooks,
73
+ ) {}
74
+
75
+ // -------------------------------------------------------------------- data
76
+
77
+ /**
78
+ * The Data view: filters, the bulk toolbar, the grid, the pager.
79
+ *
80
+ * `data` is the page the server just returned and `ms` is what it said it
81
+ * cost — the status bar's timing is the envelope's own number rather than a
82
+ * round trip timed here, so a slow filter is attributable.
83
+ */
84
+ paint(
85
+ main: HTMLElement,
86
+ view: ViewState,
87
+ table: SchemaTable,
88
+ data: TablePage,
89
+ ms: number,
90
+ ): void {
91
+ this.lastMs = ms
92
+ const editable = editableTable(this.state, table)
93
+ const columns = table.columns
94
+ main.replaceChildren()
95
+
96
+ append(main, [
97
+ this.breadcrumbs(),
98
+ editable ? null : this.readOnlyBanner(table),
99
+ this.filters(view, columns),
100
+ ])
101
+
102
+ this.toolbar = this.buildToolbar(table, columns, editable)
103
+ main.appendChild(this.toolbar.node)
104
+
105
+ this.grid = this.buildGrid(view, table, columns, editable, data)
106
+ main.appendChild(this.grid.node)
107
+
108
+ append(main, [
109
+ this.pager(view, data),
110
+ this.rowActions(table, columns, editable),
111
+ ])
112
+ this.paintStatus(view, data)
113
+ }
114
+
115
+ /** Structure and Relations: no grid, no fetch, no pager. */
116
+ paintMeta(main: HTMLElement, view: ViewState, table: SchemaTable): void {
117
+ this.grid = null
118
+ this.toolbar = null
119
+ main.replaceChildren()
120
+ append(main, [this.breadcrumbs()])
121
+
122
+ main.appendChild(
123
+ view.view === 'structure'
124
+ ? renderStructure({
125
+ table,
126
+ editable: editableTable(this.state, table),
127
+ reason: readOnlyReason(this.state, table),
128
+ })
129
+ : renderRelations({
130
+ table: table.name,
131
+ graph: this.state.graph,
132
+ onOpen: this.hooks.openTable,
133
+ }),
134
+ )
135
+ this.paintStatus(view, null)
136
+ }
137
+
138
+ /** Nothing is open, or the table went missing. */
139
+ paintEmpty(main: HTMLElement, node: HTMLElement): void {
140
+ this.grid = null
141
+ this.toolbar = null
142
+ this.lastMs = null
143
+ main.replaceChildren(node)
144
+ this.status.paint({
145
+ table: null,
146
+ page: 1,
147
+ ms: null,
148
+ access: this.state.report?.access ?? false,
149
+ dirtyRows: this.session.dirtyRows(),
150
+ filterCount: 0,
151
+ })
152
+ }
153
+
154
+ // ------------------------------------------------------------------ status
155
+
156
+ private paintStatus(view: ViewState, data: TablePage | null): void {
157
+ this.status.paint({
158
+ table: view.table,
159
+ totalRows: data?.totalRows,
160
+ page: view.page,
161
+ totalPages: data?.totalPages,
162
+ // Structure and Relations render from the schema the client already
163
+ // holds, so there is no timing to report and none is invented.
164
+ ms: data ? this.lastMs : null,
165
+ access: this.state.report?.access ?? false,
166
+ dirtyRows: this.session.dirtyRows(),
167
+ filterCount: view.filters.length,
168
+ })
169
+ }
170
+
171
+ /** Re-read the dirty count without repainting anything else. */
172
+ paintDirty(): void {
173
+ const facts = this.status
174
+ facts.bumpDirty(this.session.dirtyRows())
175
+ }
176
+
177
+ // ------------------------------------------------------------------ pieces
178
+
179
+ private buildToolbar(
180
+ table: SchemaTable,
181
+ columns: SchemaColumn[],
182
+ editable: boolean,
183
+ ): BulkToolbar {
184
+ return bulkToolbar({
185
+ table,
186
+ columns,
187
+ editable,
188
+ selectedKeys: () => this.grid?.selectedKeys() ?? [],
189
+ selectedRows: () => this.grid?.selectedRows() ?? [],
190
+ reload: this.hooks.reload,
191
+ undo: this.undo,
192
+ openImport: () =>
193
+ openImport({ table, columns, reload: this.hooks.reload }),
194
+ })
195
+ }
196
+
197
+ private buildGrid(
198
+ view: ViewState,
199
+ table: SchemaTable,
200
+ columns: SchemaColumn[],
201
+ editable: boolean,
202
+ data: TablePage,
203
+ ): Grid {
204
+ return new Grid({
205
+ table,
206
+ columns,
207
+ rows: data.rows ?? [],
208
+ editable,
209
+ graph: this.state.graph,
210
+ session: this.session,
211
+ resolver: this.resolver,
212
+ sortBy: view.sortBy,
213
+ sortOrder: view.sortOrder,
214
+ onSort: column =>
215
+ this.hooks.goto({
216
+ ...view,
217
+ sortBy: column,
218
+ sortOrder: flip(view, column),
219
+ page: 1,
220
+ }),
221
+ onSaveRow: id => this.hooks.saveRow(table, id),
222
+ onOpenRow: row => this.hooks.openRow(table, columns, editable, row),
223
+ onFollowFk: this.hooks.followFk,
224
+ onSelectionChange: count => this.toolbar?.setCount(count),
225
+ onDirtyChange: () => {
226
+ this.paintDirty()
227
+ this.hooks.onEdit()
228
+ },
229
+ })
230
+ }
231
+
232
+ private readOnlyBanner(table: SchemaTable): HTMLElement {
233
+ return el('p', {
234
+ class: 'banner warn',
235
+ text: `read-only — ${readOnlyReason(this.state, table)}`,
236
+ })
237
+ }
238
+
239
+ /**
240
+ * The filter builder.
241
+ *
242
+ * A chip per condition — column, operator, value, remove — where there used
243
+ * to be one text box per column that could only ever mean "contains". Any
244
+ * change resets to page 1, because a filtered result has different pages and
245
+ * staying on page 7 of a set that now has two is a blank screen.
246
+ */
247
+ private filters(view: ViewState, columns: SchemaColumn[]): HTMLElement {
248
+ return filterBar({
249
+ columns,
250
+ filters: view.filters,
251
+ onChange: filters => this.hooks.goto({ ...view, filters, page: 1 }),
252
+ })
253
+ }
254
+
255
+ private pager(view: ViewState, data: TablePage): HTMLElement {
256
+ const bar = el('footer', { class: 'pager' })
257
+ const previous = button(
258
+ '← prev',
259
+ () => this.hooks.goto({ ...view, page: view.page - 1 }),
260
+ { class: 'btn' },
261
+ )
262
+ previous.disabled = view.page <= 1
263
+
264
+ const next = button(
265
+ 'next →',
266
+ () => this.hooks.goto({ ...view, page: view.page + 1 }),
267
+ { class: 'btn' },
268
+ )
269
+ next.disabled =
270
+ data.totalPages !== undefined
271
+ ? view.page >= data.totalPages
272
+ : (data.rows?.length ?? 0) < PAGE_SIZE
273
+
274
+ append(bar, [previous, next, this.undoButton()])
275
+ return bar
276
+ }
277
+
278
+ private undoButton(): HTMLElement | null {
279
+ const entry = this.undo.peek()
280
+ if (!entry) return null
281
+ return button(
282
+ `Undo ${entry.label}`,
283
+ () => {
284
+ this.undo.pop()
285
+ void entry.undo()
286
+ },
287
+ { class: 'btn' },
288
+ )
289
+ }
290
+
291
+ private rowActions(
292
+ table: SchemaTable,
293
+ columns: SchemaColumn[],
294
+ editable: boolean,
295
+ ): HTMLElement {
296
+ const bar = box('toolbar')
297
+ const open = button(
298
+ 'Open row',
299
+ () => {
300
+ const row = this.grid?.focusedRow()
301
+ if (row) this.hooks.openRow(table, columns, editable, row)
302
+ },
303
+ { class: 'btn', title: 'the row under the cursor' },
304
+ )
305
+ const hint = el('span', {
306
+ class: 'note',
307
+ text: 'Enter edits · Esc reverts · Tab commits right · Delete stages NULL',
308
+ })
309
+ append(bar, [open, hint])
310
+ return bar
311
+ }
312
+
313
+ /** Where a foreign-key jump came from, so Back returns rather than resets. */
314
+ private breadcrumbs(): HTMLElement | null {
315
+ if (!this.state.trail.length) return null
316
+ const bar = box('crumbs')
317
+ bar.appendChild(el('span', { class: 'note', text: 'from' }))
318
+ this.state.trail.forEach((view, depth) => {
319
+ bar.appendChild(
320
+ button(view.table, () => this.hooks.rewind(depth), { class: 'btn' }),
321
+ )
322
+ })
323
+ return bar
324
+ }
325
+ }
326
+
327
+ function flip(view: ViewState, column: string): 'ASC' | 'DESC' {
328
+ const same = view.sortBy === column
329
+ return same && view.sortOrder === 'ASC' ? 'DESC' : 'ASC'
330
+ }
331
+
332
+ export type { TableView }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The row side panel — one row, as a form.
3
+ *
4
+ * Promoted from `drawer.ts`, which did the same job with less of it. The grid
5
+ * is the right shape for scanning and the wrong shape for a forty-column row:
6
+ * editing one means horizontal scrolling past thirty-nine others, and a `json`
7
+ * column gets a cell six characters tall. The panel reuses the same editors and
8
+ * the same `EditSession`, so a value staged here is the same staged value the
9
+ * grid shows, and one Save covers both.
10
+ *
11
+ * What it adds over the drawer:
12
+ *
13
+ * - **Long text and JSON get a textarea**, which is the whole reason a panel
14
+ * beats a cell for those two.
15
+ * - **Both directions of the graph.** "References" jumps to the row this one
16
+ * points at; "Referenced by" lists the rows pointing here.
17
+ * - **Exact counts.** They used to be labelled approximate and were: the only
18
+ * filter available was a substring `LIKE`, so a row with id `1` counted `11`
19
+ * and `21` too. With `eq` the number is the number.
20
+ *
21
+ * "Referenced by" is still **lazy** — one request per referencing table, issued
22
+ * when the section is opened. Doing that for every visible row would be the
23
+ * per-cell `fetch` that `fk.ts` exists to avoid, in a different costume.
24
+ */
25
+
26
+ import type { Filter } from '../shared/filters'
27
+ import { fetchPage } from './api'
28
+ import { append, box, button, each, el, on } from './dom'
29
+ import { type EditSession, rowId } from './edit-session'
30
+ import { createEditor } from './editors'
31
+ import { equalityFilters } from './filter-builder'
32
+ import { fkForColumn, fkKeyOf } from './fk'
33
+ import {
34
+ cellText,
35
+ type SchemaColumn,
36
+ type SchemaGraph,
37
+ type SchemaTable,
38
+ } from './meta'
39
+ import { filtersForIncoming, type Relation, relationsFor } from './relations'
40
+ import { defaultView } from './state'
41
+
42
+ export interface PanelContext {
43
+ table: SchemaTable
44
+ columns: SchemaColumn[]
45
+ row: Record<string, unknown>
46
+ editable: boolean
47
+ graph: SchemaGraph | null
48
+ session: EditSession
49
+ onSave: (id: string) => void
50
+ onDirtyChange: () => void
51
+ /** Open another table in a tab, filtered. */
52
+ onNavigate: (table: string, filters: Filter[]) => void
53
+ }
54
+
55
+ export interface PanelHandle {
56
+ close: () => void
57
+ setMessage: (message: string) => void
58
+ }
59
+
60
+ export function openPanel(ctx: PanelContext): PanelHandle {
61
+ const id = rowId(ctx.row, ctx.table.identity.cols)
62
+ const panel = el('aside', {
63
+ class: 'panel',
64
+ attrs: { role: 'dialog', 'aria-label': `row in ${ctx.table.name}` },
65
+ })
66
+
67
+ const message = el('p', { class: 'row-error' })
68
+ const close = () => panel.remove()
69
+
70
+ append(panel, [
71
+ header(ctx, close),
72
+ fields(ctx, id),
73
+ footer(ctx, id, message),
74
+ message,
75
+ references(ctx),
76
+ referencedBy(ctx),
77
+ ])
78
+
79
+ document.body.appendChild(panel)
80
+ return { close, setMessage: text => (message.textContent = text) }
81
+ }
82
+
83
+ function header(ctx: PanelContext, close: () => void): HTMLElement {
84
+ const head = box('panel-head')
85
+ const name = el('h3', { text: ctx.table.name })
86
+ const key = el('p', {
87
+ class: 'note',
88
+ text: ctx.table.identity.cols
89
+ .map(column => `${column}=${cellText(ctx.row[column])}`)
90
+ .join(' · '),
91
+ })
92
+ const title = box('panel-title', name, key)
93
+ append(head, [title, button('Close', close, { class: 'btn' })])
94
+ return head
95
+ }
96
+
97
+ function fields(ctx: PanelContext, id: string | null): HTMLElement {
98
+ const list = box('panel-fields')
99
+ each(list, ctx.columns, column => field(ctx, id, column))
100
+ return list
101
+ }
102
+
103
+ /**
104
+ * One column: its name, its declared type, and an editor or a read-only value.
105
+ *
106
+ * The same `createEditor` the grid opens in a cell — including the null toggle,
107
+ * which is the whole reason a 40-column row is editable here at all: several of
108
+ * those columns are nullable, and a form of plain text inputs cannot say so.
109
+ * `multiline` is the one difference, and it is what makes this the place long
110
+ * text gets edited.
111
+ */
112
+ function field(
113
+ ctx: PanelContext,
114
+ id: string | null,
115
+ column: SchemaColumn,
116
+ ): HTMLElement {
117
+ const wrap = box('panel-field')
118
+ append(wrap, [
119
+ el('label', { class: 'panel-label', text: column.name }),
120
+ el('span', { class: 'note', text: typeNote(column) }),
121
+ ])
122
+
123
+ const current = id
124
+ ? ctx.session.value(id, column.name, ctx.row[column.name])
125
+ : ctx.row[column.name]
126
+
127
+ if (!ctx.editable || !id) {
128
+ wrap.appendChild(
129
+ el('div', { class: 'panel-value', text: cellText(current) }),
130
+ )
131
+ return wrap
132
+ }
133
+
134
+ const editor = createEditor(
135
+ column,
136
+ current,
137
+ {
138
+ // In the panel there is no cursor to move, so every change stages
139
+ // immediately. Same buffer, same Save — the difference is only that the
140
+ // grid needs a commit key to know where to go next and this does not.
141
+ onInput: value => {
142
+ ctx.session.stage(id, ctx.row, column.name, value)
143
+ ctx.onDirtyChange()
144
+ },
145
+ onKey: () => {},
146
+ },
147
+ { multiline: true },
148
+ )
149
+ wrap.appendChild(editor.node)
150
+ return wrap
151
+ }
152
+
153
+ function typeNote(column: SchemaColumn): string {
154
+ const parts = [column.type]
155
+ if (!column.nullable) parts.push('NOT NULL')
156
+ if (column.pk) parts.push('PK')
157
+ if (column.autoIncrement) parts.push('AUTO')
158
+ return parts.join(' · ')
159
+ }
160
+
161
+ function footer(
162
+ ctx: PanelContext,
163
+ id: string | null,
164
+ message: HTMLElement,
165
+ ): HTMLElement {
166
+ const bar = box('row-bar open')
167
+ if (!ctx.editable || !id) {
168
+ bar.appendChild(
169
+ el('span', { class: 'note', text: ctx.table.reason ?? 'read-only' }),
170
+ )
171
+ return bar
172
+ }
173
+ append(bar, [
174
+ button('Save', () => ctx.onSave(id), { class: 'btn primary' }),
175
+ button(
176
+ 'Revert',
177
+ () => {
178
+ ctx.session.drop(id)
179
+ ctx.onDirtyChange()
180
+ message.textContent =
181
+ 'reverted — reopen the row to see the stored values'
182
+ },
183
+ { class: 'btn' },
184
+ ),
185
+ ])
186
+ return bar
187
+ }
188
+
189
+ /**
190
+ * The rows *this* row points at — one link per outgoing foreign key.
191
+ *
192
+ * Not lazy, because it costs nothing: the key is already in the row, so the
193
+ * link is built from data in hand and only the click spends a request. A key
194
+ * whose columns are NULL points at nothing and is listed as such rather than
195
+ * offered as a link that would land on an empty page.
196
+ */
197
+ function references(ctx: PanelContext): HTMLElement | null {
198
+ const outgoing = relationsFor(ctx.graph, ctx.table.name).outgoing
199
+ if (!outgoing.length) return null
200
+
201
+ const section = el('details', { class: 'panel-refs' })
202
+ section.appendChild(el('summary', { text: `references ${outgoing.length}` }))
203
+ const list = box('panel-ref-list')
204
+ each(list, outgoing, relation => referenceRow(ctx, relation))
205
+ section.appendChild(list)
206
+ return section
207
+ }
208
+
209
+ function referenceRow(ctx: PanelContext, relation: Relation): HTMLElement {
210
+ const row = box('panel-ref')
211
+ const target = fkForColumn(ctx.graph, ctx.table.name, relation.cols[0] ?? '')
212
+ const key = target ? fkKeyOf(target, ctx.row) : null
213
+
214
+ if (!key) {
215
+ append(row, [
216
+ el('span', {
217
+ class: 'note',
218
+ text: `${relation.cols.join(', ')} → ${relation.table} (NULL)`,
219
+ }),
220
+ ])
221
+ return row
222
+ }
223
+
224
+ append(row, [
225
+ el('span', { class: 'note', text: `${relation.cols.join(', ')} →` }),
226
+ button(
227
+ relation.table,
228
+ () => ctx.onNavigate(relation.table, equalityFilters(key)),
229
+ { class: 'btn', title: `open the referenced row in ${relation.table}` },
230
+ ),
231
+ ])
232
+ return row
233
+ }
234
+
235
+ /**
236
+ * Tables that point at this row, resolved when the section is opened.
237
+ *
238
+ * The counts are **exact**. They used to be labelled approximate and were —
239
+ * the only filter the table-data endpoint offered was `col LIKE '%value%'`, so
240
+ * a row with id `1` matched `11` and `21` too. `eq` removed the caveat along
241
+ * with the reason for it.
242
+ */
243
+ function referencedBy(ctx: PanelContext): HTMLElement | null {
244
+ const incoming = relationsFor(ctx.graph, ctx.table.name).incoming
245
+ if (!incoming.length) return null
246
+
247
+ const section = el('details', { class: 'panel-refs' })
248
+ section.appendChild(
249
+ el('summary', { text: `referenced by ${incoming.length}` }),
250
+ )
251
+ const list = box('panel-ref-list')
252
+ section.appendChild(list)
253
+
254
+ let loaded = false
255
+ on(section, 'toggle', () => {
256
+ if (!section.open || loaded) return
257
+ loaded = true
258
+ each(list, incoming, relation => incomingRow(ctx, relation))
259
+ })
260
+ return section
261
+ }
262
+
263
+ function incomingRow(ctx: PanelContext, relation: Relation): HTMLElement {
264
+ const filters = filtersForIncoming(relation, ctx.row)
265
+ const row = box('panel-ref')
266
+ const count = el('span', { class: 'note', text: 'counting…' })
267
+
268
+ append(row, [
269
+ button(
270
+ `${relation.table}.${relation.cols.join(', ')}`,
271
+ () => ctx.onNavigate(relation.table, filters),
272
+ { class: 'btn' },
273
+ ),
274
+ count,
275
+ ])
276
+
277
+ void countRows(relation.table, filters).then(
278
+ total => {
279
+ count.textContent = total === null ? '' : `${total} rows`
280
+ },
281
+ () => {
282
+ // The link is still useful without the count, and a failed count is not
283
+ // worth an error banner over a row the user has not asked to see yet.
284
+ count.textContent = ''
285
+ },
286
+ )
287
+ return row
288
+ }
289
+
290
+ async function countRows(
291
+ table: string,
292
+ filters: Filter[],
293
+ ): Promise<number | null> {
294
+ const page = await fetchPage({ ...defaultView(table), filters })
295
+ return page.data.totalRows ?? null
296
+ }