feat(web): step export/import, sync defaults, auto outputs, default badge

This commit is contained in:
2026-07-21 10:31:28 +01:00
parent b543cd1b3d
commit 90ce7af769
2 changed files with 87 additions and 12 deletions
+79 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -27,6 +27,14 @@ function ShellBadge({ interpreter }: { interpreter: "bash" | "powershell" }) {
);
}
function DefaultBadge() {
return (
<span className="rounded px-1.5 py-0.5 font-mono text-[10px] uppercase bg-surface-2 text-text-secondary">
default
</span>
);
}
export default function WorkflowBuilder() {
const params = useParams<{ id: string }>();
const id = params.id;
@@ -39,6 +47,10 @@ export default function WorkflowBuilder() {
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [syncing, setSyncing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [groupKeys, setGroupKeys] = useState<Record<string, string[]>>({});
const [editWorkflowOpen, setEditWorkflowOpen] = useState(false);
const [editingStep, setEditingStep] = useState<WorkflowStep | null>(null);
@@ -178,6 +190,38 @@ export default function WorkflowBuilder() {
updateRef(selectedIdxInWf, { overrides: { ...selectedRef.overrides, secret_refs: next } });
};
const onImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
setError(null);
try {
const doc = JSON.parse(await file.text());
await api.importStep(doc);
await queryClient.invalidateQueries({ queryKey: ["steps"] });
setNotice("Step imported.");
} catch (err) {
setError((err as Error).message);
} finally {
setImporting(false);
e.target.value = "";
}
};
const onSyncDefaults = async () => {
setSyncing(true);
setError(null);
try {
const { created, updated } = await api.seedDefaults();
await queryClient.invalidateQueries({ queryKey: ["steps"] });
setNotice(`${created} created, ${updated} updated`);
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
};
const filteredLibrary = (library ?? []).filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
const bashSteps = filteredLibrary.filter((s) => s.interpreter === "bash");
const pwshSteps = filteredLibrary.filter((s) => s.interpreter === "powershell");
@@ -236,6 +280,9 @@ export default function WorkflowBuilder() {
{error && (
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>
)}
{notice && (
<div className="border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm text-signal">{notice}</div>
)}
<div className="grid h-[calc(100vh-53px)] grid-cols-[264px_1fr_320px]">
{/* LEFT: library */}
@@ -253,6 +300,26 @@ export default function WorkflowBuilder() {
+
</Button>
</div>
<div className="mb-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={onImportFile}
/>
<Button
variant="ghost"
size="sm"
loading={importing}
onClick={() => fileInputRef.current?.click()}
>
Import
</Button>
<Button variant="ghost" size="sm" loading={syncing} onClick={onSyncDefaults}>
Sync defaults
</Button>
</div>
<input
className={`${inputClass} mb-3`}
placeholder="Search steps…"
@@ -542,13 +609,23 @@ function LibraryCard({ step, onAdd, onEdit }: { step: WorkflowStep; onAdd: () =>
<div className="mb-1 flex items-center gap-2">
<span className="text-text-secondary"></span>
<ShellBadge interpreter={step.interpreter} />
{step.source === "default" && <DefaultBadge />}
<span className="text-sm font-medium text-text-primary">{step.name}</span>
<a
href={api.exportStepUrl(step.step_id)}
download
onClick={(e) => e.stopPropagation()}
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Export step"
>
</a>
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="ml-auto hidden text-text-secondary hover:text-text-primary group-hover:block"
className="hidden text-text-secondary hover:text-text-primary group-hover:block"
title="Edit step"
>