diff --git a/packages/bindx-react/src/hooks/BackendAdapterContext.tsx b/packages/bindx-react/src/hooks/BackendAdapterContext.tsx index 26167b7..8b4d2e8 100644 --- a/packages/bindx-react/src/hooks/BackendAdapterContext.tsx +++ b/packages/bindx-react/src/hooks/BackendAdapterContext.tsx @@ -6,6 +6,7 @@ import { BatchPersister } from '@contember/bindx' import { MutationCollector } from '@contember/bindx' import { SchemaRegistry } from '@contember/bindx' import { UndoManager } from '@contember/bindx' +import { NotificationStore } from '@contember/bindx' import { QueryBatcher } from '../batching/QueryBatcher.js' /** @@ -36,6 +37,8 @@ export interface BindxContextValue { undoManager: UndoManager | null /** GraphQL client (available when using ContemberBindxProvider) */ graphQlClient: BindxGraphQlClient | null + /** Notification store for user-facing feedback (toasts) */ + notificationStore: NotificationStore /** Whether debug logging is enabled */ debug: boolean } @@ -133,6 +136,7 @@ export function BindxProvider({ schema: schemaRegistry, undoManager, graphQlClient: null, + notificationStore: new NotificationStore(), debug, } }, [adapter, customStore, schemaDefinition, customMutationCollector, enableUndo, undoConfig, defaultUpdateMode, debug]) @@ -223,3 +227,15 @@ export function useQueryBatcher(): QueryBatcher { } return context.batcher } + +/** + * Hook to access the notification store. + * Must be used within a BindxProvider. + */ +export function useNotificationStore(): NotificationStore { + const context = useContext(BindxContext) + if (!context) { + throw new Error('useNotificationStore must be used within a BindxProvider') + } + return context.notificationStore +} diff --git a/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx b/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx index e8beea2..d4e34ae 100644 --- a/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx +++ b/packages/bindx-react/src/hooks/ContemberBindxProvider.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, type ReactNode } from 'react' import { GraphQlClient } from '@contember/graphql-client' import { ContentClient } from '@contember/bindx-client' -import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, type SchemaDefinition, type SchemaNames, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx' +import { ContemberAdapter, SnapshotStore, ActionDispatcher, BatchPersister, MutationCollector, ContemberSchemaMutationAdapter, UndoManager, SchemaRegistry, NotificationStore, type SchemaDefinition, type SchemaNames, type FieldDef, type UndoManagerConfig, type UpdateMode } from '@contember/bindx' import { BindxContext, type BindxContextValue } from './BackendAdapterContext.js' import { QueryBatcher } from '../batching/QueryBatcher.js' @@ -121,6 +121,7 @@ export const ContemberBindxProvider = memo(function ContemberBindxProvider({ schema: schemaRegistry, undoManager, graphQlClient, + notificationStore: new NotificationStore(), debug, } }, [schema, customStore, undoManagerProp, undoConfig, defaultUpdateMode, debug]) diff --git a/packages/bindx-react/src/hooks/index.ts b/packages/bindx-react/src/hooks/index.ts index 7aa28a7..1b1db52 100644 --- a/packages/bindx-react/src/hooks/index.ts +++ b/packages/bindx-react/src/hooks/index.ts @@ -6,6 +6,7 @@ export { useBatchPersister, useBindxContext, useSchemaRegistry, + useNotificationStore, type BindxProviderProps, type BindxContextValue, type BindxGraphQlClient, @@ -65,3 +66,14 @@ export { useEntityErrors, type EntityErrorsState, } from './useErrors.js' + +export { + useNotifications, + useShowNotification, + useDismissNotification, +} from './useNotifications.js' + +export { + usePersistWithFeedback, + type PersistWithFeedbackApi, +} from './usePersistWithFeedback.js' diff --git a/packages/bindx-react/src/hooks/useNotifications.ts b/packages/bindx-react/src/hooks/useNotifications.ts new file mode 100644 index 0000000..e55c974 --- /dev/null +++ b/packages/bindx-react/src/hooks/useNotifications.ts @@ -0,0 +1,40 @@ +import { useCallback, useSyncExternalStore } from 'react' +import type { Notification, NotificationInput } from '@contember/bindx' +import { useNotificationStore } from './BackendAdapterContext.js' + +const EMPTY: readonly Notification[] = [] + +/** + * Returns all active notifications, re-rendering on changes. + * Uses useSyncExternalStore for efficient subscriptions. + */ +export function useNotifications(): readonly Notification[] { + const store = useNotificationStore() + return useSyncExternalStore( + store.subscribe.bind(store), + () => store.getAll(), + () => EMPTY, + ) +} + +/** + * Returns a stable callback for adding a notification. + */ +export function useShowNotification(): (input: NotificationInput) => string { + const store = useNotificationStore() + return useCallback( + (input: NotificationInput) => store.add(input), + [store], + ) +} + +/** + * Returns a stable callback for dismissing a notification. + */ +export function useDismissNotification(): (id: string) => void { + const store = useNotificationStore() + return useCallback( + (id: string) => store.dismiss(id), + [store], + ) +} diff --git a/packages/bindx-react/src/hooks/usePersistWithFeedback.ts b/packages/bindx-react/src/hooks/usePersistWithFeedback.ts new file mode 100644 index 0000000..a3f29a0 --- /dev/null +++ b/packages/bindx-react/src/hooks/usePersistWithFeedback.ts @@ -0,0 +1,106 @@ +import { useCallback } from 'react' +import type { BatchPersistOptions, PersistenceResult, NotificationDetail } from '@contember/bindx' +import { usePersist, type PersistApi } from './usePersist.js' +import { useNotificationStore } from './BackendAdapterContext.js' + +/** + * Extended persist API that automatically shows notifications on success/failure. + */ +export interface PersistWithFeedbackApi extends PersistApi { + /** Persist all dirty entities with automatic toast feedback */ + persistAllWithFeedback(options?: BatchPersistOptions): Promise +} + +/** + * Wraps `usePersist()` with automatic notification feedback. + * + * - On success: shows a success notification + * - On failure with server errors: shows an error notification with details + * - On failure with client validation errors: shows a warning notification + * + * The raw `usePersist()` methods are still available for manual control. + * + * @example + * ```tsx + * function SaveButton() { + * const { persistAllWithFeedback, isPersisting, isDirty } = usePersistWithFeedback() + * return ( + * + * ) + * } + * ``` + */ +export function usePersistWithFeedback(): PersistWithFeedbackApi { + const persistApi = usePersist() + const notificationStore = useNotificationStore() + + const persistAllWithFeedback = useCallback( + async (options?: BatchPersistOptions): Promise => { + const result = await persistApi.persistAll(options) + + if (result.success) { + notificationStore.add({ + type: 'success', + message: 'Changes saved successfully', + source: 'persist', + dismissAfter: 6_000, + }) + } else { + const details: NotificationDetail[] = [] + let hasClientErrors = false + + for (const entityResult of result.results) { + if (entityResult.success) continue + + if (entityResult.error) { + // Server error + details.push({ + entityType: entityResult.entityType, + message: entityResult.error.message, + }) + + // Add field-level details from mutation result + if (entityResult.error.mutationResult) { + for (const err of entityResult.error.mutationResult.errors) { + details.push({ message: err.message }) + } + for (const err of entityResult.error.mutationResult.validation.errors) { + details.push({ message: err.message.text }) + } + } + } else { + // No server error means blocked by client validation + hasClientErrors = true + } + } + + if (hasClientErrors && details.length === 0) { + notificationStore.add({ + type: 'warning', + message: 'Please fix validation errors before saving', + source: 'persist', + dismissAfter: 15_000, + }) + } else { + notificationStore.add({ + type: 'error', + message: 'Failed to save changes', + details: details.length > 0 ? details : undefined, + source: 'persist', + dismissAfter: 60_000, + }) + } + } + + return result + }, + [persistApi, notificationStore], + ) + + return { + ...persistApi, + persistAllWithFeedback, + } +} diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index cf390dc..0a284de 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -207,6 +207,8 @@ export type { PersistApi, EntityPersistApi, AnyRefWithMeta, + // Notification hook types + PersistWithFeedbackApi, } from './hooks/index.js' // Persistence types from @contember/bindx @@ -301,6 +303,12 @@ export { useField, useHasMany, useHasOne, + // Notifications + useNotificationStore, + useNotifications, + useShowNotification, + useDismissNotification, + usePersistWithFeedback, // Contember ContemberBindxProvider, schemaNamesToDef, diff --git a/packages/bindx-ui/src/dict.ts b/packages/bindx-ui/src/dict.ts index 70b2b59..577bd3e 100644 --- a/packages/bindx-ui/src/dict.ts +++ b/packages/bindx-ui/src/dict.ts @@ -37,6 +37,16 @@ export const dict = { persist: { save: 'Save', }, + toast: { + persistSuccess: 'Changes saved successfully', + persistError: 'Failed to save changes', + persistValidationError: 'Please fix validation errors before saving', + loadError: 'Failed to load data', + networkError: 'A network error occurred', + dismiss: 'Dismiss', + showDetails: 'Show details', + hideDetails: 'Hide details', + }, select: { placeholder: 'Select…', search: 'Search…', diff --git a/packages/bindx-ui/src/errors/error-boundary.tsx b/packages/bindx-ui/src/errors/error-boundary.tsx new file mode 100644 index 0000000..ca594ac --- /dev/null +++ b/packages/bindx-ui/src/errors/error-boundary.tsx @@ -0,0 +1,66 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { Overlay } from '../ui/overlay.js' +import { Button } from '../ui/button.js' + +export interface BindxErrorBoundaryProps { + readonly children: ReactNode + readonly fallback?: ReactNode +} + +interface BindxErrorBoundaryState { + readonly error: Error | null +} + +/** + * React Error Boundary that catches render errors and displays + * a full-screen fallback. Wrap your bindx-powered UI with this + * component to prevent white screens on unexpected errors. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export class BindxErrorBoundary extends Component { + override state: BindxErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): BindxErrorBoundaryState { + return { error } + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('[Bindx ErrorBoundary]', error, errorInfo) + } + + private handleRetry = (): void => { + this.setState({ error: null }) + } + + override render(): ReactNode { + if (this.state.error) { + if (this.props.fallback) { + return this.props.fallback + } + return ( + +
+
+

