feat(export-import): add yaml export/import with id upsert for credentials, snippets, and scenarios
- all entities export a kind field (credential/snippet/scenario) for safe type checking on import - import upserts by id: overwrites if id exists, creates with explicit id otherwise - scenario export now includes id and step ids; import deletes old steps before recreating - add GET /:id/export and POST /import endpoints to credential and snippet controllers - add UuidBadge ui component: shortens uuid to first+last 4 hex chars, tooltip, clipboard copy with animated ClipboardCheck icon pop - apply UuidBadge across all entity id display sites (detail pages, card footers, table columns) - add export/import buttons to CredentialsPage, SnippetsPage, CredentialDetailPage, SnippetDetailPage
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pencil, Plus, History, Trash2 } from 'lucide-react';
|
||||
import { Play, Pencil, Plus, History, Trash2, Download } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Select,
|
||||
Table,
|
||||
Timestamp,
|
||||
UuidBadge,
|
||||
type TableColumn,
|
||||
} from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
@@ -46,7 +48,7 @@ export function ScenarioDetailPage() {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
scenarios
|
||||
.get(Number(id))
|
||||
.get(id)
|
||||
.then((s) => {
|
||||
setScenario(s);
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
@@ -71,8 +73,20 @@ export function ScenarioDetailPage() {
|
||||
navigate('/scenarios');
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (stepId: number) => {
|
||||
await steps.remove(Number(id), stepId);
|
||||
const handleExport = async () => {
|
||||
if (!scenario) return;
|
||||
const data = await scenarios.exportScenario(scenario.id);
|
||||
const blob = new Blob([yamlStringify(data)], { 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);
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (stepId: string) => {
|
||||
await steps.remove(id!, stepId);
|
||||
setScenario((prev) =>
|
||||
prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev,
|
||||
);
|
||||
@@ -86,9 +100,9 @@ export function ScenarioDetailPage() {
|
||||
if (!valid) return;
|
||||
setAddCredSaving(true);
|
||||
try {
|
||||
const sc = await scenarioCredentials.add(Number(id), Number(addCredId), addAlias.trim());
|
||||
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(Number(id));
|
||||
const full = await scenarioCredentials.list(id!);
|
||||
setScenarioCreds(full);
|
||||
void sc;
|
||||
setAddCredOpen(false);
|
||||
@@ -101,8 +115,8 @@ export function ScenarioDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCredential = async (scCredId: number) => {
|
||||
await scenarioCredentials.remove(Number(id), scCredId);
|
||||
const handleRemoveCredential = async (scCredId: string) => {
|
||||
await scenarioCredentials.remove(id!, scCredId);
|
||||
setScenarioCreds((prev) => prev.filter((sc) => sc.id !== scCredId));
|
||||
};
|
||||
|
||||
@@ -212,6 +226,10 @@ export function ScenarioDetailPage() {
|
||||
<History size={14} />
|
||||
{t('scenarios.action_runs')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleExport}>
|
||||
<Download size={14} />
|
||||
{t('scenarios.action_export')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
|
||||
<Pencil size={14} />
|
||||
{t('scenarios.action_edit')}
|
||||
@@ -240,7 +258,7 @@ export function ScenarioDetailPage() {
|
||||
<DescriptionList
|
||||
layout="comfortable"
|
||||
items={[
|
||||
{ term: t('scenarios.field_id'), detail: scenario.id },
|
||||
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
||||
{ term: t('scenarios.field_name'), detail: scenario.name },
|
||||
{ term: t('scenarios.field_steps'), detail: scenario.steps.length },
|
||||
{
|
||||
@@ -296,7 +314,8 @@ export function ScenarioDetailPage() {
|
||||
</div>
|
||||
|
||||
{addCredOpen && (
|
||||
<Card className={styles.formCard} style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<div style={{ marginBottom: 'var(--space-4)' }}>
|
||||
<Card className={styles.formCard}>
|
||||
<form onSubmit={handleAddCredential} noValidate>
|
||||
<div className={styles.formFields}>
|
||||
<Select
|
||||
@@ -332,6 +351,7 @@ export function ScenarioDetailPage() {
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scenarioCreds.length > 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user