@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 +1 -1
- package/src/composables/useEntityItemShowPage.ts +34 -10
- package/src/composables/useLiveEntity.ts +8 -0
- package/src/entity/EntityManager.ts +3 -2
- package/src/entity/EntityManager.types.ts +17 -0
- package/src/index.ts +1 -1
- package/src/notifications/NotificationBadge.vue +22 -1
- package/src/notifications/NotificationPanel.vue +5 -11
- package/src/notifications/NotificationStore.ts +26 -0
- package/src/types/index.ts +1 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
* -
|
|
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
|
-
/*
|
|
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
|
-
|
|
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: () => {},
|
package/src/types/index.ts
CHANGED
|
@@ -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?:
|
|
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
|