@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.26.1",
3
+ "version": "2.28.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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
- const response = manager.query
845
- ? await manager.query(params, { routingContext: entityContext.value })
846
- : await manager.list(params, entityContext.value)
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 concurrently by us
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
- void reload()
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: sse.url,
493
+ url,
481
494
  reconnectDelay: sse.reconnectDelay ?? 5000,
482
495
  signalPrefix: sse.signalPrefix ?? 'sse',
483
496
  autoConnect: sse.autoConnect ?? false,
@@ -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
  /**
@@ -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
- * When there are alerts, the parent layout applies an opacity blink
7
- * on the logo wrapper via :has(.notification-badge-zone--alert).
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 || store.unreadCount.value > 0 }"
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
- * Without this module, classic toast behavior is unchanged (ToastBridgeModule).
11
- * With this module, toasts are captured. Use `forceToast: true` in signal data
12
- * to also show a classic PrimeVue toast.
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
- @click="store.markRead(notif.id)"
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
- addNotification(n: Omit<Notification, 'id' | 'timestamp' | 'read'>): string
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
- function addNotification(n: Omit<Notification, 'id' | 'timestamp' | 'read'>): string {
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
- ...n,
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: Date.now(),
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: () => {},
@@ -32,6 +32,9 @@ export {
32
32
  NOTIFICATION_KEY,
33
33
  type Notification,
34
34
  type NotificationSeverity,
35
+ type NotificationKeep,
36
+ type NotificationTarget,
37
+ type NewNotification,
35
38
  type StatusItem,
36
39
  type NotificationStore,
37
40
  type NotificationStoreConfig,
@@ -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
- /** Force classic PrimeVue toast even when NotificationModule is active */
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
- /** Force classic PrimeVue toast even when NotificationModule is active */
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 forceToast - Force classic PrimeVue toast when NotificationModule is active
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, forceToast?: boolean): void {
96
- signals.emit(`toast:${severity}`, { summary, detail, life, emitter: resolvedEmitter, forceToast })
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?.forceToast)
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?.forceToast)
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?.forceToast)
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?.forceToast)
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.forceToast)
166
+ addToast(options.severity || 'info', options.summary, options.detail, options.life, options)
150
167
  },
151
168
  }
152
169
  }