@quazardous/qdadm 2.26.1 → 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 +14 -2
- package/src/composables/useEntityItemPage.ts +15 -2
- package/src/composables/useEntityItemShowPage.ts +15 -2
- package/src/composables/useListPage.ts +9 -3
- package/src/composables/useLiveEntity.ts +125 -3
- package/src/kernel/Kernel.registries.ts +14 -1
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.types.ts +21 -0
- package/src/kernel/Kernel.vue.ts +4 -0
- package/src/kernel/sseSubscriptions.ts +54 -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({
|
|
@@ -273,7 +276,14 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
273
276
|
}
|
|
274
277
|
}
|
|
275
278
|
|
|
279
|
+
// Only the latest load may write (#2666): moving quickly between records, a
|
|
280
|
+
// slow answer for the previous one would otherwise land in this record's form.
|
|
281
|
+
let loadSeq = 0
|
|
282
|
+
|
|
276
283
|
async function load(): Promise<void> {
|
|
284
|
+
const seq = ++loadSeq
|
|
285
|
+
const isLatest = (): boolean => seq === loadSeq
|
|
286
|
+
|
|
277
287
|
if (!isEdit.value) {
|
|
278
288
|
data.value = deepClone(initialData) as T
|
|
279
289
|
takeSnapshot()
|
|
@@ -284,7 +294,8 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
284
294
|
try {
|
|
285
295
|
// The item itself, never a cached list row: a list row may be a summary,
|
|
286
296
|
// and whatever this form shows empty it saves back empty (#2484).
|
|
287
|
-
const responseData = await manager.get(entityId.value!, undefined, { listCache: false })
|
|
297
|
+
const responseData = await notifications.track(manager.get(entityId.value!, undefined, { listCache: false }))
|
|
298
|
+
if (!isLatest()) return
|
|
288
299
|
const transformed = transformLoad(responseData)
|
|
289
300
|
if (debug) reportMissingFields(transformed as Record<string, unknown>)
|
|
290
301
|
data.value = transformed as T
|
|
@@ -298,6 +309,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
298
309
|
await onLoadSuccess(transformed as T)
|
|
299
310
|
}
|
|
300
311
|
} catch (error) {
|
|
312
|
+
if (!isLatest()) return
|
|
301
313
|
const axiosError = error as AxiosError
|
|
302
314
|
toast.add({
|
|
303
315
|
severity: 'error',
|
|
@@ -306,7 +318,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
306
318
|
life: 5000,
|
|
307
319
|
})
|
|
308
320
|
} finally {
|
|
309
|
-
loading.value = false
|
|
321
|
+
if (isLatest()) loading.value = false
|
|
310
322
|
}
|
|
311
323
|
}
|
|
312
324
|
|
|
@@ -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
|
|
|
@@ -315,7 +318,15 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
315
318
|
* @param id - Optional ID override (defaults to route param)
|
|
316
319
|
* @returns Loaded entity data or null on error
|
|
317
320
|
*/
|
|
321
|
+
let loadSeq = 0
|
|
322
|
+
|
|
318
323
|
async function load(id: string | number | null = entityId.value): Promise<T | null> {
|
|
324
|
+
// Only the latest call may write (#2666). Live reloads and id changes can
|
|
325
|
+
// overlap; an older answer landing last would put a past state on screen.
|
|
326
|
+
// A superseded call resolves to null and touches nothing.
|
|
327
|
+
const seq = ++loadSeq
|
|
328
|
+
const isLatest = (): boolean => seq === loadSeq
|
|
329
|
+
|
|
319
330
|
if (!id) {
|
|
320
331
|
error.value = 'No entity ID provided'
|
|
321
332
|
return null
|
|
@@ -325,7 +336,8 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
325
336
|
error.value = null
|
|
326
337
|
|
|
327
338
|
try {
|
|
328
|
-
const responseData = await manager.get(id)
|
|
339
|
+
const responseData = await notifications.track(manager.get(id))
|
|
340
|
+
if (!isLatest()) return null
|
|
329
341
|
|
|
330
342
|
if (!responseData) {
|
|
331
343
|
error.value = `${manager.label || entity} not found`
|
|
@@ -344,6 +356,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
344
356
|
|
|
345
357
|
return transformed
|
|
346
358
|
} catch (err) {
|
|
359
|
+
if (!isLatest()) return null
|
|
347
360
|
console.error(`[useEntityItemPage] Failed to load ${entity}:`, err)
|
|
348
361
|
const axiosError = err as { response?: { data?: { detail?: string } }; message?: string }
|
|
349
362
|
error.value =
|
|
@@ -357,7 +370,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
357
370
|
|
|
358
371
|
return null
|
|
359
372
|
} finally {
|
|
360
|
-
loading.value = false
|
|
373
|
+
if (isLatest()) loading.value = false
|
|
361
374
|
}
|
|
362
375
|
}
|
|
363
376
|
|
|
@@ -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
|
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { inject, onUnmounted } from 'vue'
|
|
18
|
+
import { inject, onUnmounted, watch } from 'vue'
|
|
19
19
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
20
|
+
import { useApiClient } from '../api/apiClient'
|
|
21
|
+
import type { HttpClient } from '../entity/storage/ApiStorage'
|
|
22
|
+
import { SSE_SUBSCRIPTIONS_KEY, type SseSubscriptions } from '../kernel/sseSubscriptions'
|
|
20
23
|
|
|
21
24
|
export interface UseLiveEntityOptions {
|
|
22
25
|
/**
|
|
@@ -37,7 +40,8 @@ interface LivePolicy {
|
|
|
37
40
|
*
|
|
38
41
|
* @param entityName - entity to watch
|
|
39
42
|
* @param manager - its manager, read for the `live` policy (may be null)
|
|
40
|
-
* @param reload - what to run; coalesced, never called
|
|
43
|
+
* @param reload - what to run; coalesced, and never called while a previous
|
|
44
|
+
* call's promise is still pending — changes arriving meanwhile cause one more call
|
|
41
45
|
* @returns unsubscribe function (also called automatically on unmount)
|
|
42
46
|
*/
|
|
43
47
|
export function useLiveEntity(
|
|
@@ -60,10 +64,44 @@ export function useLiveEntity(
|
|
|
60
64
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
61
65
|
let disposed = false
|
|
62
66
|
|
|
67
|
+
// Never two reloads at once (#2666), as this composable promises: an event
|
|
68
|
+
// arriving while one is in flight marks it dirty, and exactly one more runs
|
|
69
|
+
// when it settles — no overlapping requests, and the last state lands.
|
|
70
|
+
let inFlight = false
|
|
71
|
+
let dirty = false
|
|
72
|
+
|
|
73
|
+
const settle = (): void => {
|
|
74
|
+
inFlight = false
|
|
75
|
+
if (dirty && !disposed) {
|
|
76
|
+
dirty = false
|
|
77
|
+
run()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only a reload that returns a promise is in flight: a synchronous one is done
|
|
82
|
+
// when it returns. A failure — thrown or rejected — must not stop the next one;
|
|
83
|
+
// the page reports its own load errors.
|
|
84
|
+
const run = (): void => {
|
|
85
|
+
let result: void | Promise<void>
|
|
86
|
+
try {
|
|
87
|
+
result = reload()
|
|
88
|
+
} catch {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
92
|
+
inFlight = true
|
|
93
|
+
;(result as Promise<void>).then(settle, settle)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
const fire = (): void => {
|
|
64
98
|
timer = null
|
|
65
99
|
if (disposed) return
|
|
66
|
-
|
|
100
|
+
if (inFlight) {
|
|
101
|
+
dirty = true
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
run()
|
|
67
105
|
}
|
|
68
106
|
|
|
69
107
|
const off = signals.on('entity:data-invalidate', (event: { name: string; data: unknown }) => {
|
|
@@ -100,6 +138,15 @@ export function useLiveEntity(
|
|
|
100
138
|
timer = setTimeout(fire, coalesceMs)
|
|
101
139
|
})
|
|
102
140
|
|
|
141
|
+
// Per-record subscription (#2664): a detail page asks the backend to send
|
|
142
|
+
// this record's changes to this tab, for as long as it shows the record.
|
|
143
|
+
const subscriptions = inject<SseSubscriptions | null>(SSE_SUBSCRIPTIONS_KEY, null)
|
|
144
|
+
const apiClient = subscriptions && options.id ? useApiClient() : null
|
|
145
|
+
const stopSubscription =
|
|
146
|
+
subscriptions && apiClient && options.id
|
|
147
|
+
? followRecord(subscriptions, apiClient, entityName, options.id, inject<boolean>('qdadmDebug', false))
|
|
148
|
+
: () => {}
|
|
149
|
+
|
|
103
150
|
const stop = (): void => {
|
|
104
151
|
disposed = true
|
|
105
152
|
if (timer) {
|
|
@@ -107,9 +154,84 @@ export function useLiveEntity(
|
|
|
107
154
|
timer = null
|
|
108
155
|
}
|
|
109
156
|
off()
|
|
157
|
+
stopSubscription()
|
|
110
158
|
}
|
|
111
159
|
|
|
112
160
|
onUnmounted(stop)
|
|
113
161
|
|
|
114
162
|
return stop
|
|
115
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Keep a subscription to the record `id()` points at: POST when it is shown,
|
|
167
|
+
* renew at half of `expires_in`, DELETE when it changes or the page leaves.
|
|
168
|
+
*
|
|
169
|
+
* Never throws into the page — without a subscription the page still works, it
|
|
170
|
+
* just does not hear about changes. Failures are said in debug mode only.
|
|
171
|
+
*/
|
|
172
|
+
function followRecord(
|
|
173
|
+
subscriptions: SseSubscriptions,
|
|
174
|
+
api: HttpClient,
|
|
175
|
+
entity: string,
|
|
176
|
+
id: () => string | number | null | undefined,
|
|
177
|
+
debug: boolean
|
|
178
|
+
): () => void {
|
|
179
|
+
let current: string | null = null
|
|
180
|
+
let renewTimer: ReturnType<typeof setTimeout> | null = null
|
|
181
|
+
let stopped = false
|
|
182
|
+
|
|
183
|
+
const body = (recordId: string) => ({ session: subscriptions.session, entity, id: recordId })
|
|
184
|
+
|
|
185
|
+
const report = (what: string, recordId: string, error: unknown): void => {
|
|
186
|
+
if (!debug) return
|
|
187
|
+
const status = (error as { response?: { status?: number } })?.response?.status
|
|
188
|
+
const reason = status ?? (error as { message?: string })?.message ?? String(error)
|
|
189
|
+
console.warn(`[qdadm] live subscription ${what} failed for ${entity} #${recordId} (${reason}) — the page will not update on its own`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const clearRenewal = (): void => {
|
|
193
|
+
if (renewTimer) clearTimeout(renewTimer)
|
|
194
|
+
renewTimer = null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const subscribe = async (recordId: string, retryAfterMs: number | null = null): Promise<void> => {
|
|
198
|
+
try {
|
|
199
|
+
const { data } = await api.post<{ expires_in?: number }>(subscriptions.url, body(recordId))
|
|
200
|
+
if (stopped || current !== recordId) return
|
|
201
|
+
const seconds = Number(data?.expires_in)
|
|
202
|
+
if (seconds > 0) renewTimer = setTimeout(() => void subscribe(recordId, (seconds * 1000) / 2), (seconds * 1000) / 2)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
report('subscribe', recordId, error)
|
|
205
|
+
// A renewal that fails is tried again at the same pace: the subscription is about to lapse.
|
|
206
|
+
if (!stopped && current === recordId && retryAfterMs) {
|
|
207
|
+
renewTimer = setTimeout(() => void subscribe(recordId, retryAfterMs), retryAfterMs)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const unsubscribe = (recordId: string): void => {
|
|
213
|
+
api
|
|
214
|
+
.request({ method: 'DELETE', url: subscriptions.url, data: body(recordId) })
|
|
215
|
+
.catch((error: unknown) => report('unsubscribe', recordId, error))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const follow = (raw: string | number | null | undefined): void => {
|
|
219
|
+
const next = raw == null || raw === '' ? null : String(raw)
|
|
220
|
+
if (next === current) return
|
|
221
|
+
clearRenewal()
|
|
222
|
+
if (current) unsubscribe(current)
|
|
223
|
+
current = next
|
|
224
|
+
if (next) void subscribe(next)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stopWatch = watch(id, follow, { immediate: true })
|
|
228
|
+
|
|
229
|
+
return () => {
|
|
230
|
+
if (stopped) return
|
|
231
|
+
stopped = true
|
|
232
|
+
stopWatch()
|
|
233
|
+
clearRenewal()
|
|
234
|
+
if (current) unsubscribe(current)
|
|
235
|
+
current = null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -16,6 +16,7 @@ import { Orchestrator } from '../orchestrator/Orchestrator'
|
|
|
16
16
|
import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
17
17
|
import type { Kernel } from './Kernel'
|
|
18
18
|
import type { SSEConfig } from './Kernel.types'
|
|
19
|
+
import { sseTabSession, withQueryParam } from './sseSubscriptions'
|
|
19
20
|
// #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
|
|
20
21
|
type Self = Kernel
|
|
21
22
|
|
|
@@ -447,6 +448,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
447
448
|
'getToken',
|
|
448
449
|
'connectOnSignal',
|
|
449
450
|
'disconnectOnSignal',
|
|
451
|
+
'subscriptions',
|
|
450
452
|
]),
|
|
451
453
|
{
|
|
452
454
|
getToken:
|
|
@@ -454,6 +456,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
454
456
|
'and query strings reach access logs',
|
|
455
457
|
entities: 'no entity cache will be invalidated from the stream',
|
|
456
458
|
tokenParam: 'the token will be sent under the default name "token"',
|
|
459
|
+
subscriptions: 'detail pages will not subscribe to their record, and the stream carries no tab session',
|
|
457
460
|
}
|
|
458
461
|
)
|
|
459
462
|
}
|
|
@@ -466,6 +469,16 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
466
469
|
|
|
467
470
|
this._validateSseConfig(sse)
|
|
468
471
|
|
|
472
|
+
// Per-record subscriptions (#2664): the tab id is minted here and put in
|
|
473
|
+
// the stream URL, so the one the pages subscribe with is the same.
|
|
474
|
+
let url = sse.url
|
|
475
|
+
this.sseSubscriptions = null
|
|
476
|
+
if (sse.subscriptions?.url) {
|
|
477
|
+
const session = sseTabSession()
|
|
478
|
+
this.sseSubscriptions = { url: sse.subscriptions.url, session }
|
|
479
|
+
url = withQueryParam(url, sse.subscriptions.sessionParam ?? 'session', session)
|
|
480
|
+
}
|
|
481
|
+
|
|
469
482
|
// An explicit sse.getToken wins — including an explicit null, which means
|
|
470
483
|
// "send no token" and must not fall back to the auth adapter.
|
|
471
484
|
const getToken =
|
|
@@ -477,7 +490,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
477
490
|
|
|
478
491
|
this.sseBridge = createSSEBridge({
|
|
479
492
|
signals: this.signals!,
|
|
480
|
-
url
|
|
493
|
+
url,
|
|
481
494
|
reconnectDelay: sse.reconnectDelay ?? 5000,
|
|
482
495
|
signalPrefix: sse.signalPrefix ?? 'sse',
|
|
483
496
|
autoConnect: sse.autoConnect ?? false,
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -80,6 +80,7 @@ import { isDebugBarKilled } from './debugBarKillSwitch'
|
|
|
80
80
|
import { applyApiMethods } from './Kernel.api'
|
|
81
81
|
import { applyI18nMethods } from './Kernel.i18n'
|
|
82
82
|
import type { I18n } from '../i18n/I18n'
|
|
83
|
+
import type { SseSubscriptions } from './sseSubscriptions'
|
|
83
84
|
|
|
84
85
|
export class Kernel {
|
|
85
86
|
options: KernelOptions
|
|
@@ -101,6 +102,8 @@ export class Kernel {
|
|
|
101
102
|
deferred!: DeferredRegistry
|
|
102
103
|
eventRouter: EventRouter | null = null
|
|
103
104
|
sseBridge: SSEBridge | null = null
|
|
105
|
+
/** Where detail pages subscribe to their record, and as which tab (#2664). Null without `sse.subscriptions`. */
|
|
106
|
+
sseSubscriptions: SseSubscriptions | null = null
|
|
104
107
|
/** Routes external entity changes to cache invalidation (#1888). */
|
|
105
108
|
liveEntityRouter: LiveEntityRouter | null = null
|
|
106
109
|
layoutComponents: InternalLayoutComponents | null = null
|
|
@@ -135,6 +135,23 @@ export interface SSEConfig {
|
|
|
135
135
|
* See docs/adr/0009-live-entities.md.
|
|
136
136
|
*/
|
|
137
137
|
entities?: string[] | true | '*'
|
|
138
|
+
/**
|
|
139
|
+
* Per-record subscriptions (#2664): a detail page tells the backend which
|
|
140
|
+
* record it shows, so the stream carries that record's changes to this tab.
|
|
141
|
+
*
|
|
142
|
+
* `url` receives `POST {session, entity, id}` when a detail page shows a
|
|
143
|
+
* record, answering `{expires_in}` (seconds); qdadm renews at half of it, and
|
|
144
|
+
* sends `DELETE` with the same body when the page leaves the record. Calls go
|
|
145
|
+
* through the kernel's `apiClient`, with the app's own auth.
|
|
146
|
+
*
|
|
147
|
+
* `session` is a per-tab id qdadm mints and also appends to the stream URL as
|
|
148
|
+
* `sessionParam` (default `'session'`): the stream and the subscriptions name
|
|
149
|
+
* the same tab. Do not put your own session parameter in `url`.
|
|
150
|
+
*
|
|
151
|
+
* The entity must be declared in `entities`, or its frames are dropped before
|
|
152
|
+
* any page sees them. Absent: no subscription, no session parameter.
|
|
153
|
+
*/
|
|
154
|
+
subscriptions?: { url: string; sessionParam?: string }
|
|
138
155
|
/**
|
|
139
156
|
* Token appended to the SSE URL as a query parameter.
|
|
140
157
|
*
|
|
@@ -182,6 +199,10 @@ export interface DebugBarConfig {
|
|
|
182
199
|
export interface NotificationsConfig {
|
|
183
200
|
enabled?: boolean
|
|
184
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
|
|
185
206
|
}
|
|
186
207
|
|
|
187
208
|
/**
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -334,6 +334,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
334
334
|
|
|
335
335
|
if (this.sseBridge) {
|
|
336
336
|
app.provide('qdadmSSEBridge', this.sseBridge)
|
|
337
|
+
// Detail pages subscribe to their record through this (#2664); null without sse.subscriptions.
|
|
338
|
+
app.provide('qdadmSSESubscriptions', this.sseSubscriptions)
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
@@ -357,6 +359,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
357
359
|
if (this.options.notifications?.enabled) {
|
|
358
360
|
this.notificationStore = createNotificationStore({
|
|
359
361
|
maxNotifications: this.options.notifications.maxNotifications,
|
|
362
|
+
keep: this.options.notifications.keep,
|
|
363
|
+
shortKeepMs: this.options.notifications.shortKeepMs,
|
|
360
364
|
})
|
|
361
365
|
app.provide(NOTIFICATION_KEY, this.notificationStore)
|
|
362
366
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-record SSE subscriptions (#2664) — the tab's identity, shared by the
|
|
3
|
+
* stream and the subscription calls.
|
|
4
|
+
*
|
|
5
|
+
* A detail page tells the backend which record it shows, so the stream carries
|
|
6
|
+
* that record's changes to this tab only. The backend has to know which stream
|
|
7
|
+
* a subscription belongs to, and the only thing both sides can name is the
|
|
8
|
+
* tab: qdadm mints one id per browser tab, appends it to the stream URL, and
|
|
9
|
+
* sends the same id with every subscription. One owner, so the two cannot
|
|
10
|
+
* disagree.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What the kernel hands to `useLiveEntity`: where to subscribe, and as which tab. */
|
|
14
|
+
export interface SseSubscriptions {
|
|
15
|
+
url: string
|
|
16
|
+
session: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Injection key under which the kernel provides `SseSubscriptions` (or null). */
|
|
20
|
+
export const SSE_SUBSCRIPTIONS_KEY = 'qdadmSSESubscriptions'
|
|
21
|
+
|
|
22
|
+
const TAB_SESSION_KEY = 'qdadm:sse-session'
|
|
23
|
+
|
|
24
|
+
function mintId(): string {
|
|
25
|
+
// randomUUID only exists in secure contexts; a plain-http deployment still needs an id.
|
|
26
|
+
const c = typeof crypto !== 'undefined' ? crypto : null
|
|
27
|
+
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
|
|
28
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This tab's id: kept in sessionStorage, so a reload is the same tab and a new
|
|
33
|
+
* tab is a new one. Storage that throws (blocked site data) still yields an id,
|
|
34
|
+
* valid for this page load.
|
|
35
|
+
*/
|
|
36
|
+
export function sseTabSession(): string {
|
|
37
|
+
try {
|
|
38
|
+
const existing = window.sessionStorage.getItem(TAB_SESSION_KEY)
|
|
39
|
+
if (existing) return existing
|
|
40
|
+
const id = mintId()
|
|
41
|
+
window.sessionStorage.setItem(TAB_SESSION_KEY, id)
|
|
42
|
+
return id
|
|
43
|
+
} catch {
|
|
44
|
+
return mintId()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `url` with `name=value` added to its query string, before any fragment. */
|
|
49
|
+
export function withQueryParam(url: string, name: string, value: string): string {
|
|
50
|
+
const [base, fragment] = url.split('#', 2)
|
|
51
|
+
const param = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
|
|
52
|
+
const joined = `${base}${base!.includes('?') ? '&' : '?'}${param}`
|
|
53
|
+
return fragment === undefined ? joined : `${joined}#${fragment}`
|
|
54
|
+
}
|
|
@@ -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
|
}
|