feat: duplicate workflow

This commit is contained in:
Fu Diwei 2025-06-09 21:04:54 +08:00
parent 5e6d729631
commit a750592eb5
5 changed files with 177 additions and 5 deletions

View File

@ -0,0 +1,122 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Modal, notification } from "antd";
import ModalForm from "@/components/ModalForm";
import { useTriggerElement, useZustandShallowSelector } from "@/hooks";
import { getErrMsg } from "@/utils/error";
import WorkflowForm, { type WorkflowFormInstance, type WorkflowFormProps } from "./WorkflowForm";
export type WorkflowEditModalProps = {
data?: WorkflowFormProps["initialValues"];
loading?: boolean;
open?: boolean;
usage?: WorkflowFormProps["usage"];
scene: WorkflowFormProps["scene"];
trigger?: React.ReactNode;
onOpenChange?: (open: boolean) => void;
afterSubmit?: (record: WorkflowModel) => void;
};
const WorkflowEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ...props }: WorkflowEditModalProps) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { createWorkflow, updateWorkflow } = useWorkflowesStore(useZustandShallowSelector(["createWorkflow", "updateWorkflow"]));
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
defaultValuePropName: "defaultOpen",
trigger: "onOpenChange",
});
const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) });
const formRef = useRef<WorkflowFormInstance>(null);
const [formPending, setFormPending] = useState(false);
const handleOkClick = async () => {
setFormPending(true);
try {
await formRef.current!.validateFields();
} catch (err) {
setFormPending(false);
throw err;
}
try {
let values: WorkflowModel = formRef.current!.getFieldsValue();
if (scene === "add") {
if (data?.id) {
throw "Invalid props: `data`";
}
values = await createWorkflow(values);
} else if (scene === "edit") {
if (!data?.id) {
throw "Invalid props: `data`";
}
values = await updateWorkflow({ ...data, ...values });
} else {
throw "Invalid props: `preset`";
}
afterSubmit?.(values);
setOpen(false);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
} finally {
setFormPending(false);
}
};
const handleCancelClick = () => {
if (formPending) return;
setOpen(false);
};
return (
<>
{NotificationContextHolder}
{triggerEl}
<Modal
styles={{
content: {
maxHeight: "calc(80vh - 64px)",
overflowX: "hidden",
overflowY: "auto",
},
}}
afterClose={() => setOpen(false)}
cancelButtonProps={{ disabled: formPending }}
cancelText={t("common.button.cancel")}
closable
confirmLoading={formPending}
destroyOnHidden
loading={loading}
okText={scene === "edit" ? t("common.button.save") : t("common.button.submit")}
open={open}
title={t(`access.action.${scene}`)}
width={480}
onOk={handleOkClick}
onCancel={handleCancelClick}
>
<div className="pb-2 pt-4">
<WorkflowForm ref={formRef} initialValues={data} scene={scene === "add" ? "add" : "edit"} usage={usage} />
</div>
</Modal>
</>
);
};
export default WorkflowEditModal;

View File

