Shorten notification dwell time and add dismissible toast stack.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
99
server/web/src/context/ToastContext.test.tsx
Normal file
99
server/web/src/context/ToastContext.test.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
|
||||
import { ToastProvider, useToast, TOAST_DURATION_MS, MAX_VISIBLE_TOASTS } from './ToastContext';
|
||||
|
||||
function Probe() {
|
||||
const { toasts, showToast, dismissToast, clearAllToasts } = useToast();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="count">{toasts.length}</span>
|
||||
<button type="button" onClick={() => showToast('hello')}>
|
||||
info
|
||||
</button>
|
||||
<button type="button" onClick={() => showToast('bad', { level: 'error' })}>
|
||||
error
|
||||
</button>
|
||||
<button type="button" onClick={() => dismissToast(toasts[0]?.id)}>
|
||||
dismiss
|
||||
</button>
|
||||
<button type="button" onClick={clearAllToasts}>
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ToastContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses shorter default durations for info than error', () => {
|
||||
expect(TOAST_DURATION_MS.info).toBeLessThan(TOAST_DURATION_MS.error);
|
||||
expect(TOAST_DURATION_MS.info).toBeGreaterThanOrEqual(4000);
|
||||
expect(TOAST_DURATION_MS.info).toBeLessThanOrEqual(6000);
|
||||
});
|
||||
|
||||
it('auto-dismisses info toasts and keeps errors longer', () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Probe />
|
||||
</ToastProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('info'));
|
||||
fireEvent.click(screen.getByText('error'));
|
||||
expect(screen.getByTestId('count').textContent).toBe('2');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(TOAST_DURATION_MS.info);
|
||||
});
|
||||
expect(screen.getByTestId('count').textContent).toBe('1');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(TOAST_DURATION_MS.error - TOAST_DURATION_MS.info);
|
||||
});
|
||||
expect(screen.getByTestId('count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
it('caps visible toasts', () => {
|
||||
function Spam() {
|
||||
const { showToast, toasts } = useToast();
|
||||
return (
|
||||
<button type="button" onClick={() => {
|
||||
for (let i = 0; i < MAX_VISIBLE_TOASTS + 3; i += 1) showToast(`t${i}`);
|
||||
}}>
|
||||
spam ({toasts.length})
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Spam />
|
||||
</ToastProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/spam/));
|
||||
expect(screen.getByText(/spam/)).toHaveTextContent(String(MAX_VISIBLE_TOASTS));
|
||||
});
|
||||
|
||||
it('dismisses and clears all manually', () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<Probe />
|
||||
</ToastProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('info'));
|
||||
fireEvent.click(screen.getByText('error'));
|
||||
fireEvent.click(screen.getByText('dismiss'));
|
||||
expect(screen.getByTestId('count').textContent).toBe('1');
|
||||
fireEvent.click(screen.getByText('clear'));
|
||||
expect(screen.getByTestId('count').textContent).toBe('0');
|
||||
});
|
||||
});
|
||||
123
server/web/src/context/ToastContext.tsx
Normal file
123
server/web/src/context/ToastContext.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export type ToastLevel = 'info' | 'success' | 'warn' | 'error';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
level: ToastLevel;
|
||||
message: string;
|
||||
detail?: string;
|
||||
createdAt: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ShowToastOptions {
|
||||
level?: ToastLevel;
|
||||
detail?: string;
|
||||
/** Override default auto-dismiss; 0 = stay until dismissed */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export const TOAST_DURATION_MS: Record<ToastLevel, number> = {
|
||||
info: 5000,
|
||||
success: 5000,
|
||||
warn: 6000,
|
||||
error: 10000,
|
||||
};
|
||||
|
||||
export const MAX_VISIBLE_TOASTS = 5;
|
||||
|
||||
let toastCounter = 0;
|
||||
function nextToastId(): string {
|
||||
toastCounter += 1;
|
||||
return `toast-${toastCounter}`;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
showToast: (message: string, options?: ShowToastOptions) => string;
|
||||
dismissToast: (id: string) => void;
|
||||
clearAllToasts: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({
|
||||
toasts: [],
|
||||
showToast: () => '',
|
||||
dismissToast: () => {},
|
||||
clearAllToasts: () => {},
|
||||
});
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearAllToasts = useCallback(() => {
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer);
|
||||
timersRef.current.clear();
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(toast: Toast) => {
|
||||
if (toast.durationMs <= 0) return;
|
||||
const timer = setTimeout(() => dismissToast(toast.id), toast.durationMs);
|
||||
timersRef.current.set(toast.id, timer);
|
||||
},
|
||||
[dismissToast],
|
||||
);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, options?: ShowToastOptions) => {
|
||||
const level = options?.level ?? 'info';
|
||||
const durationMs = options?.durationMs ?? TOAST_DURATION_MS[level];
|
||||
const toast: Toast = {
|
||||
id: nextToastId(),
|
||||
level,
|
||||
message,
|
||||
detail: options?.detail,
|
||||
createdAt: Date.now(),
|
||||
durationMs,
|
||||
};
|
||||
setToasts((prev) => [toast, ...prev].slice(0, MAX_VISIBLE_TOASTS));
|
||||
scheduleDismiss(toast);
|
||||
return toast.id;
|
||||
},
|
||||
[scheduleDismiss],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ toasts, showToast, dismissToast, clearAllToasts }),
|
||||
[toasts, showToast, dismissToast, clearAllToasts],
|
||||
);
|
||||
|
||||
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
Reference in New Issue
Block a user