+ Something went wrong +

+

+ {this.state.error.message} +

+ +
+
+ ) + } + return this.props.children + } +} diff --git a/packages/bindx-ui/src/errors/error-overlay.tsx b/packages/bindx-ui/src/errors/error-overlay.tsx new file mode 100644 index 0000000..c8c70bb --- /dev/null +++ b/packages/bindx-ui/src/errors/error-overlay.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react' +import type { FieldError } from '@contember/bindx' +import { Overlay } from '../ui/overlay.js' +import { Button } from '../ui/button.js' +import { dict } from '../dict.js' + +export interface ErrorOverlayProps { + readonly error: FieldError + readonly onRetry?: () => void + readonly onDismiss?: () => void +} + +/** + * Full-screen error overlay for critical load errors. + * Use this when `useEntity()` or `useEntityList()` returns an error result. + * + * @example + * ```tsx + * const result = useEntity('Article', { id }) + * if (result.$status === 'error') { + * return window.location.reload()} /> + * } + * ``` + */ +export function ErrorOverlay({ error, onRetry, onDismiss }: ErrorOverlayProps): ReactNode { + return ( + +
+
+

+ {dict.toast.loadError} +

+

+ {error.message} +

+ {error.code && ( +

+ {error.code} +

+ )} +
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+
+ ) +} diff --git a/packages/bindx-ui/src/errors/index.ts b/packages/bindx-ui/src/errors/index.ts index d42f1a4..ef9d87b 100644 --- a/packages/bindx-ui/src/errors/index.ts +++ b/packages/bindx-ui/src/errors/index.ts @@ -1 +1,3 @@ export { useErrorFormatter } from './useErrorFormatter.js' +export { ErrorOverlay, type ErrorOverlayProps } from './error-overlay.js' +export { BindxErrorBoundary, type BindxErrorBoundaryProps } from './error-boundary.js' diff --git a/packages/bindx-ui/src/index.ts b/packages/bindx-ui/src/index.ts index a3bd222..bb48aa5 100644 --- a/packages/bindx-ui/src/index.ts +++ b/packages/bindx-ui/src/index.ts @@ -66,7 +66,10 @@ export { } from './labels/index.js' // Errors -export { useErrorFormatter } from './errors/index.js' +export { useErrorFormatter, ErrorOverlay, type ErrorOverlayProps, BindxErrorBoundary, type BindxErrorBoundaryProps } from './errors/index.js' + +// Toast +export { ToastContainer, ToastItem, type ToastContainerProps, type ToastItemProps } from './toast/index.js' // Upload export * from './upload/index.js' diff --git a/packages/bindx-ui/src/persist/persist-button.tsx b/packages/bindx-ui/src/persist/persist-button.tsx index 4e90365..558ba43 100644 --- a/packages/bindx-ui/src/persist/persist-button.tsx +++ b/packages/bindx-ui/src/persist/persist-button.tsx @@ -1,4 +1,4 @@ -import { usePersist } from '@contember/bindx-react' +import { usePersistWithFeedback } from '@contember/bindx-react' import { type ComponentProps, type ReactNode, useCallback } from 'react' import { Button } from '../ui/button.js' import { LoaderIcon } from '../ui/loader.js' @@ -12,16 +12,16 @@ export interface PersistButtonProps extends ComponentProps { } export function PersistButton({ label, className, onSuccess, onError, ...buttonProps }: PersistButtonProps): ReactNode { - const { persistAll, isPersisting, isDirty } = usePersist() + const { persistAllWithFeedback, isPersisting, isDirty } = usePersistWithFeedback() const handleClick = useCallback(async (): Promise => { - const result = await persistAll() + const result = await persistAllWithFeedback() if (result.success) { onSuccess?.() } else { onError?.() } - }, [persistAll, onSuccess, onError]) + }, [persistAllWithFeedback, onSuccess, onError]) return ( + {showDetails && ( +
    + {notification.details.map((detail, i) => ( +
  • + {detail.entityType && ( + {detail.entityType} + )} + {detail.fieldName && ( + .{detail.fieldName} + )} + {(detail.entityType || detail.fieldName) && ': '} + {detail.message} +
  • + ))} +
