refactor(client): centralize error feedback with toast events
- emit API/network failures through a shared toast event bridge - remove per-page inline error notifications to avoid duplicate error UI - dedupe repeated toast messages to keep feedback readable
This commit is contained in:
@@ -11,19 +11,29 @@ import type {
|
||||
ScenarioStep,
|
||||
Snippet,
|
||||
} from './types';
|
||||
import { emitApiErrorToast } from '../lib/toast-events';
|
||||
|
||||
// In dev, Vite proxies /environments /sessions /scenarios to localhost:3000.
|
||||
// In production (or when VITE_API_URL is set) we hit the configured origin directly.
|
||||
const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || 'Network request failed';
|
||||
emitApiErrorToast(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status} ${text}`);
|
||||
const message = `${res.status} ${text}`;
|
||||
emitApiErrorToast(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
const contentLength = res.headers.get('content-length');
|
||||
if (res.status === 204 || contentLength === '0') return undefined as T;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const API_ERROR_TOAST_EVENT = 'liqa:api-error-toast';
|
||||
|
||||
export interface ApiErrorToastDetail {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function emitApiErrorToast(message: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ApiErrorToastDetail>(API_ERROR_TOAST_EVENT, {
|
||||
detail: { message },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeApiErrorToasts(
|
||||
handler: (message: string) => void,
|
||||
): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
const listener = (event: Event) => {
|
||||
const custom = event as CustomEvent<ApiErrorToastDetail>;
|
||||
const message = custom.detail?.message;
|
||||
if (message) handler(message);
|
||||
};
|
||||
window.addEventListener(API_ERROR_TOAST_EVENT, listener as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(API_ERROR_TOAST_EVENT, listener as EventListener);
|
||||
};
|
||||
}
|
||||
@@ -60,8 +60,6 @@ export function CreateCredentialPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Notification,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -83,15 +82,6 @@ export function CredentialDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
variant="error"
|
||||
title={error.startsWith('404') ? t('errors.not_found') : undefined}
|
||||
>
|
||||
{error.startsWith('404') ? t('errors.not_found_credential', { id }) : error}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{loading && <p className={styles.muted}>{t('credentials.loading')}</p>}
|
||||
|
||||
{credential && (
|
||||
|
||||
@@ -154,7 +154,6 @@ export function CredentialsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading ? (
|
||||
<p className={styles.muted}>{t('credentials.loading')}</p>
|
||||
) : (
|
||||
|
||||
@@ -84,7 +84,6 @@ export function EditCredentialPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading && <p className={styles.muted}>{t('credentials.loading')}</p>}
|
||||
|
||||
{!loading && credential && (
|
||||
|
||||
@@ -67,8 +67,6 @@ export function CreateEnvironmentPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
|
||||
@@ -91,7 +91,6 @@ export function EditEnvironmentPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading && <p className={styles.muted}>{t('environments.loading')}</p>}
|
||||
|
||||
{!loading && env && (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Upload, Pencil, Trash2, Globe } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function EnvironmentDetailPage() {
|
||||
@@ -75,15 +75,6 @@ export function EnvironmentDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
variant="error"
|
||||
title={error.startsWith('404') ? t('errors.not_found') : undefined}
|
||||
>
|
||||
{error.startsWith('404') ? t('errors.not_found_environment', { id }) : error}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{loading && <p className={styles.muted}>{t('environments.loading')}</p>}
|
||||
|
||||
{env && (
|
||||
|
||||
@@ -171,7 +171,6 @@ export function EnvironmentsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading ? (
|
||||
<p className={styles.muted}>{t('environments.loading')}</p>
|
||||
) : (
|
||||
|
||||
@@ -213,11 +213,6 @@ export function RunDetailPage() {
|
||||
<AutoRefreshIndicator active={polling} pulseKey={pulseKey} error={!!error} onClick={manualRefresh} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification variant="error">
|
||||
{error.startsWith('404') ? t('errors.not_found') : error}
|
||||
</Notification>
|
||||
)}
|
||||
{loading && <p className={styles.muted}>{t('runs.loading')}</p>}
|
||||
|
||||
{run && (
|
||||
|
||||
@@ -48,8 +48,6 @@ export function CreateScenarioPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCard}>
|
||||
<div className={styles.formFields}>
|
||||
|
||||
@@ -64,8 +64,6 @@ export function CreateStepPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
|
||||
@@ -68,7 +68,6 @@ export function EditScenarioPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading && <p className={styles.muted}>{t('scenarios.loading')}</p>}
|
||||
|
||||
{!loading && scenario && (
|
||||
|
||||
@@ -72,7 +72,6 @@ export function EditStepPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading && <p className={styles.muted}>{t('steps.loading')}</p>}
|
||||
|
||||
{!loading && step && (
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Card,
|
||||
DescriptionList,
|
||||
Input,
|
||||
Notification,
|
||||
Select,
|
||||
Table,
|
||||
Timestamp,
|
||||
@@ -306,14 +305,6 @@ export function ScenarioDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
variant="error"
|
||||
title={error.startsWith('404') ? t('errors.not_found') : undefined}
|
||||
>
|
||||
{error.startsWith('404') ? t('errors.not_found_scenario', { id }) : error}
|
||||
</Notification>
|
||||
)}
|
||||
{loading && <p className={styles.muted}>{t('scenarios.loading')}</p>}
|
||||
|
||||
{scenario && (
|
||||
|
||||
@@ -114,7 +114,6 @@ export function ScenariosPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Notification,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -56,14 +55,6 @@ export function SessionDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
variant="error"
|
||||
title={error.startsWith('404') ? t('errors.not_found') : undefined}
|
||||
>
|
||||
{error.startsWith('404') ? t('errors.not_found_session', { id }) : error}
|
||||
</Notification>
|
||||
)}
|
||||
{loading && <p className={styles.muted}>{t('sessions.loading')}</p>}
|
||||
|
||||
{session && (
|
||||
|
||||
@@ -78,7 +78,6 @@ export function SessionsPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs items={[{ label: t('sessions.title'), icon: <Monitor size={14} /> }]} />
|
||||
</div>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items}
|
||||
|
||||
@@ -61,8 +61,6 @@ export function CreateSnippetPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
|
||||
@@ -83,7 +83,6 @@ export function EditSnippetPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
|
||||
|
||||
{!loading && snippet && (
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Notification,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
@@ -83,15 +82,6 @@ export function SnippetDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
variant="error"
|
||||
title={error.startsWith('404') ? t('errors.not_found') : undefined}
|
||||
>
|
||||
{error.startsWith('404') ? t('errors.not_found_snippet', { id }) : error}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
|
||||
|
||||
{snippet && (
|
||||
|
||||
@@ -132,7 +132,6 @@ export function SnippetsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
{loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
|
||||
|
||||
{!loading && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
|
||||
import styles from './ToastProvider.module.css';
|
||||
import { subscribeApiErrorToasts } from '../../lib/toast-events';
|
||||
|
||||
type ToastVariant = 'success' | 'error' | 'info';
|
||||
|
||||
@@ -34,6 +35,7 @@ export interface ToastProviderProps {
|
||||
export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProps) {
|
||||
const EXIT_ANIMATION_MS = 100;
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const lastShownAtRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const remove = useCallback((id: number) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
@@ -49,6 +51,12 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
||||
}, [remove]);
|
||||
|
||||
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
|
||||
const now = Date.now();
|
||||
const dedupeKey = `${variant}:${message}`;
|
||||
const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0;
|
||||
if (now - previousTs < 600) return;
|
||||
lastShownAtRef.current.set(dedupeKey, now);
|
||||
|
||||
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
|
||||
window.setTimeout(() => dismiss(id), durationMs);
|
||||
@@ -61,6 +69,10 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
||||
info: (message: string) => show(message, 'info'),
|
||||
}), [show]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeApiErrorToasts((message) => api.error(message));
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
|
||||
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
|
||||
Reference in New Issue
Block a user