diff --git a/client/src/api/client.ts b/client/src/api/client.ts index dcbc6c6..f3eae3f 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -182,13 +182,13 @@ export const scenarios = { get(id: string): Promise { return request(`/scenarios/${id}`); }, - create(name: string, description?: string): Promise { + create(name: string, description?: string, environmentId?: string): Promise { return request('/scenarios', { method: 'POST', - body: JSON.stringify({ name, description }), + body: JSON.stringify({ name, description, environmentId }), }); }, - update(id: string, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(patch), diff --git a/client/src/api/types.ts b/client/src/api/types.ts index dbf6812..14db7ca 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -84,6 +84,8 @@ export interface Scenario { id: string; name: string; description?: string; + environmentId?: string | null; + environment?: Pick; steps?: ScenarioStep[]; scenarioCredentials?: ScenarioCredential[]; createdAt: string; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 8b8a878..3aa148d 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -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", diff --git a/client/src/pages/scenario/CreateScenarioPage.tsx b/client/src/pages/scenario/CreateScenarioPage.tsx index 4ac6c6d..58cefb3 100644 --- a/client/src/pages/scenario/CreateScenarioPage.tsx +++ b/client/src/pages/scenario/CreateScenarioPage.tsx @@ -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([]); 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) => { 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} /> + setEnvironmentId(e.target.value)} + options={[ + { value: '', label: t('scenarios.form_environment_none') }, + ...envs.map((env) => ({ value: env.id, label: env.name })), + ]} + />
diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index 6971626..3fb268e 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -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([]); 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: }, { term: t('scenarios.field_name'), detail: scenario.name }, + ...(scenario.environment + ? [{ + term: t('scenarios.field_environment'), + detail: ( + { e.preventDefault(); navigate(`/environments/${scenario.environment!.id}`); }} + > + {scenario.environment.name} + + ), + }] + : []), { term: t('scenarios.field_created'), detail: , diff --git a/client/src/pages/scenario/ScenariosPage.tsx b/client/src/pages/scenario/ScenariosPage.tsx index f976d13..0186ae2 100644 --- a/client/src/pages/scenario/ScenariosPage.tsx +++ b/client/src/pages/scenario/ScenariosPage.tsx @@ -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 () => { diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index 3f0a5d0..5a109b8 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -472,13 +472,18 @@ export class McpService { .string() .optional() .describe("Optional markdown description"), + environmentId: z + .uuid() + .optional() + .describe("Optional linked environment ID"), }, }, - async ({ name, description }) => { + async ({ name, description, environmentId }) => { try { const scenario = await this.scenarioService.create({ name, description, + environmentId, }); return { content: [ @@ -505,13 +510,19 @@ export class McpService { .string() .optional() .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 { const scenario = await this.scenarioService.update(id, { name, description, + environmentId, }); return { content: [ diff --git a/server/src/scenario/dto/create-scenario.dto.ts b/server/src/scenario/dto/create-scenario.dto.ts index 428a342..62d6422 100644 --- a/server/src/scenario/dto/create-scenario.dto.ts +++ b/server/src/scenario/dto/create-scenario.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; export class CreateScenarioDto { @ApiProperty({ example: "Login and verify cabinet" }) @@ -11,4 +11,9 @@ export class CreateScenarioDto { @IsOptional() @IsString() description?: string; + + @ApiPropertyOptional({ description: "Optional linked environment ID" }) + @IsOptional() + @IsUUID() + environmentId?: string; } diff --git a/server/src/scenario/dto/update-scenario.dto.ts b/server/src/scenario/dto/update-scenario.dto.ts index 7d920cf..f013562 100644 --- a/server/src/scenario/dto/update-scenario.dto.ts +++ b/server/src/scenario/dto/update-scenario.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; export class UpdateScenarioDto { @ApiPropertyOptional({ example: "Updated scenario name" }) @@ -12,4 +12,9 @@ export class UpdateScenarioDto { @IsOptional() @IsString() description?: string; + + @ApiPropertyOptional({ description: "Linked environment ID (null to unlink)" }) + @IsOptional() + @IsUUID() + environmentId?: string | null; } diff --git a/server/src/scenario/scenario.entity.ts b/server/src/scenario/scenario.entity.ts index 1338894..98cd3de 100644 --- a/server/src/scenario/scenario.entity.ts +++ b/server/src/scenario/scenario.entity.ts @@ -2,10 +2,13 @@ import { Column, CreateDateColumn, Entity, + JoinColumn, + ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; +import { EnvironmentEntity } from "../environment/environment.entity"; import { ScenarioCredentialEntity } from "./scenario-credential.entity"; import { ScenarioStepEntity } from "./scenario-step.entity"; @@ -20,6 +23,13 @@ export class ScenarioEntity { @Column("text", { nullable: true }) 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, { cascade: true, eager: false, diff --git a/server/src/scenario/scenario.service.ts b/server/src/scenario/scenario.service.ts index 0751c7a..1ee042d 100644 --- a/server/src/scenario/scenario.service.ts +++ b/server/src/scenario/scenario.service.ts @@ -77,6 +77,7 @@ export class ScenarioService { "steps", "scenarioCredentials", "scenarioCredentials.credential", + "environment", ], order: { steps: { order: "ASC" } }, });