Co-Study4Grid / frontend /src /utils /notifications.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
5.8 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// Typed notification store (D5, 2026-07). Replaces the two ad-hoc toast
// channels — a sticky-but-undismissable `error` string and an
// auto-3s-hiding `infoMessage` string whose green-vs-blue colour was
// decided by a magic `'SUCCESS'` prefix — with ONE store carrying an
// explicit severity, a sticky flag, per-item dismissal and (via
// NotificationHost) an aria-live region.
//
// It is a module singleton (mirroring `interactionLogger`) so any layer —
// App, hooks, even non-React helpers — can raise a notification without
// prop-threading a setter, and `useNotifications()` subscribes React to it
// through `useSyncExternalStore`.
import { useSyncExternalStore } from 'react';
export type NotificationSeverity = 'error' | 'success' | 'info';
export interface Notification {
/** Monotonic id, stable for the item's lifetime (used as React key). */
readonly id: number;
readonly severity: NotificationSeverity;
readonly message: string;
/** Sticky items stay until dismissed; others auto-expire. */
readonly sticky: boolean;
}
export interface NotifyOptions {
/** Override the severity default (errors sticky, info/success not). */
sticky?: boolean;
/** Auto-dismiss delay for non-sticky items. Defaults to
* `DEFAULT_TIMEOUT_MS`. Ignored when sticky. */
timeoutMs?: number;
}
/** Non-sticky items auto-dismiss after this long by default. */
export const DEFAULT_TIMEOUT_MS = 4000;
type Timer = ReturnType<typeof setTimeout>;
class NotificationStore {
private items: readonly Notification[] = [];
private readonly listeners = new Set<() => void>();
private readonly timers = new Map<number, Timer>();
private seq = 0;
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
/** Stable reference between mutations — required by useSyncExternalStore. */
getSnapshot = (): readonly Notification[] => this.items;
private emit(): void {
for (const listener of this.listeners) listener();
}
private clearTimer(id: number): void {
const t = this.timers.get(id);
if (t !== undefined) {
clearTimeout(t);
this.timers.delete(id);
}
}
notify(severity: NotificationSeverity, message: string, options: NotifyOptions = {}): number {
const text = (message ?? '').trim();
if (!text) return -1;
const sticky = options.sticky ?? severity === 'error';
// De-dupe: an identical (severity, message) already showing is
// refreshed rather than stacked — stops a debounced/retrying caller
// (e.g. the SLD preview) from piling up copies of one error.
const existing = this.items.find(n => n.severity === severity && n.message === text);
if (existing) {
if (!sticky) this.scheduleExpiry(existing.id, options.timeoutMs);
return existing.id;
}
const id = ++this.seq;
this.items = [...this.items, { id, severity, message: text, sticky }];
if (!sticky) this.scheduleExpiry(id, options.timeoutMs);
this.emit();
return id;
}
private scheduleExpiry(id: number, timeoutMs?: number): void {
this.clearTimer(id);
this.timers.set(id, setTimeout(() => this.dismiss(id), timeoutMs ?? DEFAULT_TIMEOUT_MS));
}
dismiss(id: number): void {
this.clearTimer(id);
const next = this.items.filter(n => n.id !== id);
if (next.length !== this.items.length) {
this.items = next;
this.emit();
}
}
/** Remove every notification of a severity (the "clear the error" gesture). */
clearSeverity(severity: NotificationSeverity): void {
const next = this.items.filter(n => n.severity !== severity);
if (next.length !== this.items.length) {
for (const n of this.items) if (n.severity === severity) this.clearTimer(n.id);
this.items = next;
this.emit();
}
}
/** Remove everything (e.g. on a study reset). */
clear(): void {
if (this.items.length === 0) return;
for (const id of this.timers.keys()) this.clearTimer(id);
this.items = [];
this.emit();
}
}
export const notifications = new NotificationStore();
// --- Convenience raisers -------------------------------------------------
/** Raise a sticky error toast (stays until dismissed). */
export function notifyError(message: string): number {
return notifications.notify('error', message);
}
/** Raise an auto-dismissing info toast. */
export function notifyInfo(message: string): number {
return notifications.notify('info', message);
}
/** Raise an auto-dismissing success toast (replaces the `'SUCCESS:'` prefix). */
export function notifySuccess(message: string): number {
return notifications.notify('success', message);
}
export function dismissNotification(id: number): void {
notifications.dismiss(id);
}
export function clearNotifications(): void {
notifications.clear();
}
// --- React binding -------------------------------------------------------
/** Subscribe a component to the live notification list. */
export function useNotifications(): readonly Notification[] {
return useSyncExternalStore(notifications.subscribe, notifications.getSnapshot, notifications.getSnapshot);
}