fix(referencelines): and bring back the notification for worker updates but disallow duplication (#5005)
@ -289,10 +289,16 @@ export default async function init({
|
|||||||
eventTarget.addEventListenerDebounced(
|
eventTarget.addEventListenerDebounced(
|
||||||
EVENTS.ERROR_EVENT,
|
EVENTS.ERROR_EVENT,
|
||||||
({ detail }) => {
|
({ detail }) => {
|
||||||
|
// Create a stable ID for deduplication based on error type and message
|
||||||
|
const errorId = `cornerstone-error-${detail.type}-${detail.message.substring(0, 50)}`;
|
||||||
|
|
||||||
uiNotificationService.show({
|
uiNotificationService.show({
|
||||||
title: detail.type,
|
title: detail.type,
|
||||||
message: detail.message,
|
message: detail.message,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
id: errorId,
|
||||||
|
allowDuplicates: false, // Prevent duplicate error notifications
|
||||||
|
deduplicationInterval: 30000, // 30 seconds deduplication window
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
100
|
100
|
||||||
@ -310,37 +316,73 @@ export default async function init({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeWebWorkerProgressHandler(uiNotificationService) {
|
function initializeWebWorkerProgressHandler(uiNotificationService) {
|
||||||
const activeToasts = new Map();
|
// Use a single map to track all active worker tasks
|
||||||
|
const activeWorkerTasks = new Map();
|
||||||
|
|
||||||
// eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
|
// Create a normalized task key that doesn't include the random ID
|
||||||
// const { progress, type, id } = detail;
|
// This helps us identify and deduplicate the same type of task
|
||||||
|
const getNormalizedTaskKey = type => {
|
||||||
|
return `worker-task-${type.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
|
};
|
||||||
|
|
||||||
// const cacheKey = `${type}-${id}`;
|
eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
|
||||||
// if (progress === 0 && !activeToasts.has(cacheKey)) {
|
const { progress, type, id } = detail;
|
||||||
// const progressPromise = new Promise((resolve, reject) => {
|
|
||||||
// activeToasts.set(cacheKey, { resolve, reject });
|
|
||||||
// });
|
|
||||||
|
|
||||||
// uiNotificationService.show({
|
// Skip notifications for compute statistics
|
||||||
// id: cacheKey,
|
if (type === cornerstoneTools.Enums.WorkerTypes.COMPUTE_STATISTICS) {
|
||||||
// title: `${type}`,
|
return;
|
||||||
// message: `${type}: ${progress}%`,
|
}
|
||||||
// autoClose: false,
|
|
||||||
// promise: progressPromise,
|
const normalizedKey = getNormalizedTaskKey(type);
|
||||||
// promiseMessages: {
|
|
||||||
// loading: `Computing...`,
|
if (progress === 0) {
|
||||||
// success: `Completed successfully`,
|
// Check if we're already tracking a task of this type
|
||||||
// error: 'Web Worker failed',
|
if (!activeWorkerTasks.has(normalizedKey)) {
|
||||||
// },
|
const progressPromise = new Promise((resolve, reject) => {
|
||||||
// });
|
activeWorkerTasks.set(normalizedKey, {
|
||||||
// } else {
|
resolve,
|
||||||
// if (progress === 100) {
|
reject,
|
||||||
// const { resolve } = activeToasts.get(cacheKey);
|
originalId: id,
|
||||||
// resolve({ progress, type });
|
type,
|
||||||
// activeToasts.delete(cacheKey);
|
});
|
||||||
// }
|
});
|
||||||
// }
|
|
||||||
// });
|
uiNotificationService.show({
|
||||||
|
id: normalizedKey, // Use the normalized key as ID for better deduplication
|
||||||
|
title: `${type}`,
|
||||||
|
message: `Computing...`,
|
||||||
|
autoClose: false,
|
||||||
|
allowDuplicates: false,
|
||||||
|
deduplicationInterval: 60000, // 60 seconds - prevent frequent notifications of same type
|
||||||
|
promise: progressPromise,
|
||||||
|
promiseMessages: {
|
||||||
|
loading: `Computing...`,
|
||||||
|
success: `Completed successfully`,
|
||||||
|
error: 'Web Worker failed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Already tracking this type of task, just let it continue
|
||||||
|
console.debug(`Already tracking a "${type}" task, skipping duplicate notification`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Task completed
|
||||||
|
else if (progress === 100) {
|
||||||
|
// Check if we have this task type in our tracking map
|
||||||
|
const taskData = activeWorkerTasks.get(normalizedKey);
|
||||||
|
|
||||||
|
if (taskData) {
|
||||||
|
// Resolve the promise to update the notification
|
||||||
|
const { resolve } = taskData;
|
||||||
|
resolve({ progress, type });
|
||||||
|
|
||||||
|
// Remove from tracking
|
||||||
|
activeWorkerTasks.delete(normalizedKey);
|
||||||
|
|
||||||
|
console.debug(`Worker task "${type}" completed successfully`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -48,6 +48,8 @@ import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
|
|||||||
|
|
||||||
export default function initCornerstoneTools(configuration = {}) {
|
export default function initCornerstoneTools(configuration = {}) {
|
||||||
CrosshairsTool.isAnnotation = false;
|
CrosshairsTool.isAnnotation = false;
|
||||||
|
LabelmapSlicePropagationTool.isAnnotation = false;
|
||||||
|
MarkerLabelmapTool.isAnnotation = false;
|
||||||
ReferenceLinesTool.isAnnotation = false;
|
ReferenceLinesTool.isAnnotation = false;
|
||||||
AdvancedMagnifyTool.isAnnotation = false;
|
AdvancedMagnifyTool.isAnnotation = false;
|
||||||
PlanarFreehandContourSegmentationTool.isAnnotation = false;
|
PlanarFreehandContourSegmentationTool.isAnnotation = false;
|
||||||
|
|||||||
@ -180,15 +180,12 @@ const CornerstoneViewportDownloadForm = ({
|
|||||||
const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId);
|
const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId);
|
||||||
toolGroup.addViewport(downloadViewportId, renderingEngineId);
|
toolGroup.addViewport(downloadViewportId, renderingEngineId);
|
||||||
|
|
||||||
Object.keys(toolGroup.getToolInstances()).forEach(toolName => {
|
const toolInstances = toolGroup.getToolInstances();
|
||||||
if (show && toolName !== 'Crosshairs') {
|
const toolInstancesArray = Object.values(toolInstances);
|
||||||
try {
|
|
||||||
toolGroup.setToolEnabled(toolName);
|
toolInstancesArray.forEach(toolInstance => {
|
||||||
} catch (error) {
|
if (toolInstance.constructor.isAnnotation !== false) {
|
||||||
console.debug('Error enabling tool:', error);
|
toolGroup.setToolEnabled(toolInstance.toolName);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toolGroup.setToolDisabled(toolName);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const collapsedOutsideBorderSize = 4;
|
|||||||
const collapsedWidth = 25;
|
const collapsedWidth = 25;
|
||||||
|
|
||||||
const rightPanelInitialExpandedWidth = 280;
|
const rightPanelInitialExpandedWidth = 280;
|
||||||
const leftPanelInitialExpandedWidth = 292;
|
const leftPanelInitialExpandedWidth = 282;
|
||||||
|
|
||||||
const panelGroupDefinition = {
|
const panelGroupDefinition = {
|
||||||
groupId: 'viewerLayoutResizablePanelGroup',
|
groupId: 'viewerLayoutResizablePanelGroup',
|
||||||
|
|||||||
@ -120,8 +120,8 @@ const toolbarButtons: Button[] = [
|
|||||||
tooltip: 'Show Reference Lines',
|
tooltip: 'Show Reference Lines',
|
||||||
commands: 'toggleEnabledDisabledToolbar',
|
commands: 'toggleEnabledDisabledToolbar',
|
||||||
listeners: {
|
listeners: {
|
||||||
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('ReferenceLinesTool'),
|
[ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('ReferenceLines'),
|
||||||
[ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('ReferenceLinesTool'),
|
[ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('ReferenceLines'),
|
||||||
},
|
},
|
||||||
evaluate: [
|
evaluate: [
|
||||||
'evaluate.cornerstoneTool.toggle',
|
'evaluate.cornerstoneTool.toggle',
|
||||||
|
|||||||
@ -60,17 +60,24 @@ class UINotificationService {
|
|||||||
* @param {string} [notification.promiseMessages.loading] - Message to show while promise is pending
|
* @param {string} [notification.promiseMessages.loading] - Message to show while promise is pending
|
||||||
* @param {string | function} [notification.promiseMessages.success] - Message to show when promise resolves
|
* @param {string | function} [notification.promiseMessages.success] - Message to show when promise resolves
|
||||||
* @param {string | function} [notification.promiseMessages.error] - Message to show when promise rejects
|
* @param {string | function} [notification.promiseMessages.error] - Message to show when promise rejects
|
||||||
|
* @param {object} [notification.action] - Action button configuration
|
||||||
|
* @param {string} notification.action.label - The label for the action button
|
||||||
|
* @param {function} notification.action.onClick - The function to call when the action button is clicked
|
||||||
* @returns {string} id - The ID of the created notification
|
* @returns {string} id - The ID of the created notification
|
||||||
*/
|
*/
|
||||||
show({
|
show({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
duration = 5000,
|
duration = 2000,
|
||||||
position = 'bottom-right',
|
position = 'bottom-right',
|
||||||
type = 'info',
|
type = 'info',
|
||||||
autoClose = true,
|
autoClose = true,
|
||||||
promise,
|
promise,
|
||||||
promiseMessages,
|
promiseMessages,
|
||||||
|
id,
|
||||||
|
allowDuplicates = false,
|
||||||
|
deduplicationInterval = 30000,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
message: string | ((data?: any) => string);
|
message: string | ((data?: any) => string);
|
||||||
@ -90,6 +97,13 @@ class UINotificationService {
|
|||||||
success?: string | ((data: any) => string);
|
success?: string | ((data: any) => string);
|
||||||
error?: string | ((error: any) => string);
|
error?: string | ((error: any) => string);
|
||||||
};
|
};
|
||||||
|
id?: string;
|
||||||
|
allowDuplicates?: boolean;
|
||||||
|
deduplicationInterval?: number;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
}): string {
|
}): string {
|
||||||
if (promise && promiseMessages) {
|
if (promise && promiseMessages) {
|
||||||
const loadingId = serviceImplementation._show({
|
const loadingId = serviceImplementation._show({
|
||||||
@ -98,6 +112,9 @@ class UINotificationService {
|
|||||||
type: 'loading',
|
type: 'loading',
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
position,
|
position,
|
||||||
|
id: id ? `${id}-loading` : undefined,
|
||||||
|
allowDuplicates,
|
||||||
|
deduplicationInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
promise.then(
|
promise.then(
|
||||||
@ -114,6 +131,10 @@ class UINotificationService {
|
|||||||
duration,
|
duration,
|
||||||
position,
|
position,
|
||||||
autoClose,
|
autoClose,
|
||||||
|
id: id ? `${id}-success` : undefined,
|
||||||
|
allowDuplicates,
|
||||||
|
deduplicationInterval,
|
||||||
|
action,
|
||||||
});
|
});
|
||||||
this.hide(loadingId);
|
this.hide(loadingId);
|
||||||
},
|
},
|
||||||
@ -130,6 +151,10 @@ class UINotificationService {
|
|||||||
duration,
|
duration,
|
||||||
position,
|
position,
|
||||||
autoClose,
|
autoClose,
|
||||||
|
id: id ? `${id}-error` : undefined,
|
||||||
|
allowDuplicates,
|
||||||
|
deduplicationInterval,
|
||||||
|
action,
|
||||||
});
|
});
|
||||||
this.hide(loadingId);
|
this.hide(loadingId);
|
||||||
}
|
}
|
||||||
@ -145,6 +170,10 @@ class UINotificationService {
|
|||||||
position,
|
position,
|
||||||
type,
|
type,
|
||||||
autoClose,
|
autoClose,
|
||||||
|
id,
|
||||||
|
allowDuplicates,
|
||||||
|
deduplicationInterval,
|
||||||
|
action,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary as ReactErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Dialog, DialogContent } from '../Dialog/Dialog';
|
import { Dialog, DialogContent } from '../Dialog/Dialog';
|
||||||
import { ScrollArea } from '../ScrollArea/ScrollArea';
|
import { ScrollArea } from '../ScrollArea/ScrollArea';
|
||||||
import { Button } from '../Button/Button';
|
import { Button } from '../Button/Button';
|
||||||
|
import { useNotification } from '../../contextProviders';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
@ -112,7 +112,7 @@ interface ErrorBoundaryError extends Error {
|
|||||||
stack?: string;
|
stack?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DefaultFallbackProps {
|
interface DefaultFallbackProps extends FallbackProps {
|
||||||
error: ErrorBoundaryError;
|
error: ErrorBoundaryError;
|
||||||
context: string;
|
context: string;
|
||||||
resetErrorBoundary: () => void;
|
resetErrorBoundary: () => void;
|
||||||
@ -135,6 +135,8 @@ const DefaultFallback = ({
|
|||||||
}: DefaultFallbackProps) => {
|
}: DefaultFallbackProps) => {
|
||||||
const { t } = useTranslation('ErrorBoundary');
|
const { t } = useTranslation('ErrorBoundary');
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const { show } = useNotification();
|
||||||
|
|
||||||
const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`;
|
const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`;
|
||||||
const subtitle = t('Sorry, something went wrong there. Try again.');
|
const subtitle = t('Sorry, something went wrong there. Try again.');
|
||||||
|
|
||||||
@ -143,20 +145,32 @@ const DefaultFallback = ({
|
|||||||
const copyErrorToClipboard = () => {
|
const copyErrorToClipboard = () => {
|
||||||
if (code) {
|
if (code) {
|
||||||
navigator.clipboard.writeText(code);
|
navigator.clipboard.writeText(code);
|
||||||
toast.success(t('Error copied to clipboard'));
|
show({
|
||||||
|
title: t('Success'),
|
||||||
|
message: t('Error copied to clipboard'),
|
||||||
|
type: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toast.error(title, {
|
// Use a stable ID based on error message to support deduplication
|
||||||
description: subtitle,
|
const errorId = `error-${errorTitle || error.message}`;
|
||||||
|
|
||||||
|
// We don't need to track shown state - instead rely on the notification deduplication system
|
||||||
|
show({
|
||||||
|
title,
|
||||||
|
message: subtitle,
|
||||||
|
type: 'error',
|
||||||
|
duration: 0,
|
||||||
|
id: errorId,
|
||||||
action: {
|
action: {
|
||||||
label: t('Show Details'),
|
label: t('Show Details'),
|
||||||
onClick: () => setShowDetails(true),
|
onClick: () => setShowDetails(true),
|
||||||
},
|
},
|
||||||
duration: 0,
|
|
||||||
});
|
});
|
||||||
}, [error, subtitle, t, title]);
|
}, [error, errorTitle, subtitle, t, title, show]);
|
||||||
|
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
return null;
|
return null;
|
||||||
@ -286,9 +300,8 @@ const ErrorBoundary = ({
|
|||||||
<ReactErrorBoundary
|
<ReactErrorBoundary
|
||||||
fallbackRender={props => (
|
fallbackRender={props => (
|
||||||
<FallbackComponent
|
<FallbackComponent
|
||||||
error={props.error}
|
{...props}
|
||||||
context={context}
|
context={context}
|
||||||
resetErrorBoundary={props.resetErrorBoundary}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
onReset={onResetHandler}
|
onReset={onResetHandler}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useCallback, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useCallback, useEffect, ReactNode, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Toaster, toast } from '../components';
|
import { Toaster, toast } from '../components';
|
||||||
|
|
||||||
@ -6,7 +6,23 @@ const NotificationContext = createContext(null);
|
|||||||
|
|
||||||
export const useNotification = () => useContext(NotificationContext);
|
export const useNotification = () => useContext(NotificationContext);
|
||||||
|
|
||||||
const NotificationProvider = ({ children, service }) => {
|
// Notification deduplication cache
|
||||||
|
interface NotificationCacheEntry {
|
||||||
|
timestamp: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
service?: any;
|
||||||
|
deduplicationInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationProvider = ({
|
||||||
|
children,
|
||||||
|
service,
|
||||||
|
deduplicationInterval = 10000, // Default to 10 seconds
|
||||||
|
}: NotificationProviderProps) => {
|
||||||
const DEFAULT_OPTIONS = {
|
const DEFAULT_OPTIONS = {
|
||||||
title: '',
|
title: '',
|
||||||
message: '',
|
message: '',
|
||||||
@ -15,39 +31,138 @@ const NotificationProvider = ({ children, service }) => {
|
|||||||
type: 'info', // info, success, error
|
type: 'info', // info, success, error
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cache for recent notifications to prevent duplicates
|
||||||
|
// Structure: { [title_message_type]: { timestamp, id } }
|
||||||
|
const recentNotificationsRef = useRef<Record<string, NotificationCacheEntry>>({});
|
||||||
|
|
||||||
|
// Use the configurable deduplication interval from props
|
||||||
|
|
||||||
const show = useCallback(options => {
|
const show = useCallback(options => {
|
||||||
const { title, message, duration, position, type, promise } = {
|
const {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
duration,
|
||||||
|
position,
|
||||||
|
type,
|
||||||
|
promise,
|
||||||
|
allowDuplicates = false,
|
||||||
|
deduplicationInterval: optionsDeduplicationInterval,
|
||||||
|
action,
|
||||||
|
} = {
|
||||||
...DEFAULT_OPTIONS,
|
...DEFAULT_OPTIONS,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Use the provider's deduplicationInterval by default, but allow it to be overridden per notification
|
||||||
|
const notificationDeduplicationInterval = optionsDeduplicationInterval || deduplicationInterval;
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
return toast.promise(promise, {
|
return toast.promise(promise, {
|
||||||
loading: title || 'Loading...',
|
loading: title || 'Loading...',
|
||||||
success: (data: unknown) => ({
|
success: (data: unknown) => {
|
||||||
title: title || 'Success',
|
const description = typeof message === 'function' ? message(data) : message;
|
||||||
description: typeof message === 'function' ? message(data) : message,
|
return {
|
||||||
}),
|
title: title || 'Success',
|
||||||
error: (err: unknown) => ({
|
description,
|
||||||
title: title || 'Error',
|
};
|
||||||
description: typeof message === 'function' ? message(err) : message,
|
},
|
||||||
}),
|
error: (err: unknown) => {
|
||||||
|
const description = typeof message === 'function' ? message(err) : message;
|
||||||
|
return {
|
||||||
|
title: title || 'Error',
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return toast[type](title, {
|
// Create a cache key from notification properties
|
||||||
|
const messageStr = typeof message === 'function' ? 'function' : message;
|
||||||
|
const cacheKey = `${title}_${messageStr}_${type}`;
|
||||||
|
|
||||||
|
// Handle deduplication
|
||||||
|
if (!allowDuplicates && type === 'error') {
|
||||||
|
const now = Date.now();
|
||||||
|
const cachedNotification = recentNotificationsRef.current[cacheKey];
|
||||||
|
|
||||||
|
// First check if we've shown this notification recently
|
||||||
|
if (cachedNotification) {
|
||||||
|
const timeSinceLastShown = now - cachedNotification.timestamp;
|
||||||
|
|
||||||
|
// If it's been shown recently and within the deduplication interval,
|
||||||
|
// don't show it again
|
||||||
|
if (timeSinceLastShown < notificationDeduplicationInterval) {
|
||||||
|
console.debug(
|
||||||
|
`Prevented duplicate notification: "${title}" (${timeSinceLastShown}ms < ${notificationDeduplicationInterval}ms)`
|
||||||
|
);
|
||||||
|
// Return the existing notification ID
|
||||||
|
return cachedNotification.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's been shown before but outside the deduplication interval,
|
||||||
|
// dismiss the old notification first and allow showing a new one
|
||||||
|
console.debug(
|
||||||
|
`Showing notification again after interval: "${title}" (${timeSinceLastShown}ms >= ${notificationDeduplicationInterval}ms)`
|
||||||
|
);
|
||||||
|
toast.dismiss(cachedNotification.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the notification with action if provided
|
||||||
|
const toastOptions = {
|
||||||
duration,
|
duration,
|
||||||
position,
|
position,
|
||||||
description: message,
|
description: message,
|
||||||
});
|
id: options.id, // Use provided ID if available
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add action button if provided
|
||||||
|
if (action && action.label && typeof action.onClick === 'function') {
|
||||||
|
toastOptions.action = {
|
||||||
|
label: action.label,
|
||||||
|
onClick: action.onClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = toast[type](title, toastOptions);
|
||||||
|
|
||||||
|
// Cache this notification for deduplication if it's an error
|
||||||
|
if (type === 'error') {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
recentNotificationsRef.current[cacheKey] = {
|
||||||
|
timestamp,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug(
|
||||||
|
`Stored notification in cache: "${title}" (id: ${id}, timestamp: ${timestamp})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// We no longer auto-delete the cache entry after duration
|
||||||
|
// Instead, we keep it to track when the notification was last shown
|
||||||
|
// The entry will be checked against the deduplication interval
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hide = useCallback(id => {
|
const hide = useCallback(id => {
|
||||||
toast.dismiss(id);
|
toast.dismiss(id);
|
||||||
|
|
||||||
|
// Remove from cache if present
|
||||||
|
const cacheEntries = Object.entries(recentNotificationsRef.current);
|
||||||
|
for (const [key, entry] of cacheEntries) {
|
||||||
|
if (entry.id === id) {
|
||||||
|
delete recentNotificationsRef.current[key];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hideAll = useCallback(() => {
|
const hideAll = useCallback(() => {
|
||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
|
// Clear notification cache
|
||||||
|
recentNotificationsRef.current = {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,8 +176,28 @@ const NotificationProvider = ({ children, service }) => {
|
|||||||
}
|
}
|
||||||
}, [service, hide, show]);
|
}, [service, hide, show]);
|
||||||
|
|
||||||
|
// Debug function to get the current cache (for development use)
|
||||||
|
const getNotificationCache = useCallback(() => {
|
||||||
|
const cache = { ...recentNotificationsRef.current };
|
||||||
|
|
||||||
|
// Add human-readable timestamps and time since showing
|
||||||
|
const now = Date.now();
|
||||||
|
const enhancedCache = Object.entries(cache).reduce((result, [key, entry]) => {
|
||||||
|
const timeSince = now - entry.timestamp;
|
||||||
|
result[key] = {
|
||||||
|
...entry,
|
||||||
|
date: new Date(entry.timestamp).toISOString(),
|
||||||
|
timeSinceMs: timeSince,
|
||||||
|
timeSinceStr: `${Math.floor(timeSince / 1000)}s ago`,
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return enhancedCache;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider value={{ show, hide, hideAll }}>
|
<NotificationContext.Provider value={{ show, hide, hideAll, getNotificationCache }}>
|
||||||
<Toaster position="bottom-right" />
|
<Toaster position="bottom-right" />
|
||||||
{children}
|
{children}
|
||||||
</NotificationContext.Provider>
|
</NotificationContext.Provider>
|
||||||
@ -71,6 +206,8 @@ const NotificationProvider = ({ children, service }) => {
|
|||||||
|
|
||||||
NotificationProvider.propTypes = {
|
NotificationProvider.propTypes = {
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
|
service: PropTypes.object,
|
||||||
|
deduplicationInterval: PropTypes.number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withNotification = Component => {
|
export const withNotification = Component => {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 297 KiB |