@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
package/src/client.ts
CHANGED
|
@@ -1,193 +1,409 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The explorer
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The explorer's browser entry: boot, then wiring. Nothing else.
|
|
3
|
+
*
|
|
4
|
+
* This file used to be the whole client — 197 lines, one `renderTable` that
|
|
5
|
+
* fetched, built a header, built a body and built a pager, and scored **34**
|
|
6
|
+
* against biome's ceiling of 25. Growing that into a data editor would have
|
|
7
|
+
* meant growing that one function, so the shape changed first. The pieces live
|
|
8
|
+
* under `client/`, each obeying two mechanical rules — **no function both
|
|
9
|
+
* fetches and renders**, and **every loop body is a named function** — and what
|
|
10
|
+
* is left here is the composition: state, routing, and which callback goes
|
|
11
|
+
* where.
|
|
12
|
+
*
|
|
13
|
+
* The layout is the one a database client is expected to have: a table list, a
|
|
14
|
+
* strip of table tabs with VS Code preview semantics, Data / Structure /
|
|
15
|
+
* Relations under it — **one level of nesting and no more** — and a status bar.
|
|
16
|
+
* Tab state lives in `client/tabs.ts` and is pure; this module owns the hash.
|
|
17
|
+
*
|
|
18
|
+
* What is deliberately *not* here, and is not anywhere: a SQL console, an ER
|
|
19
|
+
* diagram, and grid virtualisation. The first is refused by the plugin's
|
|
20
|
+
* contract — no raw SQL, structurally — and the other two are not what a row
|
|
21
|
+
* editor is for.
|
|
5
22
|
*/
|
|
6
23
|
|
|
7
|
-
|
|
24
|
+
import {
|
|
25
|
+
adoptUrlKey,
|
|
26
|
+
fetchGraph,
|
|
27
|
+
fetchPage,
|
|
28
|
+
fetchSchema,
|
|
29
|
+
messageOf,
|
|
30
|
+
} from './client/api'
|
|
31
|
+
import { confirmChoice, notify } from './client/confirm'
|
|
32
|
+
import { el } from './client/dom'
|
|
33
|
+
import { EditSession, UndoStack } from './client/edit-session'
|
|
34
|
+
import { equalityFilters } from './client/filter-builder'
|
|
35
|
+
import { FkResolver, type FkTarget } from './client/fk'
|
|
36
|
+
import type { SchemaColumn, SchemaTable } from './client/meta'
|
|
37
|
+
import { Page } from './client/page'
|
|
38
|
+
import { openPanel } from './client/panel'
|
|
39
|
+
import { saveRow } from './client/save'
|
|
40
|
+
import { renderSidebar } from './client/sidebar'
|
|
41
|
+
import {
|
|
42
|
+
type AppState,
|
|
43
|
+
createState,
|
|
44
|
+
defaultView,
|
|
45
|
+
isSystemTable,
|
|
46
|
+
type TableView,
|
|
47
|
+
tableOf,
|
|
48
|
+
type ViewState,
|
|
49
|
+
} from './client/state'
|
|
50
|
+
import {
|
|
51
|
+
activeTab,
|
|
52
|
+
activeView,
|
|
53
|
+
closeTab,
|
|
54
|
+
createTabs,
|
|
55
|
+
decodeTabs,
|
|
56
|
+
encodeTabs,
|
|
57
|
+
openPermanent,
|
|
58
|
+
openPreview,
|
|
59
|
+
promoteActive,
|
|
60
|
+
pruneTabs,
|
|
61
|
+
replaceActiveView,
|
|
62
|
+
selectTab,
|
|
63
|
+
type TabsState,
|
|
64
|
+
} from './client/tabs'
|
|
65
|
+
import {
|
|
66
|
+
renderNewTabPage,
|
|
67
|
+
renderTabStrip,
|
|
68
|
+
renderViewTabs,
|
|
69
|
+
} from './client/tabstrip'
|
|
70
|
+
import type { Filter } from './shared/filters'
|
|
8
71
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
72
|
+
const app = document.getElementById('app')!
|
|
73
|
+
|
|
74
|
+
const state: AppState = createState()
|
|
75
|
+
const session = new EditSession()
|
|
76
|
+
const resolver = new FkResolver()
|
|
77
|
+
const undoStack = new UndoStack(20)
|
|
78
|
+
|
|
79
|
+
let tabs: TabsState = createTabs()
|
|
80
|
+
|
|
81
|
+
/** Set while `writeHash` writes, so the `hashchange` listener ignores itself. */
|
|
82
|
+
let selfNavigation = false
|
|
83
|
+
|
|
84
|
+
const page = new Page(state, session, resolver, undoStack, {
|
|
85
|
+
goto: view => void goto(view),
|
|
86
|
+
saveRow: (table, id) => void save(table, id),
|
|
87
|
+
openRow: openRowPanel,
|
|
88
|
+
followFk: (target, key) => void followFk(target, key),
|
|
89
|
+
reload: () => renderMain(),
|
|
90
|
+
openTable: (table, filters) => void openTable(table, filters),
|
|
91
|
+
onEdit: () => {
|
|
92
|
+
if (!activeTab(tabs)?.preview) return
|
|
93
|
+
tabs = promoteActive(tabs)
|
|
94
|
+
writeHash()
|
|
95
|
+
renderChrome()
|
|
96
|
+
},
|
|
97
|
+
rewind: depth => {
|
|
98
|
+
state.trail = state.trail.slice(0, depth)
|
|
99
|
+
void goto(state.trail[depth] ?? defaultView(currentTable()))
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
function currentTable(): string {
|
|
104
|
+
return activeView(tabs)?.table ?? ''
|
|
13
105
|
}
|
|
14
106
|
|
|
15
|
-
|
|
107
|
+
function save(table: SchemaTable, id: string): void {
|
|
108
|
+
void saveRow(table, id, {
|
|
109
|
+
session,
|
|
110
|
+
surface: () => page.grid,
|
|
111
|
+
reload: renderMain,
|
|
112
|
+
onDirtyChange: () => page.paintDirty(),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ------------------------------------------------------------------- routing
|
|
16
117
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
118
|
+
function writeHash(): void {
|
|
119
|
+
selfNavigation = true
|
|
120
|
+
location.hash = encodeTabs(tabs)
|
|
121
|
+
// `hashchange` fires as a task, so the flag has to survive at least until
|
|
122
|
+
// the next one — a microtask would clear it before the listener ran.
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
selfNavigation = false
|
|
125
|
+
}, 0)
|
|
126
|
+
}
|
|
22
127
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Ask before losing typed values.
|
|
130
|
+
*
|
|
131
|
+
* The navigation half of the unload guard: losing typed values to a mis-click
|
|
132
|
+
* on a table name is the same loss as losing them to a closed tab, and only one
|
|
133
|
+
* of the two was ever guarded by the browser.
|
|
134
|
+
*/
|
|
135
|
+
async function confirmDiscard(): Promise<boolean> {
|
|
136
|
+
if (session.dirtyRows() === 0) return true
|
|
137
|
+
const ok = await confirmChoice({
|
|
138
|
+
verb: 'discard',
|
|
139
|
+
count: session.dirtyRows(),
|
|
140
|
+
table: currentTable() || '—',
|
|
141
|
+
detail: 'unsaved edits will be thrown away',
|
|
142
|
+
})
|
|
143
|
+
if (ok) session.clear()
|
|
144
|
+
return ok
|
|
31
145
|
}
|
|
32
146
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
147
|
+
/** Move the active tab to a new view of the same table. */
|
|
148
|
+
async function goto(view: ViewState): Promise<void> {
|
|
149
|
+
if (!(await confirmDiscard())) return
|
|
150
|
+
tabs = replaceActiveView(tabs, view)
|
|
151
|
+
writeHash()
|
|
152
|
+
await renderMain()
|
|
36
153
|
}
|
|
37
154
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
btn.addEventListener('click', () => {
|
|
64
|
-
currentTable = name
|
|
65
|
-
currentPage = 1
|
|
66
|
-
sortBy = null
|
|
67
|
-
void renderTable()
|
|
68
|
-
side
|
|
69
|
-
.querySelectorAll('.table-btn')
|
|
70
|
-
.forEach(b => b.classList.toggle('active', b.textContent === name))
|
|
71
|
-
})
|
|
72
|
-
side.appendChild(btn)
|
|
155
|
+
/** Switch which tab is showing. Each tab keeps its own page, sort and filters. */
|
|
156
|
+
async function select(index: number): Promise<void> {
|
|
157
|
+
if (index === tabs.active) return
|
|
158
|
+
if (!(await confirmDiscard())) return
|
|
159
|
+
tabs = selectTab(tabs, index)
|
|
160
|
+
writeHash()
|
|
161
|
+
await renderAll()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Single click in the sidebar: a replaceable preview tab. */
|
|
165
|
+
async function preview(table: string): Promise<void> {
|
|
166
|
+
if (!(await confirmDiscard())) return
|
|
167
|
+
tabs = openPreview(tabs, defaultView(table))
|
|
168
|
+
writeHash()
|
|
169
|
+
await renderAll()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Double click, or a link from Relations: a tab that stays. */
|
|
173
|
+
async function openTable(table: string, filters: Filter[] = []): Promise<void> {
|
|
174
|
+
if (!(await confirmDiscard())) return
|
|
175
|
+
tabs = openPermanent(tabs, { ...defaultView(table), filters })
|
|
176
|
+
// A link that carries filters is a deliberate destination, so it replaces
|
|
177
|
+
// whatever the tab held rather than restoring the tab's old view.
|
|
178
|
+
if (filters.length) {
|
|
179
|
+
tabs = replaceActiveView(tabs, { ...defaultView(table), filters })
|
|
73
180
|
}
|
|
181
|
+
writeHash()
|
|
182
|
+
await renderAll()
|
|
183
|
+
}
|
|
74
184
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
185
|
+
async function close(index: number): Promise<void> {
|
|
186
|
+
if (index === tabs.active && !(await confirmDiscard())) return
|
|
187
|
+
tabs = closeTab(tabs, index)
|
|
188
|
+
writeHash()
|
|
189
|
+
await renderAll()
|
|
190
|
+
}
|
|
78
191
|
|
|
79
|
-
|
|
192
|
+
function switchView(view: TableView): void {
|
|
193
|
+
const current = activeView(tabs)
|
|
194
|
+
if (!current) return
|
|
195
|
+
void goto({ ...current, view })
|
|
80
196
|
}
|
|
81
197
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
198
|
+
// ------------------------------------------------------------------ painting
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The four regions, built **once**.
|
|
202
|
+
*
|
|
203
|
+
* The slots matter: `renderChrome` repaints the sidebar and the strip in
|
|
204
|
+
* place, and `#main` is not one of them. Rebuilding the whole shell on every
|
|
205
|
+
* tab-strip change would tear down the grid — including any editor open in it
|
|
206
|
+
* and the focus inside that editor — every time a staged edit promoted a
|
|
207
|
+
* preview tab, which is precisely when it must not.
|
|
208
|
+
*/
|
|
209
|
+
const sideSlot = el('div', { class: 'side-slot' })
|
|
210
|
+
const stripSlot = el('div', { class: 'strip-slot' })
|
|
211
|
+
const mainSlot = el('main', { class: 'main', id: 'main' })
|
|
85
212
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
213
|
+
function renderShell(): void {
|
|
214
|
+
const column = el('div', { class: 'column' })
|
|
215
|
+
column.append(stripSlot, mainSlot, page.status.node)
|
|
216
|
+
app.replaceChildren(sideSlot, column)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Sidebar and tab strip. Repainted whenever the *set* of tabs changes. */
|
|
220
|
+
function renderChrome(): void {
|
|
221
|
+
sideSlot.replaceChildren(
|
|
222
|
+
renderSidebar({
|
|
223
|
+
tables: state.report?.tables ?? [],
|
|
224
|
+
showSystem: state.showSystem,
|
|
225
|
+
activeTable: activeView(tabs)?.table ?? null,
|
|
226
|
+
onPreview: table => void preview(table),
|
|
227
|
+
onOpen: table => void openTable(table),
|
|
228
|
+
onToggleSystem: show => {
|
|
229
|
+
state.showSystem = show
|
|
230
|
+
renderChrome()
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
const strip = renderTabStrip({
|
|
236
|
+
tabs,
|
|
237
|
+
onSelect: index => void select(index),
|
|
238
|
+
onPromote: index => {
|
|
239
|
+
tabs = selectTab(tabs, index)
|
|
240
|
+
tabs = promoteActive(tabs)
|
|
241
|
+
writeHash()
|
|
242
|
+
renderChrome()
|
|
243
|
+
},
|
|
244
|
+
onClose: index => void close(index),
|
|
245
|
+
onNew: () => {
|
|
246
|
+
tabs = { ...tabs, active: -1 }
|
|
247
|
+
writeHash()
|
|
248
|
+
void renderAll()
|
|
249
|
+
},
|
|
91
250
|
})
|
|
92
|
-
if (sortBy) params.set('sortBy', sortBy)
|
|
93
251
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
252
|
+
const current = activeView(tabs)
|
|
253
|
+
stripSlot.replaceChildren(strip)
|
|
254
|
+
if (current) {
|
|
255
|
+
stripSlot.appendChild(
|
|
256
|
+
renderViewTabs({ current: current.view, onSelect: switchView }),
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function renderAll(): Promise<void> {
|
|
262
|
+
renderChrome()
|
|
263
|
+
await renderMain()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Fetch, then render — the two never live in one function. */
|
|
267
|
+
async function renderMain(): Promise<void> {
|
|
268
|
+
const main = mainSlot
|
|
269
|
+
const view = activeView(tabs)
|
|
270
|
+
if (!view) {
|
|
271
|
+
page.paintEmpty(main, renderNewTabPage())
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const table = tableOf(state, view.table)
|
|
276
|
+
if (!table) {
|
|
277
|
+
page.paintEmpty(
|
|
278
|
+
main,
|
|
279
|
+
el('p', { class: 'note', text: `${view.table} is not in this schema.` }),
|
|
280
|
+
)
|
|
99
281
|
return
|
|
100
282
|
}
|
|
101
283
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const meta = el('span', 'note')
|
|
108
|
-
meta.textContent =
|
|
109
|
-
data.totalRows !== undefined
|
|
110
|
-
? `${data.totalRows} rows · page ${currentPage}${data.totalPages ? ` / ${data.totalPages}` : ''}`
|
|
111
|
-
: `page ${currentPage}`
|
|
112
|
-
head.appendChild(meta)
|
|
113
|
-
main.appendChild(head)
|
|
114
|
-
|
|
115
|
-
if (!rows.length) {
|
|
116
|
-
main.appendChild(el('p', 'note', 'No rows on this page.'))
|
|
117
|
-
} else {
|
|
118
|
-
const cols = Object.keys(rows[0])
|
|
119
|
-
const table = el('table', 'grid')
|
|
120
|
-
const thead = el('thead')
|
|
121
|
-
const headRow = el('tr')
|
|
122
|
-
for (const col of cols) {
|
|
123
|
-
const th = el('th', undefined, col)
|
|
124
|
-
if (col === sortBy) th.textContent += sortOrder === 'ASC' ? ' ↑' : ' ↓'
|
|
125
|
-
// rowid is in the payload but not in the sortable allow-list server-side;
|
|
126
|
-
// offering a header click that silently no-ops reads as a bug.
|
|
127
|
-
if (col === 'rowid') {
|
|
128
|
-
headRow.appendChild(th)
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
th.addEventListener('click', () => {
|
|
132
|
-
sortOrder = sortBy === col && sortOrder === 'ASC' ? 'DESC' : 'ASC'
|
|
133
|
-
sortBy = col
|
|
134
|
-
void renderTable()
|
|
135
|
-
})
|
|
136
|
-
headRow.appendChild(th)
|
|
137
|
-
}
|
|
138
|
-
thead.appendChild(headRow)
|
|
139
|
-
table.appendChild(thead)
|
|
140
|
-
|
|
141
|
-
const tbody = el('tbody')
|
|
142
|
-
for (const row of rows) {
|
|
143
|
-
const tr = el('tr')
|
|
144
|
-
for (const col of cols) {
|
|
145
|
-
const value = row[col]
|
|
146
|
-
tr.appendChild(
|
|
147
|
-
el(
|
|
148
|
-
'td',
|
|
149
|
-
value === null ? 'null' : undefined,
|
|
150
|
-
value === null ? 'NULL' : String(value),
|
|
151
|
-
),
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
tbody.appendChild(tr)
|
|
155
|
-
}
|
|
156
|
-
table.appendChild(tbody)
|
|
157
|
-
|
|
158
|
-
const scroller = el('div', 'scroll')
|
|
159
|
-
scroller.appendChild(table)
|
|
160
|
-
main.appendChild(scroller)
|
|
284
|
+
// Structure and Relations render from the schema report the client already
|
|
285
|
+
// holds — no request, so no loading state and no failure path.
|
|
286
|
+
if (view.view !== 'data') {
|
|
287
|
+
page.paintMeta(main, view, table)
|
|
288
|
+
return
|
|
161
289
|
}
|
|
162
290
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
291
|
+
main.replaceChildren(
|
|
292
|
+
el('p', { class: 'note', text: `loading ${table.name}…` }),
|
|
293
|
+
)
|
|
294
|
+
try {
|
|
295
|
+
const answer = await fetchPage(view)
|
|
296
|
+
page.paint(main, view, table, answer.data, answer.ms)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
main.replaceChildren(el('p', { class: 'error', text: messageOf(error) }))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ----------------------------------------------------------- panel and links
|
|
303
|
+
|
|
304
|
+
function openRowPanel(
|
|
305
|
+
table: SchemaTable,
|
|
306
|
+
columns: SchemaColumn[],
|
|
307
|
+
editable: boolean,
|
|
308
|
+
row: Record<string, unknown>,
|
|
309
|
+
): void {
|
|
310
|
+
const handle = openPanel({
|
|
311
|
+
table,
|
|
312
|
+
columns,
|
|
313
|
+
row,
|
|
314
|
+
editable,
|
|
315
|
+
graph: state.graph,
|
|
316
|
+
session,
|
|
317
|
+
onSave: id => {
|
|
318
|
+
handle.close()
|
|
319
|
+
save(table, id)
|
|
320
|
+
},
|
|
321
|
+
// A real edit is what makes a preview tab permanent — VS Code's rule, and
|
|
322
|
+
// the one that matters: nobody wants the tab they just typed into replaced
|
|
323
|
+
// by the next single click in the sidebar.
|
|
324
|
+
onDirtyChange: () => {
|
|
325
|
+
tabs = promoteActive(tabs)
|
|
326
|
+
page.paintDirty()
|
|
327
|
+
renderChrome()
|
|
328
|
+
},
|
|
329
|
+
onNavigate: (name, filters) => void openTable(name, filters),
|
|
178
330
|
})
|
|
179
|
-
pager.append(prev, next)
|
|
180
|
-
main.appendChild(pager)
|
|
181
331
|
}
|
|
182
332
|
|
|
183
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Follow a foreign key.
|
|
335
|
+
*
|
|
336
|
+
* One `eq` filter per referenced column, which *is* the row identity — so the
|
|
337
|
+
* destination page holds exactly the referenced row. This used to need a
|
|
338
|
+
* second mechanism: `filters` was a substring `LIKE`, `id=1` also matched `11`,
|
|
339
|
+
* so a link carried a separate `focus` identity and the grid highlighted it.
|
|
340
|
+
* `eq` removed the need and `focus` went with it.
|
|
341
|
+
*
|
|
342
|
+
* The current view is pushed first, so Back returns to where the reference was
|
|
343
|
+
* followed from — the breadcrumb, which stays.
|
|
344
|
+
*/
|
|
345
|
+
async function followFk(
|
|
346
|
+
target: FkTarget,
|
|
347
|
+
key: Record<string, unknown>,
|
|
348
|
+
): Promise<void> {
|
|
349
|
+
const current = activeView(tabs)
|
|
350
|
+
if (current) state.trail.push(current)
|
|
351
|
+
await openTable(target.refTable, equalityFilters(key))
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ------------------------------------------------------------------ plumbing
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The unload guard.
|
|
358
|
+
*
|
|
359
|
+
* `preventDefault` is the modern spelling and `returnValue` is what Safari
|
|
360
|
+
* still reads. Both, because losing an edit to a closed tab is the failure this
|
|
361
|
+
* exists for and the second line costs a line.
|
|
362
|
+
*/
|
|
363
|
+
function guardUnload(event: BeforeUnloadEvent): void {
|
|
364
|
+
if (session.dirtyRows() === 0) return
|
|
365
|
+
event.preventDefault()
|
|
366
|
+
event.returnValue = ''
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function boot(): Promise<void> {
|
|
370
|
+
adoptUrlKey()
|
|
184
371
|
try {
|
|
185
|
-
// `
|
|
186
|
-
|
|
187
|
-
|
|
372
|
+
// `{access, tables}`: the client has to know its posture *before* it
|
|
373
|
+
// renders, so it never draws an edit affordance it cannot honour.
|
|
374
|
+
state.report = await fetchSchema()
|
|
188
375
|
} catch (error) {
|
|
189
|
-
app.replaceChildren(el('p', 'error',
|
|
376
|
+
app.replaceChildren(el('p', { class: 'error', text: messageOf(error) }))
|
|
377
|
+
return
|
|
190
378
|
}
|
|
379
|
+
|
|
380
|
+
// The graph is decoration — a schema with no declared foreign keys is
|
|
381
|
+
// ordinary, and a failure here must not cost anyone the grid.
|
|
382
|
+
state.graph = await fetchGraph().catch(() => null)
|
|
383
|
+
|
|
384
|
+
// A hash can name a table that has since been dropped; pruning here means
|
|
385
|
+
// the strip never carries a tab that can only ever render an error.
|
|
386
|
+
const known = new Set((state.report.tables ?? []).map(table => table.name))
|
|
387
|
+
tabs = pruneTabs(decodeTabs(location.hash), known)
|
|
388
|
+
|
|
389
|
+
// A system table reached by link opens with the sidebar toggle already on,
|
|
390
|
+
// so the tab it lands in is visible in the list beside it rather than
|
|
391
|
+
// appearing to have come from nowhere.
|
|
392
|
+
if (tabs.tabs.some(tab => isSystemTable(tab.view.table))) {
|
|
393
|
+
state.showSystem = true
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
renderShell()
|
|
397
|
+
await renderAll()
|
|
398
|
+
|
|
399
|
+
window.addEventListener('beforeunload', guardUnload)
|
|
400
|
+
window.addEventListener('hashchange', () => {
|
|
401
|
+
if (selfNavigation) return
|
|
402
|
+
tabs = pruneTabs(decodeTabs(location.hash), known)
|
|
403
|
+
void renderAll()
|
|
404
|
+
})
|
|
191
405
|
}
|
|
192
406
|
|
|
193
|
-
void boot()
|
|
407
|
+
void boot().catch(error => {
|
|
408
|
+
notify(messageOf(error), 'error')
|
|
409
|
+
})
|