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[] }> { get(id: string): Promise<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`); return request(`/scenarios/${id}`);
}, },
create(name: string, description?: string): Promise<Scenario> { create(name: string, description?: string, environmentId?: string): Promise<Scenario> {
return request('/scenarios', { return request('/scenarios', {
method: 'POST', 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}`, { return request(`/scenarios/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
+2
View File
@@ -84,6 +84,8 @@ export interface Scenario {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
environmentId?: string | null;
environment?: Pick<Environment, 'id' | 'name'>;
steps?: ScenarioStep[]; steps?: ScenarioStep[];
scenarioCredentials?: ScenarioCredential[]; scenarioCredentials?: ScenarioCredential[];
createdAt: string; createdAt: string;
+3
View File
@@ -157,6 +157,9 @@
"edit_title": "Edit Scenario", "edit_title": "Edit Scenario",
"form_description": "Description", "form_description": "Description",
"form_description_placeholder": "Describe this scenario", "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": "Name",
"form_name_placeholder": "e.g. Login flow", "form_name_placeholder": "e.g. Login flow",
"form_name_required": "Name is required", "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 { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react'; import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios } from '../../api'; import { scenarios, environments } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui'; import type { Environment } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, Select, useToast } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function CreateScenarioPage() { export function CreateScenarioPage() {
@@ -13,9 +14,15 @@ export function CreateScenarioPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState('');
const [envs, setEnvs] = useState<Environment[]>([]);
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => {
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
}, []);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
@@ -24,7 +31,7 @@ export function CreateScenarioPage() {
} }
setSaving(true); setSaving(true);
try { 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')); toast.success(t('scenarios.created'));
navigate(`/scenarios/${scenario.id}`); navigate(`/scenarios/${scenario.id}`);
} catch (err) { } catch (err) {
@@ -74,6 +81,15 @@ export function CreateScenarioPage() {
rows={8} rows={8}
/> />
</div> </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>
<div className={styles.formActions}> <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 { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react'; import { X, Save, ClipboardList } from 'lucide-react';
import { scenarios } from '../../api'; import { scenarios, environments } from '../../api';
import type { Scenario } from '../../api'; import type { Scenario, Environment } from '../../api';
import { Breadcrumbs, Button, Card, Input, CodeEditor, useToast } from '../../ui'; import { Breadcrumbs, Button, Card, Input, CodeEditor, Select, useToast } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function EditScenarioPage() { export function EditScenarioPage() {
@@ -16,6 +16,8 @@ export function EditScenarioPage() {
const [scenario, setScenario] = useState<Scenario | null>(null); const [scenario, setScenario] = useState<Scenario | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState<string>('');
const [envs, setEnvs] = useState<Environment[]>([]);
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -28,8 +30,10 @@ export function EditScenarioPage() {
setScenario(data); setScenario(data);
setName(data.name); setName(data.name);
setDescription(data.description || ''); setDescription(data.description || '');
setEnvironmentId(data.environmentId ?? '');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
}, [id]); }, [id]);
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
@@ -43,6 +47,7 @@ export function EditScenarioPage() {
await scenarios.update(id!, { await scenarios.update(id!, {
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
environmentId: environmentId || null,
}); });
toast.success(t('scenarios.updated')); toast.success(t('scenarios.updated'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
@@ -100,6 +105,15 @@ export function EditScenarioPage() {
rows={8} rows={8}
/> />
</div> </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>
<div className={styles.formActions}> <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 { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -68,6 +68,7 @@ export function ScenarioDetailPage() {
const [saveSessionFlag, setSaveSessionFlag] = useState(false); const [saveSessionFlag, setSaveSessionFlag] = useState(false);
const [envs, setEnvs] = useState<Environment[]>([]); const [envs, setEnvs] = useState<Environment[]>([]);
const [selectedEnvId, setSelectedEnvId] = useState(''); const [selectedEnvId, setSelectedEnvId] = useState('');
const envInitRef = useRef(false);
const [pendingDelete, setPendingDelete] = useState< const [pendingDelete, setPendingDelete] = useState<
{ type: 'scenario' } | { type: 'step'; id: string } | { type: 'credential'; id: string } | null { type: 'scenario' } | { type: 'step'; id: string } | { type: 'credential'; id: string } | null
>(null); >(null);
@@ -90,11 +91,19 @@ export function ScenarioDetailPage() {
.list(1, 200) .list(1, 200)
.then((r) => { .then((r) => {
setEnvs(r.data); setEnvs(r.data);
setSelectedEnvId((prev) => prev || r.data[0]?.id || '');
}) })
.catch(() => {}); .catch(() => {});
}, [id]); }, [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 () => { const reloadScenario = async () => {
if (!id) return; if (!id) return;
const s = await scenarios.get(id); const s = await scenarios.get(id);
@@ -389,6 +398,19 @@ export function ScenarioDetailPage() {
items={[ items={[
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> }, { term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
{ term: t('scenarios.field_name'), detail: scenario.name }, { 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'), term: t('scenarios.field_created'),
detail: <Timestamp value={scenario.createdAt} />, detail: <Timestamp value={scenario.createdAt} />,
+4 -3
View File
@@ -52,9 +52,10 @@ export function ScenariosPage() {
const openRunModal = (id: string) => { const openRunModal = (id: string) => {
setRunScenarioId(id); setRunScenarioId(id);
if (!selectedEnvId && envs.length > 0) { const scenario = items.find((s) => s.id === id);
setSelectedEnvId(envs[0].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 () => { const handleRun = async () => {
+13 -2
View File
@@ -472,13 +472,18 @@ export class McpService {
.string() .string()
.optional() .optional()
.describe("Optional markdown description"), .describe("Optional markdown description"),
environmentId: z
.uuid()
.optional()
.describe("Optional linked environment ID"),
}, },
}, },
async ({ name, description }) => { async ({ name, description, environmentId }) => {
try { try {
const scenario = await this.scenarioService.create({ const scenario = await this.scenarioService.create({
name, name,
description, description,
environmentId,
}); });
return { return {
content: [ content: [
@@ -505,13 +510,19 @@ export class McpService {
.string() .string()
.optional() .optional()
.describe("New markdown description"), .describe("New markdown description"),
environmentId: z
.uuid()
.nullable()
.optional()
.describe("Linked environment ID (null to unlink)"),
}, },
}, },
async ({ id, name, description }) => { async ({ id, name, description, environmentId }) => {
try { try {
const scenario = await this.scenarioService.update(id, { const scenario = await this.scenarioService.update(id, {
name, name,
description, description,
environmentId,
}); });
return { return {
content: [ content: [
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator"; import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
export class CreateScenarioDto { export class CreateScenarioDto {
@ApiProperty({ example: "Login and verify cabinet" }) @ApiProperty({ example: "Login and verify cabinet" })
@@ -11,4 +11,9 @@ export class CreateScenarioDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@ApiPropertyOptional({ description: "Optional linked environment ID" })
@IsOptional()
@IsUUID()
environmentId?: string;
} }
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator"; import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
export class UpdateScenarioDto { export class UpdateScenarioDto {
@ApiPropertyOptional({ example: "Updated scenario name" }) @ApiPropertyOptional({ example: "Updated scenario name" })
@@ -12,4 +12,9 @@ export class UpdateScenarioDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@ApiPropertyOptional({ description: "Linked environment ID (null to unlink)" })
@IsOptional()
@IsUUID()
environmentId?: string | null;
} }
+10
View File
@@ -2,10 +2,13 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn,
ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from "typeorm"; } from "typeorm";
import { EnvironmentEntity } from "../environment/environment.entity";
import { ScenarioCredentialEntity } from "./scenario-credential.entity"; import { ScenarioCredentialEntity } from "./scenario-credential.entity";
import { ScenarioStepEntity } from "./scenario-step.entity"; import { ScenarioStepEntity } from "./scenario-step.entity";
@@ -20,6 +23,13 @@ export class ScenarioEntity {
@Column("text", { nullable: true }) @Column("text", { nullable: true })
description: string | null; description: string | null;
@Column({ nullable: true, type: "text" })
environmentId: string | null;
@ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false })
@JoinColumn({ name: "environmentId" })
environment: EnvironmentEntity | null;
@OneToMany(() => ScenarioStepEntity, (step) => step.scenario, { @OneToMany(() => ScenarioStepEntity, (step) => step.scenario, {
cascade: true, cascade: true,
eager: false, eager: false,
+1
View File
@@ -77,6 +77,7 @@ export class ScenarioService {
"steps", "steps",
"scenarioCredentials", "scenarioCredentials",
"scenarioCredentials.credential", "scenarioCredentials.credential",
"environment",
], ],
order: { steps: { order: "ASC" } }, order: { steps: { order: "ASC" } },
}); });