@quazardous/qdadm 2.25.0 → 2.26.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@
24
24
  * </FormPage>
25
25
  * ```
26
26
  */
27
- import { ref, computed, watch, onMounted, onUnmounted, provide, type Ref } from 'vue'
27
+ import { ref, computed, watch, onMounted, onUnmounted, provide, inject, type Ref } from 'vue'
28
28
  import { useRouter, useRoute } from 'vue-router'
29
29
  import { useConfirm } from 'primevue/useconfirm'
30
30
  import { requireDeleteConfirmation } from './confirmDelete'
@@ -56,6 +56,9 @@ import { createOrchestratorToast } from './useOrchestratorToast'
56
56
  import { useActionRegistry } from './useActionRegistry'
57
57
  import { TYPE_MAPPINGS, TYPE_VALIDATORS, snakeCaseToTitle, isEmpty } from './useEntityItemFormPage.types'
58
58
 
59
+ /** Missing fields already reported, as `entity:field` — each is said once per page load of the app (#2484). */
60
+ const reportedMissingFields = new Set<string>()
61
+
59
62
  // Re-export public types
60
63
  export type {
61
64
  FieldDefinition,
@@ -126,6 +129,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
126
129
  const router = useRouter()
127
130
  const route = useRoute()
128
131
  const confirm = useConfirm() as ConfirmService
132
+ const debug = inject<boolean>('qdadmDebug', false)
129
133
 
130
134
  // Use useEntityItemPage for common infrastructure
131
135
  const itemPage = useEntityItemPage({
@@ -247,6 +251,28 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
247
251
 
248
252
  // ============ LOADING ============
249
253
 
254
+ /**
255
+ * A field the form edits that the loaded record does not carry at all —
256
+ * `undefined`, not empty — is shown empty and saved back empty (#2484). Said
257
+ * in debug mode only, once per entity and field: some APIs omit null fields.
258
+ * Password fields are left out: no API returns one.
259
+ */
260
+ function reportMissingFields(record: Record<string, unknown> | null): void {
261
+ if (!record || typeof record !== 'object') return
262
+ const entityName = String((manager as { name?: unknown }).name ?? entity)
263
+ for (const field of fields.value) {
264
+ if (field.type === 'password' || field.name.includes('.')) continue
265
+ if (record[field.name] !== undefined) continue
266
+ const key = `${entityName}:${field.name}`
267
+ if (reportedMissingFields.has(key)) continue
268
+ reportedMissingFields.add(key)
269
+ console.warn(
270
+ `[qdadm] ${entityName} #${entityId.value} loaded without "${field.name}", which the form edits: ` +
271
+ 'saving would send it back empty. Check that the record endpoint returns it.'
272
+ )
273
+ }
274
+ }
275
+
250
276
  async function load(): Promise<void> {
251
277
  if (!isEdit.value) {
252
278
  data.value = deepClone(initialData) as T
@@ -256,8 +282,11 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
256
282
 
257
283
  loading.value = true
258
284
  try {
259
- const responseData = await manager.get(entityId.value!)
285
+ // The item itself, never a cached list row: a list row may be a summary,
286
+ // and whatever this form shows empty it saves back empty (#2484).
287
+ const responseData = await manager.get(entityId.value!, undefined, { listCache: false })
260
288
  const transformed = transformLoad(responseData)
289
+ if (debug) reportMissingFields(transformed as Record<string, unknown>)
261
290
  data.value = transformed as T
262
291
  originalData.value = deepClone(transformed)
263
292
  takeSnapshot()
@@ -93,15 +93,27 @@ export function useNavigation(): UseNavigationReturn {
93
93
  const alterVersion = ref(0)
94
94
 
95
95
  /**
96
- * Check if user can access a nav item based on its entity's canRead()
96
+ * Check if user can access a nav item: canList() when it opens a list route, canRead() otherwise
97
97
  */
98
98
  function canAccessNavItem(item: NavItem): boolean {
99
99
  if (!item.entity || !orchestrator) return true
100
100
  const manager = orchestrator.get(item.entity)
101
101
  if (!manager) return true
102
+ // A menu entry is shown to whoever may do what its page does (#2497): a
103
+ // ctx.crud() entry opens a list, so it follows `list`. A route that
104
+ // declares no action, or is not registered, keeps `read`.
105
+ if (routeAction(item.route) === 'list' && manager.canList) return manager.canList()
102
106
  return manager.canRead()
103
107
  }
104
108
 
109
+ function routeAction(name: string): string | undefined {
110
+ try {
111
+ return router.resolve({ name }).meta?.entityAction as string | undefined
112
+ } catch {
113
+ return undefined
114
+ }
115
+ }
116
+
105
117
  // Note: Auth signal listeners removed - Kernel.invalidateApp() remounts entire app
106
118
  // on auth changes, so composable is re-initialized with fresh state
107
119
 
@@ -1,5 +1,5 @@
1
1
  import type { ListParams, ListResult } from '../types'
2
- import type { EntityManagerInternal, Orchestrator, RoutingContext } from './EntityManager.types'
2
+ import type { EntityManagerInternal, GetOptions, Orchestrator, RoutingContext } from './EntityManager.types'
3
3
 
4
4
  type Self = EntityManagerInternal<any>
5
5
 
@@ -208,7 +208,8 @@ export function applyCrudMethods(EntityManagerClass: { prototype: any }): void {
208
208
  proto.get = async function (
209
209
  this: Self,
210
210
  id: string | number,
211
- context?: RoutingContext
211
+ context?: RoutingContext,
212
+ options: GetOptions = {}
212
213
  ): Promise<any> {
213
214
  const { storage, endpoint } = this._normalizeResolveResult(
214
215
  this.resolveStorage('get', context),
@@ -287,8 +288,9 @@ export function applyCrudMethods(EntityManagerClass: { prototype: any }): void {
287
288
 
288
289
  // ── Symmetric mode (default): try list cache ──
289
290
 
290
- // Try cache first if valid and complete
291
- if (cache.valid && !this.overflow) {
291
+ // Try cache first if valid and complete — unless the caller needs the item
292
+ // itself: a list row may be a summary (#2484).
293
+ if (options.listCache !== false && cache.valid && !this.overflow) {
292
294
  const cached = cache.items.find(
293
295
  (item: any) => String(item[this.idField]) === idStr
294
296
  )
@@ -71,7 +71,7 @@ export interface EntityManagerPermissions<T = unknown> {
71
71
  * Read operations + delete — used by list pages and other read-oriented views.
72
72
  */
73
73
  export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, EntityManagerPermissions<T> {
74
- get: (id: string | number, context?: unknown) => Promise<T>
74
+ get: (id: string | number, context?: unknown, options?: { listCache?: boolean }) => Promise<T>
75
75
  list: (params?: unknown, context?: unknown) => Promise<{ items: T[]; total?: number; fromCache?: boolean; [key: string]: unknown }>
76
76
  // query/invalidateCache/getFieldConfig are required since #1253: they are
77
77
  // unconditionally implemented on the class (prototype-applied) and called
@@ -160,6 +160,8 @@ export interface EntityManagerLike {
160
160
  getEntityLabel(data: unknown): string | null
161
161
  getEntityBadges?(data: unknown): EntityBadge[]
162
162
  canRead(entity?: unknown): boolean
163
+ /** Whether the user may list — what a list page and its menu entry need (#2497). */
164
+ canList?(): boolean
163
165
  canCreate(): boolean
164
166
  canUpdate(entity?: unknown): boolean
165
167
  canDelete(entity?: unknown): boolean
@@ -168,7 +170,7 @@ export interface EntityManagerLike {
168
170
  total?: number
169
171
  fromCache?: boolean
170
172
  }>
171
- get(id: string | number, context?: unknown): Promise<unknown>
173
+ get(id: string | number, context?: unknown, options?: { listCache?: boolean }): Promise<unknown>
172
174
  create(data: Record<string, unknown>, context?: unknown): Promise<unknown>
173
175
  update(id: string | number, data: Record<string, unknown>, context?: unknown): Promise<unknown>
174
176
  delete(id: string | number, context?: unknown): Promise<unknown>
@@ -29,6 +29,7 @@ import type {
29
29
  ResolvedStorage,
30
30
  StorageResolution,
31
31
  Orchestrator,
32
+ GetOptions,
32
33
  } from './EntityManager.types'
33
34
  import type { NullSortMode } from '../query/clientFilter'
34
35
 
@@ -48,6 +49,7 @@ export type {
48
49
  SeverityMapValue,
49
50
  SeverityMap,
50
51
  EntityManagerOptions,
52
+ GetOptions,
51
53
  } from './EntityManager.types'
52
54
 
53
55
  // Import concrete types needed in method signatures
@@ -1001,7 +1003,7 @@ export interface EntityManager<T extends EntityRecord = EntityRecord> {
1001
1003
 
1002
1004
  // --- CRUD methods ---
1003
1005
  list(params?: ListParams & { _internal?: boolean }, context?: RoutingContext): Promise<ListResult<T>>
1004
- get(id: string | number, context?: RoutingContext): Promise<T>
1006
+ get(id: string | number, context?: RoutingContext, options?: GetOptions): Promise<T>
1005
1007
  getMany(ids: Array<string | number>, context?: RoutingContext): Promise<T[]>
1006
1008
  create(data: Partial<T>, context?: RoutingContext): Promise<T>
1007
1009
  update(id: string | number, data: Partial<T>, context?: RoutingContext): Promise<T>
@@ -32,6 +32,19 @@ export interface CacheState<T> {
32
32
  overflowed?: boolean
33
33
  }
34
34
 
35
+ /**
36
+ * Options for `EntityManager.get()` (#2484).
37
+ */
38
+ export interface GetOptions {
39
+ /**
40
+ * `false` reads the item from storage instead of a cached list row. A list
41
+ * row may be a summary: whoever loads a record in order to write it back
42
+ * needs the item itself. Default true — in symmetric mode, a valid list
43
+ * cache answers `get()`. Asymmetric entities never read the list cache.
44
+ */
45
+ listCache?: boolean
46
+ }
47
+
35
48
  /**
36
49
  * Detail cache entry for asymmetric entities (full detail per item)
37
50
  */
@@ -255,12 +255,17 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
255
255
  const orchestrator = this.orchestrator
256
256
  if (!entity || !orchestrator || !orchestrator.isRegistered(entity)) return
257
257
 
258
+ // The action the route declares (#2497): a list route is governed by
259
+ // `list`. A route that declares none — any custom page — checks `read`.
260
+ const action = to.meta?.entityAction === 'list' ? 'list' : 'read'
261
+
258
262
  const checkEntity = (): { path: string } | undefined => {
259
263
  try {
260
264
  const manager = orchestrator.get(entity)
261
- if (manager && !manager.canRead()) {
265
+ const allowed = manager && (action === 'list' ? manager.canList() : manager.canRead())
266
+ if (manager && !allowed) {
262
267
  console.warn(
263
- `[qdadm] Access denied to ${to.path} (entity: ${entity})`
268
+ `[qdadm] Access denied to ${to.path} (entity: ${entity}, action: ${action})`
264
269
  )
265
270
  orchestrator.toast.error(
266
271
  'Access Denied',
@@ -323,6 +323,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
323
323
  }
324
324
 
325
325
  app.provide('qdadmSignals', this.signals)
326
+ // Whether the app runs in debug mode, for composables that only speak up
327
+ // then (#2484). Until now it reached nothing but window.__qdadm.
328
+ app.provide('qdadmDebug', !!this.options.debug)
326
329
 
327
330
  if (this.i18nInstance) {
328
331
  app.provide(I18N_INJECTION_KEY, this.i18nInstance)
@@ -245,7 +245,9 @@ export function applyRoutingMethods(KernelContextClass: { prototype: KernelConte
245
245
  path: '',
246
246
  name: routePrefix,
247
247
  component: pages.list,
248
- meta: { layout: 'list' },
248
+ // The action this route performs, for the guard and the menu (#2497):
249
+ // a list page is governed by `list`, not by `read`.
250
+ meta: { layout: 'list', entityAction: 'list' },
249
251
  })
250
252
  }
251
253
 
@@ -254,7 +256,7 @@ export function applyRoutingMethods(KernelContextClass: { prototype: KernelConte
254
256
  path: `:${idParam}`,
255
257
  name: `${routePrefix}-show`,
256
258
  component: pages.show,
257
- meta: { layout: 'show' },
259
+ meta: { layout: 'show', entityAction: 'read' },
258
260
  })
259
261
  }
260
262
 
@@ -129,6 +129,12 @@ export class RolesManager extends EntityManager<RoleRecord> {
129
129
  return this._isAdmin()
130
130
  }
131
131
 
132
+ // The list page and its menu entry check `list` (#2497): without this, they
133
+ // would follow `entity:<x>:list` and show a system entity to non-admins.
134
+ canList(): boolean {
135
+ return this._isAdmin()
136
+ }
137
+
132
138
  canCreate(): boolean {
133
139
  return this._isAdmin()
134
140
  }
@@ -146,6 +146,12 @@ export class UsersManager extends EntityManager {
146
146
  return this._isAdmin()
147
147
  }
148
148
 
149
+ // The list page and its menu entry check `list` (#2497): without this, they
150
+ // would follow `entity:<x>:list` and show a system entity to non-admins.
151
+ canList(): boolean {
152
+ return this._isAdmin()
153
+ }
154
+
149
155
  canCreate(): boolean {
150
156
  // little dumb but should work for simple storages
151
157
  return this._isAdmin()