feat(ui): add toasts for create edit and run flows

- add global ToastProvider and useToast hook mounted at app root

- show success/error toasts for environment, credential, snippet, and scenario create/edit actions

- show run-started/error toasts on scenario run actions with 100ms exit animation
This commit is contained in:
2026-04-10 21:44:04 +03:00
parent d511c85891
commit 7444082b65
18 changed files with 285 additions and 31 deletions
+6 -3
View File
@@ -4,11 +4,14 @@ import { HashRouter } from 'react-router-dom';
import './i18n';
import './ui/tokens.css';
import App from './App';
import { ToastProvider } from './ui';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
<App />
</HashRouter>
<ToastProvider>
<HashRouter>
<App />
</HashRouter>
</ToastProvider>
</StrictMode>,
);
@@ -3,12 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { KeyRound, X, Save } from 'lucide-react';
import { credentials } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function CreateCredentialPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [name, setName] = useState('');
const [data, setData] = useState('');
@@ -37,9 +38,12 @@ export function CreateCredentialPage() {
setError(null);
try {
const credential = await credentials.create(name.trim(), data.trim() || undefined);
toast.success('Credential created');
navigate(`/credentials/${credential.id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
@@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { KeyRound, X, Save } from 'lucide-react';
import { credentials } from '../../api';
import type { Credential } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function EditCredentialPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [credential, setCredential] = useState<Credential | null>(null);
const [name, setName] = useState('');
@@ -57,9 +58,12 @@ export function EditCredentialPage() {
name: name.trim(),
data: data.trim() || null,
});
toast.success('Credential updated');
navigate(`/credentials/${id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
@@ -3,12 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { X, Globe, Save } from 'lucide-react';
import { environments } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function CreateEnvironmentPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [name, setName] = useState('');
const [dataJson, setDataJson] = useState('{\n "id": "https://id.example.com"\n}');
@@ -44,9 +45,12 @@ export function CreateEnvironmentPage() {
setError(null);
try {
const env = await environments.create(name.trim(), parsedData);
toast.success('Environment created');
navigate(`/environments/${env.id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
@@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { X, Globe, Save } from 'lucide-react';
import { environments } from '../../api';
import type { Environment } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function EditEnvironmentPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [env, setEnv] = useState<Environment | null>(null);
const [name, setName] = useState('');
@@ -64,9 +65,12 @@ export function EditEnvironmentPage() {
name: name.trim(),
data: parsedData,
});
toast.success('Environment updated');
navigate(`/environments/${id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
+9 -2
View File
@@ -12,6 +12,7 @@ import {
Table,
Timestamp,
UuidBadge,
useToast,
type TableColumn,
} from '../../ui';
import type { BadgeVariant } from '../../ui';
@@ -37,6 +38,7 @@ export function RunsPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [scenario, setScenario] = useState<Scenario | null>(null);
const [items, setItems] = useState<RunRow[]>([]);
@@ -71,8 +73,13 @@ export function RunsPage() {
}, [load]);
const handleRun = async () => {
const run = await scenarios.run(id!);
navigate(`/scenarios/${id}/runs/${run.id}`);
try {
const run = await scenarios.run(id!);
toast.success('Scenario run started');
navigate(`/scenarios/${id}/runs/${run.id}`);
} catch (err) {
toast.error((err as Error).message);
}
};
const columns: TableColumn<RunRow>[] = [
@@ -3,12 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios } from '../../api';
import { Breadcrumbs, Button, Card, Input } from '../../ui';
import { Breadcrumbs, Button, Card, Input, useToast } from '../../ui';
import styles from '../Page.module.css';
export function CreateScenarioPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [name, setName] = useState('');
const [nameError, setNameError] = useState('');
@@ -25,9 +26,12 @@ export function CreateScenarioPage() {
setError(null);
try {
const scenario = await scenarios.create(name.trim());
toast.success('Scenario created');
navigate(`/scenarios/${scenario.id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
+6 -2
View File
@@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios, steps } from '../../api';
import type { Scenario } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function CreateStepPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [scenario, setScenario] = useState<Scenario | null>(null);
const [title, setTitle] = useState('');
@@ -37,9 +38,12 @@ export function CreateStepPage() {
execCode: execCode.trim() || undefined,
validateCode: validateCode.trim() || undefined,
});
toast.success('Step created');
navigate(`/scenarios/${id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
@@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios } from '../../api';
import type { Scenario } from '../../api';
import { Breadcrumbs, Button, Card, Input } from '../../ui';
import { Breadcrumbs, Button, Card, Input, useToast } from '../../ui';
import styles from '../Page.module.css';
export function EditScenarioPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [scenario, setScenario] = useState<Scenario | null>(null);
const [name, setName] = useState('');
@@ -41,9 +42,12 @@ export function EditScenarioPage() {
setError(null);
try {
await scenarios.update(id!, { name: name.trim() });
toast.success('Scenario updated');
navigate(`/scenarios/${id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
+6 -2
View File
@@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios, steps } from '../../api';
import type { Scenario, ScenarioStep } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function EditStepPage() {
const { t } = useTranslation();
const { id, stepId } = useParams<{ id: string; stepId: string }>();
const navigate = useNavigate();
const toast = useToast();
const [scenario, setScenario] = useState<Scenario | null>(null);
const [step, setStep] = useState<ScenarioStep | null>(null);
@@ -45,9 +46,12 @@ export function EditStepPage() {
execCode: execCode.trim() || undefined,
validateCode: validateCode.trim() || undefined,
});
toast.success('Step updated');
navigate(`/scenarios/${id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
@@ -16,6 +16,7 @@ import {
Table,
Timestamp,
UuidBadge,
useToast,
type TableColumn,
} from '../../ui';
import styles from '../Page.module.css';
@@ -24,6 +25,7 @@ export function ScenarioDetailPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -66,8 +68,13 @@ export function ScenarioDetailPage() {
const handleRun = async () => {
if (!scenario) return;
const run = await scenarios.run(scenario.id);
navigate(`/scenarios/${id}/runs/${run.id}`);
try {
const run = await scenarios.run(scenario.id);
toast.success('Scenario run started');
navigate(`/scenarios/${id}/runs/${run.id}`);
} catch (err) {
toast.error((err as Error).message);
}
};
const handleDelete = async () => {
+9 -3
View File
@@ -5,12 +5,13 @@ import { Play, Plus, Trash2, Download, ClipboardList } from 'lucide-react';
import { parse as yamlParse } from 'yaml';
import { scenarios } from '../../api';
import type { Scenario } from '../../api';
import { Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge } from '../../ui';
import { Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge, useToast } from '../../ui';
import styles from '../Page.module.css';
export function ScenariosPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [items, setItems] = useState<Scenario[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -30,8 +31,13 @@ export function ScenariosPage() {
}, [load]);
const handleRun = async (id: string) => {
const run = await scenarios.run(id);
navigate(`/scenarios/${id}/runs/${run.id}`);
try {
const run = await scenarios.run(id);
toast.success('Scenario run started');
navigate(`/scenarios/${id}/runs/${run.id}`);
} catch (err) {
toast.error((err as Error).message);
}
};
const handleDelete = async (id: string) => {
@@ -3,12 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { X, Save, Braces } from 'lucide-react';
import { snippets } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function CreateSnippetPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const toast = useToast();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
@@ -38,9 +39,12 @@ export function CreateSnippetPage() {
description: description.trim() || undefined,
code: code.trim(),
});
toast.success('Snippet created');
navigate(`/snippets/${snippet.id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
+6 -2
View File
@@ -4,13 +4,14 @@ import { useTranslation } from 'react-i18next';
import { X, Save, Braces } from 'lucide-react';
import { snippets } from '../../api';
import type { Snippet } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import styles from '../Page.module.css';
export function EditSnippetPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [snippet, setSnippet] = useState<Snippet | null>(null);
const [name, setName] = useState('');
@@ -56,9 +57,12 @@ export function EditSnippetPage() {
description: description.trim() || undefined,
code: code.trim(),
});
toast.success('Snippet updated');
navigate(`/snippets/${id}`);
} catch (err) {
setError((err as Error).message);
const message = (err as Error).message;
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
@@ -0,0 +1,87 @@
.viewport {
position: fixed;
top: var(--space-4);
right: var(--space-4);
z-index: 2000;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: min(380px, calc(100vw - (var(--space-4) * 2)));
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
border: var(--border-width) solid var(--color-border);
background: var(--color-bg-subtle);
box-shadow: var(--shadow-md);
pointer-events: auto;
opacity: 1;
transform: translateX(0) scale(1);
transition: opacity 100ms ease, transform 100ms ease;
}
.leaving {
opacity: 0;
transform: translateX(8px) scale(0.98);
pointer-events: none;
}
.content {
display: flex;
align-items: flex-start;
gap: var(--space-2);
min-width: 0;
}
.icon {
margin-top: 1px;
flex-shrink: 0;
}
.message {
margin: 0;
font-size: var(--font-size-sm);
line-height: 1.4;
color: var(--color-text);
overflow-wrap: anywhere;
}
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
}
.close:hover {
background: var(--color-secondary);
color: var(--color-secondary-fg);
}
.success {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
background: var(--color-success-bg);
}
.error {
border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border));
background: var(--color-error-bg);
}
.info {
border-color: color-mix(in srgb, var(--color-secondary-fg) 35%, var(--color-border));
background: var(--color-info-bg);
}
+101
View File
@@ -0,0 +1,101 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
import styles from './ToastProvider.module.css';
type ToastVariant = 'success' | 'error' | 'info';
interface ToastItem {
id: number;
message: string;
variant: ToastVariant;
closing: boolean;
}
interface ToastApi {
show: (message: string, variant?: ToastVariant) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
const ICONS: Record<ToastVariant, React.ReactNode> = {
success: <CheckCircle2 size={16} />,
error: <AlertCircle size={16} />,
info: <Info size={16} />,
};
export interface ToastProviderProps {
children: React.ReactNode;
durationMs?: number;
}
export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProps) {
const EXIT_ANIMATION_MS = 100;
const [items, setItems] = useState<ToastItem[]>([]);
const remove = useCallback((id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const dismiss = useCallback((id: number) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, closing: true } : item,
),
);
window.setTimeout(() => remove(id), EXIT_ANIMATION_MS);
}, [remove]);
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
window.setTimeout(() => dismiss(id), durationMs);
}, [dismiss, durationMs]);
const api = useMemo<ToastApi>(() => ({
show,
success: (message: string) => show(message, 'success'),
error: (message: string) => show(message, 'error'),
info: (message: string) => show(message, 'info'),
}), [show]);
return (
<ToastContext.Provider value={api}>
{children}
<div className={styles.viewport} aria-live="polite" aria-atomic="true">
{items.map((item) => (
<div
key={item.id}
className={[styles.toast, styles[item.variant], item.closing ? styles.leaving : '']
.filter(Boolean)
.join(' ')}
role="status"
>
<div className={styles.content}>
<span className={styles.icon} aria-hidden="true">{ICONS[item.variant]}</span>
<p className={styles.message}>{item.message}</p>
</div>
<button
type="button"
className={styles.close}
aria-label="Dismiss notification"
onClick={() => dismiss(item.id)}
>
<X size={14} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToast must be used within ToastProvider');
}
return ctx;
}
+3
View File
@@ -27,6 +27,9 @@ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
export { ToastProvider, useToast } from './Toast/ToastProvider';
export type { ToastProviderProps } from './Toast/ToastProvider';
export { Timestamp } from './Timestamp/Timestamp';
export type { TimestampProps } from './Timestamp/Timestamp';
+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/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/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"}