@quazardous/qdadm 2.0.0 → 2.2.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 +1 -1
- package/src/api/apiClient.ts +34 -0
- package/src/components/editors/ScopeEditor.vue +15 -4
- package/src/composables/useOptionsLookup.ts +128 -20
- package/src/index.ts +6 -0
- package/src/kernel/Kernel.types.ts +29 -0
- package/src/kernel/Kernel.vue.ts +6 -0
- package/src/kernel/KernelContext.routing.ts +110 -2
- package/src/kernel/KernelContext.types.ts +8 -0
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kernel-level default API client (qdadm #1198)
|
|
3
|
+
*
|
|
4
|
+
* Apps already build an axios-compatible HttpClient (base URL + auth
|
|
5
|
+
* interceptors) for their ApiStorages. Registering it once on the kernel
|
|
6
|
+
* makes it available to composables/widgets that need ad-hoc API calls
|
|
7
|
+
* (useOptionsLookup endpoint mode, ScopeEditor), so relative endpoints
|
|
8
|
+
* get base URL + auth applied without per-call wiring:
|
|
9
|
+
*
|
|
10
|
+
* new Kernel({ apiClient: myAxios })
|
|
11
|
+
*/
|
|
12
|
+
import { inject } from 'vue'
|
|
13
|
+
import type { HttpClient } from '../entity/storage/ApiStorage'
|
|
14
|
+
|
|
15
|
+
/** Injection key under which the kernel provides the default API client. */
|
|
16
|
+
export const API_CLIENT_INJECTION_KEY = 'qdadmApiClient'
|
|
17
|
+
|
|
18
|
+
/** Accepted forms of the kernel `apiClient` option. */
|
|
19
|
+
export type ApiClientSource = HttpClient | (() => HttpClient)
|
|
20
|
+
|
|
21
|
+
/** Resolve an ApiClientSource (direct instance or factory) to a client. */
|
|
22
|
+
export function resolveApiClient(source: ApiClientSource | null | undefined): HttpClient | null {
|
|
23
|
+
if (!source) return null
|
|
24
|
+
return typeof source === 'function' ? source() : source
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inject the kernel-registered default API client, or null when none is
|
|
29
|
+
* registered. Must be called from a setup context (like any inject).
|
|
30
|
+
*/
|
|
31
|
+
export function useApiClient(): HttpClient | null {
|
|
32
|
+
const source = inject<ApiClientSource | null>(API_CLIENT_INJECTION_KEY, null)
|
|
33
|
+
return resolveApiClient(source)
|
|
34
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, computed, onMounted, watch, inject } from 'vue'
|
|
3
|
+
import type { HttpClient } from '../../entity/storage/ApiStorage'
|
|
3
4
|
import AutoComplete from 'primevue/autocomplete'
|
|
4
5
|
import Select from 'primevue/select'
|
|
5
6
|
import Button from 'primevue/button'
|
|
@@ -43,8 +44,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
43
44
|
defaultActions: null
|
|
44
45
|
})
|
|
45
46
|
|
|
46
|
-
// Get API adapter (optional)
|
|
47
|
+
// Get API adapter (optional); the kernel default client (Kernel({ apiClient }))
|
|
48
|
+
// serves as fallback so scope endpoints get base URL + auth without app wiring.
|
|
47
49
|
const api = inject<ApiAdapter | null>('apiAdapter', null)
|
|
50
|
+
const kernelApiClient = inject<HttpClient | (() => HttpClient) | null>('qdadmApiClient', null)
|
|
48
51
|
|
|
49
52
|
// Get global scope config (optional) - allows app-level configuration
|
|
50
53
|
const globalScopeConfig = inject<ScopeConfig>('scopeConfig', {})
|
|
@@ -178,8 +181,17 @@ function isRowComplete(row: ScopeRow): boolean {
|
|
|
178
181
|
async function loadScopeDefinition(): Promise<void> {
|
|
179
182
|
loading.value = true
|
|
180
183
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
let data: ScopeDefinition | null = null
|
|
185
|
+
if (api) {
|
|
186
|
+
data = await api.request('GET', config.value.endpoint)
|
|
187
|
+
} else if (kernelApiClient) {
|
|
188
|
+
// Fallback: kernel default API client (base URL + auth applied)
|
|
189
|
+
const client = typeof kernelApiClient === 'function' ? kernelApiClient() : kernelApiClient
|
|
190
|
+
data = (await client.get<ScopeDefinition>(config.value.endpoint)).data
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!data) {
|
|
194
|
+
// No API layer available, use defaults
|
|
183
195
|
scopeDefinition.value = {
|
|
184
196
|
resources: [...config.value.resources],
|
|
185
197
|
actions: [...config.value.actions],
|
|
@@ -187,7 +199,6 @@ async function loadScopeDefinition(): Promise<void> {
|
|
|
187
199
|
return
|
|
188
200
|
}
|
|
189
201
|
|
|
190
|
-
const data = await api.request('GET', config.value.endpoint)
|
|
191
202
|
scopeDefinition.value = {
|
|
192
203
|
resources: data.resources || [...config.value.resources],
|
|
193
204
|
actions: data.actions || [...config.value.actions],
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { ref, onMounted, type Ref } from 'vue'
|
|
44
44
|
import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
|
|
45
|
+
import { useApiClient } from '../api/apiClient'
|
|
45
46
|
|
|
46
47
|
export interface OptionsLookupItem {
|
|
47
48
|
label: string
|
|
@@ -66,11 +67,27 @@ export interface UseOptionsLookupConfig {
|
|
|
66
67
|
entity?: string
|
|
67
68
|
/** Extract distinct values from a specific field (entity mode only) */
|
|
68
69
|
field?: string
|
|
69
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Fetch options from an API endpoint.
|
|
72
|
+
*
|
|
73
|
+
* Routing (#1198): a **relative** endpoint goes through the kernel's
|
|
74
|
+
* default API client when one is registered (`Kernel({ apiClient })`) —
|
|
75
|
+
* base URL + auth applied, no per-call wiring. An **absolute** URL is
|
|
76
|
+
* fetched raw (escape hatch for third-party APIs, combine with `headers`).
|
|
77
|
+
* A relative endpoint with no registered client falls back to a raw fetch
|
|
78
|
+
* (legacy) with a console warning.
|
|
79
|
+
*/
|
|
70
80
|
endpoint?: string
|
|
81
|
+
/**
|
|
82
|
+
* Route the endpoint request through the named entity's storage
|
|
83
|
+
* (`storage.request('GET', endpoint)`) instead of the kernel client —
|
|
84
|
+
* explicit refinement for multi-API apps. Takes precedence over the
|
|
85
|
+
* kernel client.
|
|
86
|
+
*/
|
|
87
|
+
via?: string
|
|
71
88
|
/** When using endpoint: pick a sub-key from the response data */
|
|
72
89
|
pick?: string
|
|
73
|
-
/** Extra headers for
|
|
90
|
+
/** Extra headers for the raw-fetch paths (e.g. Authorization) */
|
|
74
91
|
headers?: Record<string, string> | (() => Record<string, string>)
|
|
75
92
|
/** Static options (no fetch needed) */
|
|
76
93
|
static?: unknown[]
|
|
@@ -164,10 +181,16 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
|
|
|
164
181
|
: defaultDecode
|
|
165
182
|
)
|
|
166
183
|
|
|
167
|
-
// Resolve orchestrator lazily (
|
|
168
|
-
|
|
184
|
+
// Resolve orchestrator lazily (entity mode, or via:'entityName' routing)
|
|
185
|
+
type LookupManager = {
|
|
186
|
+
list: (params?: Record<string, unknown>) => Promise<{ items: unknown[] }>
|
|
187
|
+
storage?: {
|
|
188
|
+
request?: (method: string, path: string, options?: Record<string, unknown>) => Promise<unknown>
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let getManager: ((name: string) => LookupManager) | null = null
|
|
169
192
|
|
|
170
|
-
if (config.entity) {
|
|
193
|
+
if (config.entity || config.via) {
|
|
171
194
|
try {
|
|
172
195
|
const orc = useOrchestrator()
|
|
173
196
|
// Generic-vs-non-generic: getManager is parameterised by an
|
|
@@ -180,6 +203,105 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
|
|
|
180
203
|
}
|
|
181
204
|
}
|
|
182
205
|
|
|
206
|
+
// Kernel default API client (Kernel({ apiClient })) — used for relative
|
|
207
|
+
// endpoints so base URL + auth apply without per-call wiring (#1198).
|
|
208
|
+
let apiClient: ReturnType<typeof useApiClient> = null
|
|
209
|
+
if (config.endpoint) {
|
|
210
|
+
try {
|
|
211
|
+
apiClient = useApiClient()
|
|
212
|
+
} catch {
|
|
213
|
+
// Outside a setup/injection context
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Bare-relative warning: once per composable instance, not per load
|
|
218
|
+
let warnedBareRelative = false
|
|
219
|
+
|
|
220
|
+
function warnLookup(msg: string): void {
|
|
221
|
+
console.warn(`[qdadm] useOptionsLookup(endpoint: '${config.endpoint}'): ${msg}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Shared normalization for every endpoint path: unwrap data, pick, array-check. */
|
|
225
|
+
function normalizeEndpointPayload(json: unknown): unknown[] {
|
|
226
|
+
let data: unknown = (json as { data?: unknown })?.data ?? json
|
|
227
|
+
if (config.pick && typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
228
|
+
data = (data as Record<string, unknown>)[config.pick]
|
|
229
|
+
}
|
|
230
|
+
if (!Array.isArray(data)) {
|
|
231
|
+
warnLookup('response contained no usable array (after data/pick unwrap) — check the endpoint payload shape')
|
|
232
|
+
return []
|
|
233
|
+
}
|
|
234
|
+
return data
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Endpoint routing (#1198): via storage > relative→kernel client > raw fetch. */
|
|
238
|
+
async function loadFromEndpoint(): Promise<unknown[]> {
|
|
239
|
+
const endpoint = config.endpoint!
|
|
240
|
+
const isAbsolute = /^https?:\/\//i.test(endpoint)
|
|
241
|
+
|
|
242
|
+
// 1. via:'entityName' → that entity's storage (explicit refinement)
|
|
243
|
+
if (config.via && getManager) {
|
|
244
|
+
const storage = getManager(config.via)?.storage
|
|
245
|
+
if (storage?.request) {
|
|
246
|
+
try {
|
|
247
|
+
const json = await storage.request('GET', endpoint)
|
|
248
|
+
return normalizeEndpointPayload(json)
|
|
249
|
+
} catch (err) {
|
|
250
|
+
warnLookup(`request via '${config.via}' storage failed: ${err instanceof Error ? err.message : err}`)
|
|
251
|
+
throw err
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
warnLookup(`via: '${config.via}' has no storage.request() — falling back to the default routing`)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 2. relative endpoint → kernel default API client (base URL + auth)
|
|
258
|
+
if (!isAbsolute && apiClient) {
|
|
259
|
+
try {
|
|
260
|
+
const { data } = await apiClient.get(endpoint)
|
|
261
|
+
return normalizeEndpointPayload(data)
|
|
262
|
+
} catch (err) {
|
|
263
|
+
warnLookup(`request through the kernel apiClient failed: ${err instanceof Error ? err.message : err}`)
|
|
264
|
+
throw err
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3. raw fetch — absolute URL (escape hatch) or legacy relative call
|
|
269
|
+
if (!isAbsolute && !warnedBareRelative) {
|
|
270
|
+
warnedBareRelative = true
|
|
271
|
+
warnLookup(
|
|
272
|
+
'relative endpoint fetched bare — no API base URL or auth header applied. ' +
|
|
273
|
+
'Register Kernel({ apiClient }) (recommended), use via: \'entityName\', or pass an absolute URL + headers.'
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
const extraHeaders = typeof config.headers === 'function' ? config.headers() : config.headers
|
|
277
|
+
const response = await fetch(endpoint, {
|
|
278
|
+
credentials: 'include',
|
|
279
|
+
headers: { 'Accept': 'application/json', ...extraHeaders },
|
|
280
|
+
})
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
const authHint = response.status === 401 || response.status === 403
|
|
283
|
+
? ' — missing auth? register Kernel({ apiClient }) or pass headers'
|
|
284
|
+
: ''
|
|
285
|
+
warnLookup(`HTTP ${response.status}${authHint}`)
|
|
286
|
+
throw new Error(`HTTP ${response.status}`)
|
|
287
|
+
}
|
|
288
|
+
const contentType = response.headers.get('content-type') || ''
|
|
289
|
+
if (!contentType.includes('json')) {
|
|
290
|
+
warnLookup(
|
|
291
|
+
`response is not JSON (content-type: '${contentType || 'unknown'}') — the relative URL likely resolved ` +
|
|
292
|
+
'to the app origin (HTML page). Register Kernel({ apiClient }) or use an absolute URL.'
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
let json: unknown
|
|
296
|
+
try {
|
|
297
|
+
json = await response.json()
|
|
298
|
+
} catch (err) {
|
|
299
|
+
warnLookup(`response body is not parseable JSON: ${err instanceof Error ? err.message : err}`)
|
|
300
|
+
throw err
|
|
301
|
+
}
|
|
302
|
+
return normalizeEndpointPayload(json)
|
|
303
|
+
}
|
|
304
|
+
|
|
183
305
|
// Encoded strings cache for mapped mode (rebuilt on processItems)
|
|
184
306
|
let encodedStrings: string[] = []
|
|
185
307
|
|
|
@@ -255,21 +377,7 @@ export function useOptionsLookup(config: UseOptionsLookupConfig): UseOptionsLook
|
|
|
255
377
|
items = rawItems
|
|
256
378
|
}
|
|
257
379
|
} else if (config.endpoint) {
|
|
258
|
-
|
|
259
|
-
const response = await fetch(config.endpoint, {
|
|
260
|
-
credentials: 'include',
|
|
261
|
-
headers: { 'Accept': 'application/json', ...extraHeaders },
|
|
262
|
-
})
|
|
263
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
264
|
-
const json = await response.json()
|
|
265
|
-
|
|
266
|
-
let data = json.data ?? json
|
|
267
|
-
|
|
268
|
-
if (config.pick && typeof data === 'object' && !Array.isArray(data)) {
|
|
269
|
-
data = (data as Record<string, unknown>)[config.pick]
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
items = Array.isArray(data) ? data : []
|
|
380
|
+
items = await loadFromEndpoint()
|
|
273
381
|
}
|
|
274
382
|
|
|
275
383
|
processItems(items)
|
package/src/index.ts
CHANGED
|
@@ -293,6 +293,12 @@ export {
|
|
|
293
293
|
type OptionsLookupItem,
|
|
294
294
|
type OptionsDisplayMode,
|
|
295
295
|
} from './composables/useOptionsLookup'
|
|
296
|
+
export {
|
|
297
|
+
useApiClient,
|
|
298
|
+
resolveApiClient,
|
|
299
|
+
API_CLIENT_INJECTION_KEY,
|
|
300
|
+
type ApiClientSource,
|
|
301
|
+
} from './api/apiClient'
|
|
296
302
|
export { useChildPage, type UseChildPageReturn } from './composables/useChildPage'
|
|
297
303
|
export { useSidebarState, SIDEBAR_COLLAPSED_KEY } from './composables/useSidebarState'
|
|
298
304
|
|
|
@@ -11,6 +11,7 @@ import type { EntityManager } from '../entity/EntityManager'
|
|
|
11
11
|
import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
12
12
|
import type { RoleProvider } from '../security/RolesProvider'
|
|
13
13
|
import type { I18nOptions } from '../i18n/types'
|
|
14
|
+
import type { ApiClientSource } from '../api/apiClient'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Auth adapter interface (app-level authentication)
|
|
@@ -183,6 +184,34 @@ export interface KernelOptions {
|
|
|
183
184
|
debug?: boolean
|
|
184
185
|
onAuthExpired?: (payload: unknown) => void
|
|
185
186
|
i18n?: I18nOptions
|
|
187
|
+
/**
|
|
188
|
+
* Default API HTTP client (axios-compatible `HttpClient`, or a factory),
|
|
189
|
+
* provided to composables/widgets as `qdadmApiClient`. Relative-endpoint
|
|
190
|
+
* lookups (`useOptionsLookup` endpoint mode, `ScopeEditor` fallback) route
|
|
191
|
+
* through it so the app's base URL + auth headers apply without per-call
|
|
192
|
+
* wiring. Typically the same client your ApiStorages use.
|
|
193
|
+
*/
|
|
194
|
+
apiClient?: ApiClientSource
|
|
195
|
+
/**
|
|
196
|
+
* Naming policy for the URL param carrying the parent id in child route
|
|
197
|
+
* families (#1201). `auto` (default): list-only families keep the bare
|
|
198
|
+
* `idField` param; families with show/edit routes namespace the parent
|
|
199
|
+
* (`jobs/:jobId/tasks/:id`) so the principal (child) id stays `:id`.
|
|
200
|
+
* `always`: namespaced everywhere. `bare`: legacy `idField` param, with
|
|
201
|
+
* an explicit registration error on collision.
|
|
202
|
+
*/
|
|
203
|
+
parentParamMode?: 'auto' | 'always' | 'bare'
|
|
204
|
+
/**
|
|
205
|
+
* Global mapping service for parent URL param names (#1201). Consulted
|
|
206
|
+
* before `parentParamMode`; a per-call `parentParam` still wins. Return
|
|
207
|
+
* undefined to fall through to the mode-based default.
|
|
208
|
+
*/
|
|
209
|
+
routeParamResolver?: (ctx: {
|
|
210
|
+
parentEntity: string
|
|
211
|
+
parentRouteName: string
|
|
212
|
+
childEntity: string | null
|
|
213
|
+
parentIdField: string
|
|
214
|
+
}) => string | undefined
|
|
186
215
|
/**
|
|
187
216
|
* Existing Vue app to install qdadm onto. When provided, the Kernel
|
|
188
217
|
* skips its own `createApp(WrappedRoot)` and reuses the supplied
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -205,6 +205,12 @@ export function applyVueMethods(KernelClass: { prototype: any }): void {
|
|
|
205
205
|
app.provide('qdadmActiveStack', this.activeStack)
|
|
206
206
|
app.provide('qdadmStackHydrator', this.stackHydrator)
|
|
207
207
|
|
|
208
|
+
if (this.options.apiClient) {
|
|
209
|
+
// Default API client for composables/widgets (useOptionsLookup,
|
|
210
|
+
// ScopeEditor) — relative endpoints get base URL + auth (#1198).
|
|
211
|
+
app.provide('qdadmApiClient', this.options.apiClient)
|
|
212
|
+
}
|
|
213
|
+
|
|
208
214
|
if (this.options.debug && typeof window !== 'undefined') {
|
|
209
215
|
const self = this
|
|
210
216
|
// Single namespaced surface for browser-console & agent introspection.
|
|
@@ -45,6 +45,89 @@ function capitalize(str: string): string {
|
|
|
45
45
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Context handed to a kernel-level routeParamResolver (#1201). */
|
|
49
|
+
export interface RouteParamResolverContext {
|
|
50
|
+
parentEntity: string
|
|
51
|
+
parentRouteName: string
|
|
52
|
+
/** Child entity name, or null for a non-entity childPage. */
|
|
53
|
+
childEntity: string | null
|
|
54
|
+
parentIdField: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the URL param name carrying the parent id in a child route
|
|
59
|
+
* family (#1201). Precedence: per-call `parentParam` > kernel
|
|
60
|
+
* `routeParamResolver` > `parentParamMode`:
|
|
61
|
+
*
|
|
62
|
+
* - `auto` (default): families whose routes never carry the child's own
|
|
63
|
+
* id (list-only, childPage) keep the bare parent `idField` param —
|
|
64
|
+
* unchanged URLs. Families with show/edit routes namespace the parent
|
|
65
|
+
* (`:jobId`) so the principal (child) id stays `:id`.
|
|
66
|
+
* - `always`: namespaced parent param everywhere.
|
|
67
|
+
* - `bare`: legacy `idField` param everywhere; a would-be collision
|
|
68
|
+
* throws at registration instead of silently shadowing the parent.
|
|
69
|
+
*/
|
|
70
|
+
function resolveParentParam(opts: {
|
|
71
|
+
kernelOptions?: {
|
|
72
|
+
parentParamMode?: 'auto' | 'always' | 'bare'
|
|
73
|
+
routeParamResolver?: (ctx: RouteParamResolverContext) => string | undefined
|
|
74
|
+
}
|
|
75
|
+
explicit?: string
|
|
76
|
+
parentEntity: string
|
|
77
|
+
parentRouteName: string
|
|
78
|
+
childEntity: string | null
|
|
79
|
+
parentIdField: string
|
|
80
|
+
childIdParam: string
|
|
81
|
+
hasOwnIdParam: boolean
|
|
82
|
+
}): string {
|
|
83
|
+
const {
|
|
84
|
+
kernelOptions,
|
|
85
|
+
explicit,
|
|
86
|
+
parentEntity,
|
|
87
|
+
parentRouteName,
|
|
88
|
+
childEntity,
|
|
89
|
+
parentIdField,
|
|
90
|
+
childIdParam,
|
|
91
|
+
hasOwnIdParam,
|
|
92
|
+
} = opts
|
|
93
|
+
|
|
94
|
+
if (explicit) return explicit
|
|
95
|
+
|
|
96
|
+
const resolved = kernelOptions?.routeParamResolver?.({
|
|
97
|
+
parentEntity,
|
|
98
|
+
parentRouteName,
|
|
99
|
+
childEntity,
|
|
100
|
+
parentIdField,
|
|
101
|
+
})
|
|
102
|
+
if (resolved) return resolved
|
|
103
|
+
|
|
104
|
+
const mode = kernelOptions?.parentParamMode || 'auto'
|
|
105
|
+
const namespaced = `${singularize(parentEntity)}Id`
|
|
106
|
+
|
|
107
|
+
if (mode === 'always') return namespaced
|
|
108
|
+
|
|
109
|
+
if (mode === 'bare') {
|
|
110
|
+
if (hasOwnIdParam && parentIdField === childIdParam) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`[qdadm] crud('${childEntity}', { parentRoute: '${parentRouteName}' }): parent and child ` +
|
|
113
|
+
`both use the URL param ':${childIdParam}' — the child id would shadow the parent id. ` +
|
|
114
|
+
`Switch parentParamMode to 'auto', or set an explicit parentParam.`
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return parentIdField
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// mode === 'auto'
|
|
121
|
+
if (!hasOwnIdParam) return parentIdField
|
|
122
|
+
if (namespaced === childIdParam) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[qdadm] crud('${childEntity}', { parentRoute: '${parentRouteName}' }): the namespaced ` +
|
|
125
|
+
`parent param ':${namespaced}' collides with the child idField — set an explicit parentParam.`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return namespaced
|
|
129
|
+
}
|
|
130
|
+
|
|
48
131
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
132
|
export function applyRoutingMethods(KernelContextClass: { prototype: any }): void {
|
|
50
133
|
const proto = KernelContextClass.prototype as Self
|
|
@@ -103,7 +186,20 @@ export function applyRoutingMethods(KernelContextClass: { prototype: any }): voi
|
|
|
103
186
|
const parentManager = parentEntityName
|
|
104
187
|
? this._kernel.orchestrator?.get(parentEntityName)
|
|
105
188
|
: null
|
|
106
|
-
const
|
|
189
|
+
const parentIdField = parentManager?.idField || 'id'
|
|
190
|
+
// Families with show/edit routes carry the child's own :id — the
|
|
191
|
+
// parent param must not collide with it (#1201).
|
|
192
|
+
const hasOwnIdParam = !!(pages.show || pages.form || pages.edit)
|
|
193
|
+
const parentIdParam = resolveParentParam({
|
|
194
|
+
kernelOptions: this._kernel.options,
|
|
195
|
+
explicit: options.parentParam,
|
|
196
|
+
parentEntity: parentEntityName || parentRouteName,
|
|
197
|
+
parentRouteName,
|
|
198
|
+
childEntity: entity,
|
|
199
|
+
parentIdField,
|
|
200
|
+
childIdParam: idParam,
|
|
201
|
+
hasOwnIdParam,
|
|
202
|
+
})
|
|
107
203
|
|
|
108
204
|
// Build base path: parentPath/:parentId/entity (e.g., books/:bookId/loans)
|
|
109
205
|
const parentBasePath =
|
|
@@ -238,7 +334,19 @@ export function applyRoutingMethods(KernelContextClass: { prototype: any }): voi
|
|
|
238
334
|
const parentManager = parentEntityName
|
|
239
335
|
? this._kernel.orchestrator?.get(parentEntityName)
|
|
240
336
|
: null
|
|
241
|
-
const
|
|
337
|
+
const parentIdField = parentManager?.idField || 'id'
|
|
338
|
+
// childPage has no own :id (single-id family) — auto mode keeps the
|
|
339
|
+
// bare param; explicit parentParam / resolver / 'always' still apply.
|
|
340
|
+
const parentIdParam = resolveParentParam({
|
|
341
|
+
kernelOptions: this._kernel.options,
|
|
342
|
+
explicit: options.parentParam,
|
|
343
|
+
parentEntity: parentEntityName || parentRouteName,
|
|
344
|
+
parentRouteName,
|
|
345
|
+
childEntity: null,
|
|
346
|
+
parentIdField,
|
|
347
|
+
childIdParam: 'id',
|
|
348
|
+
hasOwnIdParam: false,
|
|
349
|
+
})
|
|
242
350
|
|
|
243
351
|
const parentBasePath =
|
|
244
352
|
parentRoute.path.replace(/\/(create|:.*)?$/, '') || parentEntityName
|
|
@@ -148,6 +148,12 @@ export interface CrudOptions {
|
|
|
148
148
|
parentRoute?: string
|
|
149
149
|
foreignKey?: string
|
|
150
150
|
label?: string
|
|
151
|
+
/**
|
|
152
|
+
* Explicit URL param name carrying the parent id in child routes
|
|
153
|
+
* (e.g. 'jobUuid' → `jobs/:jobUuid/tasks/:id`). Overrides the kernel
|
|
154
|
+
* `routeParamResolver` and `parentParamMode` (#1201).
|
|
155
|
+
*/
|
|
156
|
+
parentParam?: string
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
/** Child page options for non-entity child routes */
|
|
@@ -156,6 +162,8 @@ export interface ChildPageOptions {
|
|
|
156
162
|
label?: string
|
|
157
163
|
icon?: string
|
|
158
164
|
meta?: Record<string, unknown>
|
|
165
|
+
/** Explicit URL param name for the parent id (see CrudOptions.parentParam). */
|
|
166
|
+
parentParam?: string
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
/** User entity options */
|