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:
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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} />,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export class ScenarioService {
|
|||||||
"steps",
|
"steps",
|
||||||
"scenarioCredentials",
|
"scenarioCredentials",
|
||||||
"scenarioCredentials.credential",
|
"scenarioCredentials.credential",
|
||||||
|
"environment",
|
||||||
],
|
],
|
||||||
order: { steps: { order: "ASC" } },
|
order: { steps: { order: "ASC" } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user