@quazardous/qdadm 2.28.0 → 2.30.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.28.0",
3
+ "version": "2.30.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -58,7 +58,8 @@ import {
58
58
  type ShowResolvedFieldConfig,
59
59
  } from './createShowFieldResolver'
60
60
  import type { OrchestratorLike } from '../entity/EntityManager.interface'
61
- import type { ButtonSeverity } from '../types'
61
+ import type { LiveEntityPolicy } from '../entity/EntityManager.types'
62
+ import type { ButtonSeverity, EntityRecord } from '../types'
62
63
  import { useActionRegistry } from './useActionRegistry'
63
64
  import { useLiveEntity } from './useLiveEntity'
64
65
 
@@ -329,18 +330,41 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
329
330
  const notifications = useNotifications()
330
331
  const route = useRoute()
331
332
  useLiveEntity(entity, manager, async () => {
333
+ // A burst of events is one reload, so this is the record from before the burst.
334
+ const before = data.value
332
335
  const record = await base.reload()
333
336
  // A change made elsewhere to the record on screen leaves a line in the
334
- // history (#2677): short-lived, linked back to it, never a pop-up.
337
+ // history (#2677): short-lived, linked back to it, never a pop-up. The
338
+ // entity may say the change is not worth one, or what it means (#2685).
335
339
  if (!record) return
336
- const name = entityLabel.value ? `"${entityLabel.value}"` : `#${entityId.value}`
337
- notifications.addNotification({
338
- severity: 'info',
339
- summary: `${manager.label ?? entity} ${name} updated elsewhere`,
340
- keep: 'short',
341
- ...(route?.name ? { to: { name: String(route.name), params: { ...route.params } } } : {}),
342
- })
343
- }, { id: () => entityId.value })
340
+ const to = route?.name ? { to: { name: String(route.name), params: { ...route.params } } } : {}
341
+ const describeUpdate = (manager as { live?: LiveEntityPolicy }).live?.describeUpdate
342
+ if (!describeUpdate) {
343
+ const name = entityLabel.value ? `"${entityLabel.value}"` : `#${entityId.value}`
344
+ notifications.addNotification({
345
+ severity: 'info',
346
+ summary: `${manager.label ?? entity} ${name} updated elsewhere`,
347
+ keep: 'short',
348
+ ...to,
349
+ })
350
+ return
351
+ }
352
+ if (!before) return
353
+ let entry
354
+ try {
355
+ entry = describeUpdate(before as unknown as EntityRecord, record as unknown as EntityRecord)
356
+ } catch (err) {
357
+ console.warn(`[qdadm] live.describeUpdate failed for ${entity} #${entityId.value}:`, err)
358
+ return
359
+ }
360
+ if (!entry) return
361
+ notifications.addNotification({ severity: 'info', keep: 'short', ...to, ...entry })
362
+ }, {
363
+ id: () => entityId.value,
364
+ // The update has arrived: say so on the badge at once — before the reload is
365
+ // coalesced, let alone finished (#2679).
366
+ onEvent: () => notifications.flash(),
367
+ })
344
368
  // Cast orchestrator to include toast methods
345
369
  const orchestrator = baseOrchestrator as Orchestrator
346
370
 
@@ -27,6 +27,12 @@ export interface UseLiveEntityOptions {
27
27
  * own id: without it, changing one record reloads every open detail page.
28
28
  */
29
29
  id?: () => string | number | null | undefined
30
+ /**
31
+ * Called at once for every event that concerns this screen, before the
32
+ * reload is coalesced (#2679) — for a cue that must not wait, like the
33
+ * notification badge's flash.
34
+ */
35
+ onEvent?: () => void
30
36
  }
31
37
 
32
38
  interface LivePolicy {
@@ -129,6 +135,8 @@ export function useLiveEntity(
129
135
  }
130
136
  }
131
137
 
138
+ options.onEvent?.()
139
+
132
140
  // Coalesce: a backend replaying fifty rows must cost one reload, not fifty.
