@quazardous/qdadm 2.24.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.24.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",
@@ -20,7 +20,7 @@
20
20
  * </FieldGroups>
21
21
  * ```
22
22
  */
23
- import { computed, type PropType } from 'vue'
23
+ import { computed, ref, watch, type PropType } from 'vue'
24
24
  import Tabs from 'primevue/tabs'
25
25
  import TabList from 'primevue/tablist'
26
26
  import Tab from 'primevue/tab'
@@ -84,8 +84,21 @@ const props = defineProps({
84
84
  type: String as PropType<LayoutMode>,
85
85
  default: 'sections',
86
86
  },
87
+ /**
88
+ * The active group (`tabs`: a group name) or the open ones (`accordion`:
89
+ * names), for `v-model:active` (#2435). Optional: without it the component
90
+ * keeps its own, starting on the first group.
91
+ */
92
+ active: {
93
+ type: [String, Array] as PropType<string | string[] | null>,
94
+ default: undefined,
95
+ },
87
96
  })
88
97
 
98
+ const emit = defineEmits<{
99
+ 'update:active': [value: string | string[]]
100
+ }>()
101
+
89
102
  // Expose slots for field rendering
90
103
  defineSlots<{
91
104
  field: (props: { field: BaseFieldConfig; value: unknown; groupName: string }) => unknown
@@ -105,6 +118,34 @@ const displayGroups = computed(() => {
105
118
  })
106
119
  })
107
120
 
121
+ // The active tab / open panels are state, not a constant: a re-render must not
122
+ // send the user back to the first group (#2435). An app owning it passes
123
+ // v-model:active; otherwise this is the only copy.
124
+ const localActive = ref<string | string[] | null | undefined>(props.active)
125
+ watch(
126
+ () => props.active,
127
+ (value) => {
128
+ if (value !== undefined) localActive.value = value
129
+ }
130
+ )
131
+ const firstGroupName = () => displayGroups.value[0]?.name || '0'
132
+ const activeTab = computed(() => (typeof localActive.value === 'string' ? localActive.value : firstGroupName()))
133
+ const openPanels = computed(() => {
134
+ const value = localActive.value
135
+ if (Array.isArray(value)) return value
136
+ return typeof value === 'string' ? [value] : [firstGroupName()]
137
+ })
138
+ function setActive(value: string | string[]) {
139
+ localActive.value = value
140
+ emit('update:active', value)
141
+ }
142
+ function onTabChange(value: string | number) {
143
+ setActive(String(value))
144
+ }
145
+ function onPanelsChange(value: unknown) {
146
+ setActive(Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)])
147
+ }
148
+
108
149
  // Get value from data
109
150
  function getValue(fieldName: string): unknown {
110
151
  return props.data?.[fieldName]
@@ -227,7 +268,7 @@ function getBadgeValue(group: FieldGroup): string | number | null {
227
268
 
228
269
  <!-- Tabs layout -->
229
270
  <template v-else-if="layout === 'tabs'">
230
- <Tabs :value="displayGroups[0]?.name || '0'">
271
+ <Tabs :value="activeTab" @update:value="onTabChange">
231
272
  <TabList>
232
273
  <Tab
233
274
  v-for="group in displayGroups"
@@ -275,7 +316,7 @@ function getBadgeValue(group: FieldGroup): string | number | null {
275
316
 
276
317
  <!-- Accordion layout -->
277
318
  <template v-else-if="layout === 'accordion'">
278
- <Accordion :value="[displayGroups[0]?.name || '0']" multiple>
319
+ <Accordion :value="openPanels" multiple @update:value="onPanelsChange">
279
320
  <AccordionPanel
280
321
  v-for="group in displayGroups"
281
322
  :key="group.name"
@@ -35,7 +35,8 @@
35
35
  * - #fields: Field content (required) - renders in right column (or full width if no media)
36
36
  * - #footer: Custom footer (replaces ShowActions if provided)
37
37
  * - #error: Custom error display
38
- * - #loading: Custom loading display
38
+ * - #loading: Custom display for the first load. A reload of a record already
39
+ * on screen keeps it there, marked busy (#2435).
39
40
  */
40
41
  import { computed, type PropType } from 'vue'
41
42
  import PageHeader from '../layout/PageHeader.vue'
@@ -203,8 +204,11 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
203
204
  <!-- Toolbar slot (between header and content) -->
204
205
  <slot name="toolbar" />
205
206
 
206
- <!-- Loading State -->
207
- <template v-if="loading">
207
+ <!-- First load only. A reload of a record already shown keeps the content
208
+ mounted (#2435): unmounting it threw away the active tab, open panels,
209
+ scroll and any local state — on every action reload and every live
210
+ update of the record. -->
211
+ <template v-if="loading && !data">
208
212
  <slot name="loading">
209
213
  <div class="loading-state">
210
214
  <i class="pi pi-spin pi-spinner" style="font-size: 2rem"></i>
@@ -223,6 +227,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
223
227
 
224
228
  <!-- Content -->
225
229
  <template v-else>
230
+ <div v-if="loading" class="show-refreshing" role="progressbar" aria-label="Refreshing" />
226
231
  <!-- Single content body; Card wrapper is conditional (#1193) -->
227
232
  <CardShell :card="cardWrapper">
228
233
  <!-- Grid layout with optional media zone -->
@@ -230,6 +235,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
230
235
  class="show-content"
231
236
  :class="{ 'show-content--with-media': slots.media }"
232
237
  :style="slots.media ? { '--media-width': mediaWidth } : {}"
238
+ :aria-busy="loading ? 'true' : undefined"
233
239
  >
234
240
  <!-- Media zone (optional) -->
235
241
  <div v-if="slots.media" class="show-media">
@@ -304,6 +310,29 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
304
310
  padding: 3rem;
305
311
  }
306
312
 
313
+ /* A record being refreshed stays on screen: a thin bar says so (#2435). */
314
+ .show-refreshing {
315
+ height: 3px;
316
+ margin-bottom: -1rem; /* inside the page gap: the content does not move */
317
+ background: linear-gradient(90deg, transparent, var(--p-primary-color, #3b82f6), transparent);
318
+ background-size: 40% 100%;
319
+ background-repeat: no-repeat;
320
+ animation: show-refreshing 1.1s ease-in-out infinite;
321
+ }
322
+
323
+ @keyframes show-refreshing {
324
+ from { background-position: -40% 0; }
325
+ to { background-position: 140% 0; }
326
+ }
327
+
328
+ @media (prefers-reduced-motion: reduce) {
329
+ .show-refreshing {
330
+ animation: none;
331
+ background: var(--p-primary-color, #3b82f6);
332
+ opacity: 0.4;
333
+ }
334
+ }
335
+
307
336
  .show-error-message {
308
337
  margin: 1rem 0;
309
338
  }
@@ -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()