import { useEffect, useRef, 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 { 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 { Breadcrumbs, Button, Card, DescriptionList, Input, Modal, Select, Table, Timestamp, UuidBadge, MarkdownContent, useToast, type TableColumn, } from '../../ui'; import { ExportScenarioModal } from '../../components/modals'; import styles from '../Page.module.css'; export function ScenarioDetailPage() { const { t } = useTranslation(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const toast = useToast(); const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null); const [loading, setLoading] = useState(true); // Credentials state const [scenarioCreds, setScenarioCreds] = useState([]); const [allCredentials, setAllCredentials] = useState([]); const [addCredOpen, setAddCredOpen] = useState(false); const [addCredId, setAddCredId] = useState(''); const [addAlias, setAddAlias] = useState(''); const [addCredError, setAddCredError] = useState(''); const [addAliasError, setAddAliasError] = useState(''); const [addCredSaving, setAddCredSaving] = useState(false); const [draggedStepId, setDraggedStepId] = useState(null); const [dragOverStepId, setDragOverStepId] = useState(null); const [reorderingSteps, setReorderingSteps] = useState(false); const [runModalOpen, setRunModalOpen] = useState(false); const [running, setRunning] = useState(false); 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); const [deleting, setDeleting] = useState(false); // Export modal state const [exportModalOpen, setExportModalOpen] = useState(false); const [includeEnvironment, setIncludeEnvironment] = useState(true); const [includedCredentialIds, setIncludedCredentialIds] = useState>(new Set()); const [isExporting, setIsExporting] = useState(false); useEffect(() => { if (!id) return; void scenarios .get(id) .then((s) => { setScenario(s); setScenarioCreds(s.scenarioCredentials ?? []); }) .finally(() => setLoading(false)); credentialsApi .list(1, 200) .then((r) => setAllCredentials(r.data)) .catch(() => {}); environments .list(1, 200) .then((r) => { setEnvs(r.data); }) .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); setScenario(s); setScenarioCreds(s.scenarioCredentials ?? []); }; const handleRun = async () => { if (!scenario) return; if (!selectedEnvId) { toast.error(t('scenarios.run_modal_env_required')); return; } setRunning(true); try { const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag); toast.success(t('scenarios.run_started')); navigate(`/scenarios/${id}/runs/${run.id}`); setRunModalOpen(false); setSaveSessionFlag(false); } catch (err) { toast.error((err as Error).message); } finally { setRunning(false); } }; const handleDelete = async () => { if (!scenario) return; await scenarios.remove(scenario.id); navigate('/scenarios'); }; const handleOpenExportModal = () => { if (!scenario) return; // Initialise all credential checkboxes to checked setIncludedCredentialIds(new Set(scenarioCreds.map((sc) => sc.credentialId))); setIncludeEnvironment(true); setExportModalOpen(true); }; const handleExport = async () => { if (!scenario) return; setIsExporting(true); try { const credIds = Array.from(includedCredentialIds); const yamlContent = await scenarios.exportScenario(scenario.id, { includeEnvironment, credentialIds: credIds, }); const blob = new Blob([yamlContent], { type: 'application/yaml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${scenario.name}.yaml`; a.click(); URL.revokeObjectURL(url); setExportModalOpen(false); } catch (err) { toast.error((err as Error).message); } finally { setIsExporting(false); } }; const handleDeleteStep = async (stepId: string) => { await steps.remove(id!, stepId); await reloadScenario(); }; const handleMoveStep = async (fromStepId: string, toStepId: string) => { if (!scenario || reorderingSteps) return; setReorderingSteps(true); try { const orderedSteps = [...scenario.steps].sort((a, b) => a.order - b.order); const toIndex = orderedSteps.findIndex((step) => step.id === toStepId); if (toIndex < 0) return; await steps.update(id!, fromStepId, { order: toIndex }); await reloadScenario(); } finally { setReorderingSteps(false); } }; const handleAddCredential = async (e: SubmitEvent) => { e.preventDefault(); let valid = true; if (!addCredId) { setAddCredError(t('scenarios.cred_form_cred_required')); valid = false; } if (!addAlias.trim()) { setAddAliasError(t('scenarios.cred_form_alias_required')); valid = false; } if (!valid) return; setAddCredSaving(true); try { const sc = await scenarioCredentials.add(id!, addCredId, addAlias.trim()); // Re-fetch the credential object since the response may not include it const full = await scenarioCredentials.list(id!); setScenarioCreds(full); void sc; setAddCredOpen(false); setAddCredId(''); setAddAlias(''); } catch (err) { setAddCredError((err as Error).message); } finally { setAddCredSaving(false); } }; const handleRemoveCredential = async (scCredId: string) => { await scenarioCredentials.remove(id!, scCredId); setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId)); }; const confirmDelete = async () => { if (!pendingDelete) return; setDeleting(true); try { if (pendingDelete.type === 'scenario') { await handleDelete(); return; } if (pendingDelete.type === 'step') { await handleDeleteStep(pendingDelete.id); } if (pendingDelete.type === 'credential') { await handleRemoveCredential(pendingDelete.id); } setPendingDelete(null); } finally { setDeleting(false); } }; const stepColumns: TableColumn[] = [ { key: 'drag', header: '', width: 44, align: 'center', render: (s) => ( e.stopPropagation()} onDragStart={(e) => { if (reorderingSteps) { e.preventDefault(); return; } setDraggedStepId(s.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', s.id); }} onDragEnd={() => { setDraggedStepId(null); setDragOverStepId(null); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); } }} > ), }, { key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 }, { key: 'title', header: t('scenarios.step_title'), render: (s) => s.title ?? ( {'—'} ), }, { key: 'updated', header: t('scenarios.step_updated'), width: 140, render: (s) => , }, { key: 'actions', header: '', width: 80, align: 'right', render: (s) => ( ), }, ]; const credColumns: TableColumn[] = [ { key: 'alias', header: t('scenarios.cred_col_alias'), render: (sc) => sc.alias }, { key: 'name', header: t('scenarios.cred_col_name'), render: (sc) => sc.credential?.name ?? `#${sc.credentialId}`, }, { key: 'added', header: t('scenarios.cred_col_added'), width: 140, render: (sc) => , }, { key: 'actions', header: '', width: 60, align: 'right', render: (sc) => ( ), }, ]; return (
, onClick: () => navigate('/scenarios'), }, { label: scenario?.name ?? `#${id}` }, ]} /> {scenario && (
)}
{loading &&

{t('scenarios.loading')}

} {scenario && ( <> }, { 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: , }, { term: t('scenarios.field_updated'), detail: , }, ]} /> {scenario.description && (

{t('scenarios.field_description')}

)} {scenario.steps.length > 0 && (

{t('scenarios.steps_heading')}

s.id} loading={false} emptyMessage="" getRowProps={(s) => ({ className: dragOverStepId === s.id ? styles.dragOverRow : undefined, onDragOver: (e) => { if (reorderingSteps) return; e.preventDefault(); if (draggedStepId && draggedStepId !== s.id) { setDragOverStepId(s.id); } }, onDragLeave: () => { if (dragOverStepId === s.id) { setDragOverStepId(null); } }, onDrop: async (e) => { if (reorderingSteps) return; e.preventDefault(); const fromId = draggedStepId ?? e.dataTransfer.getData('text/plain'); setDragOverStepId(null); setDraggedStepId(null); if (!fromId || fromId === s.id) return; await handleMoveStep(fromId, s.id); }, })} /> )} {scenario.steps.length === 0 && (

{t('scenarios.steps_heading')}

)} {/* ── Credentials section ──────────────────────────────────── */}

