feat(scenarios): link scenarios to a default environment

- add optional ManyToOne relation from scenario to environment entity
- expose environmentId in create/update DTOs, service, and MCP tools
- pre-select linked environment in run modals on detail and list pages
- add environment selector to create/edit scenario forms
- show linked environment as a navigable link on scenario detail page
This commit is contained in:
2026-04-15 00:05:55 +03:00
parent e6aacc899d
commit a779ab4667
12 changed files with 109 additions and 19 deletions
+3 -3
View File
@@ -182,13 +182,13 @@ export const scenarios = {
get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`);
},
create(name: string, description?: string): Promise<Scenario> {
create(name: string, description?: string, environmentId?: string): Promise<Scenario> {
return request('/scenarios', {
method: 'POST',
body: JSON.stringify({ name, description }),
body: JSON.stringify({ name, description, environmentId }),
});
},
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description'>>): Promise<Scenario> {
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId'>>): Promise<Scenario> {
return request(`/scenarios/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
+2
View File
@@ -84,6 +84,8 @@ export interface Scenario {
id: string;
name: string;
description?: string;
environmentId?: string | null;
environment?: Pick<Environment, 'id' | 'name'>;
steps?: ScenarioStep[];
scenarioCredentials?: ScenarioCredential[];
createdAt: string;
+3
View File
@@ -157,6 +157,9 @@
"edit_title": "Edit Scenario",
"form_description": "Description",
"form_description_placeholder": "Describe this scenario",
"form_environment": "Default Environment",
"form_environment_none": "None (no default environment)",
"field_environment": "Default Environment",
"form_name": "Name",
"form_name_placeholder": "e.g. Login flow",
"form_name_required": "Name is required",
@@ -1,9 +1,10 @@
import { useState, SubmitEvent } from 'react';
import { useEffect, useState, SubmitEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import { scenarios, environments } from '../../api';
import type { Environment } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, Select, useToast } from '../../ui';
import styles from '../Page.module.css';
export function CreateScenarioPage() {
@@ -13,9 +14,15 @@ export function CreateScenarioPage() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState('');
const [envs, setEnvs] = useState<Environment[]>([]);
const [nameError, setNameError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
}, []);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
if (!name.trim()) {
@@ -24,7 +31,7 @@ export function CreateScenarioPage() {
}
setSaving(true);
try {
const scenario = await scenarios.create(name.trim(), description.trim() || undefined);
const scenario = await scenarios.create(name.trim(), description.trim() || undefined, environmentId || undefined);
toast.success(t('scenarios.created'));
navigate(`/scenarios/${scenario.id}`);
} catch (err) {
@@ -74,6 +81,15 @@ export function CreateScenarioPage() {
rows={8}
/>
</div>
<Select
label={t('scenarios.form_environment')}
value={environmentId}
onChange={(e) => setEnvironmentId(e.target.value)}
options={[
{ value: '', label: t('scenarios.form_environment_none') },
...envs.map((env) => ({ value: env.id, label: env.name })),
]}
/>
</div>
<div className={styles.formActions}>
+17 -3
View File
@@ -2,9 +2,9 @@ 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';
import { scenarios } from '../../api';
import type { Scenario } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui';
import { scenarios, environments } from '../../api';
import type { Scenario, Environment } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, Select, useToast } from '../../ui';
import styles from '../Page.module.css';
export function EditScenarioPage() {
@@ -16,6 +16,8 @@ export function EditScenarioPage() {
const [scenario, setScenario] = useState<Scenario | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState<string>('');
const [envs, setEnvs] = useState<Environment[]>([]);
const [nameError, setNameError] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -28,8 +30,10 @@ export function EditScenarioPage() {
setScenario(data);
setName(data.name);
setDescription(data.description || '');
setEnvironmentId(data.environmentId ?? '');
})
.finally(() => setLoading(false));
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
}, [id]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
@@ -43,6 +47,7 @@ export function EditScenarioPage() {
await scenarios.update(id!, {
name: name.trim(),
description: description.trim() || undefined,
environmentId: environmentId || null,
});
toast.success(t('scenarios.updated'));
navigate(`/scenarios/${id}`);
@@ -100,6 +105,15 @@ export function EditScenarioPage() {
rows={8}
/>
</div>
<Select
label={t('scenarios.form_environment')}
value={environmentId}
onChange={(e) => setEnvironmentId(e.target.value)}
options={[
{ value: '', label: t('scenarios.form_environment_none') },
...envs.map((env) => ({ value: env.id, label: env.name })),
]}
/>
</div>
<div className={styles.formActions}>
@@ -1,4 +1,4 @@
import { useEffect, useState, SubmitEvent } from 'react';
import { useEffect, useRef, useState, SubmitEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -68,6 +68,7 @@ export function ScenarioDetailPage() {
const [saveSessionFlag, setSaveSessionFlag] = useState(false);
const [envs, setEnvs] = useState<Environment[]>([]);
const [selectedEnvId, setSelectedEnvId] = useState('');
const envInitRef = useRef(false);
const [pendingDelete, setPendingDelete] = useState<
{ type: 'scenario' } | { type: 'step'; id: string } | { type: 'credential'; id: string } | null
>(null);
@@ -90,11 +91,19 @@ export function ScenarioDetailPage() {
.list(1, 200)
.then((r) => {
setEnvs(r.data);
setSelectedEnvId((prev) => prev || r.data[0]?.id || '');
})
.catch(() => {});
}, [id]);
// Default selected env to scenario's linked environment (or first env) once both are loaded
useEffect(() => {
if (envInitRef.current || envs.length === 0) return;
envInitRef.current = true;
const preferred = scenario?.environmentId ?? null;
const exists = preferred ? envs.some((e) => e.id === preferred) : false;
setSelectedEnvId(exists ? preferred! : envs[0].id);
}, [scenario, envs]);
const reloadScenario = async () => {
if (!id) return;
const s = await scenarios.get(id);
@@ -389,6 +398,19 @@ export function ScenarioDetailPage() {
items={[
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
{ term: t('scenarios.field_name'), detail: scenario.name },
...(scenario.environment
? [{
term: t('scenarios.field_environment'),
detail: (
<a
href={`/environments/${scenario.environment.id}`}
onClick={(e) => { e.preventDefault(); navigate(`/environments/${scenario.environment!.id}`); }}
>
{scenario.environment.name}
</a>
),
}]
: []),
{
term: t('scenarios.field_created'),
detail: <Timestamp value={scenario.createdAt} />,
+4 -3
View File
@@ -52,9 +52,10 @@ export function ScenariosPage() {
const openRunModal = (id: string) => {
setRunScenarioId(id);
if (!selectedEnvId && envs.length > 0) {
setSelectedEnvId(envs[0].id);
}
const scenario = items.find((s) => s.id === id);
const preferred = scenario?.environmentId ?? null;
const exists = preferred ? envs.some((e) => e.id === preferred) : false;
setSelectedEnvId(exists ? preferred! : envs[0]?.id || '');
};
const handleRun = async () => {