feat: centralized error notification service (issue #15)
- Create NotificationService with context provider for centralized error/success messaging - Add NotificationContainer component to render notification stack - Integrate NotificationProvider into App root - Refactor BanUnbanForm to use notification service instead of local error state - Update fetchError utility to optionally use notification callbacks - Add comprehensive error handling guidelines to Web-Development.md - Prevent duplicate notifications with deduplication logic - Support auto-dismiss with configurable TTL per notification type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
165
frontend/src/services/notificationService.tsx
Normal file
165
frontend/src/services/notificationService.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Centralized notification service and provider.
|
||||
*
|
||||
* Provides a single channel for displaying user-facing notifications (errors, success, warnings, info).
|
||||
* Prevents duplicate messaging, handles auto-dismiss, and maintains a queue of active notifications.
|
||||
* Consumers use the `useNotification` hook to access the service.
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
import type { Notification, NotificationIntent, NotificationService } from "../types/notification";
|
||||
|
||||
/** Default auto-dismiss durations (milliseconds) for each notification type. */
|
||||
const DEFAULT_DURATIONS: Record<NotificationIntent, number> = {
|
||||
success: 5000,
|
||||
error: 8000,
|
||||
warning: 6000,
|
||||
info: 5000,
|
||||
};
|
||||
|
||||
/** Context holding active notifications and the service API. */
|
||||
const NotificationContext = createContext<NotificationService | null>(null);
|
||||
|
||||
/**
|
||||
* NotificationProvider component.
|
||||
* Must wrap the entire application to make notifications available to all children.
|
||||
*/
|
||||
export function NotificationProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
|
||||
/**
|
||||
* Remove a notification by ID.
|
||||
*/
|
||||
const dismiss = useCallback((id: string): void => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if a notification with this message and intent already exists to prevent duplicates.
|
||||
*/
|
||||
const isDuplicate = useCallback(
|
||||
(message: string, intent: NotificationIntent): boolean =>
|
||||
notifications.some((n) => n.message === message && n.intent === intent),
|
||||
[notifications],
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a notification.
|
||||
*/
|
||||
const generateId = useCallback((): string => {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 9);
|
||||
return `notif-${timestamp}-${random}`;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add a notification to the queue.
|
||||
*/
|
||||
const add = useCallback(
|
||||
(intent: NotificationIntent, message: string, autoCloseMsec?: number): void => {
|
||||
// Prevent duplicate identical notifications
|
||||
if (isDuplicate(message, intent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const duration = autoCloseMsec !== undefined ? autoCloseMsec : DEFAULT_DURATIONS[intent];
|
||||
|
||||
setNotifications((prev) => [...prev, { id, intent, message, autoCloseMsec: duration }]);
|
||||
|
||||
// Schedule auto-dismiss if duration is set
|
||||
if (duration) {
|
||||
setTimeout(() => {
|
||||
dismiss(id);
|
||||
}, duration);
|
||||
}
|
||||
},
|
||||
[isDuplicate, generateId, dismiss],
|
||||
);
|
||||
|
||||
/**
|
||||
* Public service API.
|
||||
*/
|
||||
const service: NotificationService = useMemo((): NotificationService => {
|
||||
return {
|
||||
success: (message: string, autoCloseMsec?: number): void => {
|
||||
add("success", message, autoCloseMsec);
|
||||
},
|
||||
error: (message: string, autoCloseMsec?: number): void => {
|
||||
add("error", message, autoCloseMsec);
|
||||
},
|
||||
warning: (message: string, autoCloseMsec?: number): void => {
|
||||
add("warning", message, autoCloseMsec);
|
||||
},
|
||||
info: (message: string, autoCloseMsec?: number): void => {
|
||||
add("info", message, autoCloseMsec);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}, [add, dismiss]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={service}>
|
||||
<NotificationContextInternal.Provider value={{ notifications, service }}>
|
||||
{children}
|
||||
</NotificationContextInternal.Provider>
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context for accessing the notification queue (for rendering).
|
||||
* This is separate from the public context to keep the public API clean.
|
||||
*/
|
||||
interface NotificationContextValue {
|
||||
notifications: Notification[];
|
||||
service: NotificationService;
|
||||
}
|
||||
|
||||
const NotificationContextInternal = createContext<NotificationContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to access the notification service.
|
||||
* Use this in any component to show success, error, warning, or info messages.
|
||||
*
|
||||
* @returns NotificationService API for showing messages.
|
||||
* @throws Error if used outside NotificationProvider.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const notification = useNotification();
|
||||
*
|
||||
* const handleSave = async () => {
|
||||
* try {
|
||||
* await saveData();
|
||||
* notification.success("Data saved successfully");
|
||||
* } catch (err) {
|
||||
* notification.error("Failed to save data");
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* return <button onClick={handleSave}>Save</button>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useNotification(): NotificationService {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error("useNotification must be used within NotificationProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hook to access the notifications queue for rendering.
|
||||
* Only used by NotificationContainer.
|
||||
*/
|
||||
export function useNotificationQueue(): Notification[] {
|
||||
const context = useContext(NotificationContextInternal);
|
||||
if (!context) {
|
||||
throw new Error("NotificationContextInternal must be used within NotificationProvider");
|
||||
}
|
||||
return context.notifications;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user