diff --git a/ui/src/components/workflow/WorkflowEditModal.tsx b/ui/src/components/workflow/WorkflowEditModal.tsx new file mode 100644 index 00000000..76932ece --- /dev/null +++ b/ui/src/components/workflow/WorkflowEditModal.tsx @@ -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(props, { + valuePropName: "open", + defaultValuePropName: "defaultOpen", + trigger: "onOpenChange", + }); + + const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) }); + + const formRef = useRef(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} + + 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} + > +
+ +
+
+ + ); +}; + +export default WorkflowEditModal; diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index d9069d5b..415123c4 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -509,18 +509,22 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} 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 deepClone = (node: WorkflowNode): WorkflowNode => { return produce(node, (draft) => { draft.id = nanoid(); if (draft.next) { - draft.next = cloneNode(draft.next); + draft.next = cloneNode(draft.next, { withCopySuffix }); } if (draft.branches) { - draft.branches = draft.branches.map((branch) => cloneNode(branch)); + draft.branches = draft.branches.map((branch) => cloneNode(branch, { withCopySuffix })); } return draft; @@ -528,7 +532,7 @@ export const cloneNode = (sourceNode: WorkflowNode): WorkflowNode => { }; const copyNode = produce(sourceNode, (draft) => { - draft.name = `${draft.name}-copy`; + draft.name = withCopySuffix ? `${draft.name}-copy` : draft.name; }); return deepClone(copyNode); }; diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json index b086e25f..900a7f44 100644 --- a/ui/src/i18n/locales/en/nls.workflow.json +++ b/ui/src/i18n/locales/en/nls.workflow.json @@ -7,6 +7,8 @@ "workflow.action.create": "Create 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.confirm": "Are you sure to delete this workflow?", "workflow.action.enable": "Enable", diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json index 9ff12aac..66e59ece 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.json +++ b/ui/src/i18n/locales/zh/nls.workflow.json @@ -7,6 +7,8 @@ "workflow.action.create": "新建工作流", "workflow.action.edit": "编辑工作流", + "workflow.action.duplicate": "复制工作流", + "workflow.action.duplicate.confirm": "确定要复制此工作流吗?", "workflow.action.delete": "删除工作流", "workflow.action.delete.confirm": "确定要删除此工作流吗?", "workflow.action.enable": "启用", diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index e40bc894..dd12e53a 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -9,6 +9,7 @@ import { EditOutlined as EditOutlinedIcon, PlusOutlined as PlusOutlinedIcon, ReloadOutlined as ReloadOutlinedIcon, + SnippetsOutlined as SnippetsOutlinedIcon, StopOutlined as StopOutlinedIcon, SyncOutlined as SyncOutlinedIcon, } from "@ant-design/icons"; @@ -39,7 +40,7 @@ import { import dayjs from "dayjs"; 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 { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow"; import { getErrMsg } from "@/utils/error"; @@ -219,6 +220,17 @@ const WorkflowList = () => { /> + +