chore: apply code formatting and linting
- format long import statements with consistent line wrapping - apply consistent indentation across client and server modules - remove generated tsconfig.tsbuildinfo file
This commit is contained in:
@@ -12,3 +12,4 @@ server/dist
|
||||
client/dist
|
||||
*storybook.log
|
||||
storybook-static
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"test:compile": "tsc --noEmit",
|
||||
"test:storybook": "vitest run --project storybook",
|
||||
"lint": "eslint src .storybook",
|
||||
"lint:fix": "eslint src .storybook --fix",
|
||||
@@ -15,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
+16
-8
@@ -11,13 +11,19 @@ const EnvironmentsPage = lazy(() =>
|
||||
import('./pages/environment/EnvironmentsPage').then((m) => ({ default: m.EnvironmentsPage })),
|
||||
);
|
||||
const EnvironmentDetailPage = lazy(() =>
|
||||
import('./pages/environment/EnvironmentDetailPage').then((m) => ({ default: m.EnvironmentDetailPage })),
|
||||
import('./pages/environment/EnvironmentDetailPage').then((m) => ({
|
||||
default: m.EnvironmentDetailPage,
|
||||
})),
|
||||
);
|
||||
const CreateEnvironmentPage = lazy(() =>
|
||||
import('./pages/environment/CreateEnvironmentPage').then((m) => ({ default: m.CreateEnvironmentPage })),
|
||||
import('./pages/environment/CreateEnvironmentPage').then((m) => ({
|
||||
default: m.CreateEnvironmentPage,
|
||||
})),
|
||||
);
|
||||
const EditEnvironmentPage = lazy(() =>
|
||||
import('./pages/environment/EditEnvironmentPage').then((m) => ({ default: m.EditEnvironmentPage })),
|
||||
import('./pages/environment/EditEnvironmentPage').then((m) => ({
|
||||
default: m.EditEnvironmentPage,
|
||||
})),
|
||||
);
|
||||
|
||||
const SessionsPage = lazy(() =>
|
||||
@@ -46,9 +52,7 @@ const EditStepPage = lazy(() =>
|
||||
import('./pages/scenario/EditStepPage').then((m) => ({ default: m.EditStepPage })),
|
||||
);
|
||||
|
||||
const RunsPage = lazy(() =>
|
||||
import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })),
|
||||
);
|
||||
const RunsPage = lazy(() => import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })));
|
||||
const RunDetailPage = lazy(() =>
|
||||
import('./pages/run/RunDetailPage').then((m) => ({ default: m.RunDetailPage })),
|
||||
);
|
||||
@@ -60,13 +64,17 @@ const CredentialsPage = lazy(() =>
|
||||
import('./pages/credential/CredentialsPage').then((m) => ({ default: m.CredentialsPage })),
|
||||
);
|
||||
const CreateCredentialPage = lazy(() =>
|
||||
import('./pages/credential/CreateCredentialPage').then((m) => ({ default: m.CreateCredentialPage })),
|
||||
import('./pages/credential/CreateCredentialPage').then((m) => ({
|
||||
default: m.CreateCredentialPage,
|
||||
})),
|
||||
);
|
||||
const EditCredentialPage = lazy(() =>
|
||||
import('./pages/credential/EditCredentialPage').then((m) => ({ default: m.EditCredentialPage })),
|
||||
);
|
||||
const CredentialDetailPage = lazy(() =>
|
||||
import('./pages/credential/CredentialDetailPage').then((m) => ({ default: m.CredentialDetailPage })),
|
||||
import('./pages/credential/CredentialDetailPage').then((m) => ({
|
||||
default: m.CredentialDetailPage,
|
||||
})),
|
||||
);
|
||||
|
||||
const SnippetsPage = lazy(() =>
|
||||
|
||||
@@ -15,7 +15,10 @@ import { emitApiErrorToast } from '../lib/toast-events';
|
||||
|
||||
// API calls are versioned under /api/v1 by default.
|
||||
// Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin.
|
||||
const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(/\/$/, '');
|
||||
const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
@@ -90,7 +93,9 @@ export const snippets = {
|
||||
get(id: string): Promise<Snippet> {
|
||||
return request(`/snippets/${id}`);
|
||||
},
|
||||
create(payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string }): Promise<Snippet> {
|
||||
create(
|
||||
payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string },
|
||||
): Promise<Snippet> {
|
||||
return request('/snippets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Trash2 } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
|
||||
export interface DeleteConfirmationModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function DeleteConfirmationModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
}: DeleteConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={() => !isDeleting && onClose()}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
|
||||
<X size={14} />
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onConfirm} disabled={isDeleting}>
|
||||
<Trash2 size={14} />
|
||||
{t('common.button_delete')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Play } from 'lucide-react';
|
||||
import { Modal, Button, Select } from '../../ui';
|
||||
import type { Environment } from '../../api';
|
||||
|
||||
export interface RunScenarioModalProps {
|
||||
open: boolean;
|
||||
environments: Environment[];
|
||||
selectedEnvId: string;
|
||||
onEnvSelect: (envId: string) => void;
|
||||
saveSessionFlag: boolean;
|
||||
onSaveSessionChange: (flag: boolean) => void;
|
||||
onClose: () => void;
|
||||
onRun: () => void;
|
||||
isRunning: boolean;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export function RunScenarioModal({
|
||||
open,
|
||||
environments,
|
||||
selectedEnvId,
|
||||
onEnvSelect,
|
||||
saveSessionFlag,
|
||||
onSaveSessionChange,
|
||||
onClose,
|
||||
onRun,
|
||||
isRunning,
|
||||
actionLabel,
|
||||
}: RunScenarioModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('scenarios.run_modal_title')}
|
||||
onClose={() => !isRunning && onClose()}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isRunning}>
|
||||
<X size={14} />
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onRun} disabled={isRunning || !selectedEnvId}>
|
||||
<Play size={14} />
|
||||
{actionLabel || t('scenarios.action_run')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{environments.length === 0 ? (
|
||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
label={t('scenarios.run_modal_env_label')}
|
||||
value={selectedEnvId}
|
||||
onChange={(e) => onEnvSelect(e.target.value)}
|
||||
options={environments.map((env) => ({ value: env.id, label: env.name }))}
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '1rem',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveSessionFlag}
|
||||
onChange={(e) => onSaveSessionChange(e.target.checked)}
|
||||
/>
|
||||
<span>{t('common.save_session')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal';
|
||||
export { RunScenarioModal } from './RunScenarioModal';
|
||||
export type { RunScenarioModalProps } from './RunScenarioModal';
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"common": {
|
||||
"button_cancel": "Cancel",
|
||||
"button_confirm": "Confirm",
|
||||
"button_delete": "Delete",
|
||||
"confirm_delete_credential": "Are you sure you want to delete this credential?",
|
||||
"confirm_delete_environment": "Are you sure you want to delete this environment?",
|
||||
"confirm_delete_scenario": "Are you sure you want to delete this scenario?",
|
||||
"confirm_delete_step": "Are you sure you want to delete this step?",
|
||||
"confirm_delete_session": "Are you sure you want to delete this session?",
|
||||
"confirm_delete_snippet": "Are you sure you want to delete this snippet?",
|
||||
"confirm_remove_credential": "Are you sure you want to remove this credential from scenario?",
|
||||
"save_session": "Save session"
|
||||
},
|
||||
"errors": {
|
||||
"not_found": "Not found",
|
||||
"not_found_session": "Session {{id}} does not exist or has been deleted.",
|
||||
@@ -19,6 +32,8 @@
|
||||
},
|
||||
"environments": {
|
||||
"title": "Environments",
|
||||
"created": "Environment created",
|
||||
"updated": "Environment updated",
|
||||
"col_id": "ID",
|
||||
"col_name": "Name",
|
||||
"col_data": "Data",
|
||||
@@ -51,6 +66,8 @@
|
||||
},
|
||||
"credentials": {
|
||||
"title": "Credentials",
|
||||
"created": "Credential created",
|
||||
"updated": "Credential updated",
|
||||
"empty": "No credentials yet.",
|
||||
"loading": "Loading…",
|
||||
"menu_label": "Credential options",
|
||||
@@ -99,6 +116,9 @@
|
||||
"field_lastUsed": "Last Used"
|
||||
},
|
||||
"scenarios": { "title": "Scenarios",
|
||||
"created": "Scenario created",
|
||||
"updated": "Scenario updated",
|
||||
"run_started": "Scenario run started",
|
||||
"col_id": "ID",
|
||||
"col_name": "Name",
|
||||
"col_updated": "Updated",
|
||||
@@ -165,6 +185,8 @@
|
||||
"page_size": "Items per page"
|
||||
},
|
||||
"steps": {
|
||||
"created": "Step created",
|
||||
"updated": "Step updated",
|
||||
"create_title": "Add Step",
|
||||
"edit_title": "Edit Step #{{order}}",
|
||||
"loading": "Loading…",
|
||||
@@ -215,6 +237,8 @@
|
||||
},
|
||||
"snippets": {
|
||||
"title": "Snippets",
|
||||
"created": "Snippet created",
|
||||
"updated": "Snippet updated",
|
||||
"empty": "No snippets yet.",
|
||||
"loading": "Loading…",
|
||||
"menu_label": "Snippet options",
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ApiErrorToastDetail {
|
||||
|
||||
export function emitApiErrorToast(message: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ApiErrorToastDetail>(API_ERROR_TOAST_EVENT, {
|
||||
detail: { message },
|
||||
@@ -13,17 +14,17 @@ export function emitApiErrorToast(message: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribeApiErrorToasts(
|
||||
handler: (message: string) => void,
|
||||
): () => void {
|
||||
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);
|
||||
|
||||
window.addEventListener(API_ERROR_TOAST_EVENT, listener);
|
||||
return () => {
|
||||
window.removeEventListener(API_ERROR_TOAST_EVENT, listener as EventListener);
|
||||
window.removeEventListener(API_ERROR_TOAST_EVENT, listener);
|
||||
};
|
||||
}
|
||||
|
||||
+10
-5
@@ -1,17 +1,22 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import './i18n';
|
||||
import './ui/tokens.css';
|
||||
import App from './App';
|
||||
import { ToastProvider } from './ui';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, X, Save } from 'lucide-react';
|
||||
@@ -16,9 +16,8 @@ export function CreateCredentialPage() {
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [dataError, setDataError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!name.trim()) {
|
||||
@@ -35,14 +34,12 @@ export function CreateCredentialPage() {
|
||||
}
|
||||
if (!valid) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const credential = await credentials.create(name.trim(), data.trim() || undefined);
|
||||
toast.success('Credential created');
|
||||
toast.success(t('credentials.created'));
|
||||
navigate(`/credentials/${credential.id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -54,7 +51,11 @@ export function CreateCredentialPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') },
|
||||
{
|
||||
label: t('credentials.title'),
|
||||
icon: <KeyRound size={14} />,
|
||||
onClick: () => navigate('/credentials'),
|
||||
},
|
||||
{ label: t('credentials.create_title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -6,15 +6,8 @@ import { CodeBlock } from '../../ui';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { credentials } from '../../api';
|
||||
import type { Credential } from '../../api';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||
import { DeleteConfirmationModal } from '../../components/modals';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function CredentialDetailPage() {
|
||||
@@ -23,7 +16,6 @@ export function CredentialDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -32,7 +24,6 @@ export function CredentialDetailPage() {
|
||||
credentials
|
||||
.get(id)
|
||||
.then(setCredential)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -65,7 +56,11 @@ export function CredentialDetailPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') },
|
||||
{
|
||||
label: t('credentials.title'),
|
||||
icon: <KeyRound size={14} />,
|
||||
onClick: () => navigate('/credentials'),
|
||||
},
|
||||
{ label: credential?.name ?? `#${id}` },
|
||||
]}
|
||||
/>
|
||||
@@ -136,23 +131,14 @@ export function CredentialDetailPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<DeleteConfirmationModal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('credentials.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('credentials.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this credential?
|
||||
</Modal>
|
||||
message={t('common.confirm_delete_credential')}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
Card,
|
||||
ContextMenu,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
import { DeleteConfirmationModal } from '../../components/modals';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function CredentialCard({
|
||||
@@ -103,7 +103,6 @@ export function CredentialsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -112,7 +111,6 @@ export function CredentialsPage() {
|
||||
credentials
|
||||
.list()
|
||||
.then((res) => setItems(res.data))
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
@@ -145,8 +143,8 @@ export function CredentialsPage() {
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await credentials.importCredential(payload);
|
||||
navigate(`/credentials/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} catch {
|
||||
// Import errors are silently ignored
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,23 +177,14 @@ export function CredentialsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<DeleteConfirmationModal
|
||||
open={deleteId != null}
|
||||
title={t('credentials.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('credentials.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this credential?
|
||||
</Modal>
|
||||
message={t('common.confirm_delete_credential')}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, X, Save } from 'lucide-react';
|
||||
@@ -20,7 +20,6 @@ export function EditCredentialPage() {
|
||||
const [dataError, setDataError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -31,11 +30,10 @@ export function EditCredentialPage() {
|
||||
setName(c.name);
|
||||
setData(c.data ?? '');
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!name.trim()) {
|
||||
@@ -52,17 +50,15 @@ export function EditCredentialPage() {
|
||||
}
|
||||
if (!valid) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await credentials.update(id!, {
|
||||
name: name.trim(),
|
||||
data: data.trim() || null,
|
||||
});
|
||||
toast.success('Credential updated');
|
||||
toast.success(t('credentials.updated'));
|
||||
navigate(`/credentials/${id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -74,7 +70,11 @@ export function EditCredentialPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') },
|
||||
{
|
||||
label: t('credentials.title'),
|
||||
icon: <KeyRound size={14} />,
|
||||
onClick: () => navigate('/credentials'),
|
||||
},
|
||||
{
|
||||
label: credential?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/credentials/${id}`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Globe, Save } from 'lucide-react';
|
||||
@@ -16,9 +16,8 @@ export function CreateEnvironmentPage() {
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [dataError, setDataError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setNameError(t('environments.form_name_required'));
|
||||
@@ -42,14 +41,12 @@ export function CreateEnvironmentPage() {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const env = await environments.create(name.trim(), parsedData);
|
||||
toast.success('Environment created');
|
||||
toast.success(t('environments.created'));
|
||||
navigate(`/environments/${env.id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -61,7 +58,11 @@ export function CreateEnvironmentPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') },
|
||||
{
|
||||
label: t('environments.title'),
|
||||
icon: <Globe size={14} />,
|
||||
onClick: () => navigate('/environments'),
|
||||
},
|
||||
{ label: t('environments.create_title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Globe, Save } from 'lucide-react';
|
||||
@@ -20,7 +20,6 @@ export function EditEnvironmentPage() {
|
||||
const [dataError, setDataError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -31,11 +30,10 @@ export function EditEnvironmentPage() {
|
||||
setName(data.name);
|
||||
setDataJson(JSON.stringify(data.data ?? {}, null, 2));
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setNameError(t('environments.form_name_required'));
|
||||
@@ -59,17 +57,15 @@ export function EditEnvironmentPage() {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await environments.update(id!, {
|
||||
name: name.trim(),
|
||||
data: parsedData,
|
||||
});
|
||||
toast.success('Environment updated');
|
||||
toast.success(t('environments.updated'));
|
||||
navigate(`/environments/${id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -81,7 +77,11 @@ export function EditEnvironmentPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') },
|
||||
{
|
||||
label: t('environments.title'),
|
||||
icon: <Globe size={14} />,
|
||||
onClick: () => navigate('/environments'),
|
||||
},
|
||||
{
|
||||
label: env?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/environments/${id}`),
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, Pencil, Trash2, Globe } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { environments } from '../../api';
|
||||
import type { Environment } from '../../api';
|
||||
import { Breadcrumbs, Button, Card, DescriptionList, Modal, Timestamp, UuidBadge } from '../../ui';
|
||||
import { environments } from '../../api';
|
||||
import { DeleteConfirmationModal } from '../../components/modals';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
useToast,
|
||||
} from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function EnvironmentDetailPage() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [env, setEnv] = useState<Environment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
|
||||
@@ -24,9 +33,9 @@ export function EnvironmentDetailPage() {
|
||||
environments
|
||||
.get(id)
|
||||
.then(setEnv)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.catch((err: Error) => toast.error((err as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
}, [id, toast]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!env) return;
|
||||
@@ -57,7 +66,11 @@ export function EnvironmentDetailPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') },
|
||||
{
|
||||
label: t('environments.title'),
|
||||
icon: <Globe size={14} />,
|
||||
onClick: () => navigate('/environments'),
|
||||
},
|
||||
{ label: env?.name ?? `#${id}` },
|
||||
]}
|
||||
/>
|
||||
@@ -107,12 +120,12 @@ export function EnvironmentDetailPage() {
|
||||
<div className={styles.stepsSection}>
|
||||
<h2 className={styles.sectionHeading}>{t('environments.section_data')}</h2>
|
||||
<Card>
|
||||
{dataEntries.length === 0 ? (
|
||||
<p className={styles.muted}>{t('environments.no_data')}</p>
|
||||
) : (
|
||||
<DescriptionList
|
||||
layout="grid"
|
||||
items={dataEntries.map(([key, value]) => ({
|
||||
{dataEntries.length === 0 ? (
|
||||
<p className={styles.muted}>{t('environments.no_data')}</p>
|
||||
) : (
|
||||
<DescriptionList
|
||||
layout="grid"
|
||||
items={dataEntries.map(([key, value]) => ({
|
||||
term: key,
|
||||
detail: (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer">
|
||||
@@ -120,30 +133,21 @@ export function EnvironmentDetailPage() {
|
||||
</a>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<DeleteConfirmationModal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('environments.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('environments.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this environment?
|
||||
</Modal>
|
||||
message={t('common.confirm_delete_environment')}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
Card,
|
||||
ContextMenu,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
import { DeleteConfirmationModal } from '../../components/modals';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) {
|
||||
@@ -120,7 +120,6 @@ export function EnvironmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -129,7 +128,6 @@ export function EnvironmentsPage() {
|
||||
environments
|
||||
.list()
|
||||
.then((res) => setItems(res.data))
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
@@ -162,8 +160,8 @@ export function EnvironmentsPage() {
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await environments.importEnvironment(payload);
|
||||
navigate(`/environments/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} catch {
|
||||
// Import errors are silently ignored
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,23 +194,14 @@ export function EnvironmentsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<DeleteConfirmationModal
|
||||
open={deleteId != null}
|
||||
title={t('environments.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('environments.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this environment?
|
||||
</Modal>
|
||||
message={t('common.confirm_delete_environment')}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity } from 'lucide-react';
|
||||
@@ -39,35 +39,20 @@ export function AllRunsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [items, setItems] = useState<AllRunRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const hasDataRef = useRef(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!hasDataRef.current) setLoading(true);
|
||||
runs
|
||||
.listAll()
|
||||
.then((res) => {
|
||||
setItems(res.data as AllRunRow[]);
|
||||
setError(null);
|
||||
setPulseKey((k) => k + 1);
|
||||
hasDataRef.current = true;
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
pollRef.current = setInterval(load, 10_000);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, []);
|
||||
const {
|
||||
data: items = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<AllRunRow[]>({
|
||||
queryKey: ['runs'],
|
||||
queryFn: async () => {
|
||||
const res = await runs.listAll();
|
||||
return res.data as AllRunRow[];
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const columns: TableColumn<AllRunRow>[] = [
|
||||
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
|
||||
@@ -110,14 +95,19 @@ export function AllRunsPage() {
|
||||
<div>
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs items={[{ label: t('runs.title'), icon: <Activity size={14} /> }]} />
|
||||
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={load} />
|
||||
<AutoRefreshIndicator
|
||||
active
|
||||
pulseKey={items.length}
|
||||
error={!!error}
|
||||
onClick={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items}
|
||||
rowKey={(r) => r.id}
|
||||
loading={loading}
|
||||
loading={isLoading}
|
||||
emptyMessage={t('runs.empty')}
|
||||
pageSize={20}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Card,
|
||||
CodeBlock,
|
||||
DescriptionList,
|
||||
Notification,
|
||||
Pagination,
|
||||
Search,
|
||||
Table,
|
||||
@@ -175,7 +174,14 @@ export function RunDetailPage() {
|
||||
{
|
||||
key: 'description',
|
||||
header: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>{t('runs.step_description')}</span>
|
||||
{run?.stepRuns.some((s) => s.output) && (
|
||||
<button
|
||||
@@ -185,7 +191,9 @@ export function RunDetailPage() {
|
||||
setExpandedStepIds(new Set());
|
||||
} else {
|
||||
setExpandAll(true);
|
||||
setExpandedStepIds(new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []));
|
||||
setExpandedStepIds(
|
||||
new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []),
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
@@ -208,7 +216,14 @@ export function RunDetailPage() {
|
||||
const isExpanded = expandAll || expandedStepIds.has(s.id);
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: isExpanded && s.output ? '8px' : 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
marginBottom: isExpanded && s.output ? '8px' : 0,
|
||||
}}
|
||||
>
|
||||
{s.output && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -231,11 +246,7 @@ export function RunDetailPage() {
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
)}
|
||||
<code style={{ wordBreak: 'break-word' }}>
|
||||
@@ -275,7 +286,11 @@ export function RunDetailPage() {
|
||||
width: 70,
|
||||
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
|
||||
},
|
||||
{ key: 'message', header: t('runs.log_message'), render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code> },
|
||||
{
|
||||
key: 'message',
|
||||
header: t('runs.log_message'),
|
||||
render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code>,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
header: t('runs.log_time'),
|
||||
@@ -289,7 +304,11 @@ export function RunDetailPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{
|
||||
label: scenario?.name ?? `${id}`,
|
||||
onClick: () => navigate(`/scenarios/${id}`),
|
||||
@@ -302,7 +321,12 @@ export function RunDetailPage() {
|
||||
{ label: `${runId}` },
|
||||
]}
|
||||
/>
|
||||
<AutoRefreshIndicator active={polling} pulseKey={pulseKey} error={!!error} onClick={manualRefresh} />
|
||||
<AutoRefreshIndicator
|
||||
active={polling}
|
||||
pulseKey={pulseKey}
|
||||
error={!!error}
|
||||
onClick={manualRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && <p className={styles.muted}>{t('runs.loading')}</p>}
|
||||
@@ -327,10 +351,30 @@ export function RunDetailPage() {
|
||||
{ term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> },
|
||||
];
|
||||
if (run.environment) {
|
||||
items.push({ term: 'Environment', detail: <Link to={`/environments/${run.environment.id}`} style={{ color: 'var(--color-link)' }}>{run.environment.name}</Link> });
|
||||
items.push({
|
||||
term: 'Environment',
|
||||
detail: (
|
||||
<Link
|
||||
to={`/environments/${run.environment.id}`}
|
||||
style={{ color: 'var(--color-link)' }}
|
||||
>
|
||||
{run.environment.name}
|
||||
</Link>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (run.session) {
|
||||
items.push({ term: 'Session', detail: <Link to={`/sessions/${run.session.id}`} style={{ color: 'var(--color-link)' }}>{run.session.sessionName}</Link> });
|
||||
items.push({
|
||||
term: 'Session',
|
||||
detail: (
|
||||
<Link
|
||||
to={`/sessions/${run.session.id}`}
|
||||
style={{ color: 'var(--color-link)' }}
|
||||
>
|
||||
{run.session.sessionName}
|
||||
</Link>
|
||||
),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
})()}
|
||||
|
||||
@@ -76,7 +76,6 @@ export function RunsPage() {
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
pollRef.current = setInterval(load, 10_000);
|
||||
return () => {
|
||||
@@ -102,7 +101,7 @@ export function RunsPage() {
|
||||
setRunning(true);
|
||||
try {
|
||||
const run = await scenarios.run(id!, selectedEnvId);
|
||||
toast.success('Scenario run started');
|
||||
toast.success(t('scenarios.run_started'));
|
||||
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||
setRunModalOpen(false);
|
||||
} catch (err) {
|
||||
@@ -139,7 +138,11 @@ export function RunsPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{
|
||||
label: scenario?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/scenarios/${id}`),
|
||||
@@ -171,16 +174,16 @@ export function RunsPage() {
|
||||
open={runModalOpen}
|
||||
title={t('scenarios.run_modal_title')}
|
||||
onClose={() => !running && setRunModalOpen(false)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
|
||||
Cancel
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
|
||||
{t('runs.action_run')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{envs.length === 0 ? (
|
||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
@@ -14,23 +14,20 @@ export function CreateScenarioPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setNameError(t('scenarios.form_name_required'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const scenario = await scenarios.create(name.trim());
|
||||
toast.success('Scenario created');
|
||||
toast.success(t('scenarios.created'));
|
||||
navigate(`/scenarios/${scenario.id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -42,7 +39,11 @@ export function CreateScenarioPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{ label: t('scenarios.create_title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
@@ -17,7 +17,6 @@ export function CreateStepPage() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -27,20 +26,18 @@ export function CreateStepPage() {
|
||||
.catch(() => null);
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await steps.create(id!, {
|
||||
title: title.trim() || undefined,
|
||||
execCode: execCode.trim() || undefined,
|
||||
});
|
||||
toast.success('Step created');
|
||||
toast.success(t('steps.created'));
|
||||
navigate(`/scenarios/${id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -52,7 +49,11 @@ export function CreateStepPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{
|
||||
label: scenario?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/scenarios/${id}`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
@@ -18,7 +18,6 @@ export function EditScenarioPage() {
|
||||
const [nameError, setNameError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -28,25 +27,22 @@ export function EditScenarioPage() {
|
||||
setScenario(data);
|
||||
setName(data.name);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
setNameError(t('scenarios.form_name_required'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await scenarios.update(id!, { name: name.trim() });
|
||||
toast.success('Scenario updated');
|
||||
toast.success(t('scenarios.updated'));
|
||||
navigate(`/scenarios/${id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -58,7 +54,11 @@ export function EditScenarioPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{
|
||||
label: scenario?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/scenarios/${id}`),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, ClipboardList } from 'lucide-react';
|
||||
@@ -19,7 +19,6 @@ export function EditStepPage() {
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !stepId) return;
|
||||
@@ -30,24 +29,21 @@ export function EditStepPage() {
|
||||
setTitle(st.title ?? '');
|
||||
setExecCode(st.execCode ?? '');
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, stepId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await steps.update(id!, stepId!, {
|
||||
title: title.trim() || undefined,
|
||||
execCode: execCode.trim() || undefined,
|
||||
});
|
||||
toast.success('Step updated');
|
||||
toast.success(t('steps.updated'));
|
||||
navigate(`/scenarios/${id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -59,7 +55,11 @@ export function EditStepPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{
|
||||
label: scenario?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/scenarios/${id}`),
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pencil, Plus, History, Trash2, Upload, GripVertical, ClipboardList } from 'lucide-react';
|
||||
import {
|
||||
Play,
|
||||
Pencil,
|
||||
Plus,
|
||||
History,
|
||||
Trash2,
|
||||
Upload,
|
||||
GripVertical,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { environments, scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential, Environment } from '../../api';
|
||||
import {
|
||||
environments,
|
||||
scenarios,
|
||||
steps,
|
||||
scenarioCredentials,
|
||||
credentials as credentialsApi,
|
||||
} from '../../api';
|
||||
import type {
|
||||
Scenario,
|
||||
ScenarioStep,
|
||||
ScenarioCredential,
|
||||
Credential,
|
||||
Environment,
|
||||
} from '../../api';
|
||||
import {
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
@@ -28,7 +49,6 @@ export function ScenarioDetailPage() {
|
||||
const toast = useToast();
|
||||
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Credentials state
|
||||
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
|
||||
@@ -60,7 +80,6 @@ export function ScenarioDetailPage() {
|
||||
setScenario(s);
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
credentialsApi
|
||||
.list(1, 200)
|
||||
@@ -91,7 +110,7 @@ export function ScenarioDetailPage() {
|
||||
setRunning(true);
|
||||
try {
|
||||
const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag);
|
||||
toast.success('Scenario run started');
|
||||
toast.success(t('scenarios.run_started'));
|
||||
navigate(`/scenarios/${id}/runs/${run.id}`);
|
||||
setRunModalOpen(false);
|
||||
setSaveSessionFlag(false);
|
||||
@@ -140,7 +159,7 @@ export function ScenarioDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = async (e: React.FormEvent) => {
|
||||
const handleAddCredential = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!addCredId) {
|
||||
@@ -321,7 +340,11 @@ export function ScenarioDetailPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') },
|
||||
{
|
||||
label: t('scenarios.title'),
|
||||
icon: <ClipboardList size={14} />,
|
||||
onClick: () => navigate('/scenarios'),
|
||||
},
|
||||
{ label: scenario?.name ?? `#${id}` },
|
||||
]}
|
||||
/>
|
||||
@@ -343,7 +366,11 @@ export function ScenarioDetailPage() {
|
||||
<Pencil size={14} />
|
||||
{t('scenarios.action_edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setPendingDelete({ type: 'scenario' })}>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setPendingDelete({ type: 'scenario' })}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('scenarios.action_delete')}
|
||||
</Button>
|
||||
@@ -513,36 +540,36 @@ export function ScenarioDetailPage() {
|
||||
: t('scenarios.action_delete')
|
||||
}
|
||||
onClose={() => !deleting && setPendingDelete(null)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}>
|
||||
Cancel
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirmDelete} disabled={deleting}>
|
||||
Confirm
|
||||
{t('common.button_confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{pendingDelete?.type === 'step' && 'Are you sure you want to delete this step?'}
|
||||
{pendingDelete?.type === 'credential' && 'Are you sure you want to remove this credential from scenario?'}
|
||||
{pendingDelete?.type === 'scenario' && 'Are you sure you want to delete this scenario?'}
|
||||
{pendingDelete?.type === 'step' && t('common.confirm_delete_step')}
|
||||
{pendingDelete?.type === 'credential' && t('common.confirm_remove_credential')}
|
||||
{pendingDelete?.type === 'scenario' && t('common.confirm_delete_scenario')}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={runModalOpen}
|
||||
title={t('scenarios.run_modal_title')}
|
||||
onClose={() => !running && setRunModalOpen(false)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
|
||||
Cancel
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
|
||||
{t('scenarios.action_run')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{envs.length === 0 ? (
|
||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||
@@ -554,13 +581,15 @@ export function ScenarioDetailPage() {
|
||||
onChange={(e) => setSelectedEnvId(e.target.value)}
|
||||
options={envs.map((env) => ({ value: env.id, label: env.name }))}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}>
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveSessionFlag}
|
||||
onChange={(e) => setSaveSessionFlag(e.target.checked)}
|
||||
/>
|
||||
<span>Save session</span>
|
||||
<span>{t('common.save_session')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,6 @@ export function ScenariosPage() {
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [runScenarioId, setRunScenarioId] = useState<string | null>(null);
|
||||
@@ -41,7 +40,6 @@ export function ScenariosPage() {
|
||||
setItems(scenarioRes.data);
|
||||
setEnvs(envRes.data);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -61,7 +59,7 @@ export function ScenariosPage() {
|
||||
setRunning(true);
|
||||
try {
|
||||
const run = await scenarios.run(runScenarioId, selectedEnvId, saveSessionFlag);
|
||||
toast.success('Scenario run started');
|
||||
toast.success(t('scenarios.run_started'));
|
||||
navigate(`/scenarios/${runScenarioId}/runs/${run.id}`);
|
||||
setRunScenarioId(null);
|
||||
setSaveSessionFlag(false);
|
||||
@@ -94,8 +92,8 @@ export function ScenariosPage() {
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await scenarios.importScenario(payload);
|
||||
navigate(`/scenarios/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} catch {
|
||||
// Import errors are silently ignored
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,25 +176,25 @@ export function ScenariosPage() {
|
||||
open={deleteId != null}
|
||||
title={t('scenarios.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('scenarios.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete this scenario?
|
||||
{t('common.confirm_delete_scenario')}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={runScenarioId != null}
|
||||
title={t('scenarios.run_modal_title')}
|
||||
onClose={() => !running && setRunScenarioId(null)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setRunScenarioId(null)} disabled={running}>
|
||||
Cancel
|
||||
@@ -205,7 +203,7 @@ export function ScenariosPage() {
|
||||
{t('scenarios.action_run')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
{envs.length === 0 ? (
|
||||
<p>{t('scenarios.run_modal_no_env')}</p>
|
||||
@@ -217,13 +215,15 @@ export function ScenariosPage() {
|
||||
onChange={(e) => setSelectedEnvId(e.target.value)}
|
||||
options={envs.map((env) => ({ value: env.id, label: env.name }))}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}>
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveSessionFlag}
|
||||
onChange={(e) => setSaveSessionFlag(e.target.checked)}
|
||||
/>
|
||||
<span>Save session</span>
|
||||
<span>{t('common.save_session')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,16 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Monitor } from 'lucide-react';
|
||||
import { sessions } from '../../api';
|
||||
import type { Session } from '../../api';
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
DescriptionList,
|
||||
Modal,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
import { Badge, Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
|
||||
import { DeleteConfirmationModal } from '../../components/modals';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function SessionDetailPage() {
|
||||
@@ -22,7 +14,6 @@ export function SessionDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -31,7 +22,6 @@ export function SessionDetailPage() {
|
||||
sessions
|
||||
.get(id)
|
||||
.then(setSession)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -52,7 +42,11 @@ export function SessionDetailPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('sessions.title'), icon: <Monitor size={14} />, onClick: () => navigate('/sessions') },
|
||||
{
|
||||
label: t('sessions.title'),
|
||||
icon: <Monitor size={14} />,
|
||||
onClick: () => navigate('/sessions'),
|
||||
},
|
||||
{ label: session?.sessionName ?? `#${id}` },
|
||||
]}
|
||||
/>
|
||||
@@ -102,23 +96,14 @@ export function SessionDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
<DeleteConfirmationModal
|
||||
open={confirmDeleteOpen}
|
||||
title={t('sessions.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('sessions.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this session?
|
||||
</Modal>
|
||||
message={t('common.confirm_delete_session')}
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Modal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
} from '../../ui';
|
||||
import { DeleteConfirmationModal } from '../../components/modals';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function SessionsPage() {
|
||||
@@ -21,7 +21,6 @@ export function SessionsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -29,7 +28,6 @@ export function SessionsPage() {
|
||||
sessions
|
||||
.list()
|
||||
.then((res) => setItems(res.data))
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -102,23 +100,14 @@ export function SessionsPage() {
|
||||
onRowClick={(s) => navigate(`/sessions/${s.id}`)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
<DeleteConfirmationModal
|
||||
open={deleteId != null}
|
||||
title={t('sessions.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('sessions.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
Are you sure you want to delete this session?
|
||||
</Modal>
|
||||
message={t('common.confirm_delete_session')}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, SubmitEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, Braces } from 'lucide-react';
|
||||
@@ -19,9 +19,8 @@ export function CreateSnippetPage() {
|
||||
const [titleError, setTitleError] = useState('');
|
||||
const [codeError, setCodeError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!alias.trim()) {
|
||||
@@ -38,7 +37,6 @@ export function CreateSnippetPage() {
|
||||
}
|
||||
if (!valid) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const snippet = await snippets.create({
|
||||
alias: alias.trim(),
|
||||
@@ -46,11 +44,10 @@ export function CreateSnippetPage() {
|
||||
description: description.trim() || undefined,
|
||||
code: code.trim(),
|
||||
});
|
||||
toast.success('Snippet created');
|
||||
toast.success(t('snippets.created'));
|
||||
navigate(`/snippets/${snippet.id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -62,7 +59,11 @@ export function CreateSnippetPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
||||
{
|
||||
label: t('snippets.title'),
|
||||
icon: <Braces size={14} />,
|
||||
onClick: () => navigate('/snippets'),
|
||||
},
|
||||
{ label: t('snippets.create_title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, SubmitEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Save, Braces } from 'lucide-react';
|
||||
@@ -23,7 +23,6 @@ export function EditSnippetPage() {
|
||||
const [codeError, setCodeError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -36,11 +35,10 @@ export function EditSnippetPage() {
|
||||
setDescription(s.description ?? '');
|
||||
setCode(s.code);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
let valid = true;
|
||||
if (!alias.trim()) {
|
||||
@@ -57,7 +55,6 @@ export function EditSnippetPage() {
|
||||
}
|
||||
if (!valid) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await snippets.update(id!, {
|
||||
alias: alias.trim(),
|
||||
@@ -65,11 +62,10 @@ export function EditSnippetPage() {
|
||||
description: description.trim() || undefined,
|
||||
code: code.trim(),
|
||||
});
|
||||
toast.success('Snippet updated');
|
||||
toast.success(t('snippets.updated'));
|
||||
navigate(`/snippets/${id}`);
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -81,7 +77,11 @@ export function EditSnippetPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
||||
{
|
||||
label: t('snippets.title'),
|
||||
icon: <Braces size={14} />,
|
||||
onClick: () => navigate('/snippets'),
|
||||
},
|
||||
{
|
||||
label: snippet?.title ?? `#${id}`,
|
||||
onClick: () => navigate(`/snippets/${id}`),
|
||||
|
||||
@@ -24,7 +24,6 @@ export function SnippetDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [snippet, setSnippet] = useState<Snippet | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -33,7 +32,6 @@ export function SnippetDetailPage() {
|
||||
snippets
|
||||
.get(id)
|
||||
.then(setSnippet)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -66,7 +64,11 @@ export function SnippetDetailPage() {
|
||||
<div className={styles.pageToolbar}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') },
|
||||
{
|
||||
label: t('snippets.title'),
|
||||
icon: <Braces size={14} />,
|
||||
onClick: () => navigate('/snippets'),
|
||||
},
|
||||
{ label: snippet?.title ?? `#${id}` },
|
||||
]}
|
||||
/>
|
||||
@@ -141,18 +143,22 @@ export function SnippetDetailPage() {
|
||||
open={confirmDeleteOpen}
|
||||
title={t('snippets.action_delete')}
|
||||
onClose={() => !deleting && setConfirmDeleteOpen(false)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}>
|
||||
Cancel
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('snippets.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete this snippet?
|
||||
{t('common.confirm_delete_snippet')}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
} from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
function firstParagraphBlocks(markdown: string, limit = 2): { preview: string; truncated: boolean } {
|
||||
function firstParagraphBlocks(
|
||||
markdown: string,
|
||||
limit = 2,
|
||||
): { preview: string; truncated: boolean } {
|
||||
const blocks = markdown
|
||||
.split(/\n\s*\n/g)
|
||||
.map((block) => block.trim())
|
||||
@@ -81,16 +84,17 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
|
||||
<p className={styles.muted}>
|
||||
{t('snippets.field_alias')}: <code className={styles.codeInline}>{snippet.alias}</code>
|
||||
</p>
|
||||
{snippet.description && (() => {
|
||||
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<>
|
||||
<MarkdownContent content={preview} />
|
||||
{truncated && <p className={styles.muted}>...</p>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{snippet.description &&
|
||||
(() => {
|
||||
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<>
|
||||
<MarkdownContent content={preview} />
|
||||
{truncated && <p className={styles.muted}>...</p>}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +117,6 @@ export function SnippetsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -122,7 +125,6 @@ export function SnippetsPage() {
|
||||
snippets
|
||||
.list()
|
||||
.then((res) => setItems(res.data))
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
@@ -155,8 +157,8 @@ export function SnippetsPage() {
|
||||
const payload = yamlParse(text) as unknown;
|
||||
const imported = await snippets.importSnippet(payload);
|
||||
navigate(`/snippets/${imported.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} catch {
|
||||
// Import errors are silently ignored
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,18 +197,18 @@ export function SnippetsPage() {
|
||||
open={deleteId != null}
|
||||
title={t('snippets.action_delete')}
|
||||
onClose={() => !deleting && setDeleteId(null)}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
|
||||
Cancel
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
|
||||
{t('snippets.action_delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
>
|
||||
Are you sure you want to delete this snippet?
|
||||
{t('common.confirm_delete_snippet')}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,15 +36,18 @@ export function Breadcrumbs({ items, separator, className }: BreadcrumbsProps) {
|
||||
<li key={i} className={styles.item}>
|
||||
{isLast ? (
|
||||
<span className={styles.current} aria-current="page">
|
||||
{item.icon}{item.label}
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</span>
|
||||
) : item.href ? (
|
||||
<a href={item.href} className={styles.link} onClick={item.onClick}>
|
||||
{item.icon}{item.label}
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" className={styles.link} onClick={item.onClick}>
|
||||
{item.icon}{item.label}
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
{!isLast && <span className={styles.separator}>{sep}</span>}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, 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';
|
||||
import { ToastContext, type ToastApi, type ToastVariant } from './toast-context';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
@@ -12,15 +11,6 @@ interface ToastItem {
|
||||
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} />,
|
||||
@@ -41,33 +31,38 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
||||
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 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 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 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);
|
||||
}, [dismiss, durationMs]);
|
||||
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]);
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
success: (message: string) => show(message, 'success'),
|
||||
error: (message: string) => show(message, 'error'),
|
||||
info: (message: string) => show(message, 'info'),
|
||||
}),
|
||||
[show],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeApiErrorToasts((message) => api.error(message));
|
||||
@@ -86,7 +81,9 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
||||
role="status"
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon} aria-hidden="true">{ICONS[item.variant]}</span>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{ICONS[item.variant]}
|
||||
</span>
|
||||
<p className={styles.message}>{item.message}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -103,11 +100,3 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info';
|
||||
|
||||
export interface ToastApi {
|
||||
show: (message: string, variant?: ToastVariant) => void;
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastApi | null>(null);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react';
|
||||
import { ToastContext, type ToastApi } from './toast-context';
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -27,8 +27,9 @@ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs
|
||||
|
||||
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
|
||||
|
||||
export { ToastProvider, useToast } from './Toast/ToastProvider';
|
||||
export { ToastProvider } from './Toast/ToastProvider';
|
||||
export type { ToastProviderProps } from './Toast/ToastProvider';
|
||||
export { useToast } from './Toast/useToast';
|
||||
|
||||
export { Timestamp } from './Timestamp/Timestamp';
|
||||
export type { TimestampProps } from './Timestamp/Timestamp';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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/MarkdownContent/MarkdownContent.tsx","./src/ui/Modal/Modal.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"}
|
||||
Generated
+25
@@ -16,6 +16,7 @@
|
||||
"name": "liqa-client",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^1.7.0",
|
||||
@@ -4202,6 +4203,30 @@
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.99.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
|
||||
"integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.99.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
|
||||
"integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.99.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
|
||||
@@ -78,6 +78,12 @@ export class BrowserController {
|
||||
}
|
||||
}
|
||||
|
||||
return this.browserService.exec(dto.sessionName, dto.code, dto.url, environment, credentials);
|
||||
return this.browserService.exec(
|
||||
dto.sessionName,
|
||||
dto.code,
|
||||
dto.url,
|
||||
environment,
|
||||
credentials,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@ import { BrowserController } from "./browser.controller";
|
||||
import { BrowserService } from "./browser.service";
|
||||
|
||||
@Module({
|
||||
imports: [SessionModule, CodeExecutorModule, SnippetModule, EnvironmentModule, CredentialModule],
|
||||
imports: [
|
||||
SessionModule,
|
||||
CodeExecutorModule,
|
||||
SnippetModule,
|
||||
EnvironmentModule,
|
||||
CredentialModule,
|
||||
],
|
||||
controllers: [BrowserController],
|
||||
providers: [BrowserService],
|
||||
exports: [BrowserService],
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { BrowserContext, Cookie } from "playwright";
|
||||
import { chromium } from "playwright";
|
||||
import type { ExecResult, ScriptLogger } from "../code-executor/code-executor.service";
|
||||
import type {
|
||||
ExecResult,
|
||||
ScriptLogger,
|
||||
} from "../code-executor/code-executor.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import type { EnvironmentData } from "../environment/environment.entity";
|
||||
@@ -172,8 +175,7 @@ export class BrowserService {
|
||||
const label = sessionName ?? "anonymous";
|
||||
|
||||
if (sessionName) {
|
||||
const { page, context } =
|
||||
await this.getOrCreateNamedHandle(sessionName);
|
||||
const { page, context } = await this.getOrCreateNamedHandle(sessionName);
|
||||
this.logger.log(`[${label}] exec: using persistent context`);
|
||||
const snippets = await this.snippetService
|
||||
.buildSnippetMap()
|
||||
|
||||
@@ -81,7 +81,17 @@ export class CodeExecutorService {
|
||||
* Always call `validate()` before this method.
|
||||
*/
|
||||
async execute(ctx: ExecContext): Promise<ExecResult> {
|
||||
const { page, browser, code, log, getStepOutput, credentials, environment, snippets, result } = ctx;
|
||||
const {
|
||||
page,
|
||||
browser,
|
||||
code,
|
||||
log,
|
||||
getStepOutput,
|
||||
credentials,
|
||||
environment,
|
||||
snippets,
|
||||
result,
|
||||
} = ctx;
|
||||
const scriptLog: ScriptLogger =
|
||||
log ?? ((level, msg) => this.logger[level](msg));
|
||||
const toStr = (args: unknown[]) =>
|
||||
@@ -146,12 +156,7 @@ export class CodeExecutorService {
|
||||
"expect",
|
||||
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
|
||||
);
|
||||
return snippetFn(
|
||||
scriptContext,
|
||||
fakeConsole,
|
||||
args,
|
||||
playwrightExpect,
|
||||
);
|
||||
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -52,8 +52,10 @@ export class ExecContextBuilder {
|
||||
|
||||
build(): ExecContext {
|
||||
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
|
||||
if (!this.ctx.browser) throw new Error("ExecContextBuilder: browser is required");
|
||||
if (this.ctx.code === undefined) throw new Error("ExecContextBuilder: code is required");
|
||||
if (!this.ctx.browser)
|
||||
throw new Error("ExecContextBuilder: browser is required");
|
||||
if (this.ctx.code === undefined)
|
||||
throw new Error("ExecContextBuilder: code is required");
|
||||
return this.ctx as ExecContext;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,9 @@ export class EnvironmentService {
|
||||
};
|
||||
}
|
||||
|
||||
async importEnvironment(dto: EnvironmentExportDto): Promise<EnvironmentEntity> {
|
||||
async importEnvironment(
|
||||
dto: EnvironmentExportDto,
|
||||
): Promise<EnvironmentEntity> {
|
||||
if (dto.id) {
|
||||
const existing = await this.repo.findOneBy({ id: dto.id });
|
||||
if (existing) {
|
||||
|
||||
+1
-4
@@ -34,10 +34,7 @@ async function bootstrap() {
|
||||
const nodeEnv = config.get("NODE_ENV");
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
],
|
||||
origin: ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
@@ -359,7 +359,9 @@ export class McpService {
|
||||
resolvedCredentials = {};
|
||||
for (const [alias, credId] of Object.entries(credentials)) {
|
||||
const cred = await this.credentialService.findOne(credId);
|
||||
resolvedCredentials[alias] = cred.data ? JSON.parse(cred.data) : {};
|
||||
resolvedCredentials[alias] = cred.data
|
||||
? JSON.parse(cred.data)
|
||||
: {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -702,13 +704,22 @@ export class McpService {
|
||||
description: "Trigger an immediate run of a scenario by ID",
|
||||
inputSchema: {
|
||||
id: z.uuid().describe("Scenario ID to run"),
|
||||
environmentId: z.uuid().describe("Environment ID to run the scenario in"),
|
||||
saveSession: z.boolean().optional().describe("Save session after run completes"),
|
||||
environmentId: z
|
||||
.uuid()
|
||||
.describe("Environment ID to run the scenario in"),
|
||||
saveSession: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Save session after run completes"),
|
||||
},
|
||||
},
|
||||
async ({ id, environmentId, saveSession }) => {
|
||||
try {
|
||||
const run = await this.scenarioService.createRun(id, environmentId, saveSession);
|
||||
const run = await this.scenarioService.createRun(
|
||||
id,
|
||||
environmentId,
|
||||
saveSession,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
||||
};
|
||||
@@ -817,9 +828,7 @@ export class McpService {
|
||||
{ credentialId, alias },
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify(result) },
|
||||
],
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -836,7 +845,11 @@ export class McpService {
|
||||
description: "Remove a credential assignment from a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.uuid().describe("Scenario ID"),
|
||||
scenarioCredentialId: z.uuid().describe("Scenario-credential assignment ID (not the credential ID)"),
|
||||
scenarioCredentialId: z
|
||||
.uuid()
|
||||
.describe(
|
||||
"Scenario-credential assignment ID (not the credential ID)",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, scenarioCredentialId }) => {
|
||||
|
||||
@@ -10,7 +10,10 @@ import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { ExecContextBuilder } from "../code-executor/exec-context.builder";
|
||||
import { traceStorage } from "../common/trace-context";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { EnvironmentData, EnvironmentEntity } from "../environment/environment.entity";
|
||||
import {
|
||||
EnvironmentData,
|
||||
EnvironmentEntity,
|
||||
} from "../environment/environment.entity";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { SessionService } from "../session/session.service";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
@@ -115,7 +118,7 @@ export class ScenarioSchedulerService {
|
||||
const environmentData = await this.environmentRepo
|
||||
.findOneBy({ id: run.environmentId })
|
||||
.then((env) => env?.data ?? {})
|
||||
.catch(() => ({} as EnvironmentData));
|
||||
.catch(() => ({}) as EnvironmentData);
|
||||
this.runEnvironments.set(run.id, environmentData);
|
||||
const traceId = crypto.randomUUID();
|
||||
void traceStorage.run({ traceId }, () =>
|
||||
@@ -187,7 +190,12 @@ export class ScenarioSchedulerService {
|
||||
cookies.find((c) => c.name === "token")?.value ??
|
||||
"";
|
||||
|
||||
const session = await this.sessionService.upsert(sessionName, token, cookies, localStorage);
|
||||
const session = await this.sessionService.upsert(
|
||||
sessionName,
|
||||
token,
|
||||
cookies,
|
||||
localStorage,
|
||||
);
|
||||
await this.runRepo.update(runId, { sessionId: session.id });
|
||||
this.sessionContextService.register(
|
||||
sessionName,
|
||||
|
||||
@@ -214,7 +214,11 @@ export class ScenarioController {
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateScenarioRunDto,
|
||||
) {
|
||||
return this.scenarioService.createRun(id, dto.environmentId, dto.saveSession);
|
||||
return this.scenarioService.createRun(
|
||||
id,
|
||||
dto.environmentId,
|
||||
dto.saveSession,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":id/run/:runId")
|
||||
|
||||
@@ -322,7 +322,12 @@ export class ScenarioService {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const run = await this.runRepo.findOne({
|
||||
where: { id: runId, scenarioId },
|
||||
relations: ["stepRuns", "stepRuns.scenarioStep", "environment", "session"],
|
||||
relations: [
|
||||
"stepRuns",
|
||||
"stepRuns.scenarioStep",
|
||||
"environment",
|
||||
"session",
|
||||
],
|
||||
order: { stepRuns: { order: "ASC" } },
|
||||
});
|
||||
if (!run)
|
||||
@@ -370,7 +375,12 @@ export class ScenarioService {
|
||||
}
|
||||
|
||||
const run = await this.runRepo.save(
|
||||
this.runRepo.create({ scenarioId, environmentId, status: "pending", saveSession }),
|
||||
this.runRepo.create({
|
||||
scenarioId,
|
||||
environmentId,
|
||||
status: "pending",
|
||||
saveSession,
|
||||
}),
|
||||
);
|
||||
|
||||
const stepRuns = scenario.steps.map((step, index) =>
|
||||
|
||||
@@ -15,7 +15,12 @@ import { SnippetExportDto } from "./dto/snippet-export.dto";
|
||||
import { UpdateSnippetDto } from "./dto/update-snippet.dto";
|
||||
import { SnippetEntity } from "./snippet.entity";
|
||||
|
||||
export type SnippetOrderBy = "id" | "alias" | "title" | "createdAt" | "updatedAt";
|
||||
export type SnippetOrderBy =
|
||||
| "id"
|
||||
| "alias"
|
||||
| "title"
|
||||
| "createdAt"
|
||||
| "updatedAt";
|
||||
|
||||
@Injectable()
|
||||
export class SnippetService implements OnModuleInit {
|
||||
@@ -47,7 +52,9 @@ export class SnippetService implements OnModuleInit {
|
||||
async create(dto: CreateSnippetDto): Promise<SnippetEntity> {
|
||||
const existing = await this.repo.findOneBy({ alias: dto.alias });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
|
||||
throw new ConflictException(
|
||||
`Snippet alias "${dto.alias}" already exists`,
|
||||
);
|
||||
}
|
||||
return this.repo.save(
|
||||
this.repo.create({
|
||||
@@ -85,7 +92,9 @@ export class SnippetService implements OnModuleInit {
|
||||
if (dto.alias && dto.alias !== snippet.alias) {
|
||||
const conflict = await this.repo.findOneBy({ alias: dto.alias });
|
||||
if (conflict)
|
||||
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`);
|
||||
throw new ConflictException(
|
||||
`Snippet alias "${dto.alias}" already exists`,
|
||||
);
|
||||
}
|
||||
Object.assign(snippet, dto);
|
||||
return this.repo.save(snippet);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getRepositoryToken } from "@nestjs/typeorm";
|
||||
import request from "supertest";
|
||||
import { buildTestApp } from "./app.harness";
|
||||
import { SessionEntity } from "../src/session/session.entity";
|
||||
import { EnvironmentEntity } from "../src/environment/environment.entity";
|
||||
import { CredentialEntity } from "../src/credential/credential.entity";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
/**
|
||||
@@ -15,6 +17,8 @@ import { Repository } from "typeorm";
|
||||
describe("BrowserController", () => {
|
||||
let app: INestApplication;
|
||||
let sessionRepo: Repository<SessionEntity>;
|
||||
let environmentRepo: Repository<EnvironmentEntity>;
|
||||
let credentialRepo: Repository<CredentialEntity>;
|
||||
|
||||
const FAKE_SESSION = "test-browser-session";
|
||||
|
||||
@@ -23,6 +27,12 @@ describe("BrowserController", () => {
|
||||
sessionRepo = app.get<Repository<SessionEntity>>(
|
||||
getRepositoryToken(SessionEntity),
|
||||
);
|
||||
environmentRepo = app.get<Repository<EnvironmentEntity>>(
|
||||
getRepositoryToken(EnvironmentEntity),
|
||||
);
|
||||
credentialRepo = app.get<Repository<CredentialEntity>>(
|
||||
getRepositoryToken(CredentialEntity),
|
||||
);
|
||||
|
||||
// Seed a session with minimal but valid JSON so the browser code can
|
||||
// deserialise it (it will still fail to open a real page, tested separately)
|
||||
@@ -40,6 +50,33 @@ describe("BrowserController", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function createEnvironment(
|
||||
data: Record<string, string | undefined>,
|
||||
name = `browser-env-${Math.random().toString(36).slice(2, 8)}`,
|
||||
): Promise<string> {
|
||||
const environment = await environmentRepo.save(
|
||||
environmentRepo.create({
|
||||
name,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return environment.id;
|
||||
}
|
||||
|
||||
async function createCredential(
|
||||
data: Record<string, unknown>,
|
||||
name = `browser-cred-${Math.random().toString(36).slice(2, 8)}`,
|
||||
): Promise<string> {
|
||||
const credential = await credentialRepo.save(
|
||||
credentialRepo.create({
|
||||
name,
|
||||
data: JSON.stringify(data),
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
);
|
||||
return credential.id;
|
||||
}
|
||||
|
||||
// ── POST /open ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /open", () => {
|
||||
@@ -137,35 +174,46 @@ describe("BrowserController", () => {
|
||||
});
|
||||
|
||||
it("exposes environment via context.env in a named session", async () => {
|
||||
const environmentId = await createEnvironment({
|
||||
BASE_URL: "https://env.example.com",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({
|
||||
sessionName: "no-such-session",
|
||||
code: "return context.env.BASE_URL;",
|
||||
environment: { BASE_URL: "https://env.example.com" },
|
||||
environmentId,
|
||||
})
|
||||
.expect(201);
|
||||
expect(res.body).toEqual({ result: "https://env.example.com" });
|
||||
});
|
||||
|
||||
it("exposes environment via context.env in a sessionless exec", async () => {
|
||||
const environmentId = await createEnvironment({ KEY: "value123" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({
|
||||
code: "return context.env.KEY;",
|
||||
environment: { KEY: "value123" },
|
||||
environmentId,
|
||||
})
|
||||
.expect(201);
|
||||
expect(res.body).toEqual({ result: "value123" });
|
||||
});
|
||||
|
||||
it("exposes credentials via context.getCredential in a named session", async () => {
|
||||
const credentialId = await createCredential({
|
||||
username: "user1",
|
||||
password: "pass1",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({
|
||||
sessionName: "no-such-session",
|
||||
code: "return context.getCredential('admin');",
|
||||
credentials: { admin: { username: "user1", password: "pass1" } },
|
||||
credentials: { admin: credentialId },
|
||||
})
|
||||
.expect(201);
|
||||
expect(res.body).toEqual({
|
||||
@@ -174,11 +222,13 @@ describe("BrowserController", () => {
|
||||
});
|
||||
|
||||
it("exposes credentials via context.getCredential in a sessionless exec", async () => {
|
||||
const credentialId = await createCredential({ token: "abc" });
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post("/exec")
|
||||
.send({
|
||||
code: "return context.getCredential('svc');",
|
||||
credentials: { svc: { token: "abc" } },
|
||||
credentials: { svc: credentialId },
|
||||
})
|
||||
.expect(201);
|
||||
expect(res.body).toEqual({ result: { token: "abc" } });
|
||||
|
||||
@@ -160,7 +160,9 @@ describe("EnvironmentController", () => {
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).get("/environments/00000000-0000-0000-0000-000000000001").expect(404);
|
||||
await request(app.getHttpServer())
|
||||
.get("/environments/00000000-0000-0000-0000-000000000001")
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric id", async () => {
|
||||
|
||||
@@ -75,7 +75,10 @@ describe("McpController", () => {
|
||||
it("returns MCP error for removed tool", async () => {
|
||||
const { status, rpc } = await mcpCall("list_keys");
|
||||
expect(status).toBe(200);
|
||||
const result = rpc.result as { isError: boolean; content?: { text: string }[] };
|
||||
const result = rpc.result as {
|
||||
isError: boolean;
|
||||
content?: { text: string }[];
|
||||
};
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,7 +163,9 @@ describe("ScenarioController", () => {
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).get("/scenarios/00000000-0000-0000-0000-000000000001").expect(404);
|
||||
await request(app.getHttpServer())
|
||||
.get("/scenarios/00000000-0000-0000-0000-000000000001")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -199,7 +201,9 @@ describe("ScenarioController", () => {
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).delete("/scenarios/00000000-0000-0000-0000-000000000001").expect(404);
|
||||
await request(app.getHttpServer())
|
||||
.delete("/scenarios/00000000-0000-0000-0000-000000000001")
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,8 +331,8 @@ describe("ScenarioController", () => {
|
||||
it("reorders by exact target index", async () => {
|
||||
const sc = await createScenario();
|
||||
const stepA = await createStep(sc.id, { title: "A" });
|
||||
const stepB = await createStep(sc.id, { title: "B" });
|
||||
const stepC = await createStep(sc.id, { title: "C" });
|
||||
await createStep(sc.id, { title: "B" });
|
||||
await createStep(sc.id, { title: "C" });
|
||||
const stepD = await createStep(sc.id, { title: "D" });
|
||||
|
||||
await request(app.getHttpServer())
|
||||
@@ -339,12 +343,15 @@ describe("ScenarioController", () => {
|
||||
let res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}`)
|
||||
.expect(200);
|
||||
expect(
|
||||
res.body.steps.map((s: { title: string }) => s.title),
|
||||
).toEqual(["B", "C", "A", "D"]);
|
||||
expect(
|
||||
res.body.steps.map((s: { order: number }) => s.order),
|
||||
).toEqual([0, 1, 2, 3]);
|
||||
expect(res.body.steps.map((s: { title: string }) => s.title)).toEqual([
|
||||
"B",
|
||||
"C",
|
||||
"A",
|
||||
"D",
|
||||
]);
|
||||
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
|
||||
0, 1, 2, 3,
|
||||
]);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/scenarios/${sc.id}/steps/${stepD.id}`)
|
||||
@@ -354,12 +361,15 @@ describe("ScenarioController", () => {
|
||||
res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}`)
|
||||
.expect(200);
|
||||
expect(
|
||||
res.body.steps.map((s: { title: string }) => s.title),
|
||||
).toEqual(["D", "B", "C", "A"]);
|
||||
expect(
|
||||
res.body.steps.map((s: { order: number }) => s.order),
|
||||
).toEqual([0, 1, 2, 3]);
|
||||
expect(res.body.steps.map((s: { title: string }) => s.title)).toEqual([
|
||||
"D",
|
||||
"B",
|
||||
"C",
|
||||
"A",
|
||||
]);
|
||||
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
|
||||
0, 1, 2, 3,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -395,9 +405,7 @@ describe("ScenarioController", () => {
|
||||
expect(Array.isArray(res.stepRuns)).toBe(true);
|
||||
expect(res.stepRuns).toHaveLength(3);
|
||||
|
||||
const statuses = res.stepRuns.map(
|
||||
(s: { status: string }) => s.status,
|
||||
);
|
||||
const statuses = res.stepRuns.map((s: { status: string }) => s.status);
|
||||
expect(statuses[0]).toBe("pending");
|
||||
expect(statuses[1]).toBe("waiting");
|
||||
expect(statuses[2]).toBe("waiting");
|
||||
@@ -495,9 +503,18 @@ describe("ScenarioController", () => {
|
||||
|
||||
it("exports steps ordered by sequential position", async () => {
|
||||
const sc = await createScenario("export-order");
|
||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'first';" });
|
||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'second';" });
|
||||
await createStep(sc.id, { sessionName: "s", execCode: "return 'third';" });
|
||||
await createStep(sc.id, {
|
||||
sessionName: "s",
|
||||
execCode: "return 'first';",
|
||||
});
|
||||
await createStep(sc.id, {
|
||||
sessionName: "s",
|
||||
execCode: "return 'second';",
|
||||
});
|
||||
await createStep(sc.id, {
|
||||
sessionName: "s",
|
||||
execCode: "return 'third';",
|
||||
});
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/export`)
|
||||
@@ -616,9 +633,7 @@ describe("ScenarioController", () => {
|
||||
|
||||
expect(importRes.body.name).toBe("roundtrip");
|
||||
expect(importRes.body.steps).toHaveLength(1);
|
||||
expect(importRes.body.steps[0].execCode).toBe(
|
||||
exported.steps[0].execCode,
|
||||
);
|
||||
expect(importRes.body.steps[0].execCode).toBe(exported.steps[0].execCode);
|
||||
});
|
||||
|
||||
it("imports with empty steps array", async () => {
|
||||
@@ -663,7 +678,7 @@ describe("ScenarioController", () => {
|
||||
const sc = await createScenario();
|
||||
await createStep(sc.id, { order: 0 });
|
||||
const runRes = await createRun(sc.id);
|
||||
const runId = runRes.id;
|
||||
const runId = runRes.id;
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/scenarios/${sc.id}/run/${runId}`)
|
||||
@@ -766,7 +781,9 @@ describe("ScenarioController", () => {
|
||||
it("returns 404 for unknown run", async () => {
|
||||
const sc = await createScenario();
|
||||
await request(app.getHttpServer())
|
||||
.post(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`)
|
||||
.post(
|
||||
`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`,
|
||||
)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ describe("SessionController", () => {
|
||||
});
|
||||
|
||||
it("returns 404 for unknown id", async () => {
|
||||
await request(app.getHttpServer()).delete("/sessions/00000000-0000-0000-0000-000000000001").expect(404);
|
||||
await request(app.getHttpServer())
|
||||
.delete("/sessions/00000000-0000-0000-0000-000000000001")
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("returns 400 for non-numeric id", async () => {
|
||||
|
||||
Reference in New Issue
Block a user