From 87e17495532396c8fde78366cdd4073fc19283e1 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Mon, 6 Jan 2025 19:10:29 +0800 Subject: [PATCH] fix(ui): antd nested form bugs --- ui/src/components/DrawerForm.tsx | 63 +-- ui/src/components/ModalForm.tsx | 58 +-- ui/src/components/MultipleInput.tsx | 2 +- ui/src/components/access/AccessEditForm.tsx | 31 +- ui/src/components/access/AccessEditModal.tsx | 29 +- ui/src/components/access/AccessSelect.tsx | 2 +- .../notification/NotifyChannels.tsx | 8 +- .../notification/NotifyTemplate.tsx | 2 + .../components/workflow/WorkflowElement.tsx | 23 +- ui/src/components/workflow/node/AddNode.tsx | 9 +- ui/src/components/workflow/node/ApplyNode.tsx | 92 ++++ .../workflow/node/ApplyNodeConfigForm.tsx | 386 +++++++++++++++++ .../workflow/node/ApplyNodeForm.tsx | 408 ------------------ .../components/workflow/node/BranchNode.tsx | 6 +- .../components/workflow/node/CommonNode.tsx | 317 -------------- .../workflow/node/ConditionNode.tsx | 8 +- .../components/workflow/node/DeployNode.tsx | 103 +++++ .../workflow/node/DeployNodeConfigForm.tsx | 290 +++++++++++++ ...> DeployNodeConfigFormAliyunALBFields.tsx} | 0 ...> DeployNodeConfigFormAliyunCDNFields.tsx} | 0 ...> DeployNodeConfigFormAliyunCLBFields.tsx} | 0 ... DeployNodeConfigFormAliyunDCDNFields.tsx} | 0 ...> DeployNodeConfigFormAliyunNLBFields.tsx} | 0 ...> DeployNodeConfigFormAliyunOSSFields.tsx} | 0 ...ployNodeConfigFormBaiduCloudCDNFields.tsx} | 0 ...DeployNodeConfigFormBytePlusCDNFields.tsx} | 0 ...eployNodeConfigFormDogeCloudCDNFields.tsx} | 0 ...loyNodeConfigFormHuaweiCloudCDNFields.tsx} | 0 ...loyNodeConfigFormHuaweiCloudELBFields.tsx} | 0 ...yNodeConfigFormKubernetesSecretFields.tsx} | 0 ...sx => DeployNodeConfigFormLocalFields.tsx} | 0 ...=> DeployNodeConfigFormQiniuCDNFields.tsx} | 0 ....tsx => DeployNodeConfigFormSSHFields.tsx} | 0 ...oyNodeConfigFormTencentCloudCDNFields.tsx} | 0 ...oyNodeConfigFormTencentCloudCLBFields.tsx} | 0 ...oyNodeConfigFormTencentCloudCOSFields.tsx} | 0 ...yNodeConfigFormTencentCloudECDNFields.tsx} | 0 ...loyNodeConfigFormTencentCloudEOFields.tsx} | 0 ...ployNodeConfigFormVolcEngineCDNFields.tsx} | 0 ...loyNodeConfigFormVolcEngineLiveFields.tsx} | 0 ... => DeployNodeConfigFormWebhookFields.tsx} | 0 .../workflow/node/DeployNodeForm.tsx | 273 ------------ .../components/workflow/node/NotifyNode.tsx | 95 ++++ .../workflow/node/NotifyNodeConfigForm.tsx | 126 ++++++ .../workflow/node/NotifyNodeForm.tsx | 112 ----- ui/src/components/workflow/node/StartNode.tsx | 99 +++++ .../workflow/node/StartNodeConfigForm.tsx | 146 +++++++ .../workflow/node/StartNodeForm.tsx | 131 ------ .../components/workflow/node/_SharedNode.tsx | 201 +++++++++ ui/src/hooks/useAntdForm.ts | 4 +- ui/src/hooks/useAntdFormName.ts | 3 +- ui/src/hooks/useTriggerElement.ts | 4 +- .../i18n/locales/en/nls.workflow.nodes.json | 2 +- .../i18n/locales/zh/nls.workflow.nodes.json | 2 +- ui/src/pages/login/Login.tsx | 2 + ui/src/pages/settings/SettingsAccount.tsx | 2 + ui/src/pages/settings/SettingsPassword.tsx | 2 + ui/src/pages/workflows/WorkflowDetail.tsx | 4 +- ui/src/pages/workflows/WorkflowList.tsx | 2 +- ui/src/pages/workflows/WorkflowNew.tsx | 5 +- 60 files changed, 1694 insertions(+), 1358 deletions(-) create mode 100644 ui/src/components/workflow/node/ApplyNode.tsx create mode 100644 ui/src/components/workflow/node/ApplyNodeConfigForm.tsx delete mode 100644 ui/src/components/workflow/node/ApplyNodeForm.tsx delete mode 100644 ui/src/components/workflow/node/CommonNode.tsx create mode 100644 ui/src/components/workflow/node/DeployNode.tsx create mode 100644 ui/src/components/workflow/node/DeployNodeConfigForm.tsx rename ui/src/components/workflow/node/{DeployNodeFormAliyunALBFields.tsx => DeployNodeConfigFormAliyunALBFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormAliyunCDNFields.tsx => DeployNodeConfigFormAliyunCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormAliyunCLBFields.tsx => DeployNodeConfigFormAliyunCLBFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormAliyunDCDNFields.tsx => DeployNodeConfigFormAliyunDCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormAliyunNLBFields.tsx => DeployNodeConfigFormAliyunNLBFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormAliyunOSSFields.tsx => DeployNodeConfigFormAliyunOSSFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormBaiduCloudCDNFields.tsx => DeployNodeConfigFormBaiduCloudCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormBytePlusCDNFields.tsx => DeployNodeConfigFormBytePlusCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormDogeCloudCDNFields.tsx => DeployNodeConfigFormDogeCloudCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormHuaweiCloudCDNFields.tsx => DeployNodeConfigFormHuaweiCloudCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormHuaweiCloudELBFields.tsx => DeployNodeConfigFormHuaweiCloudELBFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormKubernetesSecretFields.tsx => DeployNodeConfigFormKubernetesSecretFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormLocalFields.tsx => DeployNodeConfigFormLocalFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormQiniuCDNFields.tsx => DeployNodeConfigFormQiniuCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormSSHFields.tsx => DeployNodeConfigFormSSHFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormTencentCloudCDNFields.tsx => DeployNodeConfigFormTencentCloudCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormTencentCloudCLBFields.tsx => DeployNodeConfigFormTencentCloudCLBFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormTencentCloudCOSFields.tsx => DeployNodeConfigFormTencentCloudCOSFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormTencentCloudECDNFields.tsx => DeployNodeConfigFormTencentCloudECDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormTencentCloudEOFields.tsx => DeployNodeConfigFormTencentCloudEOFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormVolcEngineCDNFields.tsx => DeployNodeConfigFormVolcEngineCDNFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormVolcEngineLiveFields.tsx => DeployNodeConfigFormVolcEngineLiveFields.tsx} (100%) rename ui/src/components/workflow/node/{DeployNodeFormWebhookFields.tsx => DeployNodeConfigFormWebhookFields.tsx} (100%) delete mode 100644 ui/src/components/workflow/node/DeployNodeForm.tsx create mode 100644 ui/src/components/workflow/node/NotifyNode.tsx create mode 100644 ui/src/components/workflow/node/NotifyNodeConfigForm.tsx delete mode 100644 ui/src/components/workflow/node/NotifyNodeForm.tsx create mode 100644 ui/src/components/workflow/node/StartNode.tsx create mode 100644 ui/src/components/workflow/node/StartNodeConfigForm.tsx delete mode 100644 ui/src/components/workflow/node/StartNodeForm.tsx create mode 100644 ui/src/components/workflow/node/_SharedNode.tsx diff --git a/ui/src/components/DrawerForm.tsx b/ui/src/components/DrawerForm.tsx index bfacfd5f..0c319fb6 100644 --- a/ui/src/components/DrawerForm.tsx +++ b/ui/src/components/DrawerForm.tsx @@ -11,15 +11,16 @@ export interface DrawerFormProps = any> extends O cancelButtonProps?: ModalProps["cancelButtonProps"]; cancelText?: ModalProps["cancelText"]; defaultOpen?: boolean; - drawerProps?: Omit; + drawerProps?: Omit; okButtonProps?: ModalProps["okButtonProps"]; okText?: ModalProps["okText"]; open?: boolean; title?: React.ReactNode; trigger?: React.ReactNode; width?: string | number; + onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise; + onFinish?: (values: T) => unknown | Promise; onOpenChange?: (open: boolean) => void; - onFinish?: (values: T) => void | Promise; } const DrawerForm = = any>({ @@ -46,7 +47,13 @@ const DrawerForm = = any>({ trigger: "onOpenChange", }); - const triggerEl = useTriggerElement(trigger, { onClick: () => setOpen(true) }); + const triggerEl = useTriggerElement(trigger, { + onClick: () => { + console.log("click"); + setOpen(true); + console.log(open); + }, + }); const { form: formInst, @@ -55,31 +62,38 @@ const DrawerForm = = any>({ submit, } = useAntdForm({ form, - onSubmit: async (values) => { - try { - const ret = await onFinish?.(values); - if (ret != null && !ret) return false; - return true; - } catch { - return false; - } + onSubmit: (values) => { + return onFinish?.(values); }, }); - const mergedFormProps = { + + const mergedFormProps: FormProps = { clearOnDestroy: drawerProps?.destroyOnClose ? true : undefined, ...formProps, ...props, }; - const handleClose = () => { - if (formPending) return; + const mergedDrawerProps: DrawerProps = { + ...drawerProps, + afterOpenChange: (open) => { + if (!open && !mergedFormProps.preserve) { + formInst.resetFields(); + } - setOpen(false); + drawerProps?.afterOpenChange?.(open); + }, + onClose: async (e) => { + if (formPending) return; + + // 关闭 Drawer 时 Promise.reject 阻止关闭 + await drawerProps?.onClose?.(e); + setOpen(false); + }, }; const handleOkClick = async () => { - const ret = await submit(); - if (ret != null && !ret) return; + // 提交表单返回 Promise.reject 时不关闭 Drawer + await submit(); setOpen(false); }; @@ -95,30 +109,23 @@ const DrawerForm = = any>({ {triggerEl} { - if (!open && !mergedFormProps.preserve) { - formInst.resetFields(); - } - - drawerProps?.afterOpenChange?.(open); - }} + {...mergedDrawerProps} footer={ - } + forceRender open={open} title={title} width={width} - {...drawerProps} - onClose={handleClose} > -
+ {children}
diff --git a/ui/src/components/ModalForm.tsx b/ui/src/components/ModalForm.tsx index 56b4321b..1bf84c79 100644 --- a/ui/src/components/ModalForm.tsx +++ b/ui/src/components/ModalForm.tsx @@ -15,6 +15,7 @@ export interface ModalFormProps = any> extends Om | "cancelButtonProps" | "cancelText" | "confirmLoading" + | "defaultOpen" | "forceRender" | "okButtonProps" | "okText" @@ -22,8 +23,9 @@ export interface ModalFormProps = any> extends Om | "open" | "title" | "width" - | "onOk" | "onCancel" + | "onOk" + | "onOpenChange" >; okButtonProps?: ModalProps["okButtonProps"]; okText?: ModalProps["okText"]; @@ -31,8 +33,9 @@ export interface ModalFormProps = any> extends Om title?: ModalProps["title"]; trigger?: React.ReactNode; width?: ModalProps["width"]; + onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise; + onFinish?: (values: T) => unknown | Promise; onOpenChange?: (open: boolean) => void; - onFinish?: (values: T) => void | Promise; } const ModalForm = = any>({ @@ -63,29 +66,41 @@ const ModalForm = = any>({ form: formInst, formPending, formProps, - submit, + submit: submitForm, } = useAntdForm({ form, - onSubmit: async (values) => { - try { - const ret = await onFinish?.(values); - if (ret != null && !ret) return false; - return true; - } catch { - return false; - } + onSubmit: (values) => { + return onFinish?.(values); }, }); - const mergedFormProps = { + + const mergedFormProps: FormProps = { clearOnDestroy: modalProps?.destroyOnClose ? true : undefined, ...formProps, ...props, }; - const handleOkClick = async () => { - const ret = await submit(); - if (ret != null && !ret) return; + const mergedModalProps: ModalProps = { + ...modalProps, + afterClose: () => { + if (!mergedFormProps.preserve) { + formInst.resetFields(); + } + modalProps?.afterClose?.(); + }, + onClose: async (e) => { + if (formPending) return; + + // 关闭 Modal 时 Promise.reject 阻止关闭 + await modalProps?.onClose?.(e as React.MouseEvent | React.KeyboardEvent); + setOpen(false); + }, + }; + + const handleOkClick = async () => { + // 提交表单返回 Promise.reject 时不关闭 Modal + await submitForm(); setOpen(false); }; @@ -100,29 +115,22 @@ const ModalForm = = any>({ {triggerEl} { - if (!mergedFormProps.preserve) { - formInst.resetFields(); - } - - modalProps?.afterClose?.(); - }} + {...mergedModalProps} cancelButtonProps={cancelButtonProps} cancelText={cancelText} confirmLoading={formPending} - forceRender={true} + forceRender okButtonProps={okButtonProps} okText={okText} okType="primary" open={open} title={title} width={width} - {...modalProps} onOk={handleOkClick} onCancel={handleCancelClick} >
-
+ {children}
diff --git a/ui/src/components/MultipleInput.tsx b/ui/src/components/MultipleInput.tsx index ea9e2fab..f1383e34 100644 --- a/ui/src/components/MultipleInput.tsx +++ b/ui/src/components/MultipleInput.tsx @@ -48,7 +48,7 @@ const MultipleInput = ({ }); const handleCreate = () => { - const newValue = produce(value, (draft) => { + const newValue = produce(value ?? [], (draft) => { draft.push(""); }); setValue(newValue); diff --git a/ui/src/components/access/AccessEditForm.tsx b/ui/src/components/access/AccessEditForm.tsx index 325b71c3..8b967244 100644 --- a/ui/src/components/access/AccessEditForm.tsx +++ b/ui/src/components/access/AccessEditForm.tsx @@ -64,16 +64,23 @@ const AccessEditForm = forwardRef(( initialValues: initialValues, }); - const configProvider = Form.useWatch("provider", formInst); - const [configFormInst] = Form.useForm(); - const configFormName = useAntdFormName({ form: configFormInst, name: "accessEditConfigForm" }); - const configFormEl = useMemo(() => { + const fieldProvider = Form.useWatch("provider", formInst); + + const [nestedFormInst] = Form.useForm(); + const nestedFormName = useAntdFormName({ form: nestedFormInst, name: "accessEditFormConfigForm" }); + const nestedFormEl = useMemo(() => { + const configFormProps = { + form: nestedFormInst, + formName: nestedFormName, + disabled: disabled, + initialValues: initialValues?.config, + }; + /* 注意:如果追加新的子组件,请保持以 ASCII 排序。 NOTICE: If you add new child component, please keep ASCII order. */ - const configFormProps = { form: configFormInst, formName: configFormName, disabled: disabled, initialValues: initialValues?.config }; - switch (configProvider) { + switch (fieldProvider) { case ACCESS_PROVIDERS.ACMEHTTPREQ: return ; case ACCESS_PROVIDERS.ALIYUN: @@ -113,17 +120,17 @@ const AccessEditForm = forwardRef(( case ACCESS_PROVIDERS.WEBHOOK: return ; } - }, [disabled, initialValues, configProvider, configFormInst, configFormName]); + }, [disabled, initialValues, fieldProvider, nestedFormInst, nestedFormName]); const handleFormProviderChange = (name: string) => { - if (name === configFormName) { - formInst.setFieldValue("config", configFormInst.getFieldsValue()); + if (name === nestedFormName) { + formInst.setFieldValue("config", nestedFormInst.getFieldsValue()); onValuesChange?.(formInst.getFieldsValue(true)); } }; const handleFormChange = (_: unknown, values: AccessEditFormFieldValues) => { - if (values.provider !== configProvider) { + if (values.provider !== fieldProvider) { formInst.setFieldValue("provider", values.provider); } @@ -140,7 +147,7 @@ const AccessEditForm = forwardRef(( }, validateFields: (nameList, config) => { const t1 = formInst.validateFields(nameList, config); - const t2 = configFormInst.validateFields(undefined, config); + const t2 = nestedFormInst.validateFields(undefined, config); return Promise.all([t1, t2]).then(() => t1); }, } as AccessEditFormInstance; @@ -164,7 +171,7 @@ const AccessEditForm = forwardRef(( - {configFormEl} + {nestedFormEl} ); diff --git a/ui/src/components/access/AccessEditModal.tsx b/ui/src/components/access/AccessEditModal.tsx index cc735dee..6d9bc7ad 100644 --- a/ui/src/components/access/AccessEditModal.tsx +++ b/ui/src/components/access/AccessEditModal.tsx @@ -18,10 +18,10 @@ export type AccessEditModalProps = { preset: AccessEditFormProps["preset"]; trigger?: React.ReactNode; onOpenChange?: (open: boolean) => void; - onSubmit?: (record: AccessModel) => void; + afterSubmit?: (record: AccessModel) => void; }; -const AccessEditModal = ({ data, loading, trigger, preset, onSubmit, ...props }: AccessEditModalProps) => { +const AccessEditModal = ({ data, loading, trigger, preset, afterSubmit, ...props }: AccessEditModalProps) => { const { t } = useTranslation(); const [notificationApi, NotificationContextHolder] = notification.useNotification(); @@ -39,45 +39,48 @@ const AccessEditModal = ({ data, loading, trigger, preset, onSubmit, ...props }: const formRef = useRef(null); const [formPending, setFormPending] = useState(false); - const handleClickOk = async () => { + const handleOkClick = async () => { setFormPending(true); try { await formRef.current!.validateFields(); } catch (err) { setFormPending(false); - return Promise.reject(err); + throw err; } try { - let temp: AccessModel = formRef.current!.getFieldsValue(); - temp.usage = accessProvidersMap.get(temp.provider)!.usage; + let values: AccessModel = formRef.current!.getFieldsValue(); + values.usage = accessProvidersMap.get(values.provider)!.usage; + if (preset === "add") { if (data?.id) { throw "Invalid props: `data`"; } - temp = await createAccess(temp); + values = await createAccess(values); } else if (preset === "edit") { if (!data?.id) { throw "Invalid props: `data`"; } - temp = await updateAccess({ ...data, ...temp }); + values = await updateAccess({ ...data, ...values }); } else { throw "Invalid props: `preset`"; } - onSubmit?.(temp); + afterSubmit?.(values); setOpen(false); } catch (err) { notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + throw err; } finally { setFormPending(false); } }; - const handleClickCancel = () => { - if (formPending) return Promise.reject(); + const handleCancelClick = () => { + if (formPending) return; setOpen(false); }; @@ -99,8 +102,8 @@ const AccessEditModal = ({ data, loading, trigger, preset, onSubmit, ...props }: open={open} title={t(`access.action.${preset}`)} width={480} - onOk={handleClickOk} - onCancel={handleClickCancel} + onOk={handleOkClick} + onCancel={handleCancelClick} >
diff --git a/ui/src/components/access/AccessSelect.tsx b/ui/src/components/access/AccessSelect.tsx index 488f5e00..3ad60348 100644 --- a/ui/src/components/access/AccessSelect.tsx +++ b/ui/src/components/access/AccessSelect.tsx @@ -17,7 +17,7 @@ const AccessSelect = ({ filter, ...props }: AccessTypeSelectProps) => { const { accesses, loadedAtOnce, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "loadedAtOnce", "fetchAccesses"])); useEffect(() => { fetchAccesses(); - }, [fetchAccesses]); + }, []); const [options, setOptions] = useState>([]); useEffect(() => { diff --git a/ui/src/components/notification/NotifyChannels.tsx b/ui/src/components/notification/NotifyChannels.tsx index 8c869ea4..8dacecb9 100644 --- a/ui/src/components/notification/NotifyChannels.tsx +++ b/ui/src/components/notification/NotifyChannels.tsx @@ -79,10 +79,12 @@ export type NotifyChannelsProps = { const NotifyChannels = ({ className, classNames, style, styles }: NotifyChannelsProps) => { const { t, i18n } = useTranslation(); - const { channels, loadedAtOnce, setChannel, fetchChannels } = useNotifyChannelsStore(); + const { channels, loadedAtOnce, setChannel, fetchChannels } = useNotifyChannelsStore( + useZustandShallowSelector(["channels", "loadedAtOnce", "setChannel", "fetchChannels"]) + ); useEffect(() => { fetchChannels(); - }, [fetchChannels]); + }, []); const channelCollapseItems: CollapseProps["items"] = useDeepCompareMemo( () => @@ -115,7 +117,7 @@ const NotifyChannels = ({ className, classNames, style, styles }: NotifyChannels return (
}> - +
); diff --git a/ui/src/components/notification/NotifyTemplate.tsx b/ui/src/components/notification/NotifyTemplate.tsx index fa554583..b7cf4e6d 100644 --- a/ui/src/components/notification/NotifyTemplate.tsx +++ b/ui/src/components/notification/NotifyTemplate.tsx @@ -55,6 +55,8 @@ const NotifyTemplateForm = ({ className, style }: NotifyTemplateFormProps) => { messageApi.success(t("common.text.operation_succeeded")); } catch (err) { notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + throw err; } }, }); diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index 4ec94e1e..758699b6 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -2,10 +2,13 @@ import { memo, useMemo } from "react"; import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; +import ApplyNode from "./node/ApplyNode"; import BranchNode from "./node/BranchNode"; -import CommonNode from "./node/CommonNode"; import ConditionNode from "./node/ConditionNode"; +import DeployNode from "./node/DeployNode"; import EndNode from "./node/EndNode"; +import NotifyNode from "./node/NotifyNode"; +import StartNode from "./node/StartNode"; export type WorkflowElementProps = { node: WorkflowNode; @@ -14,20 +17,26 @@ export type WorkflowElementProps = { branchIndex?: number; }; -const WorkflowElement = ({ node, disabled, ...props }: WorkflowElementProps) => { - const workflowNodeEl = useMemo(() => { +const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElementProps) => { + const nodeEl = useMemo(() => { switch (node.type) { case WorkflowNodeType.Start: + return ; + case WorkflowNodeType.Apply: + return ; + case WorkflowNodeType.Deploy: + return ; + case WorkflowNodeType.Notify: - return ; + return ; case WorkflowNodeType.Branch: return ; case WorkflowNodeType.Condition: - return ; + return ; case WorkflowNodeType.End: return ; @@ -36,9 +45,9 @@ const WorkflowElement = ({ node, disabled, ...props }: WorkflowElementProps) => console.warn(`[certimate] unsupported workflow node type: ${node.type}`); return <>; } - }, [node, disabled, props]); + }, [node, disabled, branchId, branchIndex]); - return <>{workflowNodeEl}; + return <>{nodeEl}; }; export default memo(WorkflowElement); diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index 39e2b2fb..059ad0a2 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -9,14 +9,13 @@ import { } from "@ant-design/icons"; import { Dropdown } from "antd"; -import { type WorkflowNode, WorkflowNodeType, newNode } from "@/domain/workflow"; +import { WorkflowNodeType, newNode } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; -export type AddNodeProps = { - node: WorkflowNode; - disabled?: boolean; -}; +import { type SharedNodeProps } from "./_SharedNode"; + +export type AddNodeProps = SharedNodeProps; const AddNode = ({ node, disabled }: AddNodeProps) => { const { t } = useTranslation(); diff --git a/ui/src/components/workflow/node/ApplyNode.tsx b/ui/src/components/workflow/node/ApplyNode.tsx new file mode 100644 index 00000000..f3f901ee --- /dev/null +++ b/ui/src/components/workflow/node/ApplyNode.tsx @@ -0,0 +1,92 @@ +import { memo, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Typography } from "antd"; +import { produce } from "immer"; + +import { type WorkflowNodeConfigForApply, WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useAccessesStore } from "@/stores/access"; +import { useContactEmailsStore } from "@/stores/contact"; +import { useWorkflowStore } from "@/stores/workflow"; + +import ApplyNodeConfigForm, { type ApplyNodeConfigFormInstance } from "./ApplyNodeConfigForm"; +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; + +export type ApplyNodeProps = SharedNodeProps; + +const ApplyNode = ({ node, disabled }: ApplyNodeProps) => { + if (node.type !== WorkflowNodeType.Apply) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Apply}`); + } + + const { t } = useTranslation(); + + const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); + const { addEmail } = useContactEmailsStore(useZustandShallowSelector(["addEmail"])); + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Apply) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Apply}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForApply) ?? {}; + return {config.domains || " "}; + }, [node]); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const [drawerOpen, setDrawerOpen] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForApply; + + const handleDrawerConfirm = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + const newValues = getFormValues(); + const newNode = produce(node, (draft) => { + draft.config = { + ...newValues, + provider: accesses.find((e) => e.id === newValues.providerAccessId)?.provider, + }; + draft.validated = true; + }); + await updateNode(newNode); + await addEmail(newValues.contactEmail); + } finally { + setFormPending(false); + } + }; + + return ( + <> + setDrawerOpen(true)}> + {wrappedEl} + + + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + + + ); +}; + +export default memo(ApplyNode); diff --git a/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx b/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx new file mode 100644 index 00000000..1f216bee --- /dev/null +++ b/ui/src/components/workflow/node/ApplyNodeConfigForm.tsx @@ -0,0 +1,386 @@ +import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FormOutlined as FormOutlinedIcon, PlusOutlined as PlusOutlinedIcon, QuestionCircleOutlined as QuestionCircleOutlinedIcon } from "@ant-design/icons"; +import { useControllableValue } from "ahooks"; +import { AutoComplete, type AutoCompleteProps, Button, Divider, Form, type FormInstance, Input, Select, Space, Switch, Tooltip, Typography } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import ModalForm from "@/components/ModalForm"; +import MultipleInput from "@/components/MultipleInput"; +import AccessEditModal from "@/components/access/AccessEditModal"; +import AccessSelect from "@/components/access/AccessSelect"; +import { ACCESS_USAGES, accessProvidersMap } from "@/domain/provider"; +import { type WorkflowNodeConfigForApply } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; +import { useContactEmailsStore } from "@/stores/contact"; +import { validDomainName, validIPv4Address, validIPv6Address } from "@/utils/validators"; + +type ApplyNodeConfigFormFieldValues = Partial; + +export type ApplyNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: ApplyNodeConfigFormFieldValues; + onValuesChange?: (values: ApplyNodeConfigFormFieldValues) => void; +}; + +export type ApplyNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const MULTIPLE_INPUT_DELIMITER = ";"; + +const initFormModel = (): ApplyNodeConfigFormFieldValues => { + return { + keyAlgorithm: "RSA2048", + propagationTimeout: 60, + disableFollowCNAME: true, + }; +}; + +const ApplyNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + domains: z.string({ message: t("workflow_node.apply.form.domains.placeholder") }).refine((v) => { + return String(v) + .split(MULTIPLE_INPUT_DELIMITER) + .every((e) => validDomainName(e, true)); + }, t("common.errmsg.domain_invalid")), + contactEmail: z.string({ message: t("workflow_node.apply.form.contact_email.placeholder") }).email(t("common.errmsg.email_invalid")), + providerAccessId: z + .string({ message: t("workflow_node.apply.form.provider_access.placeholder") }) + .min(1, t("workflow_node.apply.form.provider_access.placeholder")), + keyAlgorithm: z + .string({ message: t("workflow_node.apply.form.key_algorithm.placeholder") }) + .nonempty(t("workflow_node.apply.form.key_algorithm.placeholder")), + nameservers: z + .string() + .nullish() + .refine((v) => { + if (!v) return true; + return String(v) + .split(MULTIPLE_INPUT_DELIMITER) + .every((e) => validIPv4Address(e) || validIPv6Address(e) || validDomainName(e)); + }, t("common.errmsg.host_invalid")), + propagationTimeout: z + .union([ + z.number().int().gte(1, t("workflow_node.apply.form.propagation_timeout.placeholder")), + z.string().refine((v) => !v || /^[1-9]\d*$/.test(v), t("workflow_node.apply.form.propagation_timeout.placeholder")), + ]) + .nullish(), + disableFollowCNAME: z.boolean().nullish(), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeApplyConfigForm", + initialValues: initialValues ?? initFormModel(), + }); + + const fieldDomains = Form.useWatch("domains", formInst); + const fieldNameservers = Form.useWatch("nameservers", formInst); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as ApplyNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as ApplyNodeConfigFormInstance; + }); + + return ( +
+ } + > + + + + + + + + } + onChange={(v) => { + formInst.setFieldValue("domains", v); + }} + /> + + + + } + > + + + + + + + { + const provider = accessProvidersMap.get(record.provider); + return ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage; + }} + /> + + + + + + {t("workflow_node.apply.form.advanced_config.label")} + + + + + { + formInst.setFieldValue("nameservers", e.target.value); + }} + /> + + + + + } + onChange={(value) => { + formInst.setFieldValue("nameservers", value); + }} + /> + + + + } + > + + + + } + > + + + + ); + } +); + +const EmailInput = memo( + ({ disabled, placeholder, ...props }: { disabled?: boolean; placeholder?: string; value?: string; onChange?: (value: string) => void }) => { + const { emails, fetchEmails } = useContactEmailsStore(); + const emailsToOptions = () => emails.map((email) => ({ label: email, value: email })); + useEffect(() => { + fetchEmails(); + }, []); + + const [value, setValue] = useControllableValue(props, { + valuePropName: "value", + defaultValuePropName: "defaultValue", + trigger: "onChange", + }); + + const [options, setOptions] = useState([]); + useEffect(() => { + setOptions(emailsToOptions()); + }, [emails]); + + const handleChange = (value: string) => { + setValue(value); + }; + + const handleSearch = (text: string) => { + const temp = emailsToOptions(); + if (text?.trim()) { + if (temp.every((option) => option.label !== text)) { + temp.unshift({ label: text, value: text }); + } + } + + setOptions(temp); + }; + + return ( + + ); + } +); + +const DomainsModalInput = memo(({ value, trigger, onChange }: { value?: string; trigger?: React.ReactNode; onChange?: (value: string) => void }) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + domains: z.array(z.string()).refine((v) => { + return v.every((e) => !e?.trim() || validDomainName(e.trim(), true)); + }, t("common.errmsg.domain_invalid")), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeApplyConfigFormDomainsModalInput", + initialValues: { domains: value?.split(MULTIPLE_INPUT_DELIMITER) }, + onSubmit: (values) => { + onChange?.( + values.domains + .map((e) => e.trim()) + .filter((e) => !!e) + .join(MULTIPLE_INPUT_DELIMITER) + ); + }, + }); + + return ( + + + + + + ); +}); + +const NameserversModalInput = memo(({ trigger, value, onChange }: { trigger?: React.ReactNode; value?: string; onChange?: (value: string) => void }) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + nameservers: z.array(z.string()).refine((v) => { + return v.every((e) => !e?.trim() || validIPv4Address(e) || validIPv6Address(e) || validDomainName(e)); + }, t("common.errmsg.domain_invalid")), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeApplyConfigFormNameserversModalInput", + initialValues: { nameservers: value?.split(MULTIPLE_INPUT_DELIMITER) }, + onSubmit: (values) => { + onChange?.( + values.nameservers + .map((e) => e.trim()) + .filter((e) => !!e) + .join(MULTIPLE_INPUT_DELIMITER) + ); + }, + }); + + return ( + + + + + + ); +}); + +export default memo(ApplyNodeConfigForm); diff --git a/ui/src/components/workflow/node/ApplyNodeForm.tsx b/ui/src/components/workflow/node/ApplyNodeForm.tsx deleted file mode 100644 index 1d365f72..00000000 --- a/ui/src/components/workflow/node/ApplyNodeForm.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import { memo, useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { FormOutlined as FormOutlinedIcon, PlusOutlined as PlusOutlinedIcon, QuestionCircleOutlined as QuestionCircleOutlinedIcon } from "@ant-design/icons"; -import { useControllableValue } from "ahooks"; -import { AutoComplete, type AutoCompleteProps, Button, Divider, Form, type FormInstance, Input, Select, Space, Switch, Tooltip, Typography } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import { z } from "zod"; - -import ModalForm from "@/components/ModalForm"; -import MultipleInput from "@/components/MultipleInput"; -import AccessEditModal from "@/components/access/AccessEditModal"; -import AccessSelect from "@/components/access/AccessSelect"; -import { ACCESS_USAGES, accessProvidersMap } from "@/domain/provider"; -import { type WorkflowNode, type WorkflowNodeConfigForApply } from "@/domain/workflow"; -import { useContactEmailsStore } from "@/stores/contact"; -import { validDomainName, validIPv4Address, validIPv6Address } from "@/utils/validators"; - -type ApplyNodeFormFieldValues = Partial; - -export type ApplyNodeFormProps = { - form: FormInstance; - formName?: string; - disabled?: boolean; - workflowNode: WorkflowNode; - onValuesChange?: (values: ApplyNodeFormFieldValues) => void; -}; - -const MULTIPLE_INPUT_DELIMITER = ";"; - -const initFormModel = (): ApplyNodeFormFieldValues => { - return { - keyAlgorithm: "RSA2048", - propagationTimeout: 60, - disableFollowCNAME: true, - }; -}; - -const ApplyNodeForm = ({ form, formName, disabled, workflowNode, onValuesChange }: ApplyNodeFormProps) => { - const { t } = useTranslation(); - - const formSchema = z.object({ - domains: z.string({ message: t("workflow_node.apply.form.domains.placeholder") }).refine((v) => { - return String(v) - .split(MULTIPLE_INPUT_DELIMITER) - .every((e) => validDomainName(e, true)); - }, t("common.errmsg.domain_invalid")), - contactEmail: z.string({ message: t("workflow_node.apply.form.contact_email.placeholder") }).email(t("common.errmsg.email_invalid")), - providerAccessId: z - .string({ message: t("workflow_node.apply.form.provider_access.placeholder") }) - .min(1, t("workflow_node.apply.form.provider_access.placeholder")), - keyAlgorithm: z - .string({ message: t("workflow_node.apply.form.key_algorithm.placeholder") }) - .nonempty(t("workflow_node.apply.form.key_algorithm.placeholder")), - nameservers: z - .string() - .nullish() - .refine((v) => { - if (!v) return true; - return String(v) - .split(MULTIPLE_INPUT_DELIMITER) - .every((e) => validIPv4Address(e) || validIPv6Address(e) || validDomainName(e)); - }, t("common.errmsg.host_invalid")), - propagationTimeout: z - .union([ - z.number().int().gte(1, t("workflow_node.apply.form.propagation_timeout.placeholder")), - z.string().refine((v) => !v || /^[1-9]\d*$/.test(v), t("workflow_node.apply.form.propagation_timeout.placeholder")), - ]) - .nullish(), - disableFollowCNAME: z.boolean().nullish(), - }); - const formRule = createSchemaFieldRule(formSchema); - - const initialValues: ApplyNodeFormFieldValues = (workflowNode.config as WorkflowNodeConfigForApply) ?? initFormModel(); - - const fieldDomains = Form.useWatch("domains", form); - const fieldNameservers = Form.useWatch("nameservers", form); - - const handleFormChange = (_: unknown, values: z.infer) => { - onValuesChange?.(values as ApplyNodeFormFieldValues); - }; - - return ( -
- } - > - - { - form.setFieldValue("domains", e.target.value); - }} - /> - - - - } - onFinish={(v) => { - form.setFieldValue("domains", v); - }} - /> - - - - } - > - - - - - - - { - const provider = accessProvidersMap.get(record.provider); - return ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage; - }} - /> - - - - - - {t("workflow_node.apply.form.advanced_config.label")} - - - - - { - form.setFieldValue("nameservers", e.target.value); - }} - /> - - - - } - onFinish={(v) => { - form.setFieldValue("nameservers", v); - }} - /> - - - - } - > - - - - } - > - - -
- ); -}; - -const FormFieldEmailSelect = ({ - className, - style, - disabled, - placeholder, - ...props -}: { - className?: string; - style?: React.CSSProperties; - defaultValue?: string; - disabled?: boolean; - placeholder?: string; - value?: string; - onChange?: (value: string) => void; -}) => { - const { emails, fetchEmails } = useContactEmailsStore(); - const emailsToOptions = useCallback(() => emails.map((email) => ({ label: email, value: email })), [emails]); - useEffect(() => { - fetchEmails(); - }, [fetchEmails]); - - const [value, setValue] = useControllableValue(props, { - valuePropName: "value", - defaultValuePropName: "defaultValue", - trigger: "onChange", - }); - - const [options, setOptions] = useState([]); - useEffect(() => { - setOptions(emailsToOptions()); - }, [emails, emailsToOptions]); - - const handleChange = (value: string) => { - setValue(value); - }; - - const handleSearch = (text: string) => { - const temp = emailsToOptions(); - if (text?.trim()) { - if (temp.every((option) => option.label !== text)) { - temp.unshift({ label: text, value: text }); - } - } - - setOptions(temp); - }; - - return ( - - ); -}; - -const FormFieldDomainsModalForm = ({ - data, - trigger, - onFinish, -}: { - data?: string; - disabled?: boolean; - trigger?: React.ReactNode; - onFinish?: (data: string) => void; -}) => { - const { t } = useTranslation(); - - const formSchema = z.object({ - domains: z.array(z.string()).refine((v) => { - return v.every((e) => !e?.trim() || validDomainName(e.trim(), true)); - }, t("common.errmsg.domain_invalid")), - }); - const formRule = createSchemaFieldRule(formSchema); - const [form] = Form.useForm>(); - - const [model, setModel] = useState>>({ domains: data?.split(MULTIPLE_INPUT_DELIMITER) }); - useEffect(() => { - setModel({ domains: data?.split(MULTIPLE_INPUT_DELIMITER) }); - }, [data]); - - const handleFormFinish = (values: z.infer) => { - onFinish?.( - values.domains - .map((e) => e.trim()) - .filter((e) => !!e) - .join(MULTIPLE_INPUT_DELIMITER) - ); - }; - - return ( - - - - - - ); -}; - -const FormFieldNameserversModalForm = ({ data, trigger, onFinish }: { data?: string; trigger?: React.ReactNode; onFinish?: (data: string) => void }) => { - const { t } = useTranslation(); - - const formSchema = z.object({ - nameservers: z.array(z.string()).refine((v) => { - return v.every((e) => !e?.trim() || validIPv4Address(e) || validIPv6Address(e) || validDomainName(e)); - }, t("common.errmsg.domain_invalid")), - }); - const formRule = createSchemaFieldRule(formSchema); - const [form] = Form.useForm>(); - - const [model, setModel] = useState>>({ nameservers: data?.split(MULTIPLE_INPUT_DELIMITER) }); - useEffect(() => { - setModel({ nameservers: data?.split(MULTIPLE_INPUT_DELIMITER) }); - }, [data]); - - const handleFormFinish = (values: z.infer) => { - onFinish?.( - values.nameservers - .map((e) => e.trim()) - .filter((e) => !!e) - .join(MULTIPLE_INPUT_DELIMITER) - ); - }; - - return ( - - - - - - ); -}; - -export default memo(ApplyNodeForm); diff --git a/ui/src/components/workflow/node/BranchNode.tsx b/ui/src/components/workflow/node/BranchNode.tsx index 8b406960..61eb0c9e 100644 --- a/ui/src/components/workflow/node/BranchNode.tsx +++ b/ui/src/components/workflow/node/BranchNode.tsx @@ -8,11 +8,9 @@ import { useWorkflowStore } from "@/stores/workflow"; import AddNode from "./AddNode"; import WorkflowElement from "../WorkflowElement"; +import { type SharedNodeProps } from "./_SharedNode"; -export type BrandNodeProps = { - node: WorkflowNode; - disabled?: boolean; -}; +export type BrandNodeProps = SharedNodeProps; const BranchNode = ({ node, disabled }: BrandNodeProps) => { const { t } = useTranslation(); diff --git a/ui/src/components/workflow/node/CommonNode.tsx b/ui/src/components/workflow/node/CommonNode.tsx deleted file mode 100644 index 76bf097c..00000000 --- a/ui/src/components/workflow/node/CommonNode.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { memo, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { CloseCircleOutlined as CloseCircleOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon } from "@ant-design/icons"; -import { useControllableValue } from "ahooks"; -import { Avatar, Button, Card, Drawer, Dropdown, Modal, Popover, Space, Typography } from "antd"; -import { produce } from "immer"; -import { isEqual } from "radash"; - -import Show from "@/components/Show"; -import { deployProvidersMap } from "@/domain/provider"; -import { notifyChannelsMap } from "@/domain/settings"; -import { - WORKFLOW_TRIGGERS, - type WorkflowNode, - type WorkflowNodeConfigForApply, - type WorkflowNodeConfigForDeploy, - type WorkflowNodeConfigForNotify, - type WorkflowNodeConfigForStart, - WorkflowNodeType, -} from "@/domain/workflow"; -import { useAntdForm, useZustandShallowSelector } from "@/hooks"; -import { useAccessesStore } from "@/stores/access"; -import { useContactEmailsStore } from "@/stores/contact"; -import { useWorkflowStore } from "@/stores/workflow"; - -import AddNode from "./AddNode"; -import ApplyNodeForm from "./ApplyNodeForm"; -import DeployNodeForm from "./DeployNodeForm"; -import NotifyNodeForm from "./NotifyNodeForm"; -import StartNodeForm from "./StartNodeForm"; - -export type CommonNodeProps = { - node: WorkflowNode; - disabled?: boolean; -}; - -const CommonNode = ({ node, disabled }: CommonNodeProps) => { - const { t } = useTranslation(); - - const { updateNode, removeNode } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode"])); - - const [drawerOpen, setDrawerOpen] = useState(false); - - const workflowNodeEl = useMemo(() => { - if (!node.validated) { - return {t("workflow_node.action.configure_node")}; - } - - switch (node.type) { - case WorkflowNodeType.Start: { - const config = (node.config as WorkflowNodeConfigForStart) ?? {}; - return ( -
- - {config.trigger === WORKFLOW_TRIGGERS.AUTO - ? t("workflow.props.trigger.auto") - : config.trigger === WORKFLOW_TRIGGERS.MANUAL - ? t("workflow.props.trigger.manual") - : " "} - - - {config.trigger === WORKFLOW_TRIGGERS.AUTO ? config.triggerCron : ""} - -
- ); - } - - case WorkflowNodeType.Apply: { - const config = (node.config as WorkflowNodeConfigForApply) ?? {}; - return {config.domains || " "}; - } - - case WorkflowNodeType.Deploy: { - const config = (node.config as WorkflowNodeConfigForDeploy) ?? {}; - const provider = deployProvidersMap.get(config.provider); - return ( - - - {t(provider?.name ?? "")} - - ); - } - - case WorkflowNodeType.Notify: { - const config = (node.config as WorkflowNodeConfigForNotify) ?? {}; - const channel = notifyChannelsMap.get(config.channel as string); - return ( -
- {t(channel?.name ?? " ")} - - {config.subject ?? ""} - -
- ); - } - - default: { - console.warn(`[certimate] unsupported workflow node type: ${node.type}`); - return <>; - } - } - }, [node]); - - const handleNodeClick = () => { - setDrawerOpen(true); - }; - - const handleNodeNameBlur = (e: React.FocusEvent) => { - const oldName = node.name; - const newName = e.target.innerText.trim(); - if (oldName === newName) { - return; - } - - updateNode( - produce(node, (draft) => { - draft.name = newName; - }) - ); - }; - - return ( - <> - - , - danger: true, - onClick: () => { - if (disabled) return; - - removeNode(node.id); - }, - }, - ], - }} - trigger={["click"]} - > - - - - } - open={open} - title={node.name} - width={640} - onClose={handleClose} - > - {formEl} - - - ); -}; - -export default memo(CommonNode); diff --git a/ui/src/components/workflow/node/ConditionNode.tsx b/ui/src/components/workflow/node/ConditionNode.tsx index 5edd80d7..9b536c2b 100644 --- a/ui/src/components/workflow/node/ConditionNode.tsx +++ b/ui/src/components/workflow/node/ConditionNode.tsx @@ -4,20 +4,18 @@ import { CloseCircleOutlined as CloseCircleOutlinedIcon, EllipsisOutlined as Ell import { Button, Card, Dropdown, Popover } from "antd"; import { produce } from "immer"; -import { type WorkflowNode } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; import AddNode from "./AddNode"; +import { type SharedNodeProps } from "./_SharedNode"; -export type ConditionNodeProps = { - node: WorkflowNode; +export type ConditionNodeProps = SharedNodeProps & { branchId: string; branchIndex: number; - disabled?: boolean; }; -const ConditionNode = ({ node, branchId, branchIndex, disabled }: ConditionNodeProps) => { +const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeProps) => { const { t } = useTranslation(); const { updateNode, removeBranch } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeBranch"])); diff --git a/ui/src/components/workflow/node/DeployNode.tsx b/ui/src/components/workflow/node/DeployNode.tsx new file mode 100644 index 00000000..48c20a8f --- /dev/null +++ b/ui/src/components/workflow/node/DeployNode.tsx @@ -0,0 +1,103 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar, Space, Typography } from "antd"; +import { produce } from "immer"; + +import { deployProvidersMap } from "@/domain/provider"; +import { type WorkflowNodeConfigForDeploy, WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import DeployNodeConfigForm, { type DeployNodeConfigFormInstance } from "./DeployNodeConfigForm"; +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; + +export type DeployNodeProps = SharedNodeProps; + +const DeployNode = ({ node, disabled }: DeployNodeProps) => { + if (node.type !== WorkflowNodeType.Deploy) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Deploy}`); + } + + const { t } = useTranslation(); + + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const [fieldProvider, setFieldProvider] = useState((node.config as WorkflowNodeConfigForDeploy)?.provider); + useEffect(() => { + setFieldProvider((node.config as WorkflowNodeConfigForDeploy)?.provider); + }, [node.config?.provider]); + + const [drawerOpen, setDrawerOpen] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForDeploy; + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Deploy) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Deploy}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForDeploy) ?? {}; + const provider = deployProvidersMap.get(config.provider); + return ( + + + {t(provider?.name ?? "")} + + ); + }, [node]); + + const handleDrawerConfirm = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + const newValues = getFormValues(); + const newNode = produce(node, (draft) => { + draft.config = { + ...newValues, + }; + draft.validated = true; + }); + await updateNode(newNode); + } finally { + setFormPending(false); + } + }; + + const handleFormValuesChange = (values: Partial) => { + setFieldProvider(values.provider!); + }; + + return ( + <> + setDrawerOpen(true)}> + {wrappedEl} + + + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + + + ); +}; + +export default memo(DeployNode); diff --git a/ui/src/components/workflow/node/DeployNodeConfigForm.tsx b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx new file mode 100644 index 00000000..b73f079c --- /dev/null +++ b/ui/src/components/workflow/node/DeployNodeConfigForm.tsx @@ -0,0 +1,290 @@ +import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { PlusOutlined as PlusOutlinedIcon, QuestionCircleOutlined as QuestionCircleOutlinedIcon } from "@ant-design/icons"; +import { Button, Divider, Form, type FormInstance, Select, Tooltip, Typography } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import Show from "@/components/Show"; +import AccessEditModal from "@/components/access/AccessEditModal"; +import AccessSelect from "@/components/access/AccessSelect"; +import DeployProviderPicker from "@/components/provider/DeployProviderPicker"; +import DeployProviderSelect from "@/components/provider/DeployProviderSelect"; +import { ACCESS_USAGES, DEPLOY_PROVIDERS, accessProvidersMap, deployProvidersMap } from "@/domain/provider"; +import { type WorkflowNode, type WorkflowNodeConfigForDeploy } from "@/domain/workflow"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import DeployNodeConfigFormAliyunALBFields from "./DeployNodeConfigFormAliyunALBFields"; +import DeployNodeConfigFormAliyunCDNFields from "./DeployNodeConfigFormAliyunCDNFields"; +import DeployNodeConfigFormAliyunCLBFields from "./DeployNodeConfigFormAliyunCLBFields"; +import DeployNodeConfigFormAliyunDCDNFields from "./DeployNodeConfigFormAliyunDCDNFields"; +import DeployNodeConfigFormAliyunNLBFields from "./DeployNodeConfigFormAliyunNLBFields"; +import DeployNodeConfigFormAliyunOSSFields from "./DeployNodeConfigFormAliyunOSSFields"; +import DeployNodeConfigFormBaiduCloudCDNFields from "./DeployNodeConfigFormBaiduCloudCDNFields"; +import DeployNodeConfigFormBytePlusCDNFields from "./DeployNodeConfigFormBytePlusCDNFields"; +import DeployNodeConfigFormDogeCloudCDNFields from "./DeployNodeConfigFormDogeCloudCDNFields"; +import DeployNodeConfigFormHuaweiCloudCDNFields from "./DeployNodeConfigFormHuaweiCloudCDNFields"; +import DeployNodeConfigFormHuaweiCloudELBFields from "./DeployNodeConfigFormHuaweiCloudELBFields"; +import DeployNodeConfigFormKubernetesSecretFields from "./DeployNodeConfigFormKubernetesSecretFields"; +import DeployNodeConfigFormLocalFields from "./DeployNodeConfigFormLocalFields"; +import DeployNodeConfigFormQiniuCDNFields from "./DeployNodeConfigFormQiniuCDNFields"; +import DeployNodeConfigFormSSHFields from "./DeployNodeConfigFormSSHFields"; +import DeployNodeConfigFormTencentCloudCDNFields from "./DeployNodeConfigFormTencentCloudCDNFields"; +import DeployNodeConfigFormTencentCloudCLBFields from "./DeployNodeConfigFormTencentCloudCLBFields"; +import DeployNodeConfigFormTencentCloudCOSFields from "./DeployNodeConfigFormTencentCloudCOSFields"; +import DeployNodeConfigFormTencentCloudECDNFields from "./DeployNodeConfigFormTencentCloudECDNFields"; +import DeployNodeConfigFormTencentCloudEOFields from "./DeployNodeConfigFormTencentCloudEOFields"; +import DeployNodeConfigFormVolcEngineCDNFields from "./DeployNodeConfigFormVolcEngineCDNFields"; +import DeployNodeConfigFormVolcEngineLiveFields from "./DeployNodeConfigFormVolcEngineLiveFields"; +import DeployNodeConfigFormWebhookFields from "./DeployNodeConfigFormWebhookFields"; + +type DeployNodeConfigFormFieldValues = Partial; + +export type DeployNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: DeployNodeConfigFormFieldValues; + nodeId: string; + onValuesChange?: (values: DeployNodeConfigFormFieldValues) => void; +}; + +export type DeployNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): DeployNodeConfigFormFieldValues => { + return {}; +}; + +const DeployNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, nodeId, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"])); + + // TODO: 优化此处逻辑 + const [previousNodes, setPreviousNodes] = useState([]); + useEffect(() => { + const previousNodes = getWorkflowOuptutBeforeId(nodeId, "certificate"); + setPreviousNodes(previousNodes); + }, [nodeId]); + + const formSchema = z.object({ + provider: z.string({ message: t("workflow_node.deploy.form.provider.placeholder") }).nonempty(t("workflow_node.deploy.form.provider.placeholder")), + providerAccessId: z + .string({ message: t("workflow_node.deploy.form.provider_access.placeholder") }) + .nonempty(t("workflow_node.deploy.form.provider_access.placeholder")), + certificate: z + .string({ message: t("workflow_node.deploy.form.certificate.placeholder") }) + .nonempty(t("workflow_node.deploy.form.certificate.placeholder")), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + initialValues: initialValues ?? initFormModel(), + }); + + const fieldProvider = Form.useWatch("provider", { form: formInst, preserve: true }); + + const formFieldsEl = useMemo(() => { + /* + 注意:如果追加新的子组件,请保持以 ASCII 排序。 + NOTICE: If you add new child component, please keep ASCII order. + */ + switch (fieldProvider) { + case DEPLOY_PROVIDERS.ALIYUN_ALB: + return ; + case DEPLOY_PROVIDERS.ALIYUN_CLB: + return ; + case DEPLOY_PROVIDERS.ALIYUN_CDN: + return ; + case DEPLOY_PROVIDERS.ALIYUN_DCDN: + return ; + case DEPLOY_PROVIDERS.ALIYUN_NLB: + return ; + case DEPLOY_PROVIDERS.ALIYUN_OSS: + return ; + case DEPLOY_PROVIDERS.BAIDUCLOUD_CDN: + return ; + case DEPLOY_PROVIDERS.BYTEPLUS_CDN: + return ; + case DEPLOY_PROVIDERS.DOGECLOUD_CDN: + return ; + case DEPLOY_PROVIDERS.HUAWEICLOUD_CDN: + return ; + case DEPLOY_PROVIDERS.HUAWEICLOUD_ELB: + return ; + case DEPLOY_PROVIDERS.KUBERNETES_SECRET: + return ; + case DEPLOY_PROVIDERS.LOCAL: + return ; + case DEPLOY_PROVIDERS.QINIU_CDN: + return ; + case DEPLOY_PROVIDERS.SSH: + return ; + case DEPLOY_PROVIDERS.TENCENTCLOUD_CDN: + return ; + case DEPLOY_PROVIDERS.TENCENTCLOUD_CLB: + return ; + case DEPLOY_PROVIDERS.TENCENTCLOUD_COS: + return ; + case DEPLOY_PROVIDERS.TENCENTCLOUD_ECDN: + return ; + case DEPLOY_PROVIDERS.TENCENTCLOUD_EO: + return ; + case DEPLOY_PROVIDERS.VOLCENGINE_CDN: + return ; + case DEPLOY_PROVIDERS.VOLCENGINE_LIVE: + return ; + case DEPLOY_PROVIDERS.WEBHOOK: + return ; + } + }, [fieldProvider]); + + const handleProviderPick = (value: string) => { + formInst.setFieldValue("provider", value); + onValuesChange?.(formInst.getFieldsValue(true)); + }; + + const handleProviderSelect = (value: string) => { + if (fieldProvider === value) return; + + // TODO: 暂时不支持切换部署目标,需后端调整,否则之前若存在部署结果输出就不会再部署 + // 切换部署目标时重置表单,避免其他部署目标的配置字段影响当前部署目标 + if (initialValues?.provider === value) { + formInst.resetFields(); + } else { + const oldValues = formInst.getFieldsValue(); + const newValues: Record = {}; + for (const key in oldValues) { + if (key === "provider" || key === "providerAccessId" || key === "certificate") { + newValues[key] = oldValues[key]; + } else { + newValues[key] = undefined; + } + } + (formInst as FormInstance).setFieldsValue(newValues); + + if (deployProvidersMap.get(fieldProvider)?.provider !== deployProvidersMap.get(value)?.provider) { + formInst.setFieldValue("providerAccessId", undefined); + onValuesChange?.(formInst.getFieldsValue(true)); + } + } + }; + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as DeployNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as DeployNodeConfigFormInstance; + }); + + return ( +
+ }> + + + + + + + + { + if (fieldProvider) { + return deployProvidersMap.get(fieldProvider)?.provider === record.provider; + } + + const provider = accessProvidersMap.get(record.provider); + return ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage; + }} + /> + + + + } + > + { - return { - label: item.name, - options: item.outputs?.map((output) => { - return { - label: `${item.name} - ${output.label}`, - value: `${item.id}#${output.name}`, - }; - }), - }; - })} - placeholder={t("workflow_node.deploy.form.certificate.placeholder")} - /> - - - - - {t("workflow_node.deploy.form.params_config.label")} - - - - {formFieldsEl} - -
- ); -}; - -export default memo(DeployNodeForm); diff --git a/ui/src/components/workflow/node/NotifyNode.tsx b/ui/src/components/workflow/node/NotifyNode.tsx new file mode 100644 index 00000000..7cb54b5c --- /dev/null +++ b/ui/src/components/workflow/node/NotifyNode.tsx @@ -0,0 +1,95 @@ +import { memo, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Typography } from "antd"; +import { produce } from "immer"; + +import { notifyChannelsMap } from "@/domain/settings"; +import { type WorkflowNodeConfigForNotify, WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import NotifyNodeConfigForm, { type NotifyNodeConfigFormInstance } from "./NotifyNodeConfigForm"; +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; + +export type NotifyNodeProps = SharedNodeProps; + +const NotifyNode = ({ node, disabled }: NotifyNodeProps) => { + if (node.type !== WorkflowNodeType.Notify) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Notify}`); + } + + const { t } = useTranslation(); + + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const [drawerOpen, setDrawerOpen] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForNotify; + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Notify) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Notify}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForNotify) ?? {}; + const channel = notifyChannelsMap.get(config.channel as string); + return ( +
+ {t(channel?.name ?? " ")} + + {config.subject ?? ""} + +
+ ); + }, [node]); + + const handleDrawerConfirm = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + const newValues = getFormValues(); + const newNode = produce(node, (draft) => { + draft.config = { + ...newValues, + }; + draft.validated = true; + }); + await updateNode(newNode); + } finally { + setFormPending(false); + } + }; + + return ( + <> + setDrawerOpen(true)}> + {wrappedEl} + + + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + + + ); +}; + +export default memo(NotifyNode); diff --git a/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx b/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx new file mode 100644 index 00000000..8afb6ec2 --- /dev/null +++ b/ui/src/components/workflow/node/NotifyNodeConfigForm.tsx @@ -0,0 +1,126 @@ +import { forwardRef, memo, useEffect, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { RightOutlined as RightOutlinedIcon } from "@ant-design/icons"; +import { Button, Form, type FormInstance, Input, Select } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { notifyChannelsMap } from "@/domain/settings"; +import { type WorkflowNodeConfigForNotify } from "@/domain/workflow"; +import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useNotifyChannelsStore } from "@/stores/notify"; + +type NotifyNodeConfigFormFieldValues = Partial; + +export type NotifyNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: NotifyNodeConfigFormFieldValues; + onValuesChange?: (values: NotifyNodeConfigFormFieldValues) => void; +}; + +export type NotifyNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): NotifyNodeConfigFormFieldValues => { + return { + subject: "Completed!", + message: "Your workflow has been completed on Certimate.", + }; +}; + +const NotifyNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const { + channels, + loadedAtOnce: channelsLoadedAtOnce, + fetchChannels, + } = useNotifyChannelsStore(useZustandShallowSelector(["channels", "loadedAtOnce", "fetchChannels"])); + useEffect(() => { + fetchChannels(); + }, []); + + const formSchema = z.object({ + subject: z + .string({ message: t("workflow_node.notify.form.subject.placeholder") }) + .min(1, t("workflow_node.notify.form.subject.placeholder")) + .max(1000, t("common.errmsg.string_max", { max: 1000 })), + message: z + .string({ message: t("workflow_node.notify.form.message.placeholder") }) + .min(1, t("workflow_node.notify.form.message.placeholder")) + .max(1000, t("common.errmsg.string_max", { max: 1000 })), + channel: z.string({ message: t("workflow_node.notify.form.channel.placeholder") }).min(1, t("workflow_node.notify.form.channel.placeholder")), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + initialValues: initialValues ?? initFormModel(), + }); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as NotifyNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields as (keyof NotifyNodeConfigFormFieldValues)[]); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as NotifyNodeConfigFormInstance; + }); + + return ( +
+ + + + + + + + + + + + - - - - - - - - - - + + + + + } /> + + + + ); + } +); + +export default memo(StartNodeConfigForm); diff --git a/ui/src/components/workflow/node/StartNodeForm.tsx b/ui/src/components/workflow/node/StartNodeForm.tsx deleted file mode 100644 index 8d5e1a9c..00000000 --- a/ui/src/components/workflow/node/StartNodeForm.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { memo, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Alert, Form, type FormInstance, Input, Radio } from "antd"; -import { createSchemaFieldRule } from "antd-zod"; -import dayjs from "dayjs"; -import { z } from "zod"; - -import Show from "@/components/Show"; -import { WORKFLOW_TRIGGERS, type WorkflowNode, type WorkflowNodeConfigForStart, type WorkflowTriggerType } from "@/domain/workflow"; -import { getNextCronExecutions, validCronExpression } from "@/utils/cron"; - -type StartNodeFormFieldValues = Partial; - -export type StartNodeFormProps = { - form: FormInstance; - formName?: string; - disabled?: boolean; - workflowNode: WorkflowNode; - onValuesChange?: (values: StartNodeFormFieldValues) => void; -}; - -const initFormModel = (): StartNodeFormFieldValues => { - return { - trigger: WORKFLOW_TRIGGERS.AUTO, - triggerCron: "0 0 * * *", - }; -}; - -const StartNodeForm = ({ form, formName, disabled, workflowNode, onValuesChange }: StartNodeFormProps) => { - const { t } = useTranslation(); - - const formSchema = z - .object({ - trigger: z.string({ message: t("workflow_node.start.form.trigger.placeholder") }).min(1, t("workflow_node.start.form.trigger.placeholder")), - triggerCron: z.string().nullish(), - }) - .superRefine((data, ctx) => { - if (data.trigger !== WORKFLOW_TRIGGERS.AUTO) { - return; - } - - if (!validCronExpression(data.triggerCron!)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("workflow_node.start.form.trigger_cron.errmsg.invalid"), - path: ["triggerCron"], - }); - } - }); - const formRule = createSchemaFieldRule(formSchema); - - const initialValues: StartNodeFormFieldValues = (workflowNode.config as WorkflowNodeConfigForStart) ?? initFormModel(); - - const fieldTrigger = Form.useWatch("trigger", form); - const fieldTriggerCron = Form.useWatch("triggerCron", form); - const [fieldTriggerCronExpectedExecutions, setFieldTriggerCronExpectedExecutions] = useState([]); - useEffect(() => { - setFieldTriggerCronExpectedExecutions(getNextCronExecutions(fieldTriggerCron, 5)); - }, [fieldTriggerCron]); - - const handleTriggerChange = (value: string) => { - if (value === WORKFLOW_TRIGGERS.AUTO) { - form.setFieldValue("triggerCron", initialValues.triggerCron || initFormModel().triggerCron); - } else { - form.setFieldValue("triggerCron", undefined); - } - - onValuesChange?.(form.getFieldsValue(true)); - }; - - const handleFormChange = (_: unknown, values: z.infer) => { - onValuesChange?.(values as StartNodeFormFieldValues); - }; - - return ( -
- } - > - handleTriggerChange(e.target.value)}> - {t("workflow_node.start.form.trigger.option.auto.label")} - {t("workflow_node.start.form.trigger.option.manual.label")} - - - - - - - - } /> - - -
- ); -}; - -export default memo(StartNodeForm); diff --git a/ui/src/components/workflow/node/_SharedNode.tsx b/ui/src/components/workflow/node/_SharedNode.tsx new file mode 100644 index 00000000..09ad9b1a --- /dev/null +++ b/ui/src/components/workflow/node/_SharedNode.tsx @@ -0,0 +1,201 @@ +import { memo } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseCircleOutlined as CloseCircleOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon } from "@ant-design/icons"; +import { useControllableValue } from "ahooks"; +import { Button, Card, Drawer, Dropdown, Modal, Popover, Space } from "antd"; +import { produce } from "immer"; +import { isEqual } from "radash"; + +import Show from "@/components/Show"; +import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import AddNode from "./AddNode"; + +export type SharedNodeProps = { + node: WorkflowNode; + disabled?: boolean; +}; + +type SharedNodeWrapperProps = SharedNodeProps & { + children: React.ReactNode; + onClick?: (e: React.MouseEvent) => void; +}; + +const SharedNodeWrapper = ({ children, node, disabled, onClick }: SharedNodeWrapperProps) => { + const { t } = useTranslation(); + + const { updateNode, removeNode } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode"])); + + const handleNodeClick = (e: React.MouseEvent) => { + onClick?.(e); + }; + + const handleNodeNameBlur = (e: React.FocusEvent) => { + const oldName = node.name; + const newName = e.target.innerText.trim(); + if (oldName === newName) { + return; + } + + updateNode( + produce(node, (draft) => { + draft.name = newName; + }) + ); + }; + + return ( + <> + + , + danger: true, + onClick: () => { + if (disabled) return; + + removeNode(node.id); + }, + }, + ], + }} + trigger={["click"]} + > + + + + ) + } + open={open} + width={640} + onClose={handleClose} + > + {children} + + + ); +}; + +export default { + Wrapper: memo(SharedNodeWrapper), + ConfigDrawer: memo(SharedNodeConfigDrawer), +}; diff --git a/ui/src/hooks/useAntdForm.ts b/ui/src/hooks/useAntdForm.ts index ffaef47b..91326dd2 100644 --- a/ui/src/hooks/useAntdForm.ts +++ b/ui/src/hooks/useAntdForm.ts @@ -8,7 +8,7 @@ export interface UseAntdFormOptions = any> { form?: FormInstance; initialValues?: Partial | (() => Partial | Promise>); name?: string; - onSubmit?: (values: T) => unknown; + onSubmit?: (values: T) => unknown | Promise; } export interface UseAntdFormReturns = any> { @@ -95,7 +95,7 @@ const useAntdForm = = any>({ form, initialValues const formProps: FormProps = { form: formInst, initialValues: formInitialValues, - name: options.name ? formName : undefined, + name: formName, onFinish, }; diff --git a/ui/src/hooks/useAntdFormName.ts b/ui/src/hooks/useAntdFormName.ts index fc9e4501..111aca8c 100644 --- a/ui/src/hooks/useAntdFormName.ts +++ b/ui/src/hooks/useAntdFormName.ts @@ -1,5 +1,6 @@ import { useCreation } from "ahooks"; import { type FormInstance } from "antd"; +import { nanoid } from "nanoid/non-secure"; export interface UseAntdFormNameOptions = any> { form: FormInstance; @@ -13,7 +14,7 @@ export interface UseAntdFormNameOptions = any> { * @returns {string} */ const useAntdFormName = = any>(options: UseAntdFormNameOptions) => { - const formName = useCreation(() => `${options.name}_${Math.random().toString(36).substring(2, 10)}${new Date().getTime()}`, [options.name, options.form]); + const formName = useCreation(() => `${options.name}_${nanoid()}`, [options.name, options.form]); return formName; }; diff --git a/ui/src/hooks/useTriggerElement.ts b/ui/src/hooks/useTriggerElement.ts index 46c71fb2..06ec4b76 100644 --- a/ui/src/hooks/useTriggerElement.ts +++ b/ui/src/hooks/useTriggerElement.ts @@ -1,7 +1,7 @@ import { Fragment, cloneElement, createElement, isValidElement, useMemo } from "react"; export type UseTriggerElementOptions = { - onClick?: (e: MouseEvent) => void; + onClick?: (e: React.MouseEvent) => void; }; /** @@ -21,7 +21,7 @@ const useTriggerElement = (trigger: React.ReactNode, options?: UseTriggerElement const el = isValidElement(trigger) ? trigger : createElement(Fragment, null, trigger); return cloneElement(el, { ...el.props, - onClick: (e: MouseEvent) => { + onClick: (e: React.MouseEvent) => { onClick?.(e); el.props?.onClick?.(e); }, diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index 6bf2210f..6de94d5d 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -38,7 +38,7 @@ "workflow_node.apply.form.key_algorithm.placeholder": "Please select certificate key algorithm", "workflow_node.apply.form.nameservers.label": "DNS recursive nameservers (Optional)", "workflow_node.apply.form.nameservers.placeholder": "Please enter DNS recursive nameservers (separated by semicolons)", - "workflow_node.apply.form.nameservers.tooltip": "It determines whether to custom DNS recursive nameservers during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.", + "workflow_node.apply.form.nameservers.tooltip": "It determines whether to custom DNS recursive nameservers during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.
Learn more.", "workflow_node.apply.form.nameservers.multiple_input_modal.title": "Change DNS rcursive nameservers", "workflow_node.apply.form.nameservers.multiple_input_modal.placeholder": "Please enter DNS recursive nameserver", "workflow_node.apply.form.propagation_timeout.label": "DNS propagation timeout (Optional)", diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index 7d92ef68..963c449f 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -38,7 +38,7 @@ "workflow_node.apply.form.key_algorithm.placeholder": "请选择数字证书算法", "workflow_node.apply.form.nameservers.label": "DNS 递归服务器(可选)", "workflow_node.apply.form.nameservers.placeholder": "请输入 DNS 递归服务器(多个值请用半角分号隔开)", - "workflow_node.apply.form.nameservers.tooltip": "在 ACME DNS-01 认证时使用自定义的 DNS 递归服务器。如果你不了解该选项的用途,保持默认即可。", + "workflow_node.apply.form.nameservers.tooltip": "在 ACME DNS-01 认证时使用自定义的 DNS 递归服务器。如果你不了解该选项的用途,保持默认即可。
点此了解更多。", "workflow_node.apply.form.nameservers.multiple_input_modal.title": "修改 DNS 递归服务器", "workflow_node.apply.form.nameservers.multiple_input_modal.placeholder": "请输入 DNS 递归服务器", "workflow_node.apply.form.propagation_timeout.label": "DNS 传播检查超时时间(可选)", diff --git a/ui/src/pages/login/Login.tsx b/ui/src/pages/login/Login.tsx index 627ac306..8725c4e4 100644 --- a/ui/src/pages/login/Login.tsx +++ b/ui/src/pages/login/Login.tsx @@ -31,6 +31,8 @@ const Login = () => { await navigage("/"); } catch (err) { notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + throw err; } }, }); diff --git a/ui/src/pages/settings/SettingsAccount.tsx b/ui/src/pages/settings/SettingsAccount.tsx index d5983523..5a3aac0b 100644 --- a/ui/src/pages/settings/SettingsAccount.tsx +++ b/ui/src/pages/settings/SettingsAccount.tsx @@ -43,6 +43,8 @@ const SettingsAccount = () => { }, 500); } catch (err) { notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + throw err; } }, }); diff --git a/ui/src/pages/settings/SettingsPassword.tsx b/ui/src/pages/settings/SettingsPassword.tsx index 96429a5b..e7b28656 100644 --- a/ui/src/pages/settings/SettingsPassword.tsx +++ b/ui/src/pages/settings/SettingsPassword.tsx @@ -47,6 +47,8 @@ const SettingsPassword = () => { }, 500); } catch (err) { notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); + + throw err; } }, }); diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 7c67a5d0..5dab83ef 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -316,9 +316,9 @@ const WorkflowBaseInfoModal = ({ trigger }: { trigger?: React.ReactNode }) => { try { await workflowState.setBaseInfo(values.name!, values.description!); } catch (err) { - console.error(err); notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) }); - return false; + + throw err; } }, }); diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx index e5335f87..967d8410 100644 --- a/ui/src/pages/workflows/WorkflowList.tsx +++ b/ui/src/pages/workflows/WorkflowList.tsx @@ -216,7 +216,7 @@ const WorkflowList = () => {
{ width={480} onFinish={handleModalFormFinish} onOpenChange={handleModalOpenChange} - {...formProps} > setTimeout(() => ref?.focus({ cursor: "end" }), 0)} placeholder={t("workflow.new.modal.form.name.placeholder")} />