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:
2026-04-10 22:24:30 +03:00
parent 7444082b65
commit ccce3381c1
25 changed files with 59 additions and 80 deletions
+15 -5
View File
@@ -11,19 +11,29 @@ import type {
ScenarioStep, ScenarioStep,
Snippet, Snippet,
} from './types'; } from './types';
import { emitApiErrorToast } from '../lib/toast-events';
// In dev, Vite proxies /environments /sessions /scenarios to localhost:3000. // 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. // 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) ?? ''; const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, { let res: Response;
headers: { 'Content-Type': 'application/json', ...init?.headers }, try {
...init, 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) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); 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'); const contentLength = res.headers.get('content-length');
if (res.status === 204 || contentLength === '0') return undefined as T; if (res.status === 204 || contentLength === '0') return undefined as T;
+29
View File
@@ -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> </div>
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCardFull}> <Card className={styles.formCardFull}>
<div className={styles.formFields}> <div className={styles.formFields}>
@@ -11,7 +11,6 @@ import {
Button, Button,
Card, Card,
DescriptionList, DescriptionList,
Notification,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
@@ -83,15 +82,6 @@ export function CredentialDetailPage() {
)} )}
</div> </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>} {loading && <p className={styles.muted}>{t('credentials.loading')}</p>}
{credential && ( {credential && (
@@ -154,7 +154,6 @@ export function CredentialsPage() {
</Button> </Button>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading ? ( {loading ? (
<p className={styles.muted}>{t('credentials.loading')}</p> <p className={styles.muted}>{t('credentials.loading')}</p>
) : ( ) : (
@@ -84,7 +84,6 @@ export function EditCredentialPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('credentials.loading')}</p>} {loading && <p className={styles.muted}>{t('credentials.loading')}</p>}
{!loading && credential && ( {!loading && credential && (
@@ -67,8 +67,6 @@ export function CreateEnvironmentPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCardFull}> <Card className={styles.formCardFull}>
<div className={styles.formFields}> <div className={styles.formFields}>
@@ -91,7 +91,6 @@ export function EditEnvironmentPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('environments.loading')}</p>} {loading && <p className={styles.muted}>{t('environments.loading')}</p>}
{!loading && env && ( {!loading && env && (
@@ -5,7 +5,7 @@ import { Upload, Pencil, Trash2, Globe } from 'lucide-react';
import { stringify as yamlStringify } from 'yaml'; import { stringify as yamlStringify } from 'yaml';
import { environments } from '../../api'; import { environments } from '../../api';
import type { Environment } 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'; import styles from '../Page.module.css';
export function EnvironmentDetailPage() { export function EnvironmentDetailPage() {
@@ -75,15 +75,6 @@ export function EnvironmentDetailPage() {
)} )}
</div> </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>} {loading && <p className={styles.muted}>{t('environments.loading')}</p>}
{env && ( {env && (
@@ -171,7 +171,6 @@ export function EnvironmentsPage() {
</Button> </Button>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading ? ( {loading ? (
<p className={styles.muted}>{t('environments.loading')}</p> <p className={styles.muted}>{t('environments.loading')}</p>
) : ( ) : (
-5
View File
@@ -213,11 +213,6 @@ export function RunDetailPage() {
<AutoRefreshIndicator active={polling} pulseKey={pulseKey} error={!!error} onClick={manualRefresh} /> <AutoRefreshIndicator active={polling} pulseKey={pulseKey} error={!!error} onClick={manualRefresh} />
</div> </div>
{error && (
<Notification variant="error">
{error.startsWith('404') ? t('errors.not_found') : error}
</Notification>
)}
{loading && <p className={styles.muted}>{t('runs.loading')}</p>} {loading && <p className={styles.muted}>{t('runs.loading')}</p>}
{run && ( {run && (
@@ -48,8 +48,6 @@ export function CreateScenarioPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCard}> <Card className={styles.formCard}>
<div className={styles.formFields}> <div className={styles.formFields}>
@@ -64,8 +64,6 @@ export function CreateStepPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCardFull}> <Card className={styles.formCardFull}>
<div className={styles.formFields}> <div className={styles.formFields}>
@@ -68,7 +68,6 @@ export function EditScenarioPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('scenarios.loading')}</p>} {loading && <p className={styles.muted}>{t('scenarios.loading')}</p>}
{!loading && scenario && ( {!loading && scenario && (
@@ -72,7 +72,6 @@ export function EditStepPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('steps.loading')}</p>} {loading && <p className={styles.muted}>{t('steps.loading')}</p>}
{!loading && step && ( {!loading && step && (
@@ -11,7 +11,6 @@ import {
Card, Card,
DescriptionList, DescriptionList,
Input, Input,
Notification,
Select, Select,
Table, Table,
Timestamp, Timestamp,
@@ -306,14 +305,6 @@ export function ScenarioDetailPage() {
)} )}
</div> </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>} {loading && <p className={styles.muted}>{t('scenarios.loading')}</p>}
{scenario && ( {scenario && (
@@ -114,7 +114,6 @@ export function ScenariosPage() {
</Button> </Button>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>}
<Table <Table
columns={columns} columns={columns}
data={items} data={items}
@@ -10,7 +10,6 @@ import {
Button, Button,
Card, Card,
DescriptionList, DescriptionList,
Notification,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
@@ -56,14 +55,6 @@ export function SessionDetailPage() {
)} )}
</div> </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>} {loading && <p className={styles.muted}>{t('sessions.loading')}</p>}
{session && ( {session && (
@@ -78,7 +78,6 @@ export function SessionsPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs items={[{ label: t('sessions.title'), icon: <Monitor size={14} /> }]} /> <Breadcrumbs items={[{ label: t('sessions.title'), icon: <Monitor size={14} /> }]} />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
<Table <Table
columns={columns} columns={columns}
data={items} data={items}
@@ -61,8 +61,6 @@ export function CreateSnippetPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
<Card className={styles.formCardFull}> <Card className={styles.formCardFull}>
<div className={styles.formFields}> <div className={styles.formFields}>
@@ -83,7 +83,6 @@ export function EditSnippetPage() {
/> />
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('snippets.loading')}</p>} {loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
{!loading && snippet && ( {!loading && snippet && (
@@ -11,7 +11,6 @@ import {
Button, Button,
Card, Card,
DescriptionList, DescriptionList,
Notification,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
@@ -83,15 +82,6 @@ export function SnippetDetailPage() {
)} )}
</div> </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>} {loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
{snippet && ( {snippet && (
@@ -132,7 +132,6 @@ export function SnippetsPage() {
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>}
{loading && <p className={styles.muted}>{t('snippets.loading')}</p>} {loading && <p className={styles.muted}>{t('snippets.loading')}</p>}
{!loading && ( {!loading && (
+13 -1
View File
@@ -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 { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
import styles from './ToastProvider.module.css'; import styles from './ToastProvider.module.css';
import { subscribeApiErrorToasts } from '../../lib/toast-events';
type ToastVariant = 'success' | 'error' | 'info'; type ToastVariant = 'success' | 'error' | 'info';
@@ -34,6 +35,7 @@ export interface ToastProviderProps {
export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProps) { export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProps) {
const EXIT_ANIMATION_MS = 100; const EXIT_ANIMATION_MS = 100;
const [items, setItems] = useState<ToastItem[]>([]); const [items, setItems] = useState<ToastItem[]>([]);
const lastShownAtRef = useRef<Map<string, number>>(new Map());
const remove = useCallback((id: number) => { const remove = useCallback((id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id)); setItems((prev) => prev.filter((item) => item.id !== id));
@@ -49,6 +51,12 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
}, [remove]); }, [remove]);
const show = useCallback((message: string, variant: ToastVariant = 'info') => { 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); const id = Date.now() + Math.floor(Math.random() * 1000);
setItems((prev) => [...prev, { id, message, variant, closing: false }]); setItems((prev) => [...prev, { id, message, variant, closing: false }]);
window.setTimeout(() => dismiss(id), durationMs); window.setTimeout(() => dismiss(id), durationMs);
@@ -61,6 +69,10 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
info: (message: string) => show(message, 'info'), info: (message: string) => show(message, 'info'),
}), [show]); }), [show]);
useEffect(() => {
return subscribeApiErrorToasts((message) => api.error(message));
}, [api]);
return ( return (
<ToastContext.Provider value={api}> <ToastContext.Provider value={api}>
{children} {children}
+1 -1
View File
@@ -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"}