feat(workflows): implement workflow duplication feature

- Added functionality to duplicate workflows via a new API endpoint, allowing users to create copies of existing workflows with modified names and owners.
- Introduced utility functions for managing workflow filenames and ensuring unique names for duplicates.
- Created a user interface component for duplicating workflows, including validation for existing files and owner changes.
- Enhanced existing workflows to support the new duplication feature, ensuring seamless integration with the current workflow management system.

This update significantly improves user experience by enabling efficient workflow management and organization.
This commit is contained in:
2026-08-15 23:00:16 +07:00
parent 43e9370638
commit cf4b342e04
15 changed files with 579 additions and 67 deletions
+6 -22
View File
@@ -2,9 +2,9 @@ import { useMemo, useState, Fragment } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { LuArrowLeft, LuFilter } from "react-icons/lu";
import { useRun } from "../api/hooks.js";
import { JsonViewBlock } from "../components/JsonViewBlock.jsx";
import { LogViewer } from "../components/LogViewer.jsx";
import { formatTime, StatusBadge } from "../lib/format.jsx";
import { prettyJson } from "../lib/script.js";
function stepLabel(s) {
if (s.script === "set") return "set";
@@ -19,22 +19,6 @@ function isEditableScript(s) {
return Boolean(s.script) && s.script !== "set";
}
function JsonBlock({ title, value }) {
if (value == null || value === "") return null;
const text = prettyJson(value);
if (!text) return null;
return (
<details className="collapse collapse-arrow border border-base-300 bg-base-100">
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">{title}</summary>
<div className="collapse-content">
<pre className="max-h-80 overflow-auto rounded-box bg-base-200 p-3 font-mono text-xs">
{text}
</pre>
</div>
</details>
);
}
function envelopeParts(raw) {
if (raw != null && typeof raw === "object" && !Array.isArray(raw) && ("output" in raw || "context" in raw)) {
return {
@@ -128,9 +112,9 @@ export function EventDetailPage() {
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<JsonBlock title="Run input (data)" value={run.input} />
<JsonBlock title="Run output" value={runParts.output} />
<JsonBlock title="Run context" value={runParts.context} />
<JsonViewBlock title="Run input (data)" value={run.input} />
<JsonViewBlock title="Run output" value={runParts.output} />
<JsonViewBlock title="Run context" value={runParts.context} />
</div>
{runParts.skipRemaining ? (
<p className="text-sm opacity-70">This run stopped early (<span className="font-mono">skipRemaining</span>).</p>
@@ -201,8 +185,8 @@ export function EventDetailPage() {
<tr>
<td colSpan={6} className="bg-base-200">
<div className="grid gap-2 p-2 sm:grid-cols-2">
<JsonBlock title="output" value={parts.output} />
<JsonBlock title="context" value={parts.context} />
<JsonViewBlock title="output" value={parts.output} />
<JsonViewBlock title="context" value={parts.context} />
</div>
</td>
</tr>
+70 -33
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useOwners,
@@ -8,6 +8,7 @@ import {
useSetWorkflowEnabled,
useWorkflow,
} from "../api/hooks.js";
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
@@ -21,6 +22,7 @@ function WorkflowEditorLayout({
saveSuccess,
onSave,
onTest,
onDuplicate,
onToggleEnabled,
enabled,
enablePending,
@@ -60,6 +62,12 @@ function WorkflowEditorLayout({
Test
</button>
) : null}
{onDuplicate ? (
<button type="button" className="btn btn-sm" onClick={onDuplicate}>
<LuCopy className="size-4" />
Duplicate
</button>
) : null}
<button
type="button"
className="btn btn-primary btn-sm"
@@ -145,6 +153,7 @@ export function WorkflowNewPage() {
}
export function WorkflowEditPage() {
const navigate = useNavigate();
const { owner: rawOwner, file: rawFile } = useParams();
const owner = decodeURIComponent(rawOwner ?? "");
const file = decodeURIComponent(rawFile ?? "");
@@ -155,6 +164,18 @@ export function WorkflowEditPage() {
const [savedYaml, setSavedYaml] = useState("");
const [contentReady, setContentReady] = useState(false);
const [testOpen, setTestOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const routeKey = `${owner}/${file}`;
const [activeKey, setActiveKey] = useState(routeKey);
if (activeKey !== routeKey) {
setActiveKey(routeKey);
setContent("");
setSavedYaml("");
setContentReady(false);
setTestOpen(false);
setDuplicateOpen(false);
}
useEffect(() => {
if (existing.isLoading) return;
@@ -200,38 +221,54 @@ export function WorkflowEditPage() {
const pageTitle = workflowName ? `${workflowName} (${file})` : file;
return (
<WorkflowEditorLayout
title={pageTitle}
savePending={save.isPending}
saveDisabled={!contentReady}
saveError={save.isError ? errorMessage(save.error) : null}
saveSuccess={save.isSuccess}
onSave={onSave}
onTest={() => setTestOpen(true)}
onToggleEnabled={() =>
setEnabled.mutate({
owner,
file,
enabled: existing.data?.parsed?.enabled === false,
})
}
enabled={existing.data?.parsed?.enabled !== false}
enablePending={setEnabled.isPending}
enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk}
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<WorkflowVisualEditor
yaml={content}
onYamlChange={setContent}
owner={owner}
file={file}
savedYaml={savedYaml}
showTest
testOpen={testOpen}
onTestClose={() => setTestOpen(false)}
<>
<WorkflowEditorLayout
title={pageTitle}
savePending={save.isPending}
saveDisabled={!contentReady}
saveError={save.isError ? errorMessage(save.error) : null}
saveSuccess={save.isSuccess}
onSave={onSave}
onTest={() => setTestOpen(true)}
onDuplicate={() => setDuplicateOpen(true)}
onToggleEnabled={() =>
setEnabled.mutate({
owner,
file,
enabled: existing.data?.parsed?.enabled === false,
})
}
enabled={existing.data?.parsed?.enabled !== false}
enablePending={setEnabled.isPending}
enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk}
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<WorkflowVisualEditor
yaml={content}
onYamlChange={setContent}
owner={owner}
file={file}
savedYaml={savedYaml}
showTest
testOpen={testOpen}
onTestClose={() => setTestOpen(false)}
/>
</div>
</WorkflowEditorLayout>
{duplicateOpen ? (
<DuplicateWorkflowDialog
source={{ owner, file, key: `${owner}/${file}` }}
warnUnsaved={contentReady && content !== savedYaml}
onClose={() => setDuplicateOpen(false)}
onDuplicated={(data) => {
setDuplicateOpen(false);
navigate(
`/workflows/${encodeURIComponent(data.owner)}/${encodeURIComponent(data.file)}/edit`,
);
}}
/>
</div>
</WorkflowEditorLayout>
) : null}
</>
);
}
+25 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { LuActivity, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
import { LuActivity, LuCopy, LuPencil, LuPlay, LuPlus, LuRefreshCw, LuTrash2, LuTriangleAlert } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDeleteWorkflow,
@@ -9,6 +9,7 @@ import {
useSetWorkflowEnabled,
useWorkflows,
} from "../api/hooks.js";
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { formatTime, WorkflowStatusBadge } from "../lib/format.jsx";
@@ -18,6 +19,7 @@ export function WorkflowsPage() {
const editParam = params.get("edit");
const { data: workflows = [], isLoading } = useWorkflows();
const [confirmDelete, setConfirmDelete] = useState(null);
const [duplicateSource, setDuplicateSource] = useState(null);
const [runError, setRunError] = useState(null);
const del = useDeleteWorkflow();
const run = useRunWorkflow();
@@ -188,6 +190,15 @@ export function WorkflowsPage() {
>
<LuPencil className="size-4" />
</Link>
<button
type="button"
className="btn btn-ghost btn-xs"
title="Duplicate"
disabled={Boolean(w.loadError)}
onClick={() => setDuplicateSource(w)}
>
<LuCopy className="size-4" />
</button>
<button
type="button"
className="btn btn-ghost btn-xs text-error"
@@ -204,6 +215,19 @@ export function WorkflowsPage() {
</div>
)}
{duplicateSource ? (
<DuplicateWorkflowDialog
source={duplicateSource}
onClose={() => setDuplicateSource(null)}
onDuplicated={(data) => {
setDuplicateSource(null);
navigate(
`/workflows/${encodeURIComponent(data.owner)}/${encodeURIComponent(data.file)}/edit`,
);
}}
/>
) : null}
{confirmDelete ? (
<dialog className="modal modal-open">
<div className="modal-box">