@quazardous/qdadm 2.17.4 → 2.19.0
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 +5 -7
- package/src/components/editors/VanillaJsonEditor.vue +14 -4
- package/src/composables/useLayoutResolver.ts +10 -0
- package/src/composables/useListPage.filters.ts +124 -30
- package/src/composables/useListPage.ts +72 -17
- package/src/entity/storage/ApiStorage.ts +54 -6
- package/src/gen/createManagers.ts +15 -1
- package/src/gen/index.ts +7 -1
- package/src/kernel/Kernel.registries.ts +202 -46
- package/src/kernel/Kernel.ts +39 -10
- package/src/kernel/Kernel.types.ts +3 -0
- package/src/kernel/Kernel.vue.ts +55 -2
- package/src/kernel/KernelContext.routing.ts +18 -7
- package/src/kernel/debugBarKillSwitch.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"dist/node"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@quazardous/qdcore": "^1.
|
|
58
|
-
"@quazardous/qddebug": "^1.
|
|
57
|
+
"@quazardous/qdcore": "^1.2.0",
|
|
58
|
+
"@quazardous/qddebug": "^1.2.0",
|
|
59
59
|
"@quazardous/quarkernel": "^2.1.0",
|
|
60
60
|
"yaml": "^2.8.4"
|
|
61
61
|
},
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
|
64
64
|
"primevue": "^4.0.0",
|
|
65
65
|
"sass-embedded": "^1.0.0",
|
|
66
|
+
"vanilla-jsoneditor": "^3.13.0",
|
|
66
67
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
|
67
68
|
"vue": "^3.3.0",
|
|
68
69
|
"vue-router": "^4.0.0 || ^5.0.0"
|
|
@@ -78,12 +79,9 @@
|
|
|
78
79
|
"optional": true
|
|
79
80
|
},
|
|
80
81
|
"sass-embedded": {
|
|
81
|
-
"optional":
|
|
82
|
+
"optional": true
|
|
82
83
|
}
|
|
83
84
|
},
|
|
84
|
-
"optionalDependencies": {
|
|
85
|
-
"vanilla-jsoneditor": "^0.23.0"
|
|
86
|
-
},
|
|
87
85
|
"keywords": [
|
|
88
86
|
"vue",
|
|
89
87
|
"vue3",
|
|
@@ -15,7 +15,17 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
18
|
-
|
|
18
|
+
// vanilla-jsoneditor 3.x (#1901): the `new JSONEditor(...)` constructor is
|
|
19
|
+
// deprecated — `JSONEditor` is now the Svelte component itself, and the
|
|
20
|
+
// instance type is exported as `JsonEditor`.
|
|
21
|
+
import {
|
|
22
|
+
createJSONEditor,
|
|
23
|
+
type JsonEditor,
|
|
24
|
+
type Content,
|
|
25
|
+
type ContentErrors,
|
|
26
|
+
type OnChangeStatus,
|
|
27
|
+
type Mode,
|
|
28
|
+
} from 'vanilla-jsoneditor'
|
|
19
29
|
import { buildJsonValidator, type Validator, type JSONSchema, type JsonValidatorOptions } from './jsonValidator'
|
|
20
30
|
|
|
21
31
|
type JsonValue = Record<string, unknown> | unknown[] | string | null
|
|
@@ -74,8 +84,8 @@ const emit = defineEmits<{
|
|
|
74
84
|
'error': [errors: ContentErrors]
|
|
75
85
|
}>()
|
|
76
86
|
|
|
77
|
-
const containerRef = ref<
|
|
78
|
-
let editor:
|
|
87
|
+
const containerRef = ref<HTMLDivElement | null>(null)
|
|
88
|
+
let editor: JsonEditor | null = null
|
|
79
89
|
// Flag to prevent onChange from firing during programmatic updates (instance-specific)
|
|
80
90
|
const updatingFromProp = ref<boolean>(false)
|
|
81
91
|
|
|
@@ -102,7 +112,7 @@ onMounted(() => {
|
|
|
102
112
|
json: parseValue(props.modelValue)
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
editor =
|
|
115
|
+
editor = createJSONEditor({
|
|
106
116
|
target: containerRef.value,
|
|
107
117
|
props: {
|
|
108
118
|
content,
|
|
@@ -34,6 +34,10 @@ import { useRoute } from 'vue-router'
|
|
|
34
34
|
export const LAYOUT_TYPES = {
|
|
35
35
|
LIST: 'list',
|
|
36
36
|
FORM: 'form',
|
|
37
|
+
// `show` was emitted by crud({ show }) long before it existed here (#1922).
|
|
38
|
+
// The resolver returned it, found no component under that key, and fell
|
|
39
|
+
// back to `base` — gracefully, which is exactly what made it invisible.
|
|
40
|
+
SHOW: 'show',
|
|
37
41
|
DASHBOARD: 'dashboard',
|
|
38
42
|
BASE: 'base',
|
|
39
43
|
} as const
|
|
@@ -46,6 +50,7 @@ export type LayoutType = (typeof LAYOUT_TYPES)[keyof typeof LAYOUT_TYPES]
|
|
|
46
50
|
export interface LayoutComponentsMap {
|
|
47
51
|
list?: Component | null
|
|
48
52
|
form?: Component | null
|
|
53
|
+
show?: Component | null
|
|
49
54
|
dashboard?: Component | null
|
|
50
55
|
base?: Component | null
|
|
51
56
|
[key: string]: Component | null | undefined
|
|
@@ -63,6 +68,7 @@ interface PageNamePattern {
|
|
|
63
68
|
const PAGE_NAME_PATTERNS: PageNamePattern[] = [
|
|
64
69
|
{ pattern: /List(Page)?$/i, layout: LAYOUT_TYPES.LIST },
|
|
65
70
|
{ pattern: /(Edit|Create|Form)(Page)?$/i, layout: LAYOUT_TYPES.FORM },
|
|
71
|
+
{ pattern: /Show(Page)?$/i, layout: LAYOUT_TYPES.SHOW },
|
|
66
72
|
{ pattern: /Dashboard(Page)?$/i, layout: LAYOUT_TYPES.DASHBOARD },
|
|
67
73
|
]
|
|
68
74
|
|
|
@@ -70,6 +76,10 @@ const PAGE_NAME_PATTERNS: PageNamePattern[] = [
|
|
|
70
76
|
const ROUTE_NAME_PATTERNS: PageNamePattern[] = [
|
|
71
77
|
{ pattern: /-list$/i, layout: LAYOUT_TYPES.LIST },
|
|
72
78
|
{ pattern: /-(edit|create)$/i, layout: LAYOUT_TYPES.FORM },
|
|
79
|
+
// Load-bearing, not decoration: an app that declares its detail route by
|
|
80
|
+
// hand rather than through crud({ show }) has no meta.layout to go on, and
|
|
81
|
+
// this is the only path that can reach `show` for it (#1922).
|
|
82
|
+
{ pattern: /-show$/i, layout: LAYOUT_TYPES.SHOW },
|
|
73
83
|
{ pattern: /-dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
|
|
74
84
|
// Common patterns without suffix
|
|
75
85
|
{ pattern: /^dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
|
|
@@ -44,7 +44,6 @@ export interface UseListFiltersDeps {
|
|
|
44
44
|
/** Thunks — resolved lazily, the targets are declared later in useListPage. */
|
|
45
45
|
loadItems: () => void
|
|
46
46
|
setSearch: (searchCfg: Partial<SearchConfig>) => void
|
|
47
|
-
invokeFilterAlterHook: () => Promise<void>
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
export interface UseListFiltersReturn {
|
|
@@ -63,6 +62,8 @@ export interface UseListFiltersReturn {
|
|
|
63
62
|
loadFilterOptions: () => Promise<void>
|
|
64
63
|
updateCacheBasedFilters: () => Promise<void>
|
|
65
64
|
restoreFilters: () => void
|
|
65
|
+
/** Push filters, search and the page number into the URL (#2113). */
|
|
66
|
+
writeStateToUrl: () => void
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
@@ -83,13 +84,42 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
83
84
|
entityFilters,
|
|
84
85
|
loadItems,
|
|
85
86
|
setSearch,
|
|
86
|
-
invokeFilterAlterHook,
|
|
87
87
|
} = deps
|
|
88
88
|
|
|
89
89
|
const filtersMap = ref<Map<string, FilterConfig>>(new Map())
|
|
90
90
|
const filterValues = ref<Record<string, unknown>>(savedFilters || {})
|
|
91
91
|
|
|
92
|
+
/** Names the URL sync already owns — a filter cannot have them (#2113). */
|
|
93
|
+
const RESERVED_QUERY_KEYS = new Set(['page', 'search'])
|
|
94
|
+
const warnedReserved = new Set<string>()
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A filter named `page` or `search` silently overwrites the query key of
|
|
98
|
+
* the same name, and is overwritten back on restore. `search` has behaved
|
|
99
|
+
* this way since the URL sync existed and nobody ever reported it, which
|
|
100
|
+
* says how quiet the failure is; `page` joins the reserved set now that the
|
|
101
|
+
* page number lives in the URL too.
|
|
102
|
+
*
|
|
103
|
+
* We name what happens INSTEAD of what was asked, rather than dropping the
|
|
104
|
+
* filter — renaming someone's filter behind their back would be a worse
|
|
105
|
+
* surprise than the collision (ADR 0011).
|
|
106
|
+
*/
|
|
107
|
+
function warnIfReservedName(name: string): void {
|
|
108
|
+
if (!RESERVED_QUERY_KEYS.has(name)) return
|
|
109
|
+
if (!syncUrlParams) return
|
|
110
|
+
if (warnedReserved.has(name)) return
|
|
111
|
+
warnedReserved.add(name)
|
|
112
|
+
console.warn(
|
|
113
|
+
`[qdadm] Filter "${name}" on "${entityName}" collides with the URL ` +
|
|
114
|
+
`parameter of the same name. With syncUrlParams on, the list's own ` +
|
|
115
|
+
`${name === 'page' ? 'page number' : 'search query'} wins and this ` +
|
|
116
|
+
`filter will not survive a reload. Rename the filter, or set ` +
|
|
117
|
+
`syncUrlParams: false on this list.`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
92
121
|
function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
|
|
122
|
+
warnIfReservedName(name)
|
|
93
123
|
filtersMap.value.set(name, {
|
|
94
124
|
name,
|
|
95
125
|
type: 'select',
|
|
@@ -115,6 +145,46 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
115
145
|
onFiltersChanged()
|
|
116
146
|
}
|
|
117
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Push the list's own state into the URL (#2113).
|
|
150
|
+
*
|
|
151
|
+
* Filters, search AND the page number, each written only when it differs
|
|
152
|
+
* from the default and removed otherwise, so a pristine list leaves a clean
|
|
153
|
+
* URL. `router.replace` rather than `push`: paging is not a history step of
|
|
154
|
+
* its own, but the URL must carry the page at the moment the user leaves
|
|
155
|
+
* for a detail view, so that coming back restores it.
|
|
156
|
+
*/
|
|
157
|
+
function writeStateToUrl(): void {
|
|
158
|
+
if (!syncUrlParams) return
|
|
159
|
+
|
|
160
|
+
const query = { ...route.query } as Record<string, string>
|
|
161
|
+
|
|
162
|
+
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
163
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
164
|
+
query[name] = String(value)
|
|
165
|
+
} else {
|
|
166
|
+
delete query[name]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (searchQuery.value) {
|
|
171
|
+
query.search = searchQuery.value
|
|
172
|
+
} else {
|
|
173
|
+
delete query.search
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Page 1 is the default and stays implicit. Reserved keys are written
|
|
177
|
+
// last so the list's own state wins a collision — the warning in
|
|
178
|
+
// addFilter() names that consequence at declaration time.
|
|
179
|
+
if (page.value > 1) {
|
|
180
|
+
query.page = String(page.value)
|
|
181
|
+
} else {
|
|
182
|
+
delete query.page
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
router.replace({ query })
|
|
186
|
+
}
|
|
187
|
+
|
|
118
188
|
function onFiltersChanged(): void {
|
|
119
189
|
page.value = 1
|
|
120
190
|
loadItems()
|
|
@@ -136,22 +206,7 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
136
206
|
}
|
|
137
207
|
setSessionFilters(filterSessionKey, toPersist)
|
|
138
208
|
}
|
|
139
|
-
|
|
140
|
-
const query = { ...route.query } as Record<string, string>
|
|
141
|
-
for (const [name, value] of Object.entries(filterValues.value)) {
|
|
142
|
-
if (value !== null && value !== undefined && value !== '') {
|
|
143
|
-
query[name] = String(value)
|
|
144
|
-
} else {
|
|
145
|
-
delete query[name]
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (searchQuery.value) {
|
|
149
|
-
query.search = searchQuery.value
|
|
150
|
-
} else {
|
|
151
|
-
delete query.search
|
|
152
|
-
}
|
|
153
|
-
router.replace({ query })
|
|
154
|
-
}
|
|
209
|
+
writeStateToUrl()
|
|
155
210
|
}
|
|
156
211
|
|
|
157
212
|
function clearFilters(): void {
|
|
@@ -164,15 +219,16 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
164
219
|
if (persistFilters) {
|
|
165
220
|
clearSessionFilters(filterSessionKey)
|
|
166
221
|
}
|
|
222
|
+
page.value = 1
|
|
167
223
|
if (syncUrlParams) {
|
|
168
224
|
const query = { ...route.query } as Record<string, string>
|
|
169
225
|
for (const key of filtersMap.value.keys()) {
|
|
170
226
|
delete query[key]
|
|
171
227
|
}
|
|
172
228
|
delete query.search
|
|
229
|
+
delete query.page
|
|
173
230
|
router.replace({ query })
|
|
174
231
|
}
|
|
175
|
-
page.value = 1
|
|
176
232
|
loadItems()
|
|
177
233
|
}
|
|
178
234
|
|
|
@@ -221,12 +277,49 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
221
277
|
}
|
|
222
278
|
}
|
|
223
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Fetch the remote options that populate filter dropdowns (#1934 lot 1).
|
|
282
|
+
*
|
|
283
|
+
* The loop used to be sequential: three filters with remote options cost
|
|
284
|
+
* three round trips in a queue, and the rows waited behind the sum. They
|
|
285
|
+
* are independent, so they now go together and cost the slowest.
|
|
286
|
+
*
|
|
287
|
+
* Deduplicated by `optionsEntity` on purpose. Sequential execution had one
|
|
288
|
+
* accidental virtue — two filters on the same entity meant one fetch and
|
|
289
|
+
* one cache hit — and firing them in parallel would turn that into two
|
|
290
|
+
* concurrent fetches. Trading latency for duplicate requests is not a win,
|
|
291
|
+
* so filters sharing an entity are still chained behind one another while
|
|
292
|
+
* different entities run side by side.
|
|
293
|
+
*
|
|
294
|
+
* This no longer invokes `filter:alter`: that hook establishes what the
|
|
295
|
+
* query will ask for, so it belongs with the other query-settling work in
|
|
296
|
+
* `onMounted`, not behind the network (#1934 lot 2).
|
|
297
|
+
*/
|
|
224
298
|
async function loadFilterOptions(): Promise<void> {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (filterDef.
|
|
228
|
-
|
|
299
|
+
const pending = Array.from(filtersMap.value).filter(([, filterDef]) => {
|
|
300
|
+
if (filterDef.options && filterDef.options.length > 1) return false
|
|
301
|
+
if (filterDef.optionsFromCache) return false
|
|
302
|
+
return true
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// One chain per source entity; everything else is a chain of its own.
|
|
306
|
+
const chains = new Map<string, Promise<void>>()
|
|
229
307
|
|
|
308
|
+
await Promise.all(
|
|
309
|
+
pending.map(([filterName, filterDef]) => {
|
|
310
|
+
const key = filterDef.optionsEntity ? `entity:${filterDef.optionsEntity}` : `own:${filterName}`
|
|
311
|
+
const previous = chains.get(key) ?? Promise.resolve()
|
|
312
|
+
const next = previous.then(() => loadOneFilterOptions(filterName, filterDef))
|
|
313
|
+
chains.set(key, next)
|
|
314
|
+
return next
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// Trigger Vue reactivity
|
|
319
|
+
filtersMap.value = new Map(filtersMap.value)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function loadOneFilterOptions(filterName: string, filterDef: FilterConfig): Promise<void> {
|
|
230
323
|
try {
|
|
231
324
|
let rawOptions: Array<{ label: string; value: unknown }> | null = null
|
|
232
325
|
|
|
@@ -299,13 +392,6 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
299
392
|
} catch (error) {
|
|
300
393
|
console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
|
|
301
394
|
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Invoke filter:alter hooks after all options are loaded
|
|
305
|
-
await invokeFilterAlterHook()
|
|
306
|
-
|
|
307
|
-
// Trigger Vue reactivity
|
|
308
|
-
filtersMap.value = new Map(filtersMap.value)
|
|
309
395
|
}
|
|
310
396
|
|
|
311
397
|
async function updateCacheBasedFilters(): Promise<void> {
|
|
@@ -399,6 +485,13 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
399
485
|
if (route.query.search) {
|
|
400
486
|
searchQuery.value = route.query.search as string
|
|
401
487
|
}
|
|
488
|
+
// The page must be restored HERE, before the first loadItems(): restoring
|
|
489
|
+
// it later would mean a first request for page 1 and a second for the
|
|
490
|
+
// real one (#2113).
|
|
491
|
+
const restoredPage = Number(route.query.page)
|
|
492
|
+
if (Number.isInteger(restoredPage) && restoredPage > 0) {
|
|
493
|
+
page.value = restoredPage
|
|
494
|
+
}
|
|
402
495
|
}
|
|
403
496
|
|
|
404
497
|
return {
|
|
@@ -417,5 +510,6 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
417
510
|
loadFilterOptions,
|
|
418
511
|
updateCacheBasedFilters,
|
|
419
512
|
restoreFilters,
|
|
513
|
+
writeStateToUrl,
|
|
420
514
|
}
|
|
421
515
|
}
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
provide,
|
|
29
29
|
type Ref,
|
|
30
30
|
} from 'vue'
|
|
31
|
-
import { useRouter, useRoute } from 'vue-router'
|
|
31
|
+
import { useRouter, useRoute, type RouteLocationRaw } from 'vue-router'
|
|
32
32
|
import { useConfirm } from 'primevue/useconfirm'
|
|
33
33
|
import { useHooks } from './useHooks.js'
|
|
34
34
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
@@ -586,8 +586,8 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
586
586
|
const cards = computed(() => Array.from(cardsMap.value.values()))
|
|
587
587
|
|
|
588
588
|
// ============ FILTERS (#1195 — extracted subsystem) ============
|
|
589
|
-
//
|
|
590
|
-
//
|
|
589
|
+
// loadItems and setSearch are passed as thunks: both are declared further
|
|
590
|
+
// down and only ever run post-setup.
|
|
591
591
|
const listFilters = useListFilters({
|
|
592
592
|
entityName,
|
|
593
593
|
manager,
|
|
@@ -605,7 +605,6 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
605
605
|
entityFilters,
|
|
606
606
|
loadItems: () => loadItems(),
|
|
607
607
|
setSearch: (searchCfg) => setSearch(searchCfg),
|
|
608
|
-
invokeFilterAlterHook: () => alterHooks.invokeFilterAlterHook(),
|
|
609
608
|
})
|
|
610
609
|
const {
|
|
611
610
|
filtersMap,
|
|
@@ -623,6 +622,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
623
622
|
loadFilterOptions,
|
|
624
623
|
updateCacheBasedFilters,
|
|
625
624
|
restoreFilters,
|
|
625
|
+
writeStateToUrl,
|
|
626
626
|
} = listFilters
|
|
627
627
|
|
|
628
628
|
// ============ ACTIONS ============
|
|
@@ -799,6 +799,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
799
799
|
page.value = event.page + 1
|
|
800
800
|
pageSize.value = event.rows
|
|
801
801
|
persistPageSize(event.rows)
|
|
802
|
+
// The page joins the URL (#2113) — it was the only piece of list state
|
|
803
|
+
// that survived nothing, so leaving a list for a detail view and coming
|
|
804
|
+
// back dropped the user on page 1.
|
|
805
|
+
writeStateToUrl()
|
|
802
806
|
loadItems()
|
|
803
807
|
}
|
|
804
808
|
|
|
@@ -851,21 +855,55 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
851
855
|
return { name: `${routePrefix}-create` }
|
|
852
856
|
}
|
|
853
857
|
|
|
858
|
+
/** Route names already reported as missing — one warning each. */
|
|
859
|
+
const warnedMissingRoutes = new Set<string>()
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Navigate by route name, and say something when that name does not exist
|
|
863
|
+
* (#1923 lot 2).
|
|
864
|
+
*
|
|
865
|
+
* The failure this catches is quiet by nature: the route prefix is derived
|
|
866
|
+
* from the entity name, so a mismatch between the side that NAMES routes
|
|
867
|
+
* and the side that LOOKS THEM UP produces a push to a name nobody
|
|
868
|
+
* registered. Nothing happens, and nothing says why — the divergence that
|
|
869
|
+
* caused #1923 behaved exactly like this, invisible until someone clicked.
|
|
870
|
+
*
|
|
871
|
+
* We warn at the moment of use rather than auditing the prefix upfront: an
|
|
872
|
+
* entity that only ever appears as a child of another has no routes under
|
|
873
|
+
* its own prefix and is perfectly healthy, so an upfront check would cry
|
|
874
|
+
* wolf. A warning nobody trusts is worse than none (ADR 0011).
|
|
875
|
+
*/
|
|
876
|
+
function pushNamedRoute(name: string, params?: Record<string, unknown>): void {
|
|
877
|
+
if (!router.hasRoute(name)) {
|
|
878
|
+
if (!warnedMissingRoutes.has(name)) {
|
|
879
|
+
warnedMissingRoutes.add(name)
|
|
880
|
+
console.warn(
|
|
881
|
+
`[qdadm] No route named "${name}" — this navigation does nothing. ` +
|
|
882
|
+
`The list for "${entity}" derives it from the route prefix ` +
|
|
883
|
+
`"${routePrefix}". Either that page is not declared, or the ` +
|
|
884
|
+
`prefix disagrees with the one its routes were registered under ` +
|
|
885
|
+
`(pass routePrefix on the entity, or on useListPage, to pin it).`
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
router.push({ name, ...(params ? { params } : {}) } as RouteLocationRaw)
|
|
891
|
+
}
|
|
892
|
+
|
|
854
893
|
function goToCreate(): void {
|
|
855
|
-
|
|
894
|
+
const target = findCreateRoute() as { name: string; params?: Record<string, unknown> }
|
|
895
|
+
pushNamedRoute(target.name, target.params)
|
|
856
896
|
}
|
|
857
897
|
|
|
858
898
|
function goToEdit(item: unknown): void {
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
|
|
899
|
+
pushNamedRoute(`${routePrefix}-edit`, {
|
|
900
|
+
[manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
|
|
862
901
|
})
|
|
863
902
|
}
|
|
864
903
|
|
|
865
904
|
function goToShow(item: unknown): void {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
|
|
905
|
+
pushNamedRoute(`${routePrefix}-show`, {
|
|
906
|
+
[manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
|
|
869
907
|
})
|
|
870
908
|
}
|
|
871
909
|
|
|
@@ -1018,7 +1056,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1018
1056
|
headerActionsMap,
|
|
1019
1057
|
actionRegistry,
|
|
1020
1058
|
})
|
|
1021
|
-
const { invokeListAlterHook } = alterHooks
|
|
1059
|
+
const { invokeListAlterHook, invokeFilterAlterHook } = alterHooks
|
|
1022
1060
|
|
|
1023
1061
|
// ============ LIFECYCLE ============
|
|
1024
1062
|
initFromRegistry()
|
|
@@ -1030,18 +1068,35 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1030
1068
|
onMounted(async () => {
|
|
1031
1069
|
window.addEventListener('resize', handleResize)
|
|
1032
1070
|
|
|
1071
|
+
// Two kinds of work, and they used to be interleaved (#1934).
|
|
1072
|
+
//
|
|
1073
|
+
// SETTLING THE QUERY comes first, and only this blocks the rows.
|
|
1074
|
+
// `restoreFilters()` reads the filters, the search and the page out of
|
|
1075
|
+
// the URL; the alter hooks may add filters and their default values, and
|
|
1076
|
+
// may replace a filter's `toQuery`/`local_filter` — all of which
|
|
1077
|
+
// `loadItems()` reads. Skipping ahead of them sends the wrong request,
|
|
1078
|
+
// and nothing retries it.
|
|
1079
|
+
//
|
|
1080
|
+
// FETCHING comes second. Filter options populate dropdowns and have no
|
|
1081
|
+
// bearing on what the rows request, so the rows no longer wait behind
|
|
1082
|
+
// them: a page whose filters pull options from related entities used to
|
|
1083
|
+
// show nothing until every one of those round trips had returned.
|
|
1033
1084
|
restoreFilters()
|
|
1034
1085
|
|
|
1035
|
-
|
|
1036
|
-
filterOptionsLoaded = true
|
|
1037
|
-
await loadFilterOptions()
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1086
|
+
await invokeFilterAlterHook()
|
|
1040
1087
|
await invokeListAlterHook()
|
|
1041
1088
|
|
|
1042
1089
|
if (loadOnMount && manager) {
|
|
1043
1090
|
loadItems()
|
|
1044
1091
|
}
|
|
1092
|
+
|
|
1093
|
+
if (!filterOptionsLoaded) {
|
|
1094
|
+
filterOptionsLoaded = true
|
|
1095
|
+
// Deliberately not awaited: the dropdowns fill in when they fill in.
|
|
1096
|
+
void loadFilterOptions().catch((error) => {
|
|
1097
|
+
console.warn('[qdadm] Failed to load filter options:', error)
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1045
1100
|
})
|
|
1046
1101
|
|
|
1047
1102
|
onUnmounted(() => {
|
|
@@ -36,6 +36,13 @@ export interface ApiStorageOptions<T extends EntityRecord = EntityRecord> {
|
|
|
36
36
|
getClient?: (() => HttpClient) | null
|
|
37
37
|
responseItemsKey?: string
|
|
38
38
|
responseTotalKey?: string
|
|
39
|
+
/**
|
|
40
|
+
* Response HEADER carrying the total, for APIs that report it there rather
|
|
41
|
+
* than in the body (`X-Total-Count` is the json-server convention). Read
|
|
42
|
+
* before `responseTotalKey`. The header must be CORS-exposed to be
|
|
43
|
+
* readable from a browser.
|
|
44
|
+
*/
|
|
45
|
+
responseTotalHeader?: string | null
|
|
39
46
|
paramMapping?: Record<string, string>
|
|
40
47
|
normalize?: ((data: T, context?: RoutingContext | null) => T) | null
|
|
41
48
|
denormalize?: ((data: Partial<T>) => Partial<T>) | null
|
|
@@ -60,6 +67,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
60
67
|
readonly endpoint: string
|
|
61
68
|
readonly responseItemsKey: string
|
|
62
69
|
readonly responseTotalKey: string
|
|
70
|
+
readonly responseTotalHeader: string | null
|
|
63
71
|
readonly paramMapping: Record<string, string>
|
|
64
72
|
|
|
65
73
|
protected _client: HttpClient | null
|
|
@@ -75,6 +83,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
75
83
|
getClient = null,
|
|
76
84
|
responseItemsKey = 'items',
|
|
77
85
|
responseTotalKey = 'total',
|
|
86
|
+
responseTotalHeader = null,
|
|
78
87
|
paramMapping = {},
|
|
79
88
|
normalize = null,
|
|
80
89
|
denormalize = null,
|
|
@@ -85,13 +94,16 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
85
94
|
this._getClient = getClient
|
|
86
95
|
this.responseItemsKey = responseItemsKey
|
|
87
96
|
this.responseTotalKey = responseTotalKey
|
|
97
|
+
this.responseTotalHeader = responseTotalHeader
|
|
88
98
|
this.paramMapping = paramMapping
|
|
89
99
|
this._normalize = normalize
|
|
90
100
|
this._denormalize = denormalize
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
/**
|
|
94
|
-
*
|
|
104
|
+
* Rename outgoing query parameters to the backend's vocabulary.
|
|
105
|
+
*
|
|
106
|
+
* Applies to filters AND to the pagination/sort keys (#2113) — see `list()`.
|
|
95
107
|
*/
|
|
96
108
|
protected _applyParamMapping(params: Record<string, unknown>): Record<string, unknown> {
|
|
97
109
|
if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
|
|
@@ -141,10 +153,19 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
141
153
|
async list(params: ListParams = {}, context: RoutingContext | null = null): Promise<ListResult<T>> {
|
|
142
154
|
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
143
155
|
|
|
144
|
-
|
|
156
|
+
// `paramMapping` covers the WHOLE outgoing query, pagination included
|
|
157
|
+
// (#2113). It used to be applied to the filters only, so `status` could
|
|
158
|
+
// be renamed to `state` but `page_size` could not be renamed to `limit` —
|
|
159
|
+
// and an API speaking any other pagination dialect had to subclass the
|
|
160
|
+
// storage for that alone. The names qdadm uses internally are its own
|
|
161
|
+
// business; what goes on the wire is the backend's.
|
|
162
|
+
//
|
|
163
|
+
// Filters keep their precedence over the pagination keys, exactly as
|
|
164
|
+
// before: a filter named `page` still wins, for better or worse.
|
|
165
|
+
const outgoing = { page, page_size, sort_by, sort_order, ...filters }
|
|
145
166
|
|
|
146
167
|
const response = await this.client.get<Record<string, unknown>>(this.endpoint, {
|
|
147
|
-
params:
|
|
168
|
+
params: this._applyParamMapping(outgoing),
|
|
148
169
|
})
|
|
149
170
|
|
|
150
171
|
const data = response.data
|
|
@@ -154,12 +175,39 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
154
175
|
return {
|
|
155
176
|
items,
|
|
156
177
|
total:
|
|
157
|
-
|
|
158
|
-
(data.
|
|
159
|
-
|
|
178
|
+
this._totalFromHeader(response) ??
|
|
179
|
+
((data[this.responseTotalKey] as number) ||
|
|
180
|
+
(data.total as number) ||
|
|
181
|
+
(Array.isArray(data) ? data.length : 0)),
|
|
160
182
|
}
|
|
161
183
|
}
|
|
162
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Total announced in a response header, when `responseTotalHeader` says so.
|
|
187
|
+
*
|
|
188
|
+
* Returns null — not 0 — when there is nothing to read, so the body-based
|
|
189
|
+
* fallbacks still get their turn. A 0 from the server is a real answer and
|
|
190
|
+
* is preserved.
|
|
191
|
+
*/
|
|
192
|
+
protected _totalFromHeader(response: unknown): number | null {
|
|
193
|
+
if (!this.responseTotalHeader) return null
|
|
194
|
+
|
|
195
|
+
const headers = (response as { headers?: unknown })?.headers as
|
|
196
|
+
| { get?: (name: string) => unknown }
|
|
197
|
+
| Record<string, unknown>
|
|
198
|
+
| undefined
|
|
199
|
+
if (!headers) return null
|
|
200
|
+
|
|
201
|
+
const raw =
|
|
202
|
+
typeof (headers as { get?: unknown }).get === 'function'
|
|
203
|
+
? (headers as { get: (name: string) => unknown }).get(this.responseTotalHeader)
|
|
204
|
+
: (headers as Record<string, unknown>)[this.responseTotalHeader.toLowerCase()]
|
|
205
|
+
|
|
206
|
+
if (raw === null || raw === undefined || raw === '') return null
|
|
207
|
+
const total = Number(raw)
|
|
208
|
+
return Number.isFinite(total) ? total : null
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
async get(id: string | number, context: RoutingContext | null = null): Promise<T> {
|
|
164
212
|
const response = await this.client.get<T>(`${this.endpoint}/${id}`)
|
|
165
213
|
return this._normalizeData(response.data, context) as T
|
|
@@ -196,7 +196,7 @@ function findSchemaForEntity(
|
|
|
196
196
|
* const users = await usersManager.list()
|
|
197
197
|
* ```
|
|
198
198
|
*/
|
|
199
|
-
export function
|
|
199
|
+
export function createGeneratedManagers(config: CreateManagersConfig): Map<string, EntityManager> {
|
|
200
200
|
// Validate config structure early
|
|
201
201
|
validateConfig(config)
|
|
202
202
|
|
|
@@ -243,3 +243,17 @@ export function createManagers(config: CreateManagersConfig): Map<string, Entity
|
|
|
243
243
|
|
|
244
244
|
return managers
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @deprecated Renamed to `createGeneratedManagers` (#1902).
|
|
249
|
+
*
|
|
250
|
+
* The root exports a DIFFERENT `createManagers` — `entity/factory.ts`, which
|
|
251
|
+
* takes `(config, context)` and returns a `Record`, where this one takes a
|
|
252
|
+
* single generated config and returns a `Map`. Same name, two public entry
|
|
253
|
+
* points, different signatures and different return types, with nothing to
|
|
254
|
+
* warn whoever picked the wrong import: the mistake surfaces at the first
|
|
255
|
+
* `.get()`.
|
|
256
|
+
*
|
|
257
|
+
* This alias keeps existing imports working. Use `createGeneratedManagers`.
|
|
258
|
+
*/
|
|
259
|
+
export const createManagers = createGeneratedManagers
|
package/src/gen/index.ts
CHANGED
|
@@ -51,7 +51,13 @@ export { OpenAPIConnector, type OpenAPIConnectorOptions, type OperationFilter }
|
|
|
51
51
|
export { type StorageProfileFactory, type StorageProfileOptions } from './StorageProfileFactory'
|
|
52
52
|
|
|
53
53
|
// T00317: createManagers - Runtime factory for EntityManager creation
|
|
54
|
-
export {
|
|
54
|
+
export {
|
|
55
|
+
createGeneratedManagers,
|
|
56
|
+
/** @deprecated Renamed to `createGeneratedManagers` (#1902) — it collided with the root's own `createManagers`. */
|
|
57
|
+
createManagers,
|
|
58
|
+
type CreateManagersConfig,
|
|
59
|
+
type EntityConfig,
|
|
60
|
+
} from './createManagers'
|
|
55
61
|
|
|
56
62
|
// T00318: Decorators - Per-entity field customization layer
|
|
57
63
|
export { applyDecorators, type FieldDecorator, type EntityDecorator } from './decorators'
|
|
@@ -52,9 +52,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
52
52
|
* Create hook registry for Drupal-inspired extensibility
|
|
53
53
|
*/
|
|
54
54
|
proto._createHookRegistry = function (this: Self): void {
|
|
55
|
+
// Idempotent, and built in the constructor (#1906 lot B1): replacing it in
|
|
56
|
+
// createApp() would orphan every hook registered before then — silently,
|
|
57
|
+
// which is the failure this lot exists to remove.
|
|
58
|
+
if (this.hookRegistry) return
|
|
59
|
+
|
|
55
60
|
const debug = this.options.debug ?? false
|
|
56
61
|
this.hookRegistry = createHookRegistry({
|
|
57
|
-
kernel: this.signals
|
|
62
|
+
kernel: this.signals.getKernel(),
|
|
58
63
|
debug,
|
|
59
64
|
})
|
|
60
65
|
}
|
|
@@ -94,6 +99,9 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
94
99
|
* Create PermissionRegistry early so modules can register permissions
|
|
95
100
|
*/
|
|
96
101
|
proto._createPermissionRegistry = function (this: Self): void {
|
|
102
|
+
// Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
|
|
103
|
+
if (this.permissionRegistry) return
|
|
104
|
+
|
|
97
105
|
this.permissionRegistry = new PermissionRegistry()
|
|
98
106
|
this._registerCorePermissions()
|
|
99
107
|
}
|
|
@@ -102,12 +110,12 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
102
110
|
* Register core system permissions provided by the framework
|
|
103
111
|
*/
|
|
104
112
|
proto._registerCorePermissions = function (this: Self): void {
|
|
105
|
-
this.permissionRegistry
|
|
113
|
+
this.permissionRegistry.register('auth', {
|
|
106
114
|
impersonate: 'Impersonate other users',
|
|
107
115
|
manage: 'Manage authentication settings',
|
|
108
116
|
})
|
|
109
117
|
|
|
110
|
-
this.permissionRegistry
|
|
118
|
+
this.permissionRegistry.register('admin', {
|
|
111
119
|
access: 'Access admin panel',
|
|
112
120
|
config: 'Edit system configuration',
|
|
113
121
|
})
|
|
@@ -119,12 +127,10 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
119
127
|
proto._setupSecurity = function (this: Self): void {
|
|
120
128
|
const { security, entityAuthAdapter } = this.options
|
|
121
129
|
|
|
122
|
-
if (!this.permissionRegistry) {
|
|
123
|
-
this.permissionRegistry = new PermissionRegistry()
|
|
124
|
-
}
|
|
125
|
-
|
|
126
130
|
if (!security) return
|
|
127
131
|
|
|
132
|
+
this._validateSecurityConfig(security as unknown as Record<string, unknown>)
|
|
133
|
+
|
|
128
134
|
// security configured but no user source: every isGranted() falls back
|
|
129
135
|
// to permissive — a newcomer believes gating is on when it isn't (#1388)
|
|
130
136
|
if (!entityAuthAdapter) {
|
|
@@ -173,6 +179,9 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
173
179
|
* Create zone registry for extensible UI composition
|
|
174
180
|
*/
|
|
175
181
|
proto._createZoneRegistry = function (this: Self): void {
|
|
182
|
+
// Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
|
|
183
|
+
if (this.zoneRegistry) return
|
|
184
|
+
|
|
176
185
|
const debug = this.options.debug ?? false
|
|
177
186
|
this.zoneRegistry = createZoneRegistry({ debug })
|
|
178
187
|
}
|
|
@@ -199,9 +208,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
199
208
|
* Create deferred registry for async service loading
|
|
200
209
|
*/
|
|
201
210
|
proto._createDeferredRegistry = function (this: Self): void {
|
|
211
|
+
// Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
|
|
212
|
+
if (this.deferred) return
|
|
213
|
+
|
|
202
214
|
const debug = this.options.debug ?? false
|
|
215
|
+
// `this.signals` is never null now, so the defensive `?.` this line used
|
|
216
|
+
// to carry is gone. Less guarding, not more: that is the point of closing
|
|
217
|
+
// the window rather than warning about it.
|
|
203
218
|
this.deferred = createDeferredRegistry({
|
|
204
|
-
kernel: this.signals
|
|
219
|
+
kernel: this.signals.getKernel(),
|
|
205
220
|
debug,
|
|
206
221
|
})
|
|
207
222
|
}
|
|
@@ -225,49 +240,93 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
225
240
|
* Create SSEBridge for Server-Sent Events to SignalBus integration
|
|
226
241
|
*/
|
|
227
242
|
/**
|
|
228
|
-
*
|
|
243
|
+
* The known key a typo most likely meant, or undefined.
|
|
229
244
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* was ignored: "ignored" reads as "no effect", not as "falls back to a more
|
|
238
|
-
* sensitive secret". That reading is what made the failure invisible.
|
|
245
|
+
* Case differences and prefixes were all the original matcher caught
|
|
246
|
+
* (#1898), which misses the commonest typo of all: one missing or
|
|
247
|
+
* transposed letter. `securty` suggested nothing, so the reader got
|
|
248
|
+
* "IGNORED" with no hint and no consequence — the two things that make the
|
|
249
|
+
* warning worth printing. An edit distance of one covers an omission, an
|
|
250
|
+
* insertion, a substitution or a transposition; two is allowed only for
|
|
251
|
+
* longer keys, where it stays specific enough not to guess wildly.
|
|
239
252
|
*/
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
253
|
+
function nearestKnownKey(key: string, known: Set<string>): string | undefined {
|
|
254
|
+
const exact = [...known].find(
|
|
255
|
+
(k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
|
|
256
|
+
)
|
|
257
|
+
if (exact) return exact
|
|
258
|
+
|
|
259
|
+
const budget = key.length >= 8 ? 2 : 1
|
|
260
|
+
let best: string | undefined
|
|
261
|
+
let bestDistance = budget + 1
|
|
262
|
+
for (const candidate of known) {
|
|
263
|
+
const distance = editDistance(key.toLowerCase(), candidate.toLowerCase(), bestDistance)
|
|
264
|
+
if (distance < bestDistance) {
|
|
265
|
+
bestDistance = distance
|
|
266
|
+
best = candidate
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return bestDistance <= budget ? best : undefined
|
|
270
|
+
}
|
|
257
271
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
272
|
+
/** Damerau-Levenshtein, abandoned as soon as it exceeds `cap`. */
|
|
273
|
+
function editDistance(a: string, b: string, cap: number): number {
|
|
274
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1
|
|
275
|
+
|
|
276
|
+
let previous: number[] = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
277
|
+
let beforePrevious: number[] = []
|
|
278
|
+
|
|
279
|
+
for (let i = 1; i <= a.length; i++) {
|
|
280
|
+
const current: number[] = [i]
|
|
281
|
+
let rowBest = i
|
|
282
|
+
for (let j = 1; j <= b.length; j++) {
|
|
283
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
284
|
+
let value = Math.min(
|
|
285
|
+
(current[j - 1] as number) + 1,
|
|
286
|
+
(previous[j] as number) + 1,
|
|
287
|
+
(previous[j - 1] as number) + cost
|
|
288
|
+
)
|
|
289
|
+
// Transposition: `raods` for `roads`.
|
|
290
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
291
|
+
value = Math.min(value, (beforePrevious[j - 2] as number) + 1)
|
|
292
|
+
}
|
|
293
|
+
current[j] = value
|
|
294
|
+
rowBest = Math.min(rowBest, value)
|
|
295
|
+
}
|
|
296
|
+
if (rowBest > cap) return cap + 1
|
|
297
|
+
beforePrevious = previous
|
|
298
|
+
previous = current
|
|
265
299
|
}
|
|
300
|
+
return previous[b.length] as number
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Warn about keys a config object does not recognise (#1906 lot B2).
|
|
305
|
+
*
|
|
306
|
+
* Extracted from the `sse` validator of #1898, which was the only one of its
|
|
307
|
+
* kind: every other qdadm config accepted anything in silence. The shape of
|
|
308
|
+
* the message is the lesson from that incident and is kept intact — it names
|
|
309
|
+
* what happens INSTEAD of what was asked, because "ignored" reads as "no
|
|
310
|
+
* effect" rather than "falls back to something else", and that reading is
|
|
311
|
+
* what cost a durable auth token in access logs.
|
|
312
|
+
*
|
|
313
|
+
* Only for CLOSED shapes. A config that deliberately forwards unknown keys
|
|
314
|
+
* elsewhere — `debugBar`, whose extra options reach the DebugModule — must
|
|
315
|
+
* not be passed through here: a warning that cries wolf is worse than none
|
|
316
|
+
* (ADR 0011).
|
|
317
|
+
*/
|
|
318
|
+
proto._warnUnknownKeys = function (
|
|
319
|
+
this: Self,
|
|
320
|
+
scope: string,
|
|
321
|
+
config: Record<string, unknown>,
|
|
322
|
+
known: Set<string>,
|
|
323
|
+
consequences: Record<string, string> = {}
|
|
324
|
+
): void {
|
|
325
|
+
const unknown = Object.keys(config).filter((key) => !known.has(key))
|
|
326
|
+
if (!unknown.length) return
|
|
266
327
|
|
|
267
328
|
for (const key of unknown) {
|
|
268
|
-
const near =
|
|
269
|
-
(k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
|
|
270
|
-
)
|
|
329
|
+
const near = nearestKnownKey(key, known)
|
|
271
330
|
// Look the consequence up under the NEAREST KNOWN key: a typo like
|
|
272
331
|
// `getTokens` means `getToken` is absent, and it is that absence whose
|
|
273
332
|
// effect the reader needs to hear.
|
|
@@ -275,13 +334,110 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
275
334
|
const because = consequence ? ` — ${consequence}` : ''
|
|
276
335
|
const suggestion = near ? ` Did you mean "${near}"?` : ''
|
|
277
336
|
console.warn(
|
|
278
|
-
`[Kernel]
|
|
337
|
+
`[Kernel] ${scope}${key} is not a recognised option and is IGNORED${because}.` +
|
|
279
338
|
`${suggestion} This usually means the installed qdadm predates the option ` +
|
|
280
339
|
`— check the version before assuming the key has no effect.`
|
|
281
340
|
)
|
|
282
341
|
}
|
|
283
342
|
}
|
|
284
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Every option `KernelOptions` accepts (#1906 lot B2).
|
|
346
|
+
*
|
|
347
|
+
* A misspelled TOP-LEVEL key is the worst silence of the family: it does not
|
|
348
|
+
* degrade a feature, it removes a whole section of configuration. `securty:`
|
|
349
|
+
* means no security config at all, and nothing says so. TypeScript catches
|
|
350
|
+
* it; the many consumers whose module files are plain JavaScript get
|
|
351
|
+
* nothing.
|
|
352
|
+
*
|
|
353
|
+
* Kept in sync with the interface by a test that reads `Kernel.types.ts` and
|
|
354
|
+
* compares — a hand-maintained list that drifts would start crying wolf,
|
|
355
|
+
* which is the failure mode this whole ticket exists to avoid.
|
|
356
|
+
*/
|
|
357
|
+
const KNOWN_KERNEL_OPTIONS = new Set([
|
|
358
|
+
'apiClient', 'app', 'authAdapter', 'authTypes', 'basePath', 'coreRoutes',
|
|
359
|
+
'debug', 'debugBar', 'defaultEntityCacheTtlMs', 'entityAuthAdapter',
|
|
360
|
+
'eventRouter', 'existingApp', 'existingRouter', 'existingSignals',
|
|
361
|
+
'features', 'hashMode', 'homeRoute', 'i18n', 'layouts', 'managerRegistry',
|
|
362
|
+
'managerResolver', 'managers', 'moduleDefs', 'modules', 'modulesOptions',
|
|
363
|
+
'notifications', 'onAuthExpired', 'pages', 'parentParamMode', 'primevue',
|
|
364
|
+
'root', 'routeParamResolver', 'routePrefix', 'sectionOrder', 'security',
|
|
365
|
+
'sse', 'storageResolver', 'toast', 'warmup',
|
|
366
|
+
])
|
|
367
|
+
|
|
368
|
+
proto._validateKernelOptions = function (this: Self): void {
|
|
369
|
+
this._warnUnknownKeys('', this.options as Record<string, unknown>, KNOWN_KERNEL_OPTIONS, {
|
|
370
|
+
security: 'no role hierarchy or permissions will be applied — every check falls back to the default',
|
|
371
|
+
sse: 'no server-sent events will be connected',
|
|
372
|
+
debugBar: 'no debug bar will be mounted',
|
|
373
|
+
layouts: 'pages will fall back to the base layout',
|
|
374
|
+
i18n: 'translations will not be configured',
|
|
375
|
+
authAdapter: 'the app will run unauthenticated',
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Warn about unrecognised `security` keys (#1906 lot B2).
|
|
381
|
+
*
|
|
382
|
+
* Chosen first, and by damage: a misspelled key here means a role hierarchy
|
|
383
|
+
* or a permission map is simply absent, and every check then falls through
|
|
384
|
+
* to whatever the default is. Nothing fails, nothing warns, and the app is
|
|
385
|
+
* open where its author believed it closed.
|
|
386
|
+
*/
|
|
387
|
+
proto._validateSecurityConfig = function (this: Self, security: Record<string, unknown>): void {
|
|
388
|
+
this._warnUnknownKeys(
|
|
389
|
+
'security.',
|
|
390
|
+
security,
|
|
391
|
+
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
|
|
392
|
+
{
|
|
393
|
+
role_hierarchy: 'roles will not inherit from one another',
|
|
394
|
+
role_permissions: 'no role will carry any permission',
|
|
395
|
+
entity_permissions: 'per-entity permissions will not be generated',
|
|
396
|
+
rolesProvider: "the current user's roles will not be resolved",
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Warn about unrecognised `sse` keys (#1898 lot A).
|
|
403
|
+
*
|
|
404
|
+
* A consumer configured `sse.getToken` against a version that predated it.
|
|
405
|
+
* It was not rejected — it was ignored, and the bridge fell back to the
|
|
406
|
+
* session's DURABLE token, which then travelled in the query string of every
|
|
407
|
+
* `/events` request and into logs that outlive the session by months. No
|
|
408
|
+
* error, no warning, and a stream that worked perfectly.
|
|
409
|
+
*
|
|
410
|
+
* The warning therefore names what happens INSTEAD, not merely that the key
|
|
411
|
+
* was ignored: "ignored" reads as "no effect", not as "falls back to a more
|
|
412
|
+
* sensitive secret". That reading is what made the failure invisible.
|
|
413
|
+
*/
|
|
414
|
+
proto._validateSseConfig = function (this: Self, sse: SSEConfig): void {
|
|
415
|
+
this._warnUnknownKeys(
|
|
416
|
+
'sse.',
|
|
417
|
+
sse as unknown as Record<string, unknown>,
|
|
418
|
+
new Set([
|
|
419
|
+
'url',
|
|
420
|
+
'reconnectDelay',
|
|
421
|
+
'signalPrefix',
|
|
422
|
+
'autoConnect',
|
|
423
|
+
'withCredentials',
|
|
424
|
+
'tokenParam',
|
|
425
|
+
'events',
|
|
426
|
+
'entities',
|
|
427
|
+
'getToken',
|
|
428
|
+
'connectOnSignal',
|
|
429
|
+
'disconnectOnSignal',
|
|
430
|
+
]),
|
|
431
|
+
{
|
|
432
|
+
getToken:
|
|
433
|
+
'the session auth token will be sent in the stream URL instead — ' +
|
|
434
|
+
'and query strings reach access logs',
|
|
435
|
+
entities: 'no entity cache will be invalidated from the stream',
|
|
436
|
+
tokenParam: 'the token will be sent under the default name "token"',
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
285
441
|
proto._createSSEBridge = function (this: Self): void {
|
|
286
442
|
const { sse, authAdapter } = this.options
|
|
287
443
|
if (!sse?.url) return
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -76,6 +76,7 @@ import { applyRoutingMethods } from './Kernel.routing'
|
|
|
76
76
|
import { applyModuleMethods } from './Kernel.modules'
|
|
77
77
|
import { applyRegistryMethods } from './Kernel.registries'
|
|
78
78
|
import { setQdadmDebugBar, applyVueMethods } from './Kernel.vue'
|
|
79
|
+
import { isDebugBarKilled } from './debugBarKillSwitch'
|
|
79
80
|
import { applyApiMethods } from './Kernel.api'
|
|
80
81
|
import { applyI18nMethods } from './Kernel.i18n'
|
|
81
82
|
import type { I18n } from '../i18n/I18n'
|
|
@@ -93,18 +94,18 @@ export class Kernel {
|
|
|
93
94
|
* window existed for no reason. Removing it beats warning about it
|
|
94
95
|
* (ADR 0011).
|
|
95
96
|
*/
|
|
96
|
-
signals
|
|
97
|
+
signals!: SignalBus
|
|
97
98
|
orchestrator: Orchestrator | null = null
|
|
98
|
-
zoneRegistry
|
|
99
|
-
hookRegistry
|
|
100
|
-
deferred
|
|
99
|
+
zoneRegistry!: ZoneRegistry
|
|
100
|
+
hookRegistry!: HookRegistry
|
|
101
|
+
deferred!: DeferredRegistry
|
|
101
102
|
eventRouter: EventRouter | null = null
|
|
102
103
|
sseBridge: SSEBridge | null = null
|
|
103
104
|
/** Routes external entity changes to cache invalidation (#1888). */
|
|
104
105
|
liveEntityRouter: LiveEntityRouter | null = null
|
|
105
106
|
layoutComponents: InternalLayoutComponents | null = null
|
|
106
107
|
securityChecker: SecurityChecker | null = null
|
|
107
|
-
permissionRegistry
|
|
108
|
+
permissionRegistry!: PermissionRegistry
|
|
108
109
|
moduleLoader: ModuleLoader | null = null
|
|
109
110
|
activeStack: ActiveStack | null = null
|
|
110
111
|
stackHydrator: StackHydrator | null = null
|
|
@@ -157,7 +158,10 @@ export class Kernel {
|
|
|
157
158
|
// the key inert — including to keep the bar ON, "since it changes
|
|
158
159
|
// nothing" — will now see it disappear. Both readings were possible
|
|
159
160
|
// precisely because the flag did nothing.
|
|
160
|
-
|
|
161
|
+
// The emergency switch wins over everything (#1900 lot C.3): a bar that
|
|
162
|
+
// is taking the app down has to be stoppable without a redeploy, so
|
|
163
|
+
// `?qddebug=off` overrides even an explicit `enabled: true`.
|
|
164
|
+
const debugBarEnabled = debugModuleOptions.enabled !== false && !isDebugBarKilled()
|
|
161
165
|
|
|
162
166
|
// Store component for root wrapper
|
|
163
167
|
setQdadmDebugBar(debugBarEnabled ? options.debugBar.component || null : null)
|
|
@@ -177,11 +181,28 @@ export class Kernel {
|
|
|
177
181
|
|
|
178
182
|
this.options = options
|
|
179
183
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
+
// Everything that CAN exist from here on, does (#1905 lot B, #1906 lot B1).
|
|
185
|
+
//
|
|
186
|
+
// These five have no dependency beyond `options` and each other, and the
|
|
187
|
+
// natural place to wire a kernel is right after `new Kernel()` — so they
|
|
188
|
+
// have to be usable there. Someone reached for `kernel.signals` before
|
|
189
|
+
// createApp(), got `null`, and `null?.emit?.()` swallowed it without a
|
|
190
|
+
// word; there were seventeen properties shaped like that, and these are
|
|
191
|
+
// the ones that need not have been.
|
|
192
|
+
//
|
|
193
|
+
// Every creator is idempotent, so the later calls in createApp() keep
|
|
194
|
+
// these instances rather than orphaning whatever was registered on them
|
|
195
|
+
// in between — which would be the same silent failure wearing a new hat.
|
|
196
|
+
// Before anything reads them (#1906 lot B2): a misspelled top-level key
|
|
197
|
+
// removes a whole section of configuration, and TypeScript only catches
|
|
198
|
+
// it for consumers whose module files are TypeScript.
|
|
199
|
+
this._validateKernelOptions()
|
|
200
|
+
|
|
184
201
|
this._createSignalBus()
|
|
202
|
+
this._createHookRegistry()
|
|
203
|
+
this._createZoneRegistry()
|
|
204
|
+
this._createDeferredRegistry()
|
|
205
|
+
this._createPermissionRegistry()
|
|
185
206
|
}
|
|
186
207
|
|
|
187
208
|
/**
|
|
@@ -475,6 +496,14 @@ export interface Kernel {
|
|
|
475
496
|
_createDeferredRegistry(): void
|
|
476
497
|
_createEventRouter(): void
|
|
477
498
|
_validateSseConfig(sse: SSEConfigType): void
|
|
499
|
+
_validateSecurityConfig(security: Record<string, unknown>): void
|
|
500
|
+
_validateKernelOptions(): void
|
|
501
|
+
_warnUnknownKeys(
|
|
502
|
+
scope: string,
|
|
503
|
+
config: Record<string, unknown>,
|
|
504
|
+
known: Set<string>,
|
|
505
|
+
consequences?: Record<string, string>
|
|
506
|
+
): void
|
|
478
507
|
_createSSEBridge(): void
|
|
479
508
|
|
|
480
509
|
// Vue (Kernel.vue.ts)
|
|
@@ -70,10 +70,12 @@ export interface Pages {
|
|
|
70
70
|
export interface LayoutComponents {
|
|
71
71
|
list?: Component | null
|
|
72
72
|
form?: Component | null
|
|
73
|
+
show?: Component | null
|
|
73
74
|
dashboard?: Component | null
|
|
74
75
|
base?: Component | null
|
|
75
76
|
ListLayout?: Component
|
|
76
77
|
FormLayout?: Component
|
|
78
|
+
ShowLayout?: Component
|
|
77
79
|
DashboardLayout?: Component
|
|
78
80
|
BaseLayout?: Component
|
|
79
81
|
}
|
|
@@ -293,6 +295,7 @@ export interface KernelOptions {
|
|
|
293
295
|
export interface InternalLayoutComponents {
|
|
294
296
|
list: Component | null
|
|
295
297
|
form: Component | null
|
|
298
|
+
show: Component | null
|
|
296
299
|
dashboard: Component | null
|
|
297
300
|
base: Component | null
|
|
298
301
|
}
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
h,
|
|
4
4
|
defineComponent,
|
|
5
5
|
ref,
|
|
6
|
+
onErrorCaptured,
|
|
6
7
|
type App,
|
|
7
8
|
type Component,
|
|
8
9
|
type Ref,
|
|
@@ -58,6 +59,51 @@ export function setQdadmDebugBar(component: Component | null): void {
|
|
|
58
59
|
/**
|
|
59
60
|
* Patch Kernel prototype with Vue app creation methods.
|
|
60
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* The debug bar must not be able to take down the app it observes
|
|
64
|
+
* (#1900 lot C.1).
|
|
65
|
+
*
|
|
66
|
+
* Before this, a bar that threw during render propagated the error to the root
|
|
67
|
+
* and killed the application — a diagnostic tool failing precisely when
|
|
68
|
+
* someone needed it, and the consumer's only way out was to rebuild without
|
|
69
|
+
* it.
|
|
70
|
+
*
|
|
71
|
+
* `onErrorCaptured` returning false stops the error here: the bar is dropped,
|
|
72
|
+
* the app keeps rendering. The failure is loud in the console rather than on
|
|
73
|
+
* screen, because a crashed debug tool leaving debris in the corner of a
|
|
74
|
+
* working app helps nobody.
|
|
75
|
+
*
|
|
76
|
+
* This catches THROWN errors only. A runaway render loop throws nothing, so it
|
|
77
|
+
* needs its own guard — that is the bar's circuit breaker in `@quazardous/qddebug`,
|
|
78
|
+
* and the two are deliberately separate.
|
|
79
|
+
*
|
|
80
|
+
* Exported so the guard can be tested as a unit; the Kernel is its only caller.
|
|
81
|
+
*/
|
|
82
|
+
export function createDebugBarBoundary(BarComponent: Component): Component {
|
|
83
|
+
return defineComponent({
|
|
84
|
+
name: 'QdadmDebugBarBoundary',
|
|
85
|
+
setup() {
|
|
86
|
+
const failed = ref(false)
|
|
87
|
+
|
|
88
|
+
onErrorCaptured((err: unknown) => {
|
|
89
|
+
if (!failed.value) {
|
|
90
|
+
failed.value = true
|
|
91
|
+
console.error(
|
|
92
|
+
'[qdadm] The debug bar threw and has been removed for this ' +
|
|
93
|
+
'session. The application is unaffected. Use ?qddebug=off to ' +
|
|
94
|
+
'keep it off across reloads.',
|
|
95
|
+
err
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
// Stop propagation: the app must not die with its instrument.
|
|
99
|
+
return false
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return () => (failed.value ? null : h(BarComponent))
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
62
108
|
const proto = KernelClass.prototype
|
|
63
109
|
|
|
@@ -69,6 +115,10 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
69
115
|
this.layoutComponents = {
|
|
70
116
|
list: layouts.list || layouts.ListLayout || null,
|
|
71
117
|
form: layouts.form || layouts.FormLayout || null,
|
|
118
|
+
// Until #1922 this literal had no `show` key, so an app that passed one
|
|
119
|
+
// had it dropped without a word while crud({ show }) kept emitting
|
|
120
|
+
// `layout: 'show'` for a layout nothing could honour.
|
|
121
|
+
show: layouts.show || layouts.ShowLayout || null,
|
|
72
122
|
dashboard: layouts.dashboard || layouts.DashboardLayout || null,
|
|
73
123
|
base: layouts.base || layouts.BaseLayout || null,
|
|
74
124
|
}
|
|
@@ -102,6 +152,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
102
152
|
this.options.debugBar?.component && debugBarRef.value
|
|
103
153
|
? debugBarRef.value
|
|
104
154
|
: null
|
|
155
|
+
const DebugBarBoundary = DebugBarComponent
|
|
156
|
+
? createDebugBarBoundary(DebugBarComponent as Component)
|
|
157
|
+
: null
|
|
105
158
|
const hasPrimeVue = !!this.options.primevue?.plugin
|
|
106
159
|
const appKey = this._appKey
|
|
107
160
|
|
|
@@ -116,8 +169,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
116
169
|
children.push(h(ToastListener))
|
|
117
170
|
}
|
|
118
171
|
|
|
119
|
-
if (
|
|
120
|
-
children.push(h(
|
|
172
|
+
if (DebugBarBoundary) {
|
|
173
|
+
children.push(h(DebugBarBoundary))
|
|
121
174
|
}
|
|
122
175
|
|
|
123
176
|
return h(
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
* The string helpers (`singularize`, `toKebab`, `capitalize`) are kept
|
|
10
10
|
* module-local — they're pure, stateless, and were only used by `crud()` and
|
|
11
11
|
* `childPage()`. Keeping them off the prototype keeps the public shape clean.
|
|
12
|
+
* `singularize` is a thin call into the vendored `pluralize` since #1923: the
|
|
13
|
+
* hand-rolled version disagreed with the one `EntityManager` uses, and a
|
|
14
|
+
* framework cannot afford two answers to the same question.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import type { RouteRecordRaw } from 'vue-router'
|
|
18
|
+
import pluralize from '../utils/pluralize'
|
|
15
19
|
import { registry, getRoutes } from '../module/moduleRegistry'
|
|
16
20
|
import type {
|
|
17
21
|
ChildPageOptions,
|
|
@@ -26,14 +30,21 @@ import type { KernelContext } from './KernelContext'
|
|
|
26
30
|
// #1196 Phase B — this-typing against the real KernelContext shape (was Self = any)
|
|
27
31
|
type Self = KernelContext
|
|
28
32
|
|
|
29
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Singularize an entity name (#1923).
|
|
35
|
+
*
|
|
36
|
+
* This used to be six hand-rolled lines, while `EntityManager.routePrefix`
|
|
37
|
+
* used the vendored `pluralize`. Two engines, and they disagreed: `crud()`
|
|
38
|
+
* named a route `people-show` while `useListPage` looked for `person-show`,
|
|
39
|
+
* so every navigation helper missed silently — no error at boot, nothing until
|
|
40
|
+
* someone clicked. They agreed on every regular plural, which is exactly why
|
|
41
|
+
* it went unnoticed.
|
|
42
|
+
*
|
|
43
|
+
* One engine now, the real one. `pluralize` is vendored (#1454) and imports
|
|
44
|
+
* nothing, so there is no cycle to fear from calling it here.
|
|
45
|
+
*/
|
|
30
46
|
function singularize(plural: string): string {
|
|
31
|
-
|
|
32
|
-
if (plural.endsWith('ses') || plural.endsWith('xes') || plural.endsWith('zes')) {
|
|
33
|
-
return plural.slice(0, -2)
|
|
34
|
-
}
|
|
35
|
-
if (plural.endsWith('s') && !plural.endsWith('ss')) return plural.slice(0, -1)
|
|
36
|
-
return plural
|
|
47
|
+
return pluralize.singular(plural)
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
/** Convert camelCase to kebab-case (e.g. 'botTasks' → 'bot-tasks') */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emergency switch for the debug bar (#1900 lot C.3).
|
|
3
|
+
*
|
|
4
|
+
* A consumer whose debug bar took down the application it was observing had
|
|
5
|
+
* no way to turn it off: they had to rebuild and redeploy. A diagnostic tool
|
|
6
|
+
* that can only be disabled by a deploy fails exactly when it is needed.
|
|
7
|
+
*
|
|
8
|
+
* So the bar answers to a switch that needs no rebuild and no code change:
|
|
9
|
+
*
|
|
10
|
+
* ?qddebug=off turn it off, and REMEMBER that across navigations
|
|
11
|
+
* ?qddebug=on turn it back on (clears the memory)
|
|
12
|
+
*
|
|
13
|
+
* The query parameter is the way in — it is the only thing reachable when the
|
|
14
|
+
* app is unusable — and localStorage is what makes it stick, so the next page
|
|
15
|
+
* load is survivable too. Without persistence the switch would be lost by the
|
|
16
|
+
* first redirect, which is the moment a broken app is most likely to hit.
|
|
17
|
+
*
|
|
18
|
+
* This is a KILL switch, deliberately one-directional in effect: it can only
|
|
19
|
+
* take the bar away, never conjure one that `enabled: false` refused. See
|
|
20
|
+
* [[docs/adr/0011]] — the point of the switch is that it does something
|
|
21
|
+
* observable, and `?qddebug=on` on an app with no bar configured stays a
|
|
22
|
+
* no-op because there is nothing to turn on.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** localStorage key, under qddebug's existing `qddebug:` namespace (ADR 0008). */
|
|
26
|
+
const KILL_KEY = 'qddebug:off'
|
|
27
|
+
|
|
28
|
+
/** Read a key without letting a hostile storage throw into the boot path. */
|
|
29
|
+
function readFlag(): boolean {
|
|
30
|
+
try {
|
|
31
|
+
return localStorage.getItem(KILL_KEY) === '1'
|
|
32
|
+
} catch {
|
|
33
|
+
// Private mode, disabled cookies, quota. Absent, not fatal.
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeFlag(on: boolean): void {
|
|
39
|
+
try {
|
|
40
|
+
if (on) localStorage.setItem(KILL_KEY, '1')
|
|
41
|
+
else localStorage.removeItem(KILL_KEY)
|
|
42
|
+
} catch {
|
|
43
|
+
// The switch still holds for THIS page load — the caller uses our return
|
|
44
|
+
// value, not the storage. It just will not survive a reload.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the debug bar has been killed for this browser.
|
|
50
|
+
*
|
|
51
|
+
* Reads the query parameter first (it is an explicit act, and it updates the
|
|
52
|
+
* remembered state), then falls back to what was remembered.
|
|
53
|
+
*
|
|
54
|
+
* Safe to call before anything is mounted, and outside a browser.
|
|
55
|
+
*/
|
|
56
|
+
export function isDebugBarKilled(): boolean {
|
|
57
|
+
if (typeof window === 'undefined') return false
|
|
58
|
+
|
|
59
|
+
let param: string | null = null
|
|
60
|
+
try {
|
|
61
|
+
param = new URLSearchParams(window.location.search).get('qddebug')
|
|
62
|
+
} catch {
|
|
63
|
+
// Nothing readable in the URL; fall through to the remembered flag.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (param === 'off') {
|
|
67
|
+
writeFlag(true)
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
if (param === 'on') {
|
|
71
|
+
writeFlag(false)
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return readFlag()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Exported for tests and for an app that wants to offer its own switch. */
|
|
79
|
+
export const DEBUG_BAR_KILL_KEY = KILL_KEY
|