133
141
  if (coalesceMs <= 0) {
134
142
  fire()
@@ -105,7 +105,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
105
105
  protected _warmup: boolean
106
106
  protected _authSensitive: boolean
107
107
  /** Reaction policy for changes made outside this session (#1888). */
108
- protected _live: Required<LiveEntityPolicy>
108
+ protected _live: LiveEntityPolicy & Required<Pick<LiveEntityPolicy, 'refresh' | 'coalesceMs'>>
109
109
  protected _system: boolean
110
110
 
111
111
  protected _scopeWhitelist: string[] | null
@@ -215,6 +215,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
215
215
  this._live = {
216
216
  refresh: live?.refresh ?? 'mounted',
217
217
  coalesceMs: live?.coalesceMs ?? 300,
218
+ ...(live?.describeUpdate ? { describeUpdate: live.describeUpdate } : {}),
218
219
  }
219
220
  this._system = system
220
221
 
@@ -640,7 +641,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
640
641
  * Check if user can read entities
641
642
  */
642
643
  /** Reaction policy for changes made outside this session (#1888). */
643
- get live(): Required<LiveEntityPolicy> {
644
+ get live(): LiveEntityPolicy & Required<Pick<LiveEntityPolicy, 'refresh' | 'coalesceMs'>> {
644
645
  return this._live
645
646
  }
646
647
 
@@ -17,6 +17,7 @@ import type { HookRegistry } from '../hooks/HookRegistry'
17
17
  // Circular type import — safe because `import type` is erased at runtime
18
18
  import type { EntityManager } from './EntityManager'
19
19
  import type { RouteStatePersister } from '../routeState'
20
+ import type { NewNotification } from '../notifications/NotificationStore'
20
21
 
21
22
  // ============ INTERNAL TYPES ============
22
23
 
@@ -230,8 +231,24 @@ export interface LiveEntityPolicy {
230
231
  * Default 300. `0` reloads on every event — rarely what you want.
231
232
  */
232
233
  coalesceMs?: number
234
+ /**
235
+ * Whether a live reload of a detail page is worth a notification entry, and
236
+ * what it says (#2685). Called once per reload, with the record that was on
237
+ * screen and the reloaded one.
238
+ *
239
+ * Return `null` / `undefined` for no entry (the badge still flashes), or the
240
+ * entry: `severity` defaults to `'info'`, `keep` to `'short'`, `to` to the
241
+ * record's page. Without this hook, the page adds a generic
242
+ * "… updated elsewhere" entry. If it throws, no entry is added.
243
+ *
244
+ * Do not mutate `before` or `after`: they are the page's own records.
245
+ */
246
+ describeUpdate?: (before: EntityRecord, after: EntityRecord) => LiveUpdateEntry | null | undefined
233
247
  }
234
248
 
249
+ /** The notification entry `LiveEntityPolicy.describeUpdate` asks for (#2685). */
250
+ export type LiveUpdateEntry = Omit<NewNotification, 'severity'> & Partial<Pick<NewNotification, 'severity'>>
251
+
235
252
  /**
236
253
  * EntityManager constructor options
237
254
  */
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ export { SSEBridge, createSSEBridge, SSE_SIGNALS } from './kernel/SSEBridge'
41
41
  export type { SSEBridgeOptions } from './kernel/SSEBridge'
42
42
  export { useLiveEntity } from './composables/useLiveEntity'
43
43
  export type { UseLiveEntityOptions } from './composables/useLiveEntity'
44
- export type { LiveEntityPolicy } from './entity/EntityManager.types'
44
+ export type { LiveEntityPolicy, LiveUpdateEntry } from './entity/EntityManager.types'
45
45
  export { LiveEntityRouter, createLiveEntityRouter } from './kernel/LiveEntityRouter'
46
46
  export type {
47
47
  LiveEntityRouterOptions,
@@ -7,7 +7,8 @@
7
7
  * - something needs attention: the parent layout blinks the logo via
8
8
  * :has(.notification-badge-zone--alert);
9
9
  * - how many entries are unread: a count;
10
- * - work is in progress: a ring, once tracked work has lasted `activityDelayMs`.
10
+ * - work is in progress: a ring, once tracked work has lasted `activityDelayMs`;
11
+ * - something just arrived: a short flash, immediately (#2679).
11
12
  *
12
13
  * Click toggles the notification panel open/close.
13
14
  */
@@ -29,6 +30,7 @@ const unreadLabel = computed(() => (unread.value > 99 ? '99+' : String(unread.va
29
30
  @click.stop.prevent="store.toggle()"
30
31
  >
31
32
  <span v-if="store.isBusy.value" class="notification-badge-ring" role="progressbar" aria-label="Loading" />
33
+ <span v-if="store.isFlashing.value" class="notification-badge-flash" aria-hidden="true" />
32
34
  <span v-if="unread > 0" class="notification-badge-count">{{ unreadLabel }}</span>
33
35
  </div>
34
36
  </template>
@@ -78,11 +80,30 @@ const unreadLabel = computed(() => (unread.value > 99 ? '99+' : String(unread.va
78
80
  to { transform: rotate(360deg); }
79
81
  }
80
82
 
83
+ /* Something just arrived: a halo that grows out of the logo and fades. */
84
+ .notification-badge-flash {
85
+ position: absolute;
86
+ inset: 0;
87
+ border-radius: 50%;
88
+ pointer-events: none;
89
+ animation: notification-badge-flash 0.9s ease-out 1;
90
+ }
91
+
92
+ @keyframes notification-badge-flash {
93
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--p-primary-color, #3b82f6) 70%, transparent); }
94
+ 100% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--p-primary-color, #3b82f6) 0%, transparent); }
95
+ }
96
+
81
97
  @media (prefers-reduced-motion: reduce) {
82
98
  .notification-badge-ring {
83
99
  animation: none;
84
100
  border-color: var(--p-primary-color, #3b82f6);
85
101
  opacity: 0.5;
86
102
  }
103
+
104
+ .notification-badge-flash {
105
+ animation: none;
106
+ box-shadow: 0 0 0 3px var(--p-primary-color, #3b82f6);
107
+ }
87
108
  }
88
109
  </style>
@@ -8,7 +8,7 @@
8
8
  * - Mobile: Full width overlay at bottom of screen
9
9
  *
10
10
  * Features:
11
- * - Inline action bar (mark read, clear, close) at top right
11
+ * - Toolbar row (mark read, clear, close) above the list
12
12
  * - Status items section (custom module items)
13
13
  * - Notification list (most recent first)
14
14
  * - Empty state
@@ -189,16 +189,15 @@ function formatTime(timestamp: number): string {
189
189
  left: calc(var(--fad-sidebar-width-collapsed, 2.5rem) + 0.375rem);
190
190
  }
191
191
 
192
- /* Inline toolbar - floats top-right inside the panel */
192
+ /* Toolbar - its own row above the list, so its buttons never cover an entry's dismiss button */
193
193
  .notification-toolbar {
194
- position: absolute;
195
- top: 0;
196
- right: 0;
197
194
  display: flex;
198
195
  align-items: center;
196
+ justify-content: flex-end;
199
197
  gap: 1px;
200
198
  padding: 0.25rem;
201
- z-index: 1;
199
+ border-bottom: 1px solid var(--p-surface-100, #f1f5f9);
200
+ flex-shrink: 0;
202
201
  }
203
202
 
204
203
  .notification-toolbar-btn {
@@ -228,7 +227,6 @@ function formatTime(timestamp: number): string {
228
227
  /* Status items */
229
228
  .notification-panel-status {
230
229
  padding: 0.5rem 0.625rem;
231
- padding-right: 2.5rem;
232
230
  border-bottom: 1px solid var(--p-surface-100, #f1f5f9);
233
231
  flex-shrink: 0;
234
232
  }
@@ -309,10 +307,6 @@ function formatTime(timestamp: number): string {
309
307
  transition: background 0.1s;
310
308
  }
311
309
 
312
- .notification-item:first-child {
313
- padding-right: 2.5rem;
314
- }
315
-
316
310
  .notification-item:hover {
317
311
  background: var(--p-surface-50, #f8fafc);
318
312
  }
@@ -118,6 +118,14 @@ export interface NotificationStore {
118
118
  track<T>(work: Promise<T>): Promise<T>
119
119
  /** True once tracked work has been in progress for `activityDelayMs`. */
120
120
  isBusy: ComputedRef<boolean>
121
+ /**
122
+ * Flash the badge: something just arrived — an update to the record on
123
+ * screen (#2679). Immediate, never delayed; a flash already running absorbs
124
+ * further calls, so a burst flashes once.
125
+ */
126
+ flash(): void
127
+ /** True while the badge flashes. */
128
+ isFlashing: ComputedRef<boolean>
121
129
 
122
130
  // Panel state
123
131
  isOpen: Ref<boolean>
@@ -288,6 +296,20 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
288
296
  return work
289
297
  }
290
298
 
299
+ // ── Flash ──────────────────────────────────────────────────────────────
300
+
301
+ const FLASH_MS = 900
302
+ const flashing = ref(false)
303
+
304
+ function flash(): void {
305
+ // A burst of updates flashes once: the running flash already says it.
306
+ if (flashing.value) return
307
+ flashing.value = true
308
+ setTimeout(() => {
309
+ flashing.value = false
310
+ }, FLASH_MS)
311
+ }
312
+
291
313
  // ── Panel state ────────────────────────────────────────────────────────
292
314
 
293
315
  function open(): void {
@@ -338,6 +360,8 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
338
360
  removeStatus,
339
361
  track,
340
362
  isBusy: computed(() => busy.value),
363
+ flash,
364
+ isFlashing: computed(() => flashing.value),
341
365
  isOpen,
342
366
  open,
343
367
  close,
@@ -383,6 +407,8 @@ export function useNotifications(): NotificationStore {
383
407
  removeStatus: () => {},
384
408
  track: (work) => work,
385
409
  isBusy: computed(() => false),
410
+ flash: () => {},
411
+ isFlashing: computed(() => false),
386
412
  isOpen: ref(false),
387
413
  open: () => {},
388
414
  close: () => {},
@@ -175,7 +175,7 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
175
175
  warmup?: boolean
176
176
  authSensitive?: boolean
177
177
  /** What this entity does when it changed elsewhere (#1888). */
178
- live?: { refresh?: 'mounted' | false; coalesceMs?: number }
178
+ live?: import('../entity/EntityManager.types').LiveEntityPolicy
179
179
  system?: boolean
180
180
  scopeWhitelist?: string[] | null
181
181
  isOwn?: ((record: T, user: unknown) => boolean) | null