+ )} + + )} + + {notification.technicalDetail && ( +

+ {notification.technicalDetail} +

+ )} + + + + + ) +} + +export interface ToastContainerProps { + readonly className?: string +} + +/** + * Renders all active notifications as toasts. + * Place this component once in your app tree (inside a BindxProvider). + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function ToastContainer({ className }: ToastContainerProps): ReactNode { + const notifications = useNotifications() + const dismiss = useDismissNotification() + + if (notifications.length === 0) return null + + return ( +
+ {notifications.map(notification => ( + + ))} +
+ ) +} diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 1a26d80..0751552 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -292,6 +292,16 @@ export type { export { EventEmitter } from './events/index.js' +// Notifications +export { NotificationStore } from './notifications/index.js' +export type { + Notification, + NotificationDetail, + NotificationInput, + NotificationType, + NotificationSource, +} from './notifications/index.js' + // Error types export type { ExecutionErrorType, diff --git a/packages/bindx/src/notifications/NotificationStore.ts b/packages/bindx/src/notifications/NotificationStore.ts new file mode 100644 index 0000000..1908e47 --- /dev/null +++ b/packages/bindx/src/notifications/NotificationStore.ts @@ -0,0 +1,116 @@ +import type { Notification, NotificationInput } from './types.js' + +const DEFAULT_DISMISS_MS: Record = { + error: 60_000, + warning: 15_000, + success: 6_000, + info: 6_000, +} + +let nextId = 1 + +/** + * Framework-agnostic store for user-facing notifications. + * Follows the same subscriber pattern as ChangeRegistry / SnapshotStore. + */ +export class NotificationStore { + private readonly notifications = new Map() + private readonly timers = new Map>() + private readonly subscribers = new Set<() => void>() + + /** Cached snapshot for useSyncExternalStore */ + private snapshot: readonly Notification[] = [] + + /** + * Add a notification. Returns its ID. + * Deduplicates by source + message — if an identical notification + * already exists, the existing ID is returned without adding a new one. + */ + add(input: NotificationInput): string { + // Deduplicate + for (const existing of this.notifications.values()) { + if (existing.source === input.source && existing.message === input.message) { + return existing.id + } + } + + const id = `notification-${nextId++}` + const notification: Notification = { + ...input, + id, + timestamp: Date.now(), + } + + this.notifications.set(id, notification) + + // Schedule auto-dismiss + const dismissAfter = input.dismissAfter ?? DEFAULT_DISMISS_MS[input.type] ?? 6_000 + if (dismissAfter > 0) { + this.timers.set(id, setTimeout(() => this.dismiss(id), dismissAfter)) + } + + this.updateSnapshot() + return id + } + + /** + * Remove a notification by ID. + */ + dismiss(id: string): void { + if (!this.notifications.has(id)) return + + this.notifications.delete(id) + const timer = this.timers.get(id) + if (timer !== undefined) { + clearTimeout(timer) + this.timers.delete(id) + } + this.updateSnapshot() + } + + /** + * Remove all notifications. + */ + clear(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + this.notifications.clear() + this.updateSnapshot() + } + + /** + * Get all active notifications sorted by timestamp (oldest first). + * Returns a stable reference for useSyncExternalStore. + */ + getAll(): readonly Notification[] { + return this.snapshot + } + + /** + * Subscribe to notification changes. + * Returns an unsubscribe function. + */ + subscribe(callback: () => void): () => void { + this.subscribers.add(callback) + return () => this.subscribers.delete(callback) + } + + /** + * Clean up all timers. Call when the store is no longer needed. + */ + destroy(): void { + this.clear() + this.subscribers.clear() + } + + private updateSnapshot(): void { + this.snapshot = Array.from(this.notifications.values()).sort( + (a, b) => a.timestamp - b.timestamp, + ) + for (const cb of this.subscribers) { + cb() + } + } +} diff --git a/packages/bindx/src/notifications/index.ts b/packages/bindx/src/notifications/index.ts new file mode 100644 index 0000000..fb1c78b --- /dev/null +++ b/packages/bindx/src/notifications/index.ts @@ -0,0 +1,8 @@ +export { NotificationStore } from './NotificationStore.js' +export type { + Notification, + NotificationDetail, + NotificationInput, + NotificationType, + NotificationSource, +} from './types.js' diff --git a/packages/bindx/src/notifications/types.ts b/packages/bindx/src/notifications/types.ts new file mode 100644 index 0000000..79c6853 --- /dev/null +++ b/packages/bindx/src/notifications/types.ts @@ -0,0 +1,39 @@ +/** + * Notification types for user-facing feedback (toasts, alerts). + */ + +export type NotificationType = 'success' | 'error' | 'warning' | 'info' + +export type NotificationSource = 'persist' | 'load' | 'client' + +/** + * A single notification displayed to the user. + */ +export interface Notification { + readonly id: string + readonly type: NotificationType + readonly message: string + /** Structured details (e.g., per-field errors) */ + readonly details?: readonly NotificationDetail[] + /** Raw technical detail (e.g., server errorMessage) */ + readonly technicalDetail?: string + /** Auto-dismiss timeout in ms. 0 = manual dismiss only. */ + readonly dismissAfter: number + /** What triggered this notification */ + readonly source: NotificationSource + readonly timestamp: number +} + +/** + * Structured detail within a notification. + */ +export interface NotificationDetail { + readonly entityType?: string + readonly fieldName?: string + readonly message: string +} + +/** + * Input for creating a notification (id and timestamp are auto-generated). + */ +export type NotificationInput = Omit diff --git a/tests/unit/notifications/notificationStore.test.ts b/tests/unit/notifications/notificationStore.test.ts new file mode 100644 index 0000000..4f37ea8 --- /dev/null +++ b/tests/unit/notifications/notificationStore.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test, beforeEach, mock } from 'bun:test' +import { NotificationStore } from '@contember/bindx' + +describe('NotificationStore', () => { + let store: NotificationStore + + beforeEach(() => { + store = new NotificationStore() + }) + + test('add and getAll', () => { + store.add({ type: 'success', message: 'Saved', source: 'persist', dismissAfter: 0 }) + store.add({ type: 'error', message: 'Failed', source: 'persist', dismissAfter: 0 }) + + const all = store.getAll() + expect(all).toHaveLength(2) + expect(all[0]!.message).toBe('Saved') + expect(all[1]!.message).toBe('Failed') + }) + + test('add returns unique ids', () => { + const id1 = store.add({ type: 'success', message: 'A', source: 'persist', dismissAfter: 0 }) + const id2 = store.add({ type: 'success', message: 'B', source: 'persist', dismissAfter: 0 }) + expect(id1).not.toBe(id2) + }) + + test('dismiss removes a notification', () => { + const id = store.add({ type: 'error', message: 'Oops', source: 'persist', dismissAfter: 0 }) + expect(store.getAll()).toHaveLength(1) + + store.dismiss(id) + expect(store.getAll()).toHaveLength(0) + }) + + test('dismiss with unknown id is no-op', () => { + store.add({ type: 'info', message: 'Hello', source: 'client', dismissAfter: 0 }) + store.dismiss('nonexistent') + expect(store.getAll()).toHaveLength(1) + }) + + test('clear removes all notifications', () => { + store.add({ type: 'success', message: 'A', source: 'persist', dismissAfter: 0 }) + store.add({ type: 'error', message: 'B', source: 'persist', dismissAfter: 0 }) + store.clear() + expect(store.getAll()).toHaveLength(0) + }) + + test('deduplication by source + message', () => { + const id1 = store.add({ type: 'error', message: 'Duplicate', source: 'persist', dismissAfter: 0 }) + const id2 = store.add({ type: 'error', message: 'Duplicate', source: 'persist', dismissAfter: 0 }) + expect(id1).toBe(id2) + expect(store.getAll()).toHaveLength(1) + }) + + test('different source allows same message', () => { + store.add({ type: 'error', message: 'Same msg', source: 'persist', dismissAfter: 0 }) + store.add({ type: 'error', message: 'Same msg', source: 'load', dismissAfter: 0 }) + expect(store.getAll()).toHaveLength(2) + }) + + test('subscribe notifies on add', () => { + const callback = mock(() => {}) + store.subscribe(callback) + + store.add({ type: 'info', message: 'Test', source: 'client', dismissAfter: 0 }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('subscribe notifies on dismiss', () => { + const id = store.add({ type: 'info', message: 'Test', source: 'client', dismissAfter: 0 }) + + const callback = mock(() => {}) + store.subscribe(callback) + store.dismiss(id) + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('unsubscribe stops notifications', () => { + const callback = mock(() => {}) + const unsub = store.subscribe(callback) + unsub() + + store.add({ type: 'info', message: 'Test', source: 'client', dismissAfter: 0 }) + expect(callback).not.toHaveBeenCalled() + }) + + test('getAll returns stable reference when unchanged', () => { + store.add({ type: 'info', message: 'A', source: 'client', dismissAfter: 0 }) + const ref1 = store.getAll() + const ref2 = store.getAll() + expect(ref1).toBe(ref2) + }) + + test('notifications are sorted by timestamp', () => { + store.add({ type: 'info', message: 'First', source: 'client', dismissAfter: 0 }) + store.add({ type: 'info', message: 'Second', source: 'client', dismissAfter: 0 }) + + const all = store.getAll() + expect(all[0]!.timestamp).toBeLessThanOrEqual(all[1]!.timestamp) + }) + + test('details and technicalDetail are preserved', () => { + store.add({ + type: 'error', + message: 'Failed', + source: 'persist', + dismissAfter: 0, + details: [ + { entityType: 'Article', fieldName: 'title', message: 'Required' }, + ], + technicalDetail: 'HTTP 400', + }) + + const notification = store.getAll()[0]! + expect(notification.details).toHaveLength(1) + expect(notification.details![0]!.fieldName).toBe('title') + expect(notification.technicalDetail).toBe('HTTP 400') + }) + + test('destroy cleans up everything', () => { + const callback = mock(() => {}) + store.subscribe(callback) + store.add({ type: 'info', message: 'Test', source: 'client', dismissAfter: 0 }) + expect(callback).toHaveBeenCalledTimes(1) + + store.destroy() + expect(store.getAll()).toHaveLength(0) + + // After destroy, subscribers are cleared — new adds don't notify + const callCountAfterDestroy = callback.mock.calls.length + store.add({ type: 'info', message: 'After destroy', source: 'client', dismissAfter: 0 }) + expect(callback.mock.calls.length).toBe(callCountAfterDestroy) + }) +})