@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.
- package/package.json +4 -4
- package/src/access.ts +188 -0
- package/src/client/api.ts +279 -0
- package/src/client/bulk.ts +357 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +203 -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 +89 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +164 -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 +269 -0
- package/src/client/grid-body.ts +106 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +468 -0
- package/src/client/meta.ts +185 -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 +205 -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 +234 -0
- package/src/client/tabs.ts +219 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +376 -160
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +148 -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 +186 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +173 -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,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
|
+
}
|