@quazardous/qdadm 2.27.0 → 2.28.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/components/show/ShowPage.vue +2 -23
- package/src/composables/useEntityItemFormPage.ts +4 -1
- package/src/composables/useEntityItemPage.ts +4 -1
- package/src/composables/useEntityItemShowPage.ts +15 -2
- package/src/composables/useListPage.ts +9 -3
- package/src/kernel/Kernel.types.ts +4 -0
- package/src/kernel/Kernel.vue.ts +2 -0
- package/src/notifications/NotificationBadge.vue +58 -5
- package/src/notifications/NotificationModule.ts +4 -19
- package/src/notifications/NotificationPanel.vue +15 -3
- package/src/notifications/NotificationStore.ts +115 -4
- package/src/notifications/index.ts +3 -0
- package/src/orchestrator/Orchestrator.ts +6 -0
- package/src/toast/ToastListener.vue +20 -0
- package/src/toast/useSignalToast.ts +27 -10
package/package.json
CHANGED
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
* - #footer: Custom footer (replaces ShowActions if provided)
|
|
37
37
|
* - #error: Custom error display
|
|
38
38
|
* - #loading: Custom display for the first load. A reload of a record already
|
|
39
|
-
* on screen keeps it there, marked busy (#2435)
|
|
39
|
+
* on screen keeps it there, marked busy (#2435); the notification badge shows
|
|
40
|
+
* the activity (#2677).
|
|
40
41
|
*/
|
|
41
42
|
import { computed, type PropType } from 'vue'
|
|
42
43
|
import PageHeader from '../layout/PageHeader.vue'
|
|
@@ -227,7 +228,6 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
|
|
|
227
228
|
|
|
228
229
|
<!-- Content -->
|
|
229
230
|
<template v-else>
|
|
230
|
-
<div v-if="loading" class="show-refreshing" role="progressbar" aria-label="Refreshing" />
|
|
231
231
|
<!-- Single content body; Card wrapper is conditional (#1193) -->
|
|
232
232
|
<CardShell :card="cardWrapper">
|
|
233
233
|
<!-- Grid layout with optional media zone -->
|
|
@@ -310,28 +310,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
|
|
|
310
310
|
padding: 3rem;
|
|
311
311
|
}
|
|
312
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
313
|
|
|
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
314
|
|
|
336
315
|
.show-error-message {
|
|
337
316
|
margin: 1rem 0;
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './useEntityItemPage'
|
|
38
38
|
import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
|
|
39
39
|
import { registerPageState } from './usePageState'
|
|
40
|
+
import { useNotifications } from '../notifications/NotificationStore'
|
|
40
41
|
import { deepClone } from '../utils/transformers'
|
|
41
42
|
import { getSiblingRoutes } from '../module/moduleRegistry'
|
|
42
43
|
import {
|
|
@@ -130,6 +131,8 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
130
131
|
const route = useRoute()
|
|
131
132
|
const confirm = useConfirm() as ConfirmService
|
|
132
133
|
const debug = inject<boolean>('qdadmDebug', false)
|
|
134
|
+
// Loads show as activity on the notification badge (#2677); a no-op without notifications.
|
|
135
|
+
const notifications = useNotifications()
|
|
133
136
|
|
|
134
137
|
// Use useEntityItemPage for common infrastructure
|
|
135
138
|
const itemPage = useEntityItemPage({
|
|
@@ -291,7 +294,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
291
294
|
try {
|
|
292
295
|
// The item itself, never a cached list row: a list row may be a summary,
|
|
293
296
|
// and whatever this form shows empty it saves back empty (#2484).
|
|
294
|
-
const responseData = await manager.get(entityId.value!, undefined, { listCache: false })
|
|
297
|
+
const responseData = await notifications.track(manager.get(entityId.value!, undefined, { listCache: false }))
|
|
295
298
|
if (!isLatest()) return
|
|
296
299
|
const transformed = transformLoad(responseData)
|
|
297
300
|
if (debug) reportMissingFields(transformed as Record<string, unknown>)
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { ref, computed, onMounted, provide, type Ref, type ComputedRef } from 'vue'
|
|
16
16
|
import { useRoute } from 'vue-router'
|
|
17
17
|
import { useStackHydrator, type StackHydratorReturn } from '../chain/useStackHydrator'
|
|
18
|
+
import { useNotifications } from '../notifications/NotificationStore'
|
|
18
19
|
import type { EntityManagerCrud } from '../entity/EntityManager.interface'
|
|
19
20
|
import type { OrchestratorLike } from '../entity/EntityManager.interface'
|
|
20
21
|
import { useOrchestrator } from '../orchestrator/useOrchestrator.js'
|
|
@@ -161,6 +162,8 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
161
162
|
|
|
162
163
|
// Stack hydrator for setting entity data on navigation context
|
|
163
164
|
const hydrator = useStackHydrator()
|
|
165
|
+
// Loads show as activity on the notification badge (#2677); a no-op without notifications.
|
|
166
|
+
const notifications = useNotifications()
|
|
164
167
|
|
|
165
168
|
// ============ STATE ============
|
|
166
169
|
|
|
@@ -333,7 +336,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
333
336
|
error.value = null
|
|
334
337
|
|
|
335
338
|
try {
|
|
336
|
-
const responseData = await manager.get(id)
|
|
339
|
+
const responseData = await notifications.track(manager.get(id))
|
|
337
340
|
if (!isLatest()) return null
|
|
338
341
|
|
|
339
342
|
if (!responseData) {
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { ref, computed, onUnmounted, type Ref, type ComputedRef } from 'vue'
|
|
37
37
|
import { registerPageState } from './usePageState'
|
|
38
|
-
import { useRouter, type Router } from 'vue-router'
|
|
38
|
+
import { useRouter, useRoute, type Router } from 'vue-router'
|
|
39
|
+
import { useNotifications } from '../notifications/NotificationStore'
|
|
39
40
|
import { useConfirm } from 'primevue/useconfirm'
|
|
40
41
|
import { requireDeleteConfirmation } from './confirmDelete'
|
|
41
42
|
import {
|
|
@@ -325,8 +326,20 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
|
|
|
325
326
|
// Reload when THIS record changed outside the session (#1888 lot D).
|
|
326
327
|
// Scoped by id: an event about another record must not reload every open
|
|
327
328
|
// detail page. An event carrying no id concerns the whole entity and applies.
|
|
329
|
+
const notifications = useNotifications()
|
|
330
|
+
const route = useRoute()
|
|
328
331
|
useLiveEntity(entity, manager, async () => {
|
|
329
|
-
await base.reload()
|
|
332
|
+
const record = await base.reload()
|
|
333
|
+
// 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.
|
|
335
|
+
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
|
+
})
|
|
330
343
|
}, { id: () => entityId.value })
|
|
331
344
|
// Cast orchestrator to include toast methods
|
|
332
345
|
const orchestrator = baseOrchestrator as Orchestrator
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { useConfirm } from 'primevue/useconfirm'
|
|
39
39
|
import { requireDeleteConfirmation } from './confirmDelete'
|
|
40
40
|
import { registerPageState, activeFilters } from './usePageState'
|
|
41
|
+
import { useNotifications } from '../notifications/NotificationStore'
|
|
41
42
|
import { useHooks } from './useHooks.js'
|
|
42
43
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
43
44
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
@@ -142,6 +143,8 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
142
143
|
|
|
143
144
|
const router = useRouter()
|
|
144
145
|
const route = useRoute()
|
|
146
|
+
// Loads show as activity on the notification badge (#2677); a no-op without notifications.
|
|
147
|
+
const notifications = useNotifications()
|
|
145
148
|
const confirm = useConfirm() as ConfirmService
|
|
146
149
|
|
|
147
150
|
// Mobile detection (viewport width < 768px)
|
|
@@ -841,9 +844,12 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
841
844
|
params = onBeforeLoad(params) || params
|
|
842
845
|
}
|
|
843
846
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
+
// The request shows as activity on the notification badge (#2677).
|
|
848
|
+
const response = await notifications.track(
|
|
849
|
+
manager.query
|
|
850
|
+
? manager.query(params, { routingContext: entityContext.value })
|
|
851
|
+
: manager.list(params, entityContext.value)
|
|
852
|
+
)
|
|
847
853
|
|
|
848
854
|
fromCache.value = response.fromCache || false
|
|
849
855
|
|
|
@@ -199,6 +199,10 @@ export interface DebugBarConfig {
|
|
|
199
199
|
export interface NotificationsConfig {
|
|
200
200
|
enabled?: boolean
|
|
201
201
|
maxNotifications?: number
|
|
202
|
+
/** Keep level per severity (#2677). Default: success and info `short`, warn and error `long`. */
|
|
203
|
+
keep?: Partial<Record<'success' | 'info' | 'warn' | 'error', 'none' | 'short' | 'long'>>
|
|
204
|
+
/** How long a `short` entry stays in the list, in ms. Default 5 minutes. */
|
|
205
|
+
shortKeepMs?: number
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
/**
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -359,6 +359,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
359
359
|
if (this.options.notifications?.enabled) {
|
|
360
360
|
this.notificationStore = createNotificationStore({
|
|
361
361
|
maxNotifications: this.options.notifications.maxNotifications,
|
|
362
|
+
keep: this.options.notifications.keep,
|
|
363
|
+
shortKeepMs: this.options.notifications.shortKeepMs,
|
|
362
364
|
})
|
|
363
365
|
app.provide(NOTIFICATION_KEY, this.notificationStore)
|
|
364
366
|
}
|
|
@@ -2,23 +2,35 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* NotificationBadge - Clickable overlay for the sidebar footer logo
|
|
4
4
|
*
|
|
5
|
-
* Always rendered as a transparent click zone over the logo.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Always rendered as a transparent click zone over the logo. It says three
|
|
6
|
+
* things (#2677):
|
|
7
|
+
* - something needs attention: the parent layout blinks the logo via
|
|
8
|
+
* :has(.notification-badge-zone--alert);
|
|
9
|
+
* - how many entries are unread: a count;
|
|
10
|
+
* - work is in progress: a ring, once tracked work has lasted `activityDelayMs`.
|
|
8
11
|
*
|
|
9
12
|
* Click toggles the notification panel open/close.
|
|
10
13
|
*/
|
|
14
|
+
import { computed } from 'vue'
|
|
11
15
|
import { useNotifications } from './NotificationStore'
|
|
12
16
|
|
|
13
17
|
const store = useNotifications()
|
|
18
|
+
|
|
19
|
+
const unread = computed(() => store.unreadCount.value)
|
|
20
|
+
const unreadLabel = computed(() => (unread.value > 99 ? '99+' : String(unread.value)))
|
|
14
21
|
</script>
|
|
15
22
|
|
|
16
23
|
<template>
|
|
17
24
|
<div
|
|
18
25
|
class="notification-badge-zone"
|
|
19
|
-
:class="{ 'notification-badge-zone--alert': store.hasAlert.value ||
|
|
26
|
+
:class="{ 'notification-badge-zone--alert': store.hasAlert.value || unread > 0 }"
|
|
27
|
+
role="button"
|
|
28
|
+
:aria-label="unread > 0 ? `Notifications, ${unread} unread` : 'Notifications'"
|
|
20
29
|
@click.stop.prevent="store.toggle()"
|
|
21
|
-
|
|
30
|
+
>
|
|
31
|
+
<span v-if="store.isBusy.value" class="notification-badge-ring" role="progressbar" aria-label="Loading" />
|
|
32
|
+
<span v-if="unread > 0" class="notification-badge-count">{{ unreadLabel }}</span>
|
|
33
|
+
</div>
|
|
22
34
|
</template>
|
|
23
35
|
|
|
24
36
|
<style scoped>
|
|
@@ -32,4 +44,45 @@ const store = useNotifications()
|
|
|
32
44
|
cursor: pointer;
|
|
33
45
|
z-index: 1;
|
|
34
46
|
}
|
|
47
|
+
|
|
48
|
+
.notification-badge-count {
|
|
49
|
+
position: absolute;
|
|
50
|
+
top: -0.35rem;
|
|
51
|
+
right: -0.35rem;
|
|
52
|
+
min-width: 1.1rem;
|
|
53
|
+
height: 1.1rem;
|
|
54
|
+
padding: 0 0.3rem;
|
|
55
|
+
border-radius: 999px;
|
|
56
|
+
background: var(--p-primary-color, #3b82f6);
|
|
57
|
+
color: var(--p-primary-contrast-color, #fff);
|
|
58
|
+
font-size: 0.65rem;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
line-height: 1.1rem;
|
|
61
|
+
text-align: center;
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Work in progress: a ring around the logo. */
|
|
66
|
+
.notification-badge-ring {
|
|
67
|
+
position: absolute;
|
|
68
|
+
inset: -0.25rem;
|
|
69
|
+
border-radius: 50%;
|
|
70
|
+
border: 2px solid transparent;
|
|
71
|
+
border-top-color: var(--p-primary-color, #3b82f6);
|
|
72
|
+
border-right-color: var(--p-primary-color, #3b82f6);
|
|
73
|
+
animation: notification-badge-spin 0.9s linear infinite;
|
|
74
|
+
pointer-events: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@keyframes notification-badge-spin {
|
|
78
|
+
to { transform: rotate(360deg); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@media (prefers-reduced-motion: reduce) {
|
|
82
|
+
.notification-badge-ring {
|
|
83
|
+
animation: none;
|
|
84
|
+
border-color: var(--p-primary-color, #3b82f6);
|
|
85
|
+
opacity: 0.5;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
35
88
|
</style>
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* NotificationModule - Optional notification panel system
|
|
3
3
|
*
|
|
4
4
|
* When loaded, this module:
|
|
5
|
-
* - Replaces the default ToastListener with NotificationListener
|
|
6
|
-
* (captures toasts to notification store instead of ephemeral PrimeVue toasts)
|
|
7
5
|
* - Registers NotificationBadge for the sidebar footer logo overlay
|
|
8
6
|
* - Registers NotificationPanel zone for the panel component
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* to
|
|
8
|
+
* Toasts keep showing as pop-ups; the kernel's ToastListener also records each
|
|
9
|
+
* one in the store, for as long as its keep level says (#2677). This module used
|
|
10
|
+
* to swap the listener inside the `_app:toasts` zone, which nothing renders — so
|
|
11
|
+
* no toast was ever recorded. See docs/notifications.md.
|
|
13
12
|
*
|
|
14
13
|
* @example
|
|
15
14
|
* // In kernel config
|
|
@@ -20,8 +19,6 @@
|
|
|
20
19
|
|
|
21
20
|
import { Module } from '../kernel/Module'
|
|
22
21
|
import type { KernelContext } from '../kernel/KernelContext'
|
|
23
|
-
import { TOAST_ZONE } from '../toast/ToastBridgeModule'
|
|
24
|
-
import NotificationListener from './NotificationListener.vue'
|
|
25
22
|
import NotificationBadge from './NotificationBadge.vue'
|
|
26
23
|
import NotificationPanel from './NotificationPanel.vue'
|
|
27
24
|
|
|
@@ -45,18 +42,6 @@ export class NotificationModule extends Module {
|
|
|
45
42
|
ctx.zone(NOTIFICATION_BADGE_ZONE)
|
|
46
43
|
ctx.zone(NOTIFICATION_STATUS_ZONE)
|
|
47
44
|
|
|
48
|
-
// Replace the toast-listener block in the toast zone with our NotificationListener.
|
|
49
|
-
// This intercepts toast signals and captures them to the notification store.
|
|
50
|
-
// The toast zone is defined by ToastBridgeModule - we replace its listener block.
|
|
51
|
-
ctx.zone(TOAST_ZONE) // Ensure the zone exists
|
|
52
|
-
ctx.block(TOAST_ZONE, {
|
|
53
|
-
id: 'toast-listener',
|
|
54
|
-
component: NotificationListener,
|
|
55
|
-
weight: 0,
|
|
56
|
-
operation: 'replace',
|
|
57
|
-
replaces: 'toast-listener',
|
|
58
|
-
})
|
|
59
|
-
|
|
60
45
|
// Register badge component for sidebar footer overlay
|
|
61
46
|
ctx.block(NOTIFICATION_BADGE_ZONE, {
|
|
62
47
|
id: 'notification-badge',
|
|
@@ -13,11 +13,21 @@
|
|
|
13
13
|
* - Notification list (most recent first)
|
|
14
14
|
* - Empty state
|
|
15
15
|
*/
|
|
16
|
-
import { RouterLink } from 'vue-router'
|
|
16
|
+
import { RouterLink, useRouter, type RouteLocationRaw } from 'vue-router'
|
|
17
17
|
import { useNotifications } from './NotificationStore'
|
|
18
|
-
import type { NotificationSeverity } from './NotificationStore'
|
|
18
|
+
import type { Notification, NotificationSeverity } from './NotificationStore'
|
|
19
19
|
|
|
20
20
|
const store = useNotifications()
|
|
21
|
+
const router = useRouter()
|
|
22
|
+
|
|
23
|
+
/** An entry is read when clicked; one with a `to` also takes the user there (#2677). */
|
|
24
|
+
function openNotification(notif: Notification): void {
|
|
25
|
+
store.markRead(notif.id)
|
|
26
|
+
if (!notif.to) return
|
|
27
|
+
store.close()
|
|
28
|
+
// NotificationTarget mirrors StatusItem.to: a vue-router location, typed loosely for app code.
|
|
29
|
+
void router.push(notif.to as RouteLocationRaw)
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
const severityIcons: Record<NotificationSeverity, string> = {
|
|
23
33
|
success: 'pi pi-check-circle',
|
|
@@ -113,9 +123,11 @@ function formatTime(timestamp: number): string {
|
|
|
113
123
|
class="notification-item"
|
|
114
124
|
:class="{
|
|
115
125
|
'notification-item--unread': !notif.read,
|
|
126
|
+
'notification-item--link': !!notif.to,
|
|
116
127
|
[`notification-item--${notif.severity}`]: true,
|
|
117
128
|
}"
|
|
118
|
-
|
|
129
|
+
:role="notif.to ? 'link' : undefined"
|
|
130
|
+
@click="openNotification(notif)"
|
|
119
131
|
>
|
|
120
132
|
<div class="notification-item-icon">
|
|
121
133
|
<i :class="severityIcons[notif.severity]" />
|
|
@@ -34,6 +34,16 @@ const NOTIFICATION_KEY: InjectionKey<NotificationStore> = Symbol('qdadm-notifica
|
|
|
34
34
|
|
|
35
35
|
export type NotificationSeverity = 'success' | 'info' | 'warn' | 'error'
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* How long an entry stays in the list (#2677): `none` is not recorded, `short`
|
|
39
|
+
* is dropped after `shortKeepMs` (read or not), `long` stays until cleared or
|
|
40
|
+
* pushed out by the cap.
|
|
41
|
+
*/
|
|
42
|
+
export type NotificationKeep = 'none' | 'short' | 'long'
|
|
43
|
+
|
|
44
|
+
/** Where an entry leads when clicked: a vue-router location. */
|
|
45
|
+
export type NotificationTarget = { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } | string
|
|
46
|
+
|
|
37
47
|
export interface Notification {
|
|
38
48
|
id: string
|
|
39
49
|
severity: NotificationSeverity
|
|
@@ -42,6 +52,20 @@ export interface Notification {
|
|
|
42
52
|
emitter?: string
|
|
43
53
|
timestamp: number
|
|
44
54
|
read: boolean
|
|
55
|
+
keep: Exclude<NotificationKeep, 'none'>
|
|
56
|
+
/** When a `short` entry leaves the list; null for `long`. */
|
|
57
|
+
expiresAt: number | null
|
|
58
|
+
to?: NotificationTarget
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What `addNotification` takes: `keep` defaults by severity, see `NotificationStoreConfig.keep`. */
|
|
62
|
+
export interface NewNotification {
|
|
63
|
+
severity: NotificationSeverity
|
|
64
|
+
summary: string
|
|
65
|
+
detail?: string
|
|
66
|
+
emitter?: string
|
|
67
|
+
keep?: NotificationKeep
|
|
68
|
+
to?: NotificationTarget
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
export interface StatusItem {
|
|
@@ -56,6 +80,19 @@ export interface StatusItem {
|
|
|
56
80
|
|
|
57
81
|
export interface NotificationStoreConfig {
|
|
58
82
|
maxNotifications?: number
|
|
83
|
+
/** Keep level per severity. Default: success and info `short`, warn and error `long`. */
|
|
84
|
+
keep?: Partial<Record<NotificationSeverity, NotificationKeep>>
|
|
85
|
+
/** How long a `short` entry stays, in ms. Default 5 minutes. */
|
|
86
|
+
shortKeepMs?: number
|
|
87
|
+
/** Ongoing activity shorter than this shows nothing on the badge, in ms. Default 400. */
|
|
88
|
+
activityDelayMs?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DEFAULT_KEEP: Record<NotificationSeverity, NotificationKeep> = {
|
|
92
|
+
success: 'short',
|
|
93
|
+
info: 'short',
|
|
94
|
+
warn: 'long',
|
|
95
|
+
error: 'long',
|
|
59
96
|
}
|
|
60
97
|
|
|
61
98
|
export interface NotificationStore {
|
|
@@ -63,7 +100,8 @@ export interface NotificationStore {
|
|
|
63
100
|
notifications: ComputedRef<Notification[]>
|
|
64
101
|
unreadCount: ComputedRef<number>
|
|
65
102
|
hasAlert: ComputedRef<boolean>
|
|
66
|
-
|
|
103
|
+
/** Records an entry and returns its id — `''` when its keep level is `none`. */
|
|
104
|
+
addNotification(n: NewNotification): string
|
|
67
105
|
markRead(id: string): void
|
|
68
106
|
markAllRead(): void
|
|
69
107
|
removeNotification(id: string): void
|
|
@@ -75,6 +113,12 @@ export interface NotificationStore {
|
|
|
75
113
|
updateStatus(id: string, updates: Partial<StatusItem>): void
|
|
76
114
|
removeStatus(id: string): void
|
|
77
115
|
|
|
116
|
+
// Activity (#2677)
|
|
117
|
+
/** Counts `work` as in progress until it settles, and returns it unchanged. */
|
|
118
|
+
track<T>(work: Promise<T>): Promise<T>
|
|
119
|
+
/** True once tracked work has been in progress for `activityDelayMs`. */
|
|
120
|
+
isBusy: ComputedRef<boolean>
|
|
121
|
+
|
|
78
122
|
// Panel state
|
|
79
123
|
isOpen: Ref<boolean>
|
|
80
124
|
open(): void
|
|
@@ -102,6 +146,9 @@ let _idCounter = 0
|
|
|
102
146
|
*/
|
|
103
147
|
export function createNotificationStore(config: NotificationStoreConfig = {}): NotificationStore {
|
|
104
148
|
const maxNotifications = config.maxNotifications ?? 50
|
|
149
|
+
const keepBySeverity = { ...DEFAULT_KEEP, ...(config.keep ?? {}) }
|
|
150
|
+
const shortKeepMs = config.shortKeepMs ?? 5 * 60 * 1000
|
|
151
|
+
const activityDelayMs = config.activityDelayMs ?? 400
|
|
105
152
|
|
|
106
153
|
const state = reactive<NotificationState>({
|
|
107
154
|
notifications: [],
|
|
@@ -112,13 +159,46 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
|
|
|
112
159
|
|
|
113
160
|
// ── Notifications ──────────────────────────────────────────────────────
|
|
114
161
|
|
|
115
|
-
|
|
162
|
+
// A `short` entry leaves when it expires; one timer, set for the soonest.
|
|
163
|
+
let pruneTimer: ReturnType<typeof setTimeout> | null = null
|
|
164
|
+
|
|
165
|
+
function schedulePrune(): void {
|
|
166
|
+
if (pruneTimer) clearTimeout(pruneTimer)
|
|
167
|
+
pruneTimer = null
|
|
168
|
+
const soonest = state.notifications.reduce<number | null>(
|
|
169
|
+
(min, n) => (n.expiresAt !== null && (min === null || n.expiresAt < min) ? n.expiresAt : min),
|
|
170
|
+
null
|
|
171
|
+
)
|
|
172
|
+
if (soonest === null) return
|
|
173
|
+
pruneTimer = setTimeout(prune, Math.max(0, soonest - Date.now()))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function prune(): void {
|
|
177
|
+
const now = Date.now()
|
|
178
|
+
for (let i = state.notifications.length - 1; i >= 0; i--) {
|
|
179
|
+
const expiresAt = state.notifications[i]!.expiresAt
|
|
180
|
+
if (expiresAt !== null && expiresAt <= now) state.notifications.splice(i, 1)
|
|
181
|
+
}
|
|
182
|
+
schedulePrune()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function addNotification(n: NewNotification): string {
|
|
186
|
+
const keep = n.keep ?? keepBySeverity[n.severity] ?? 'long'
|
|
187
|
+
if (keep === 'none') return ''
|
|
188
|
+
|
|
116
189
|
const id = `notif-${++_idCounter}-${Date.now()}`
|
|
190
|
+
const timestamp = Date.now()
|
|
117
191
|
const notification: Notification = {
|
|
118
|
-
|
|
192
|
+
severity: n.severity,
|
|
193
|
+
summary: n.summary,
|
|
194
|
+
...(n.detail !== undefined ? { detail: n.detail } : {}),
|
|
195
|
+
...(n.emitter !== undefined ? { emitter: n.emitter } : {}),
|
|
196
|
+
...(n.to !== undefined ? { to: n.to } : {}),
|
|
119
197
|
id,
|
|
120
|
-
timestamp
|
|
198
|
+
timestamp,
|
|
121
199
|
read: false,
|
|
200
|
+
keep,
|
|
201
|
+
expiresAt: keep === 'short' ? timestamp + shortKeepMs : null,
|
|
122
202
|
}
|
|
123
203
|
|
|
124
204
|
// Add to front (most recent first)
|
|
@@ -129,6 +209,7 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
|
|
|
129
209
|
state.notifications.length = maxNotifications
|
|
130
210
|
}
|
|
131
211
|
|
|
212
|
+
if (notification.expiresAt !== null) schedulePrune()
|
|
132
213
|
return id
|
|
133
214
|
}
|
|
134
215
|
|
|
@@ -181,6 +262,32 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
|
|
|
181
262
|
}
|
|
182
263
|
}
|
|
183
264
|
|
|
265
|
+
// ── Activity ───────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const inFlight = ref(0)
|
|
268
|
+
const busy = ref(false)
|
|
269
|
+
let busyTimer: ReturnType<typeof setTimeout> | null = null
|
|
270
|
+
|
|
271
|
+
function track<T>(work: Promise<T>): Promise<T> {
|
|
272
|
+
inFlight.value++
|
|
273
|
+
if (inFlight.value === 1) {
|
|
274
|
+
// Only work that lasts shows: a fast reload leaves the badge alone.
|
|
275
|
+
busyTimer = setTimeout(() => {
|
|
276
|
+
busyTimer = null
|
|
277
|
+
if (inFlight.value > 0) busy.value = true
|
|
278
|
+
}, activityDelayMs)
|
|
279
|
+
}
|
|
280
|
+
const settle = (): void => {
|
|
281
|
+
inFlight.value = Math.max(0, inFlight.value - 1)
|
|
282
|
+
if (inFlight.value > 0) return
|
|
283
|
+
if (busyTimer) clearTimeout(busyTimer)
|
|
284
|
+
busyTimer = null
|
|
285
|
+
busy.value = false
|
|
286
|
+
}
|
|
287
|
+
work.then(settle, settle)
|
|
288
|
+
return work
|
|
289
|
+
}
|
|
290
|
+
|
|
184
291
|
// ── Panel state ────────────────────────────────────────────────────────
|
|
185
292
|
|
|
186
293
|
function open(): void {
|
|
@@ -229,6 +336,8 @@ export function createNotificationStore(config: NotificationStoreConfig = {}): N
|
|
|
229
336
|
registerStatus,
|
|
230
337
|
updateStatus,
|
|
231
338
|
removeStatus,
|
|
339
|
+
track,
|
|
340
|
+
isBusy: computed(() => busy.value),
|
|
232
341
|
isOpen,
|
|
233
342
|
open,
|
|
234
343
|
close,
|
|
@@ -272,6 +381,8 @@ export function useNotifications(): NotificationStore {
|
|
|
272
381
|
registerStatus: () => {},
|
|
273
382
|
updateStatus: () => {},
|
|
274
383
|
removeStatus: () => {},
|
|
384
|
+
track: (work) => work,
|
|
385
|
+
isBusy: computed(() => false),
|
|
275
386
|
isOpen: ref(false),
|
|
276
387
|
open: () => {},
|
|
277
388
|
close: () => {},
|
|
@@ -59,6 +59,10 @@ export type EntityFactory = (
|
|
|
59
59
|
export interface ToastOptions {
|
|
60
60
|
life?: number
|
|
61
61
|
emitter?: string
|
|
62
|
+
/** How long the notification history keeps it (#2677); default by severity. */
|
|
63
|
+
keep?: 'none' | 'short' | 'long'
|
|
64
|
+
/** Where its history entry leads: a vue-router location. */
|
|
65
|
+
to?: { name: string; params?: Record<string, unknown>; query?: Record<string, unknown> } | string
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
/**
|
|
@@ -196,6 +200,8 @@ export class Orchestrator {
|
|
|
196
200
|
detail,
|
|
197
201
|
life: opts.life ?? defaults[severity],
|
|
198
202
|
emitter: opts.emitter,
|
|
203
|
+
...(opts.keep !== undefined ? { keep: opts.keep } : {}),
|
|
204
|
+
...(opts.to !== undefined ? { to: opts.to } : {}),
|
|
199
205
|
})
|
|
200
206
|
}
|
|
201
207
|
}
|
|
@@ -11,15 +11,26 @@ import { onMounted, onUnmounted, inject } from 'vue'
|
|
|
11
11
|
import { useToast } from 'primevue/usetoast'
|
|
12
12
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
13
13
|
import { explainMissingToast } from '../kernel/vitePluginCheck'
|
|
14
|
+
import {
|
|
15
|
+
NOTIFICATION_KEY,
|
|
16
|
+
type NotificationKeep,
|
|
17
|
+
type NotificationSeverity,
|
|
18
|
+
type NotificationTarget,
|
|
19
|
+
} from '../notifications/NotificationStore'
|
|
14
20
|
|
|
15
21
|
interface ToastEventData {
|
|
16
22
|
summary?: string
|
|
17
23
|
detail?: string
|
|
18
24
|
life?: number
|
|
25
|
+
emitter?: string
|
|
26
|
+
keep?: NotificationKeep
|
|
27
|
+
to?: NotificationTarget
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
const toast = explainMissingToast(useToast)
|
|
22
31
|
const signals = inject<SignalBus | null>('qdadmSignals', null)
|
|
32
|
+
// Provided when `notifications.enabled`: every toast also leaves a trace (#2677).
|
|
33
|
+
const notifications = inject(NOTIFICATION_KEY, null)
|
|
23
34
|
|
|
24
35
|
let unsubscribe: (() => void) | null = null
|
|
25
36
|
|
|
@@ -39,6 +50,15 @@ onMounted(() => {
|
|
|
39
50
|
detail: data?.detail,
|
|
40
51
|
life: data?.life ?? 3000
|
|
41
52
|
})
|
|
53
|
+
// The pop-up is unchanged; the history keeps it, for as long as its keep level says.
|
|
54
|
+
notifications?.addNotification({
|
|
55
|
+
severity: severity as NotificationSeverity,
|
|
56
|
+
summary: data?.summary ?? '',
|
|
57
|
+
...(data?.detail !== undefined ? { detail: data.detail } : {}),
|
|
58
|
+
...(data?.emitter !== undefined ? { emitter: data.emitter } : {}),
|
|
59
|
+
...(data?.keep !== undefined ? { keep: data.keep } : {}),
|
|
60
|
+
...(data?.to !== undefined ? { to: data.to } : {}),
|
|
61
|
+
})
|
|
42
62
|
})
|
|
43
63
|
})
|
|
44
64
|
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { inject, getCurrentInstance } from 'vue'
|
|
21
21
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
22
|
+
import type { NotificationKeep, NotificationTarget } from '../notifications/NotificationStore'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Toast severity types
|
|
@@ -33,16 +34,24 @@ export interface ToastOptions {
|
|
|
33
34
|
summary: string
|
|
34
35
|
detail?: string
|
|
35
36
|
life?: number
|
|
36
|
-
/**
|
|
37
|
+
/** @deprecated Toasts always pop up now (#2677); kept for compatibility, it does nothing. */
|
|
37
38
|
forceToast?: boolean
|
|
39
|
+
/** How long the notification history keeps it (#2677); default by severity. */
|
|
40
|
+
keep?: NotificationKeep
|
|
41
|
+
/** Where its history entry leads: a vue-router location. */
|
|
42
|
+
to?: NotificationTarget
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* Toast method options
|
|
42
47
|
*/
|
|
43
48
|
export interface ToastMethodOptions {
|
|
44
|
-
/**
|
|
49
|
+
/** @deprecated Toasts always pop up now (#2677); kept for compatibility, it does nothing. */
|
|
45
50
|
forceToast?: boolean
|
|
51
|
+
/** How long the notification history keeps it (#2677); default by severity. */
|
|
52
|
+
keep?: NotificationKeep
|
|
53
|
+
/** Where its history entry leads: a vue-router location. */
|
|
54
|
+
to?: NotificationTarget
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
/**
|
|
@@ -90,10 +99,18 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
|
|
|
90
99
|
* @param summary - Toast title
|
|
91
100
|
* @param detail - Toast detail message
|
|
92
101
|
* @param life - Duration in ms (0 for sticky)
|
|
93
|
-
* @param
|
|
102
|
+
* @param options - keep and to, for the notification history (#2677)
|
|
94
103
|
*/
|
|
95
|
-
function addToast(severity: ToastSeverity, summary: string, detail?: string, life: number = 3000,
|
|
96
|
-
signals.emit(`toast:${severity}`, {
|
|
104
|
+
function addToast(severity: ToastSeverity, summary: string, detail?: string, life: number = 3000, options?: ToastMethodOptions): void {
|
|
105
|
+
signals.emit(`toast:${severity}`, {
|
|
106
|
+
summary,
|
|
107
|
+
detail,
|
|
108
|
+
life,
|
|
109
|
+
emitter: resolvedEmitter,
|
|
110
|
+
...(options?.forceToast !== undefined ? { forceToast: options.forceToast } : {}),
|
|
111
|
+
...(options?.keep !== undefined ? { keep: options.keep } : {}),
|
|
112
|
+
...(options?.to !== undefined ? { to: options.to } : {}),
|
|
113
|
+
})
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
return {
|
|
@@ -105,7 +122,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
|
|
|
105
122
|
* @param options - Additional options
|
|
106
123
|
*/
|
|
107
124
|
success(summary: string, detail?: string, life: number = 3000, options?: ToastMethodOptions): void {
|
|
108
|
-
addToast('success', summary, detail, life, options
|
|
125
|
+
addToast('success', summary, detail, life, options)
|
|
109
126
|
},
|
|
110
127
|
|
|
111
128
|
/**
|
|
@@ -116,7 +133,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
|
|
|
116
133
|
* @param options - Additional options
|
|
117
134
|
*/
|
|
118
135
|
error(summary: string, detail?: string, life: number = 5000, options?: ToastMethodOptions): void {
|
|
119
|
-
addToast('error', summary, detail, life, options
|
|
136
|
+
addToast('error', summary, detail, life, options)
|
|
120
137
|
},
|
|
121
138
|
|
|
122
139
|
/**
|
|
@@ -127,7 +144,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
|
|
|
127
144
|
* @param options - Additional options
|
|
128
145
|
*/
|
|
129
146
|
info(summary: string, detail?: string, life: number = 3000, options?: ToastMethodOptions): void {
|
|
130
|
-
addToast('info', summary, detail, life, options
|
|
147
|
+
addToast('info', summary, detail, life, options)
|
|
131
148
|
},
|
|
132
149
|
|
|
133
150
|
/**
|
|
@@ -138,7 +155,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
|
|
|
138
155
|
* @param options - Additional options
|
|
139
156
|
*/
|
|
140
157
|
warn(summary: string, detail?: string, life: number = 4000, options?: ToastMethodOptions): void {
|
|
141
|
-
addToast('warn', summary, detail, life, options
|
|
158
|
+
addToast('warn', summary, detail, life, options)
|
|
142
159
|
},
|
|
143
160
|
|
|
144
161
|
/**
|
|
@@ -146,7 +163,7 @@ export function useSignalToast(emitter?: string): SignalToastAPI {
|
|
|
146
163
|
* @param options - Toast options
|
|
147
164
|
*/
|
|
148
165
|
add(options: ToastOptions): void {
|
|
149
|
-
addToast(options.severity || 'info', options.summary, options.detail, options.life, options
|
|
166
|
+
addToast(options.severity || 'info', options.summary, options.detail, options.life, options)
|
|
150
167
|
},
|
|
151
168
|
}
|
|
152
169
|
}
|