{t('scenarios.credentials_heading')}

{addCredOpen && (
{ setAddAlias(e.target.value); setAddAliasError(''); }} error={addAliasError || undefined} />
)} {scenarioCreds.length > 0 ? (
sc.id} loading={false} emptyMessage="" /> ) : (

{t('scenarios.cred_empty')}

)} )} !deleting && setPendingDelete(null)} footer={ <> } > {pendingDelete?.type === 'step' && t('common.confirm_delete_step')} {pendingDelete?.type === 'credential' && t('common.confirm_remove_credential')} {pendingDelete?.type === 'scenario' && t('common.confirm_delete_scenario')} !running && setRunModalOpen(false)} footer={ <> } > {envs.length === 0 ? (

{t('scenarios.run_modal_no_env')}

) : ( <> setSaveSessionFlag(e.target.checked)} /> {t('common.save_session')} )}
e.id === scenario?.environmentId) ?? null } includeEnvironment={includeEnvironment} onIncludeEnvironmentChange={setIncludeEnvironment} includedCredentialIds={includedCredentialIds} onCredentialToggle={(credId, checked) => { setIncludedCredentialIds((prev) => { const next = new Set(prev); if (checked) next.add(credId); else next.delete(credId); return next; }); }} onClose={() => setExportModalOpen(false)} onExport={handleExport} isExporting={isExporting} /> ); }