feat(scenarios): overhaul export/import with multi-entity YAML format
- export now accepts includeEnvironment and credentialIds query params - output is a YAML stream with comment-delimited environment, credential, and scenario sections - scenario entity includes credentials array with alias mappings - import accepts both old single-object and new array format, upserts envs/creds before linking - new ExportScenarioModal with env and per-credential checkboxes replaces direct download
This commit is contained in:
@@ -216,8 +216,19 @@ export const scenarios = {
|
||||
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||
},
|
||||
exportScenario(id: string): Promise<string> {
|
||||
return request(`/scenarios/${id}/export`);
|
||||
exportScenario(
|
||||
id: string,
|
||||
options: { includeEnvironment: boolean; credentialIds: string[] } = {
|
||||
includeEnvironment: false,
|
||||
credentialIds: [],
|
||||
},
|
||||
): Promise<string> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('includeEnvironment', String(options.includeEnvironment));
|
||||
for (const cid of options.credentialIds) {
|
||||
params.append('credentialIds', cid);
|
||||
}
|
||||
return request(`/scenarios/${id}/export?${params.toString()}`);
|
||||
},
|
||||
importScenario(payload: unknown): Promise<Scenario> {
|
||||
return request('/scenarios/import', {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
.exportModal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenarioMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-fg);
|
||||
opacity: 0.7;
|
||||
min-width: 4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.checkboxRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkboxRow input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Upload } from 'lucide-react';
|
||||
import { Modal, Button, UuidBadge } from '../../ui';
|
||||
import type { Scenario, ScenarioCredential } from '../../api';
|
||||
import styles from './ExportScenarioModal.module.css';
|
||||
|
||||
export interface ExportScenarioModalProps {
|
||||
open: boolean;
|
||||
scenario: Scenario | null;
|
||||
scenarioCredentials: ScenarioCredential[];
|
||||
environment: Pick<{ id: string; name: string }, 'id' | 'name'> | null;
|
||||
includeEnvironment: boolean;
|
||||
onIncludeEnvironmentChange: (v: boolean) => void;
|
||||
includedCredentialIds: Set<string>;
|
||||
onCredentialToggle: (id: string, checked: boolean) => void;
|
||||
onClose: () => void;
|
||||
onExport: () => void;
|
||||
isExporting: boolean;
|
||||
}
|
||||
|
||||
export function ExportScenarioModal({
|
||||
open,
|
||||
scenario,
|
||||
scenarioCredentials,
|
||||
environment,
|
||||
includeEnvironment,
|
||||
onIncludeEnvironmentChange,
|
||||
includedCredentialIds,
|
||||
onCredentialToggle,
|
||||
onClose,
|
||||
onExport,
|
||||
isExporting,
|
||||
}: ExportScenarioModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('scenarios.export_modal_title')}
|
||||
onClose={() => !isExporting && onClose()}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||
<X size={14} />
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onExport} disabled={isExporting}>
|
||||
<Upload size={14} />
|
||||
{t('scenarios.action_export')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{scenario && (
|
||||
<div className={styles.exportModal}>
|
||||
<div className={styles.scenarioMeta}>
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaLabel}>{t('scenarios.field_name')}</span>
|
||||
<span className={styles.metaValue}>{scenario.name}</span>
|
||||
</div>
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaLabel}>{t('scenarios.field_id')}</span>
|
||||
<span className={styles.metaValue}>
|
||||
<UuidBadge id={scenario.id} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{environment && (
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>{t('scenarios.export_include_environment')}</h4>
|
||||
<label className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeEnvironment}
|
||||
onChange={(e) => onIncludeEnvironmentChange(e.target.checked)}
|
||||
/>
|
||||
<span>{environment.name}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenarioCredentials.length > 0 && (
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>{t('scenarios.export_include_credentials')}</h4>
|
||||
{scenarioCredentials.map((sc) => (
|
||||
<label key={sc.credentialId} className={styles.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includedCredentialIds.has(sc.credentialId)}
|
||||
onChange={(e) => onCredentialToggle(sc.credentialId, e.target.checked)}
|
||||
/>
|
||||
<span>{sc.credential?.name ?? sc.alias}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal';
|
||||
export { ExportScenarioModal } from './ExportScenarioModal';
|
||||
export type { ExportScenarioModalProps } from './ExportScenarioModal';
|
||||
export { RunScenarioModal } from './RunScenarioModal';
|
||||
export type { RunScenarioModalProps } from './RunScenarioModal';
|
||||
|
||||
@@ -181,7 +181,12 @@
|
||||
"run_modal_title": "Select environment",
|
||||
"run_modal_env_label": "Environment",
|
||||
"run_modal_no_env": "No environments found. Create an environment before running a scenario.",
|
||||
"run_modal_env_required": "Please select an environment"
|
||||
"run_modal_env_required": "Please select an environment",
|
||||
"export_modal_title": "Export Scenario",
|
||||
"export_include_environment": "Include Environment",
|
||||
"export_include_credentials": "Include Credentials",
|
||||
"field_name": "Name",
|
||||
"field_id": "ID"
|
||||
},
|
||||
"theme": {
|
||||
"switch_to_light": "Switch to light theme",
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
useToast,
|
||||
type TableColumn,
|
||||
} from '../../ui';
|
||||
import { ExportScenarioModal } from '../../components/modals';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
export function ScenarioDetailPage() {
|
||||
@@ -74,6 +75,12 @@ export function ScenarioDetailPage() {
|
||||
>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Export modal state
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [includeEnvironment, setIncludeEnvironment] = useState(true);
|
||||
const [includedCredentialIds, setIncludedCredentialIds] = useState<Set<string>>(new Set());
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
void scenarios
|
||||
@@ -137,17 +144,36 @@ export function ScenarioDetailPage() {
|
||||
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;
|
||||
const data = await scenarios.exportScenario(scenario.id);
|
||||
const yamlContent = typeof data === 'string' ? data : yamlStringify(data);
|
||||
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);
|
||||
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) => {
|
||||
@@ -368,7 +394,7 @@ export function ScenarioDetailPage() {
|
||||
<History size={14} />
|
||||
{t('scenarios.action_runs')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenExportModal}>
|
||||
<Upload size={14} />
|
||||
{t('scenarios.action_export')}
|
||||
</Button>
|
||||
@@ -628,6 +654,29 @@ export function ScenarioDetailPage() {
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ExportScenarioModal
|
||||
open={exportModalOpen}
|
||||
scenario={scenario}
|
||||
scenarioCredentials={scenarioCreds}
|
||||
environment={
|
||||
scenario?.environment ?? envs.find((e) => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user