@ -509,18 +509,22 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {}
return node; return node;
}; };
export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => { type CloneNodeOptions = {
withCopySuffix?: boolean;
};
export const cloneNode = (sourceNode: WorkflowNode, { withCopySuffix }: CloneNodeOptions = { withCopySuffix: true }): WorkflowNode => {
const { produce } = new Immer({ autoFreeze: false }); const { produce } = new Immer({ autoFreeze: false });
const deepClone = (node: WorkflowNode): WorkflowNode => { const deepClone = (node: WorkflowNode): WorkflowNode => {
return produce(node, (draft) => { return produce(node, (draft) => {
draft.id = nanoid(); draft.id = nanoid();
if (draft.next) { if (draft.next) {
draft.next = cloneNode(draft.next); draft.next = cloneNode(draft.next, { withCopySuffix });
} }
if (draft.branches) { if (draft.branches) {
draft.branches = draft.branches.map((branch) => cloneNode(branch)); draft.branches = draft.branches.map((branch) => cloneNode(branch, { withCopySuffix }));
} }
return draft; return draft;
@ -528,7 +532,7 @@ export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => {
}; };
const copyNode = produce(sourceNode, (draft) => { const copyNode = produce(sourceNode, (draft) => {
draft.name = `${draft.name}-copy`; draft.name = withCopySuffix ? `${draft.name}-copy` : draft.name;
}); });
return deepClone(copyNode); return deepClone(copyNode);
}; };

View File

@ -7,6 +7,8 @@
"workflow.action.create": "Create workflow", "workflow.action.create": "Create workflow",
"workflow.action.edit": "Edit workflow", "workflow.action.edit": "Edit workflow",
"workflow.action.duplicate": "Duplicate workflow",
"workflow.action.duplicate.confirm": "Are you sure to duplicate this workflow?",
"workflow.action.delete": "Delete workflow", "workflow.action.delete": "Delete workflow",
"workflow.action.delete.confirm": "Are you sure to delete this workflow?", "workflow.action.delete.confirm": "Are you sure to delete this workflow?",
"workflow.action.enable": "Enable", "workflow.action.enable": "Enable",

View File

@ -7,6 +7,8 @@
"workflow.action.create": "新建工作流", "workflow.action.create": "新建工作流",
"workflow.action.edit": "编辑工作流", "workflow.action.edit": "编辑工作流",
"workflow.action.duplicate": "复制工作流",
"workflow.action.duplicate.confirm": "确定要复制此工作流吗?",
"workflow.action.delete": "删除工作流", "workflow.action.delete": "删除工作流",
"workflow.action.delete.confirm": "确定要删除此工作流吗?", "workflow.action.delete.confirm": "确定要删除此工作流吗?",
"workflow.action.enable": "启用", "workflow.action.enable": "启用",

View File

@ -9,6 +9,7 @@ import {
EditOutlined as EditOutlinedIcon, EditOutlined as EditOutlinedIcon,
PlusOutlined as PlusOutlinedIcon, PlusOutlined as PlusOutlinedIcon,
ReloadOutlined as ReloadOutlinedIcon, ReloadOutlined as ReloadOutlinedIcon,
SnippetsOutlined as SnippetsOutlinedIcon,
StopOutlined as StopOutlinedIcon, StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon, SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons"; } from "@ant-design/icons";
@ -39,7 +40,7 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase"; import { ClientResponseError } from "pocketbase";
import { WORKFLOW_TRIGGERS, type WorkflowModel, isAllNodesValidated } from "@/domain/workflow"; import { WORKFLOW_TRIGGERS, type WorkflowModel, cloneNode, initWorkflow, isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun"; import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow"; import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
import { getErrMsg } from "@/utils/error"; import { getErrMsg } from "@/utils/error";
@ -219,6 +220,17 @@ const WorkflowList = () => {
/> />
</Tooltip> </Tooltip>
<Tooltip title={t("workflow.action.duplicate")}>
<Button
color="primary"
icon={<SnippetsOutlinedIcon />}
variant="text"
onClick={() => {
handleDuplicateClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow.action.delete")}> <Tooltip title={t("workflow.action.delete")}>
<Button <Button
color="danger" color="danger"
@ -321,6 +333,36 @@ const WorkflowList = () => {
} }
}; };
const handleDuplicateClick = (workflow: WorkflowModel) => {
modalApi.confirm({
title: t("workflow.action.duplicate"),
content: t("workflow.action.duplicate.confirm"),
onOk: async () => {
try {
const workflowCopy = {
name: `${workflow.name}-copy`,
description: workflow.description,
trigger: workflow.trigger,
triggerCron: workflow.triggerCron,
draft: workflow.content
? cloneNode(workflow.content, { withCopySuffix: false })
: workflow.draft
? cloneNode(workflow.draft, { withCopySuffix: false })
: initWorkflow().draft,
hasDraft: true,
} as WorkflowModel;
const resp = await saveWorkflow(workflowCopy);
if (resp) {
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDeleteClick = (workflow: WorkflowModel) => { const handleDeleteClick = (workflow: WorkflowModel) => {
modalApi.confirm({ modalApi.confirm({
title: t("workflow.action.delete"), title: t("workflow.action.delete"),