@quazardous/qdadm 2.17.3 → 2.18.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 +2 -2
- package/src/composables/useListPage.filters.ts +124 -30
- package/src/composables/useListPage.ts +30 -9
- package/src/entity/storage/ApiStorage.ts +54 -6
- package/src/kernel/Kernel.ts +23 -3
- package/src/kernel/Kernel.vue.ts +51 -2
- 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.18.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@quazardous/qdcore": "^1.1.2",
|
|
58
|
-
"@quazardous/qddebug": "^1.
|
|
58
|
+
"@quazardous/qddebug": "^1.2.0",
|
|
59
59
|
"@quazardous/quarkernel": "^2.1.0",
|
|
60
60
|
"yaml": "^2.8.4"
|
|
61
61
|
},
|
|
@@ -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
|
}
|
|
@@ -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
|
|
|
@@ -1018,7 +1022,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1018
1022
|
headerActionsMap,
|
|
1019
1023
|
actionRegistry,
|
|
1020
1024
|
})
|
|
1021
|
-
const { invokeListAlterHook } = alterHooks
|
|
1025
|
+
const { invokeListAlterHook, invokeFilterAlterHook } = alterHooks
|
|
1022
1026
|
|
|
1023
1027
|
// ============ LIFECYCLE ============
|
|
1024
1028
|
initFromRegistry()
|
|
@@ -1030,18 +1034,35 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1030
1034
|
onMounted(async () => {
|
|
1031
1035
|
window.addEventListener('resize', handleResize)
|
|
1032
1036
|
|
|
1037
|
+
// Two kinds of work, and they used to be interleaved (#1934).
|
|
1038
|
+
//
|
|
1039
|
+
// SETTLING THE QUERY comes first, and only this blocks the rows.
|
|
1040
|
+
// `restoreFilters()` reads the filters, the search and the page out of
|
|
1041
|
+
// the URL; the alter hooks may add filters and their default values, and
|
|
1042
|
+
// may replace a filter's `toQuery`/`local_filter` — all of which
|
|
1043
|
+
// `loadItems()` reads. Skipping ahead of them sends the wrong request,
|
|
1044
|
+
// and nothing retries it.
|
|
1045
|
+
//
|
|
1046
|
+
// FETCHING comes second. Filter options populate dropdowns and have no
|
|
1047
|
+
// bearing on what the rows request, so the rows no longer wait behind
|
|
1048
|
+
// them: a page whose filters pull options from related entities used to
|
|
1049
|
+
// show nothing until every one of those round trips had returned.
|
|
1033
1050
|
restoreFilters()
|
|
1034
1051
|
|
|
1035
|
-
|
|
1036
|
-
filterOptionsLoaded = true
|
|
1037
|
-
await loadFilterOptions()
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1052
|
+
await invokeFilterAlterHook()
|
|
1040
1053
|
await invokeListAlterHook()
|
|
1041
1054
|
|
|
1042
1055
|
if (loadOnMount && manager) {
|
|
1043
1056
|
loadItems()
|
|
1044
1057
|
}
|
|
1058
|
+
|
|
1059
|
+
if (!filterOptionsLoaded) {
|
|
1060
|
+
filterOptionsLoaded = true
|
|
1061
|
+
// Deliberately not awaited: the dropdowns fill in when they fill in.
|
|
1062
|
+
void loadFilterOptions().catch((error) => {
|
|
1063
|
+
console.warn('[qdadm] Failed to load filter options:', error)
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1045
1066
|
})
|
|
1046
1067
|
|
|
1047
1068
|
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
|
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'
|
|
@@ -144,10 +145,29 @@ export class Kernel {
|
|
|
144
145
|
const debugModule = new DebugModuleClass(debugModuleOptions)
|
|
145
146
|
options.moduleDefs = options.moduleDefs || []
|
|
146
147
|
options.moduleDefs.push(debugModule)
|
|
148
|
+
|
|
149
|
+
// `enabled: false` now actually disables (#1900 lot D).
|
|
150
|
+
//
|
|
151
|
+
// The component used to be registered whatever `enabled` said, and
|
|
152
|
+
// Kernel forced options.debug = true on top — which DebugModule.enabled()
|
|
153
|
+
// reads as "turn on". A consumer who wrote `debugBar: { enabled: false }`
|
|
154
|
+
// to switch the bar OFF found it running in PRODUCTION, and the only way
|
|
155
|
+
// out was to omit the key entirely.
|
|
156
|
+
//
|
|
157
|
+
// Note this cuts both ways: an app that passed `enabled: false` believing
|
|
158
|
+
// the key inert — including to keep the bar ON, "since it changes
|
|
159
|
+
// nothing" — will now see it disappear. Both readings were possible
|
|
160
|
+
// precisely because the flag did nothing.
|
|
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()
|
|
165
|
+
|
|
147
166
|
// Store component for root wrapper
|
|
148
|
-
setQdadmDebugBar(options.debugBar.component || null)
|
|
149
|
-
|
|
150
|
-
|
|
167
|
+
setQdadmDebugBar(debugBarEnabled ? options.debugBar.component || null : null)
|
|
168
|
+
|
|
169
|
+
// Debug mode follows the bar, and only when the bar is actually on.
|
|
170
|
+
if (debugBarEnabled && !options.debug) {
|
|
151
171
|
options.debug = true
|
|
152
172
|
}
|
|
153
173
|
}
|
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
|
|
|
@@ -102,6 +148,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
102
148
|
this.options.debugBar?.component && debugBarRef.value
|
|
103
149
|
? debugBarRef.value
|
|
104
150
|
: null
|
|
151
|
+
const DebugBarBoundary = DebugBarComponent
|
|
152
|
+
? createDebugBarBoundary(DebugBarComponent as Component)
|
|
153
|
+
: null
|
|
105
154
|
const hasPrimeVue = !!this.options.primevue?.plugin
|
|
106
155
|
const appKey = this._appKey
|
|
107
156
|
|
|
@@ -116,8 +165,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
116
165
|
children.push(h(ToastListener))
|
|
117
166
|
}
|
|
118
167
|
|
|
119
|
-
if (
|
|
120
|
-
children.push(h(
|
|
168
|
+
if (DebugBarBoundary) {
|
|
169
|
+
children.push(h(DebugBarBoundary))
|
|
121
170
|
}
|
|
122
171
|
|
|
123
172
|
return h(
|
|
@@ -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
|