mirror of
https://github.com/usual2970/certimate.git
synced 2025-06-08 05:29:51 +00:00
fix(ui): antd nested form bugs
This commit is contained in:
parent
4ba7237326
commit
87e1749553
@ -11,15 +11,16 @@ export interface DrawerFormProps<T extends NonNullable<unknown> = any> extends O
|
||||
cancelButtonProps?: ModalProps["cancelButtonProps"];
|
||||
cancelText?: ModalProps["cancelText"];
|
||||
defaultOpen?: boolean;
|
||||
drawerProps?: Omit<DrawerProps, "open" | "title" | "width">;
|
||||
drawerProps?: Omit<DrawerProps, "defaultOpen" | "forceRender" | "open" | "title" | "width" | "onOpenChange">;
|
||||
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<unknown>;
|
||||
onFinish?: (values: T) => unknown | Promise<unknown>;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onFinish?: (values: T) => void | Promise<unknown>;
|
||||
}
|
||||
|
||||
const DrawerForm = <T extends NonNullable<unknown> = any>({
|
||||
@ -46,7 +47,13 @@ const DrawerForm = <T extends NonNullable<unknown> = 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 = <T extends NonNullable<unknown> = 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 = <T extends NonNullable<unknown> = any>({
|
||||
{triggerEl}
|
||||
|
||||
<Drawer
|
||||
afterOpenChange={(open) => {
|
||||
if (!open && !mergedFormProps.preserve) {
|
||||
formInst.resetFields();
|
||||
}
|
||||
|
||||
drawerProps?.afterOpenChange?.(open);
|
||||
}}
|
||||
{...mergedDrawerProps}
|
||||
footer={
|
||||
<Space className="w-full justify-end">
|
||||
<Button {...cancelButtonProps} onClick={handleCancelClick}>
|
||||
{cancelText ?? t("common.button.cancel")}
|
||||
</Button>
|
||||
<Button type="primary" loading={formPending} {...okButtonProps} onClick={handleOkClick}>
|
||||
<Button {...okButtonProps} type="primary" loading={formPending} onClick={handleOkClick}>
|
||||
{okText ?? t("common.button.ok")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
forceRender
|
||||
open={open}
|
||||
title={title}
|
||||
width={width}
|
||||
{...drawerProps}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Form className={className} style={style} form={formInst} {...mergedFormProps}>
|
||||
<Form className={className} style={style} {...mergedFormProps} form={formInst}>
|
||||
{children}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
@ -15,6 +15,7 @@ export interface ModalFormProps<T extends NonNullable<unknown> = any> extends Om
|
||||
| "cancelButtonProps"
|
||||
| "cancelText"
|
||||
| "confirmLoading"
|
||||
| "defaultOpen"
|
||||
| "forceRender"
|
||||
| "okButtonProps"
|
||||
| "okText"
|
||||
@ -22,8 +23,9 @@ export interface ModalFormProps<T extends NonNullable<unknown> = any> extends Om
|
||||
| "open"
|
||||
| "title"
|
||||
| "width"
|
||||
| "onOk"
|
||||
| "onCancel"
|
||||
| "onOk"
|
||||
| "onOpenChange"
|
||||
>;
|
||||
okButtonProps?: ModalProps["okButtonProps"];
|
||||
okText?: ModalProps["okText"];
|
||||
@ -31,8 +33,9 @@ export interface ModalFormProps<T extends NonNullable<unknown> = any> extends Om
|
||||
title?: ModalProps["title"];
|
||||
trigger?: React.ReactNode;
|
||||
width?: ModalProps["width"];
|
||||
onClose?: (e: React.MouseEvent | React.KeyboardEvent) => void | Promise<unknown>;
|
||||
onFinish?: (values: T) => unknown | Promise<unknown>;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onFinish?: (values: T) => void | Promise<unknown>;
|
||||
}
|
||||
|
||||
const ModalForm = <T extends NonNullable<unknown> = any>({
|
||||
@ -63,29 +66,41 @@ const ModalForm = <T extends NonNullable<unknown> = 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 = <T extends NonNullable<unknown> = any>({
|
||||
{triggerEl}
|
||||
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
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}
|
||||
>
|
||||
<div className="pb-2 pt-4">
|
||||
<Form className={className} style={style} form={formInst} {...mergedFormProps}>
|
||||
<Form className={className} style={style} {...mergedFormProps} form={formInst}>
|
||||
{children}
|
||||
</Form>
|
||||
</div>
|
||||
|
@ -48,7 +48,7 @@ const MultipleInput = ({
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
const newValue = produce(value, (draft) => {
|
||||
const newValue = produce(value ?? [], (draft) => {
|
||||
draft.push("");
|
||||
});
|
||||
setValue(newValue);
|
||||
|
@ -64,16 +64,23 @@ const AccessEditForm = forwardRef<AccessEditFormInstance, AccessEditFormProps>((
|
||||
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 <AccessEditFormACMEHttpReqConfig {...configFormProps} />;
|
||||
case ACCESS_PROVIDERS.ALIYUN:
|
||||
@ -113,17 +120,17 @@ const AccessEditForm = forwardRef<AccessEditFormInstance, AccessEditFormProps>((
|
||||
case ACCESS_PROVIDERS.WEBHOOK:
|
||||
return <AccessEditFormWebhookConfig {...configFormProps} />;
|
||||
}
|
||||
}, [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<AccessEditFormInstance, AccessEditFormProps>((
|
||||
},
|
||||
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<AccessEditFormInstance, AccessEditFormProps>((
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{configFormEl}
|
||||
{nestedFormEl}
|
||||
</div>
|
||||
</Form.Provider>
|
||||
);
|
||||
|
@ -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<AccessEditFormInstance>(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}
|
||||
>
|
||||
<div className="pb-2 pt-4">
|
||||
<AccessEditForm ref={formRef} initialValues={data} preset={preset === "add" ? "add" : "edit"} />
|
||||
|
@ -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<Array<{ key: string; value: string; label: string; data: AccessModel }>>([]);
|
||||
useEffect(() => {
|
||||
|
@ -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 (
|
||||
<div className={className} style={style}>
|
||||
<Show when={loadedAtOnce} fallback={<Skeleton active />}>
|
||||
<Collapse className={classNames?.collapse} style={styles?.collapse} accordion={true} bordered={false} items={channelCollapseItems} />
|
||||
<Collapse className={classNames?.collapse} style={styles?.collapse} accordion bordered={false} items={channelCollapseItems} />
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -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 <StartNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Apply:
|
||||
return <ApplyNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Deploy:
|
||||
return <DeployNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Notify:
|
||||
return <CommonNode node={node} disabled={disabled} />;
|
||||
return <NotifyNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Branch:
|
||||
return <BranchNode node={node} disabled={disabled} />;
|
||||
|
||||
case WorkflowNodeType.Condition:
|
||||
return <ConditionNode node={node} disabled={disabled} branchId={props.branchId!} branchIndex={props.branchIndex!} />;
|
||||
return <ConditionNode node={node} disabled={disabled} branchId={branchId!} branchIndex={branchIndex!} />;
|
||||
|
||||
case WorkflowNodeType.End:
|
||||
return <EndNode />;
|
||||
@ -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);
|
||||
|
@ -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();
|
||||
|
92
ui/src/components/workflow/node/ApplyNode.tsx
Normal file
92
ui/src/components/workflow/node/ApplyNode.tsx
Normal file
@ -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 <Typography.Link>{t("workflow_node.action.configure_node")}</Typography.Link>;
|
||||
}
|
||||
|
||||
const config = (node.config as WorkflowNodeConfigForApply) ?? {};
|
||||
return <Typography.Text className="truncate">{config.domains || " "}</Typography.Text>;
|
||||
}, [node]);
|
||||
|
||||
const formRef = useRef<ApplyNodeConfigFormInstance>(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 (
|
||||
<>
|
||||
<SharedNode.Wrapper node={node} disabled={disabled} onClick={() => setDrawerOpen(true)}>
|
||||
{wrappedEl}
|
||||
</SharedNode.Wrapper>
|
||||
|
||||
<SharedNode.ConfigDrawer
|
||||
node={node}
|
||||
open={drawerOpen}
|
||||
pending={formPending}
|
||||
onConfirm={handleDrawerConfirm}
|
||||
onOpenChange={(open) => setDrawerOpen(open)}
|
||||
getFormValues={() => formRef.current!.getFieldsValue()}
|
||||
>
|
||||
<ApplyNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
|
||||
</SharedNode.ConfigDrawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ApplyNode);
|
386
ui/src/components/workflow/node/ApplyNodeConfigForm.tsx
Normal file
386
ui/src/components/workflow/node/ApplyNodeConfigForm.tsx
Normal file
@ -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<WorkflowNodeConfigForApply>;
|
||||
|
||||
export type ApplyNodeConfigFormProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
initialValues?: ApplyNodeConfigFormFieldValues;
|
||||
onValuesChange?: (values: ApplyNodeConfigFormFieldValues) => void;
|
||||
};
|
||||
|
||||
export type ApplyNodeConfigFormInstance = {
|
||||
getFieldsValue: () => ReturnType<FormInstance<ApplyNodeConfigFormFieldValues>["getFieldsValue"]>;
|
||||
resetFields: FormInstance<ApplyNodeConfigFormFieldValues>["resetFields"];
|
||||
validateFields: FormInstance<ApplyNodeConfigFormFieldValues>["validateFields"];
|
||||
};
|
||||
|
||||
const MULTIPLE_INPUT_DELIMITER = ";";
|
||||
|
||||
const initFormModel = (): ApplyNodeConfigFormFieldValues => {
|
||||
return {
|
||||
keyAlgorithm: "RSA2048",
|
||||
propagationTimeout: 60,
|
||||
disableFollowCNAME: true,
|
||||
};
|
||||
};
|
||||
|
||||
const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeConfigFormProps>(
|
||||
({ 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<string>("domains", formInst);
|
||||
const fieldNameservers = Form.useWatch<string>("nameservers", formInst);
|
||||
|
||||
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
|
||||
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 (
|
||||
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
|
||||
<Form.Item
|
||||
label={t("workflow_node.apply.form.domains.label")}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.domains.tooltip") }}></span>}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="domains" noStyle rules={[formRule]}>
|
||||
<Input placeholder={t("workflow_node.apply.form.domains.placeholder")} />
|
||||
</Form.Item>
|
||||
<DomainsModalInput
|
||||
value={fieldDomains}
|
||||
trigger={
|
||||
<Button disabled={disabled}>
|
||||
<FormOutlinedIcon />
|
||||
</Button>
|
||||
}
|
||||
onChange={(v) => {
|
||||
formInst.setFieldValue("domains", v);
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contactEmail"
|
||||
label={t("workflow_node.apply.form.contact_email.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.contact_email.tooltip") }}></span>}
|
||||
>
|
||||
<EmailInput placeholder={t("workflow_node.apply.form.contact_email.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<label className="mb-1 block">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="max-w-full grow truncate">
|
||||
<span>{t("workflow_node.apply.form.provider_access.label")}</span>
|
||||
<Tooltip title={t("workflow_node.apply.form.provider_access.tooltip")}>
|
||||
<Typography.Text className="ms-1" type="secondary">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<AccessEditModal
|
||||
preset="add"
|
||||
trigger={
|
||||
<Button size="small" type="link">
|
||||
<PlusOutlinedIcon />
|
||||
{t("workflow_node.apply.form.provider_access.button")}
|
||||
</Button>
|
||||
}
|
||||
afterSubmit={(record) => {
|
||||
const provider = accessProvidersMap.get(record.provider);
|
||||
if (ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage) {
|
||||
formInst.setFieldValue("providerAccessId", record.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Form.Item name="providerAccessId" rules={[formRule]}>
|
||||
<AccessSelect
|
||||
placeholder={t("workflow_node.apply.form.provider_access.placeholder")}
|
||||
filter={(record) => {
|
||||
const provider = accessProvidersMap.get(record.provider);
|
||||
return ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage;
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Divider className="my-1">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("workflow_node.apply.form.advanced_config.label")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item name="keyAlgorithm" label={t("workflow_node.apply.form.key_algorithm.label")} rules={[formRule]}>
|
||||
<Select
|
||||
options={["RSA2048", "RSA3072", "RSA4096", "RSA8192", "EC256", "EC384"].map((e) => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))}
|
||||
placeholder={t("workflow_node.apply.form.key_algorithm.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t("workflow_node.apply.form.nameservers.label")}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.nameservers.tooltip") }}></span>}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="nameservers" noStyle rules={[formRule]}>
|
||||
<Input
|
||||
allowClear
|
||||
disabled={disabled}
|
||||
value={fieldNameservers}
|
||||
placeholder={t("workflow_node.apply.form.nameservers.placeholder")}
|
||||
onChange={(e) => {
|
||||
formInst.setFieldValue("nameservers", e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<NameserversModalInput
|
||||
value={fieldNameservers}
|
||||
trigger={
|
||||
<Button disabled={disabled}>
|
||||
<FormOutlinedIcon />
|
||||
</Button>
|
||||
}
|
||||
onChange={(value) => {
|
||||
formInst.setFieldValue("nameservers", value);
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="propagationTimeout"
|
||||
label={t("workflow_node.apply.form.propagation_timeout.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.propagation_timeout.tooltip") }}></span>}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
allowClear
|
||||
min={0}
|
||||
max={3600}
|
||||
placeholder={t("workflow_node.apply.form.propagation_timeout.placeholder")}
|
||||
addonAfter={t("workflow_node.apply.form.propagation_timeout.suffix")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="disableFollowCNAME"
|
||||
label={t("workflow_node.apply.form.disable_follow_cname.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.disable_follow_cname.tooltip") }}></span>}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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<string>(props, {
|
||||
valuePropName: "value",
|
||||
defaultValuePropName: "defaultValue",
|
||||
trigger: "onChange",
|
||||
});
|
||||
|
||||
const [options, setOptions] = useState<AutoCompleteProps["options"]>([]);
|
||||
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 (
|
||||
<AutoComplete
|
||||
backfill
|
||||
defaultValue={value}
|
||||
disabled={disabled}
|
||||
filterOption
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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 (
|
||||
<ModalForm
|
||||
{...formProps}
|
||||
layout="vertical"
|
||||
form={formInst}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
title={t("workflow_node.apply.form.domains.multiple_input_modal.title")}
|
||||
trigger={trigger}
|
||||
validateTrigger="onSubmit"
|
||||
width={480}
|
||||
>
|
||||
<Form.Item name="domains" rules={[formRule]}>
|
||||
<MultipleInput placeholder={t("workflow_node.apply.form.domains.multiple_input_modal.placeholder")} />
|
||||
</Form.Item>
|
||||
</ModalForm>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<ModalForm
|
||||
{...formProps}
|
||||
layout="vertical"
|
||||
form={formInst}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
title={t("workflow_node.apply.form.nameservers.multiple_input_modal.title")}
|
||||
trigger={trigger}
|
||||
validateTrigger="onSubmit"
|
||||
width={480}
|
||||
>
|
||||
<Form.Item name="nameservers" rules={[formRule]}>
|
||||
<MultipleInput placeholder={t("workflow_node.apply.form.nameservers.multiple_input_modal.placeholder")} />
|
||||
</Form.Item>
|
||||
</ModalForm>
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(ApplyNodeConfigForm);
|
@ -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<WorkflowNodeConfigForApply>;
|
||||
|
||||
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<string>("domains", form);
|
||||
const fieldNameservers = Form.useWatch<string>("nameservers", form);
|
||||
|
||||
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
|
||||
onValuesChange?.(values as ApplyNodeFormFieldValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
disabled={disabled}
|
||||
initialValues={initialValues}
|
||||
layout="vertical"
|
||||
name={formName}
|
||||
preserve={false}
|
||||
scrollToFirstError
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
<Form.Item
|
||||
name="domains"
|
||||
label={t("workflow_node.apply.form.domains.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.domains.tooltip") }}></span>}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={fieldDomains}
|
||||
placeholder={t("workflow_node.apply.form.domains.placeholder")}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("domains", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormFieldDomainsModalForm
|
||||
data={fieldDomains}
|
||||
trigger={
|
||||
<Button disabled={disabled}>
|
||||
<FormOutlinedIcon />
|
||||
</Button>
|
||||
}
|
||||
onFinish={(v) => {
|
||||
form.setFieldValue("domains", v);
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contactEmail"
|
||||
label={t("workflow_node.apply.form.contact_email.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.contact_email.tooltip") }}></span>}
|
||||
>
|
||||
<FormFieldEmailSelect placeholder={t("workflow_node.apply.form.contact_email.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<label className="mb-1 block">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="max-w-full grow truncate">
|
||||
<span>{t("workflow_node.apply.form.provider_access.label")}</span>
|
||||
<Tooltip title={t("workflow_node.apply.form.provider_access.tooltip")}>
|
||||
<Typography.Text className="ms-1" type="secondary">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<AccessEditModal
|
||||
preset="add"
|
||||
trigger={
|
||||
<Button size="small" type="link">
|
||||
<PlusOutlinedIcon />
|
||||
{t("workflow_node.apply.form.provider_access.button")}
|
||||
</Button>
|
||||
}
|
||||
onSubmit={(record) => {
|
||||
const provider = accessProvidersMap.get(record.provider);
|
||||
if (ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage) {
|
||||
form.setFieldValue("providerAccessId", record.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Form.Item name="providerAccessId" rules={[formRule]}>
|
||||
<AccessSelect
|
||||
placeholder={t("workflow_node.apply.form.provider_access.placeholder")}
|
||||
filter={(record) => {
|
||||
const provider = accessProvidersMap.get(record.provider);
|
||||
return ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage;
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Divider className="my-1">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("workflow_node.apply.form.advanced_config.label")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item name="keyAlgorithm" label={t("workflow_node.apply.form.key_algorithm.label")} rules={[formRule]}>
|
||||
<Select
|
||||
options={["RSA2048", "RSA3072", "RSA4096", "RSA8192", "EC256", "EC384"].map((e) => ({
|
||||
label: e,
|
||||
value: e,
|
||||
}))}
|
||||
placeholder={t("workflow_node.apply.form.key_algorithm.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="nameservers"
|
||||
label={t("workflow_node.apply.form.nameservers.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.nameservers.tooltip") }}></span>}
|
||||
>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input
|
||||
allowClear
|
||||
disabled={disabled}
|
||||
value={fieldNameservers}
|
||||
placeholder={t("workflow_node.apply.form.nameservers.placeholder")}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("nameservers", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<FormFieldNameserversModalForm
|
||||
data={fieldNameservers}
|
||||
trigger={
|
||||
<Button disabled={disabled}>
|
||||
<FormOutlinedIcon />
|
||||
</Button>
|
||||
}
|
||||
onFinish={(v) => {
|
||||
form.setFieldValue("nameservers", v);
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="propagationTimeout"
|
||||
label={t("workflow_node.apply.form.propagation_timeout.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.propagation_timeout.tooltip") }}></span>}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
allowClear
|
||||
min={0}
|
||||
max={3600}
|
||||
placeholder={t("workflow_node.apply.form.propagation_timeout.placeholder")}
|
||||
addonAfter={t("workflow_node.apply.form.propagation_timeout.suffix")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="disableFollowCNAME"
|
||||
label={t("workflow_node.apply.form.disable_follow_cname.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.apply.form.disable_follow_cname.tooltip") }}></span>}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
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<string>(props, {
|
||||
valuePropName: "value",
|
||||
defaultValuePropName: "defaultValue",
|
||||
trigger: "onChange",
|
||||
});
|
||||
|
||||
const [options, setOptions] = useState<AutoCompleteProps["options"]>([]);
|
||||
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 (
|
||||
<AutoComplete
|
||||
className={className}
|
||||
style={style}
|
||||
backfill
|
||||
defaultValue={value}
|
||||
disabled={disabled}
|
||||
filterOption
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onSearch={handleSearch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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<z.infer<typeof formSchema>>();
|
||||
|
||||
const [model, setModel] = useState<Partial<z.infer<typeof formSchema>>>({ domains: data?.split(MULTIPLE_INPUT_DELIMITER) });
|
||||
useEffect(() => {
|
||||
setModel({ domains: data?.split(MULTIPLE_INPUT_DELIMITER) });
|
||||
}, [data]);
|
||||
|
||||
const handleFormFinish = (values: z.infer<typeof formSchema>) => {
|
||||
onFinish?.(
|
||||
values.domains
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => !!e)
|
||||
.join(MULTIPLE_INPUT_DELIMITER)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={model}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
title={t("workflow_node.apply.form.domains.multiple_input_modal.title")}
|
||||
trigger={trigger}
|
||||
validateTrigger="onSubmit"
|
||||
width={480}
|
||||
onFinish={handleFormFinish}
|
||||
>
|
||||
<Form.Item name="domains" rules={[formRule]}>
|
||||
<MultipleInput placeholder={t("workflow_node.apply.form.domains.multiple_input_modal.placeholder")} />
|
||||
</Form.Item>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
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<z.infer<typeof formSchema>>();
|
||||
|
||||
const [model, setModel] = useState<Partial<z.infer<typeof formSchema>>>({ nameservers: data?.split(MULTIPLE_INPUT_DELIMITER) });
|
||||
useEffect(() => {
|
||||
setModel({ nameservers: data?.split(MULTIPLE_INPUT_DELIMITER) });
|
||||
}, [data]);
|
||||
|
||||
const handleFormFinish = (values: z.infer<typeof formSchema>) => {
|
||||
onFinish?.(
|
||||
values.nameservers
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => !!e)
|
||||
.join(MULTIPLE_INPUT_DELIMITER)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={model}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
title={t("workflow_node.apply.form.nameservers.multiple_input_modal.title")}
|
||||
trigger={trigger}
|
||||
validateTrigger="onSubmit"
|
||||
width={480}
|
||||
onFinish={handleFormFinish}
|
||||
>
|
||||
<Form.Item name="nameservers" rules={[formRule]}>
|
||||
<MultipleInput placeholder={t("workflow_node.apply.form.nameservers.multiple_input_modal.placeholder")} />
|
||||
</Form.Item>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(ApplyNodeForm);
|
@ -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();
|
||||
|
@ -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 <Typography.Link>{t("workflow_node.action.configure_node")}</Typography.Link>;
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case WorkflowNodeType.Start: {
|
||||
const config = (node.config as WorkflowNodeConfigForStart) ?? {};
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Typography.Text className="truncate">
|
||||
{config.trigger === WORKFLOW_TRIGGERS.AUTO
|
||||
? t("workflow.props.trigger.auto")
|
||||
: config.trigger === WORKFLOW_TRIGGERS.MANUAL
|
||||
? t("workflow.props.trigger.manual")
|
||||
: " "}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="truncate" type="secondary">
|
||||
{config.trigger === WORKFLOW_TRIGGERS.AUTO ? config.triggerCron : ""}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case WorkflowNodeType.Apply: {
|
||||
const config = (node.config as WorkflowNodeConfigForApply) ?? {};
|
||||
return <Typography.Text className="truncate">{config.domains || " "}</Typography.Text>;
|
||||
}
|
||||
|
||||
case WorkflowNodeType.Deploy: {
|
||||
const config = (node.config as WorkflowNodeConfigForDeploy) ?? {};
|
||||
const provider = deployProvidersMap.get(config.provider);
|
||||
return (
|
||||
<Space>
|
||||
<Avatar src={provider?.icon} size="small" />
|
||||
<Typography.Text className="truncate">{t(provider?.name ?? "")}</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
case WorkflowNodeType.Notify: {
|
||||
const config = (node.config as WorkflowNodeConfigForNotify) ?? {};
|
||||
const channel = notifyChannelsMap.get(config.channel as string);
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Typography.Text className="truncate">{t(channel?.name ?? " ")}</Typography.Text>
|
||||
<Typography.Text className="truncate" type="secondary">
|
||||
{config.subject ?? ""}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
console.warn(`[certimate] unsupported workflow node type: ${node.type}`);
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
}, [node]);
|
||||
|
||||
const handleNodeClick = () => {
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleNodeNameBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const oldName = node.name;
|
||||
const newName = e.target.innerText.trim();
|
||||
if (oldName === newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNode(
|
||||
produce(node, (draft) => {
|
||||
draft.name = newName;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={
|
||||
<Show when={node.type !== WorkflowNodeType.Start}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "delete",
|
||||
disabled: disabled,
|
||||
label: t("workflow_node.action.delete_node"),
|
||||
icon: <CloseCircleOutlinedIcon />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
if (disabled) return;
|
||||
|
||||
removeNode(node.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<Button color="primary" icon={<EllipsisOutlinedIcon />} variant="text" />
|
||||
</Dropdown>
|
||||
</Show>
|
||||
}
|
||||
overlayClassName="shadow-md"
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
placement="rightTop"
|
||||
>
|
||||
<Card className="relative w-[256px] overflow-hidden shadow-md" styles={{ body: { padding: 0 } }} hoverable>
|
||||
<div className="bg-primary flex h-[48px] flex-col items-center justify-center truncate px-4 py-2 text-white">
|
||||
<div
|
||||
className="focus:bg-background focus:text-foreground w-full overflow-hidden text-center outline-none focus:rounded-sm"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleNodeNameBlur}
|
||||
>
|
||||
{node.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center px-4 py-2">
|
||||
<div className="cursor-pointer text-sm" onClick={handleNodeClick}>
|
||||
{workflowNodeEl}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Popover>
|
||||
|
||||
<AddNode node={node} disabled={disabled} />
|
||||
|
||||
<CommonNodeEditDrawer node={node} disabled={disabled} open={drawerOpen} onOpenChange={(open) => setDrawerOpen(open)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type CommonNodeEditDrawerProps = CommonNodeProps & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const CommonNodeEditDrawer = ({ node, disabled, ...props }: CommonNodeEditDrawerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [modalApi, ModelContextHolder] = Modal.useModal();
|
||||
|
||||
const [open, setOpen] = useControllableValue<boolean>(props, {
|
||||
valuePropName: "open",
|
||||
defaultValuePropName: "defaultOpen",
|
||||
trigger: "onOpenChange",
|
||||
});
|
||||
|
||||
const { accesses } = useAccessesStore(useZustandShallowSelector("accesses"));
|
||||
const { addEmail } = useContactEmailsStore(useZustandShallowSelector(["addEmail"]));
|
||||
const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"]));
|
||||
|
||||
const {
|
||||
form: formInst,
|
||||
formPending,
|
||||
formProps,
|
||||
submit: submitForm,
|
||||
} = useAntdForm({
|
||||
name: "workflowNodeForm",
|
||||
onSubmit: async (values) => {
|
||||
await sleep(5000);
|
||||
if (node.type === WorkflowNodeType.Apply) {
|
||||
await addEmail(values.contactEmail);
|
||||
await updateNode(
|
||||
produce(node, (draft) => {
|
||||
draft.config = {
|
||||
provider: accesses.find((e) => e.id === values.providerAccessId)?.provider,
|
||||
...values,
|
||||
};
|
||||
draft.validated = true;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
await updateNode(
|
||||
produce(node, (draft) => {
|
||||
draft.config = { ...values };
|
||||
draft.validated = true;
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const formEl = useMemo(() => {
|
||||
const nodeFormProps = {
|
||||
form: formInst,
|
||||
formName: formProps.name,
|
||||
disabled: disabled || formPending,
|
||||
workflowNode: node,
|
||||
};
|
||||
|
||||
switch (node.type) {
|
||||
case WorkflowNodeType.Start:
|
||||
return <StartNodeForm {...nodeFormProps} />;
|
||||
case WorkflowNodeType.Apply:
|
||||
return <ApplyNodeForm {...nodeFormProps} />;
|
||||
case WorkflowNodeType.Deploy:
|
||||
return <DeployNodeForm {...nodeFormProps} />;
|
||||
case WorkflowNodeType.Notify:
|
||||
return <NotifyNodeForm {...nodeFormProps} />;
|
||||
default:
|
||||
console.warn(`[certimate] unsupported workflow node type: ${node.type}`);
|
||||
return <> </>;
|
||||
}
|
||||
}, [node, disabled, formInst, formPending, formProps]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (formPending) return;
|
||||
|
||||
const oldValues = Object.fromEntries(Object.entries(node.config ?? {}).filter(([_, value]) => value !== null && value !== undefined));
|
||||
const newValues = Object.fromEntries(Object.entries(formInst.getFieldsValue(true)).filter(([_, value]) => value !== null && value !== undefined));
|
||||
const changed = !isEqual(oldValues, newValues);
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
if (changed) {
|
||||
modalApi.confirm({
|
||||
title: t("common.text.operation_confirm"),
|
||||
content: t("workflow_node.unsaved_changes.confirm"),
|
||||
onOk: () => resolve(void 0),
|
||||
onCancel: () => reject(),
|
||||
});
|
||||
} else {
|
||||
resolve(void 0);
|
||||
}
|
||||
|
||||
promise.then(() => {
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
if (formPending) return;
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleOkClick = async () => {
|
||||
await submitForm();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ModelContextHolder}
|
||||
|
||||
<Drawer
|
||||
destroyOnClose
|
||||
footer={
|
||||
<Space className="w-full justify-end">
|
||||
<Button onClick={handleCancelClick}>{t("common.button.cancel")}</Button>
|
||||
<Button loading={formPending} type="primary" onClick={handleOkClick}>
|
||||
{t("common.button.ok")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
open={open}
|
||||
title={node.name}
|
||||
width={640}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{formEl}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CommonNode);
|
@ -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"]));
|
||||
|
103
ui/src/components/workflow/node/DeployNode.tsx
Normal file
103
ui/src/components/workflow/node/DeployNode.tsx
Normal file
@ -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<DeployNodeConfigFormInstance>(null);
|
||||
const [formPending, setFormPending] = useState(false);
|
||||
|
||||
const [fieldProvider, setFieldProvider] = useState<string | undefined>((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 <Typography.Link>{t("workflow_node.action.configure_node")}</Typography.Link>;
|
||||
}
|
||||
|
||||
const config = (node.config as WorkflowNodeConfigForDeploy) ?? {};
|
||||
const provider = deployProvidersMap.get(config.provider);
|
||||
return (
|
||||
<Space>
|
||||
<Avatar src={provider?.icon} size="small" />
|
||||
<Typography.Text className="truncate">{t(provider?.name ?? "")}</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
}, [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<WorkflowNodeConfigForDeploy>) => {
|
||||
setFieldProvider(values.provider!);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SharedNode.Wrapper node={node} disabled={disabled} onClick={() => setDrawerOpen(true)}>
|
||||
{wrappedEl}
|
||||
</SharedNode.Wrapper>
|
||||
|
||||
<SharedNode.ConfigDrawer
|
||||
node={node}
|
||||
footer={!!fieldProvider}
|
||||
open={drawerOpen}
|
||||
pending={formPending}
|
||||
onConfirm={handleDrawerConfirm}
|
||||
onOpenChange={(open) => setDrawerOpen(open)}
|
||||
getFormValues={() => formRef.current!.getFieldsValue()}
|
||||
>
|
||||
<DeployNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} nodeId={node.id} onValuesChange={handleFormValuesChange} />
|
||||
</SharedNode.ConfigDrawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DeployNode);
|
290
ui/src/components/workflow/node/DeployNodeConfigForm.tsx
Normal file
290
ui/src/components/workflow/node/DeployNodeConfigForm.tsx
Normal file
@ -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<WorkflowNodeConfigForDeploy>;
|
||||
|
||||
export type DeployNodeConfigFormProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
initialValues?: DeployNodeConfigFormFieldValues;
|
||||
nodeId: string;
|
||||
onValuesChange?: (values: DeployNodeConfigFormFieldValues) => void;
|
||||
};
|
||||
|
||||
export type DeployNodeConfigFormInstance = {
|
||||
getFieldsValue: () => ReturnType<FormInstance<DeployNodeConfigFormFieldValues>["getFieldsValue"]>;
|
||||
resetFields: FormInstance<DeployNodeConfigFormFieldValues>["resetFields"];
|
||||
validateFields: FormInstance<DeployNodeConfigFormFieldValues>["validateFields"];
|
||||
};
|
||||
|
||||
const initFormModel = (): DeployNodeConfigFormFieldValues => {
|
||||
return {};
|
||||
};
|
||||
|
||||
const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNodeConfigFormProps>(
|
||||
({ className, style, disabled, initialValues, nodeId, onValuesChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
|
||||
|
||||
// TODO: 优化此处逻辑
|
||||
const [previousNodes, setPreviousNodes] = useState<WorkflowNode[]>([]);
|
||||
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 <DeployNodeConfigFormAliyunALBFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_CLB:
|
||||
return <DeployNodeConfigFormAliyunCLBFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_CDN:
|
||||
return <DeployNodeConfigFormAliyunCDNFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_DCDN:
|
||||
return <DeployNodeConfigFormAliyunDCDNFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_NLB:
|
||||
return <DeployNodeConfigFormAliyunNLBFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_OSS:
|
||||
return <DeployNodeConfigFormAliyunOSSFields />;
|
||||
case DEPLOY_PROVIDERS.BAIDUCLOUD_CDN:
|
||||
return <DeployNodeConfigFormBaiduCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.BYTEPLUS_CDN:
|
||||
return <DeployNodeConfigFormBytePlusCDNFields />;
|
||||
case DEPLOY_PROVIDERS.DOGECLOUD_CDN:
|
||||
return <DeployNodeConfigFormDogeCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.HUAWEICLOUD_CDN:
|
||||
return <DeployNodeConfigFormHuaweiCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.HUAWEICLOUD_ELB:
|
||||
return <DeployNodeConfigFormHuaweiCloudELBFields />;
|
||||
case DEPLOY_PROVIDERS.KUBERNETES_SECRET:
|
||||
return <DeployNodeConfigFormKubernetesSecretFields />;
|
||||
case DEPLOY_PROVIDERS.LOCAL:
|
||||
return <DeployNodeConfigFormLocalFields />;
|
||||
case DEPLOY_PROVIDERS.QINIU_CDN:
|
||||
return <DeployNodeConfigFormQiniuCDNFields />;
|
||||
case DEPLOY_PROVIDERS.SSH:
|
||||
return <DeployNodeConfigFormSSHFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_CDN:
|
||||
return <DeployNodeConfigFormTencentCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_CLB:
|
||||
return <DeployNodeConfigFormTencentCloudCLBFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_COS:
|
||||
return <DeployNodeConfigFormTencentCloudCOSFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_ECDN:
|
||||
return <DeployNodeConfigFormTencentCloudECDNFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_EO:
|
||||
return <DeployNodeConfigFormTencentCloudEOFields />;
|
||||
case DEPLOY_PROVIDERS.VOLCENGINE_CDN:
|
||||
return <DeployNodeConfigFormVolcEngineCDNFields />;
|
||||
case DEPLOY_PROVIDERS.VOLCENGINE_LIVE:
|
||||
return <DeployNodeConfigFormVolcEngineLiveFields />;
|
||||
case DEPLOY_PROVIDERS.WEBHOOK:
|
||||
return <DeployNodeConfigFormWebhookFields />;
|
||||
}
|
||||
}, [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<string, unknown> = {};
|
||||
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<typeof formSchema>) => {
|
||||
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 (
|
||||
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
|
||||
<Show when={!!fieldProvider} fallback={<DeployProviderPicker onSelect={handleProviderPick} />}>
|
||||
<Form.Item name="provider" label={t("workflow_node.deploy.form.provider.label")} rules={[formRule]}>
|
||||
<DeployProviderSelect
|
||||
allowClear
|
||||
disabled
|
||||
placeholder={t("workflow_node.deploy.form.provider.placeholder")}
|
||||
showSearch
|
||||
onSelect={handleProviderSelect}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<label className="mb-1 block">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="max-w-full grow truncate">
|
||||
<span>{t("workflow_node.deploy.form.provider_access.label")}</span>
|
||||
<Tooltip title={t("workflow_node.deploy.form.provider_access.tooltip")}>
|
||||
<Typography.Text className="ms-1" type="secondary">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<AccessEditModal
|
||||
data={{ provider: deployProvidersMap.get(fieldProvider!)?.provider }}
|
||||
preset="add"
|
||||
trigger={
|
||||
<Button size="small" type="link">
|
||||
<PlusOutlinedIcon />
|
||||
{t("workflow_node.deploy.form.provider_access.button")}
|
||||
</Button>
|
||||
}
|
||||
afterSubmit={(record) => {
|
||||
const provider = accessProvidersMap.get(record.provider);
|
||||
if (ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.DEPLOY === provider?.usage) {
|
||||
formInst.setFieldValue("providerAccessId", record.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Form.Item name="providerAccessId" rules={[formRule]}>
|
||||
<AccessSelect
|
||||
placeholder={t("workflow_node.deploy.form.provider_access.placeholder")}
|
||||
filter={(record) => {
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="certificate"
|
||||
label={t("workflow_node.deploy.form.certificate.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.certificate.tooltip") }}></span>}
|
||||
>
|
||||
<Select
|
||||
options={previousNodes.map((item) => {
|
||||
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")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider className="my-1">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("workflow_node.deploy.form.params_config.label")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
{formFieldsEl}
|
||||
</Show>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default memo(DeployNodeConfigForm);
|
@ -1,273 +0,0 @@
|
||||
import { memo, useCallback, useEffect, 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 { useZustandShallowSelector } from "@/hooks";
|
||||
import { useWorkflowStore } from "@/stores/workflow";
|
||||
|
||||
import DeployNodeFormAliyunALBFields from "./DeployNodeFormAliyunALBFields";
|
||||
import DeployNodeFormAliyunCDNFields from "./DeployNodeFormAliyunCDNFields";
|
||||
import DeployNodeFormAliyunCLBFields from "./DeployNodeFormAliyunCLBFields";
|
||||
import DeployNodeFormAliyunDCDNFields from "./DeployNodeFormAliyunDCDNFields";
|
||||
import DeployNodeFormAliyunNLBFields from "./DeployNodeFormAliyunNLBFields";
|
||||
import DeployNodeFormAliyunOSSFields from "./DeployNodeFormAliyunOSSFields";
|
||||
import DeployNodeFormBaiduCloudCDNFields from "./DeployNodeFormBaiduCloudCDNFields";
|
||||
import DeployNodeFormBytePlusCDNFields from "./DeployNodeFormBytePlusCDNFields";
|
||||
import DeployNodeFormDogeCloudCDNFields from "./DeployNodeFormDogeCloudCDNFields";
|
||||
import DeployNodeFormHuaweiCloudCDNFields from "./DeployNodeFormHuaweiCloudCDNFields";
|
||||
import DeployNodeFormHuaweiCloudELBFields from "./DeployNodeFormHuaweiCloudELBFields";
|
||||
import DeployNodeFormKubernetesSecretFields from "./DeployNodeFormKubernetesSecretFields";
|
||||
import DeployNodeFormLocalFields from "./DeployNodeFormLocalFields";
|
||||
import DeployNodeFormQiniuCDNFields from "./DeployNodeFormQiniuCDNFields";
|
||||
import DeployNodeFormSSHFields from "./DeployNodeFormSSHFields";
|
||||
import DeployNodeFormTencentCloudCDNFields from "./DeployNodeFormTencentCloudCDNFields";
|
||||
import DeployNodeFormTencentCloudCLBFields from "./DeployNodeFormTencentCloudCLBFields";
|
||||
import DeployNodeFormTencentCloudCOSFields from "./DeployNodeFormTencentCloudCOSFields";
|
||||
import DeployNodeFormTencentCloudECDNFields from "./DeployNodeFormTencentCloudECDNFields";
|
||||
import DeployNodeFormTencentCloudEOFields from "./DeployNodeFormTencentCloudEOFields";
|
||||
import DeployNodeFormVolcEngineCDNFields from "./DeployNodeFormVolcEngineCDNFields";
|
||||
import DeployNodeFormVolcEngineLiveFields from "./DeployNodeFormVolcEngineLiveFields";
|
||||
import DeployNodeFormWebhookFields from "./DeployNodeFormWebhookFields";
|
||||
|
||||
type DeployNodeFormFieldValues = Partial<WorkflowNodeConfigForDeploy>;
|
||||
|
||||
export type DeployFormProps = {
|
||||
form: FormInstance;
|
||||
formName?: string;
|
||||
disabled?: boolean;
|
||||
workflowNode: WorkflowNode;
|
||||
onValuesChange?: (values: DeployNodeFormFieldValues) => void;
|
||||
};
|
||||
|
||||
const initFormModel = (): DeployNodeFormFieldValues => {
|
||||
return {};
|
||||
};
|
||||
|
||||
const DeployNodeForm = ({ form, formName, disabled, workflowNode, onValuesChange }: DeployFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { getWorkflowOuptutBeforeId } = useWorkflowStore(useZustandShallowSelector(["updateNode", "getWorkflowOuptutBeforeId"]));
|
||||
|
||||
const [previousOutput, setPreviousOutput] = useState<WorkflowNode[]>([]);
|
||||
useEffect(() => {
|
||||
const rs = getWorkflowOuptutBeforeId(workflowNode.id, "certificate");
|
||||
setPreviousOutput(rs);
|
||||
}, [workflowNode.id, getWorkflowOuptutBeforeId]);
|
||||
|
||||
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 initialValues: DeployNodeFormFieldValues = (workflowNode.config as WorkflowNodeConfigForDeploy) ?? initFormModel();
|
||||
|
||||
const fieldProvider = Form.useWatch("provider", { form: form, 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 <DeployNodeFormAliyunALBFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_CLB:
|
||||
return <DeployNodeFormAliyunCLBFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_CDN:
|
||||
return <DeployNodeFormAliyunCDNFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_DCDN:
|
||||
return <DeployNodeFormAliyunDCDNFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_NLB:
|
||||
return <DeployNodeFormAliyunNLBFields />;
|
||||
case DEPLOY_PROVIDERS.ALIYUN_OSS:
|
||||
return <DeployNodeFormAliyunOSSFields />;
|
||||
case DEPLOY_PROVIDERS.BAIDUCLOUD_CDN:
|
||||
return <DeployNodeFormBaiduCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.BYTEPLUS_CDN:
|
||||
return <DeployNodeFormBytePlusCDNFields />;
|
||||
case DEPLOY_PROVIDERS.DOGECLOUD_CDN:
|
||||
return <DeployNodeFormDogeCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.HUAWEICLOUD_CDN:
|
||||
return <DeployNodeFormHuaweiCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.HUAWEICLOUD_ELB:
|
||||
return <DeployNodeFormHuaweiCloudELBFields />;
|
||||
case DEPLOY_PROVIDERS.KUBERNETES_SECRET:
|
||||
return <DeployNodeFormKubernetesSecretFields />;
|
||||
case DEPLOY_PROVIDERS.LOCAL:
|
||||
return <DeployNodeFormLocalFields />;
|
||||
case DEPLOY_PROVIDERS.QINIU_CDN:
|
||||
return <DeployNodeFormQiniuCDNFields />;
|
||||
case DEPLOY_PROVIDERS.SSH:
|
||||
return <DeployNodeFormSSHFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_CDN:
|
||||
return <DeployNodeFormTencentCloudCDNFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_CLB:
|
||||
return <DeployNodeFormTencentCloudCLBFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_COS:
|
||||
return <DeployNodeFormTencentCloudCOSFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_ECDN:
|
||||
return <DeployNodeFormTencentCloudECDNFields />;
|
||||
case DEPLOY_PROVIDERS.TENCENTCLOUD_EO:
|
||||
return <DeployNodeFormTencentCloudEOFields />;
|
||||
case DEPLOY_PROVIDERS.VOLCENGINE_CDN:
|
||||
return <DeployNodeFormVolcEngineCDNFields />;
|
||||
case DEPLOY_PROVIDERS.VOLCENGINE_LIVE:
|
||||
return <DeployNodeFormVolcEngineLiveFields />;
|
||||
case DEPLOY_PROVIDERS.WEBHOOK:
|
||||
return <DeployNodeFormWebhookFields />;
|
||||
}
|
||||
}, [fieldProvider]);
|
||||
|
||||
const handleProviderPick = useCallback(
|
||||
(value: string) => {
|
||||
form.setFieldValue("provider", value);
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const handleProviderSelect = (value: string) => {
|
||||
if (fieldProvider === value) return;
|
||||
|
||||
// TODO: 暂时不支持切换部署目标,需后端调整,否则之前若存在部署结果输出就不会再部署
|
||||
// 切换部署目标时重置表单,避免其他部署目标的配置字段影响当前部署目标
|
||||
if (initialValues?.provider === value) {
|
||||
form.resetFields();
|
||||
} else {
|
||||
const oldValues = form.getFieldsValue();
|
||||
const newValues: Record<string, unknown> = {};
|
||||
for (const key in oldValues) {
|
||||
if (key === "provider" || key === "providerAccessId" || key === "certificate") {
|
||||
newValues[key] = oldValues[key];
|
||||
} else {
|
||||
newValues[key] = undefined;
|
||||
}
|
||||
}
|
||||
form.setFieldsValue(newValues);
|
||||
|
||||
if (deployProvidersMap.get(fieldProvider)?.provider !== deployProvidersMap.get(value)?.provider) {
|
||||
form.setFieldValue("providerAccessId", undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
|
||||
onValuesChange?.(values as DeployNodeFormFieldValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
disabled={disabled}
|
||||
initialValues={initialValues}
|
||||
layout="vertical"
|
||||
name={formName}
|
||||
preserve={false}
|
||||
scrollToFirstError
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
<Show when={!!fieldProvider} fallback={<DeployProviderPicker onSelect={handleProviderPick} />}>
|
||||
<Form.Item name="provider" label={t("workflow_node.deploy.form.provider.label")} rules={[formRule]}>
|
||||
<DeployProviderSelect
|
||||
allowClear
|
||||
disabled
|
||||
placeholder={t("workflow_node.deploy.form.provider.placeholder")}
|
||||
showSearch
|
||||
onSelect={handleProviderSelect}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<label className="mb-1 block">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="max-w-full grow truncate">
|
||||
<span>{t("workflow_node.deploy.form.provider_access.label")}</span>
|
||||
<Tooltip title={t("workflow_node.deploy.form.provider_access.tooltip")}>
|
||||
<Typography.Text className="ms-1" type="secondary">
|
||||
<QuestionCircleOutlinedIcon />
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<AccessEditModal
|
||||
data={{ provider: deployProvidersMap.get(fieldProvider!)?.provider }}
|
||||
preset="add"
|
||||
trigger={
|
||||
<Button size="small" type="link">
|
||||
<PlusOutlinedIcon />
|
||||
{t("workflow_node.deploy.form.provider_access.button")}
|
||||
</Button>
|
||||
}
|
||||
onSubmit={(record) => {
|
||||
const provider = accessProvidersMap.get(record.provider);
|
||||
if (ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.DEPLOY === provider?.usage) {
|
||||
form.setFieldValue("providerAccessId", record.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Form.Item name="providerAccessId" rules={[formRule]}>
|
||||
<AccessSelect
|
||||
placeholder={t("workflow_node.deploy.form.provider_access.placeholder")}
|
||||
filter={(record) => {
|
||||
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;
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="certificate"
|
||||
label={t("workflow_node.deploy.form.certificate.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.certificate.tooltip") }}></span>}
|
||||
>
|
||||
<Select
|
||||
options={previousOutput.map((item) => {
|
||||
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")}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider className="my-1">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("workflow_node.deploy.form.params_config.label")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
{formFieldsEl}
|
||||
</Show>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DeployNodeForm);
|
95
ui/src/components/workflow/node/NotifyNode.tsx
Normal file
95
ui/src/components/workflow/node/NotifyNode.tsx
Normal file
@ -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<NotifyNodeConfigFormInstance>(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 <Typography.Link>{t("workflow_node.action.configure_node")}</Typography.Link>;
|
||||
}
|
||||
|
||||
const config = (node.config as WorkflowNodeConfigForNotify) ?? {};
|
||||
const channel = notifyChannelsMap.get(config.channel as string);
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Typography.Text className="truncate">{t(channel?.name ?? " ")}</Typography.Text>
|
||||
<Typography.Text className="truncate" type="secondary">
|
||||
{config.subject ?? ""}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}, [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 (
|
||||
<>
|
||||
<SharedNode.Wrapper node={node} disabled={disabled} onClick={() => setDrawerOpen(true)}>
|
||||
{wrappedEl}
|
||||
</SharedNode.Wrapper>
|
||||
|
||||
<SharedNode.ConfigDrawer
|
||||
node={node}
|
||||
open={drawerOpen}
|
||||
pending={formPending}
|
||||
onConfirm={handleDrawerConfirm}
|
||||
onOpenChange={(open) => setDrawerOpen(open)}
|
||||
getFormValues={() => formRef.current!.getFieldsValue()}
|
||||
>
|
||||
<NotifyNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
|
||||
</SharedNode.ConfigDrawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NotifyNode);
|
126
ui/src/components/workflow/node/NotifyNodeConfigForm.tsx
Normal file
126
ui/src/components/workflow/node/NotifyNodeConfigForm.tsx
Normal file
@ -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<WorkflowNodeConfigForNotify>;
|
||||
|
||||
export type NotifyNodeConfigFormProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
initialValues?: NotifyNodeConfigFormFieldValues;
|
||||
onValuesChange?: (values: NotifyNodeConfigFormFieldValues) => void;
|
||||
};
|
||||
|
||||
export type NotifyNodeConfigFormInstance = {
|
||||
getFieldsValue: () => ReturnType<FormInstance<NotifyNodeConfigFormFieldValues>["getFieldsValue"]>;
|
||||
resetFields: FormInstance<NotifyNodeConfigFormFieldValues>["resetFields"];
|
||||
validateFields: FormInstance<NotifyNodeConfigFormFieldValues>["validateFields"];
|
||||
};
|
||||
|
||||
const initFormModel = (): NotifyNodeConfigFormFieldValues => {
|
||||
return {
|
||||
subject: "Completed!",
|
||||
message: "Your workflow has been completed on Certimate.",
|
||||
};
|
||||
};
|
||||
|
||||
const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNodeConfigFormProps>(
|
||||
({ 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<typeof formSchema>) => {
|
||||
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 (
|
||||
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
|
||||
<Form.Item name="subject" label={t("workflow_node.notify.form.subject.label")} rules={[formRule]}>
|
||||
<Input placeholder={t("workflow_node.notify.form.subject.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<label className="mb-1 block">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="max-w-full grow truncate">{t("workflow_node.notify.form.channel.label")}</div>
|
||||
<div className="text-right">
|
||||
<Link className="ant-typography" to="/settings/notification" target="_blank">
|
||||
<Button size="small" type="link">
|
||||
{t("workflow_node.notify.form.channel.button")}
|
||||
<RightOutlinedIcon className="text-xs" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Form.Item name="channel" rules={[formRule]}>
|
||||
<Select
|
||||
loading={!channelsLoadedAtOnce}
|
||||
options={Object.entries(channels)
|
||||
.filter(([_, v]) => v?.enabled)
|
||||
.map(([k, _]) => ({
|
||||
label: t(notifyChannelsMap.get(k)?.name ?? k),
|
||||
value: k,
|
||||
}))}
|
||||
placeholder={t("workflow_node.notify.form.channel.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default memo(NotifyNodeConfigForm);
|
@ -1,112 +0,0 @@
|
||||
import { memo, useEffect } 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 WorkflowNode, type WorkflowNodeConfigForNotify } from "@/domain/workflow";
|
||||
import { useZustandShallowSelector } from "@/hooks";
|
||||
import { useNotifyChannelsStore } from "@/stores/notify";
|
||||
|
||||
type NotifyNodeFormFieldValues = Partial<WorkflowNodeConfigForNotify>;
|
||||
|
||||
export type NotifyNodeFormProps = {
|
||||
form: FormInstance;
|
||||
formName?: string;
|
||||
disabled?: boolean;
|
||||
workflowNode: WorkflowNode;
|
||||
onValuesChange?: (values: NotifyNodeFormFieldValues) => void;
|
||||
};
|
||||
|
||||
const initFormModel = (): NotifyNodeFormFieldValues => {
|
||||
return {
|
||||
subject: "Completed!",
|
||||
message: "Your workflow has been completed on Certimate.",
|
||||
};
|
||||
};
|
||||
|
||||
const NotifyNodeForm = ({ form, formName, disabled, workflowNode, onValuesChange }: NotifyNodeFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
channels,
|
||||
loadedAtOnce: channelsLoadedAtOnce,
|
||||
fetchChannels,
|
||||
} = useNotifyChannelsStore(useZustandShallowSelector(["channels", "loadedAtOnce", "fetchChannels"]));
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [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 initialValues: NotifyNodeFormFieldValues = (workflowNode.config as WorkflowNodeConfigForNotify) ?? initFormModel();
|
||||
|
||||
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
|
||||
onValuesChange?.(values as NotifyNodeFormFieldValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
disabled={disabled}
|
||||
initialValues={initialValues}
|
||||
layout="vertical"
|
||||
name={formName}
|
||||
preserve={false}
|
||||
scrollToFirstError
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
<Form.Item name="subject" label={t("workflow_node.notify.form.subject.label")} rules={[formRule]}>
|
||||
<Input placeholder={t("workflow_node.notify.form.subject.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
|
||||
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="mb-0">
|
||||
<label className="mb-1 block">
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="max-w-full grow truncate">{t("workflow_node.notify.form.channel.label")}</div>
|
||||
<div className="text-right">
|
||||
<Link className="ant-typography" to="/settings/notification" target="_blank">
|
||||
<Button size="small" type="link">
|
||||
{t("workflow_node.notify.form.channel.button")}
|
||||
<RightOutlinedIcon className="text-xs" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<Form.Item name="channel" rules={[formRule]}>
|
||||
<Select
|
||||
loading={!channelsLoadedAtOnce}
|
||||
options={Object.entries(channels)
|
||||
.filter(([_, v]) => v?.enabled)
|
||||
.map(([k, _]) => ({
|
||||
label: t(notifyChannelsMap.get(k)?.name ?? k),
|
||||
value: k,
|
||||
}))}
|
||||
placeholder={t("workflow_node.notify.form.channel.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NotifyNodeForm);
|
99
ui/src/components/workflow/node/StartNode.tsx
Normal file
99
ui/src/components/workflow/node/StartNode.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "antd";
|
||||
import { produce } from "immer";
|
||||
|
||||
import { WORKFLOW_TRIGGERS, type WorkflowNodeConfigForStart, WorkflowNodeType } from "@/domain/workflow";
|
||||
import { useZustandShallowSelector } from "@/hooks";
|
||||
import { useWorkflowStore } from "@/stores/workflow";
|
||||
|
||||
import StartNodeConfigForm, { type StartNodeConfigFormInstance } from "./StartNodeConfigForm";
|
||||
import SharedNode, { type SharedNodeProps } from "./_SharedNode";
|
||||
|
||||
export type StartNodeProps = SharedNodeProps;
|
||||
|
||||
const StartNode = ({ node, disabled }: StartNodeProps) => {
|
||||
if (node.type !== WorkflowNodeType.Start) {
|
||||
console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Start}`);
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"]));
|
||||
|
||||
const formRef = useRef<StartNodeConfigFormInstance>(null);
|
||||
const [formPending, setFormPending] = useState(false);
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForStart;
|
||||
|
||||
const wrappedEl = useMemo(() => {
|
||||
if (node.type !== WorkflowNodeType.Start) {
|
||||
console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Start}`);
|
||||
}
|
||||
|
||||
if (!node.validated) {
|
||||
return <Typography.Link>{t("workflow_node.action.configure_node")}</Typography.Link>;
|
||||
}
|
||||
|
||||
const config = (node.config as WorkflowNodeConfigForStart) ?? {};
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Typography.Text className="truncate">
|
||||
{config.trigger === WORKFLOW_TRIGGERS.AUTO
|
||||
? t("workflow.props.trigger.auto")
|
||||
: config.trigger === WORKFLOW_TRIGGERS.MANUAL
|
||||
? t("workflow.props.trigger.manual")
|
||||
: " "}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="truncate" type="secondary">
|
||||
{config.trigger === WORKFLOW_TRIGGERS.AUTO ? config.triggerCron : ""}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}, [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 (
|
||||
<>
|
||||
<SharedNode.Wrapper node={node} disabled={disabled} onClick={() => setDrawerOpen(true)}>
|
||||
{wrappedEl}
|
||||
</SharedNode.Wrapper>
|
||||
|
||||
<SharedNode.ConfigDrawer
|
||||
node={node}
|
||||
open={drawerOpen}
|
||||
pending={formPending}
|
||||
onConfirm={handleDrawerConfirm}
|
||||
onOpenChange={(open) => setDrawerOpen(open)}
|
||||
getFormValues={() => formRef.current!.getFieldsValue()}
|
||||
>
|
||||
<StartNodeConfigForm ref={formRef} disabled={disabled} initialValues={node.config} />
|
||||
</SharedNode.ConfigDrawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(StartNode);
|
146
ui/src/components/workflow/node/StartNodeConfigForm.tsx
Normal file
146
ui/src/components/workflow/node/StartNodeConfigForm.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { forwardRef, memo, useEffect, useImperativeHandle, 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 WorkflowNodeConfigForStart, type WorkflowTriggerType } from "@/domain/workflow";
|
||||
import { useAntdForm } from "@/hooks";
|
||||
import { getNextCronExecutions, validCronExpression } from "@/utils/cron";
|
||||
|
||||
type StartNodeConfigFormFieldValues = Partial<WorkflowNodeConfigForStart>;
|
||||
|
||||
export type StartNodeConfigFormProps = {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
initialValues?: StartNodeConfigFormFieldValues;
|
||||
onValuesChange?: (values: StartNodeConfigFormFieldValues) => void;
|
||||
};
|
||||
|
||||
export type StartNodeConfigFormInstance = {
|
||||
getFieldsValue: () => ReturnType<FormInstance<StartNodeConfigFormFieldValues>["getFieldsValue"]>;
|
||||
resetFields: FormInstance<StartNodeConfigFormFieldValues>["resetFields"];
|
||||
validateFields: FormInstance<StartNodeConfigFormFieldValues>["validateFields"];
|
||||
};
|
||||
|
||||
const initFormModel = (): StartNodeConfigFormFieldValues => {
|
||||
return {
|
||||
trigger: WORKFLOW_TRIGGERS.AUTO,
|
||||
triggerCron: "0 0 * * *",
|
||||
};
|
||||
};
|
||||
|
||||
const StartNodeConfigForm = forwardRef<StartNodeConfigFormInstance, StartNodeConfigFormProps>(
|
||||
({ className, style, disabled, initialValues, onValuesChange }, ref) => {
|
||||
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 { form: formInst, formProps } = useAntdForm({
|
||||
initialValues: initialValues ?? initFormModel(),
|
||||
});
|
||||
|
||||
const fieldTrigger = Form.useWatch<WorkflowTriggerType>("trigger", formInst);
|
||||
const fieldTriggerCron = Form.useWatch<string>("triggerCron", formInst);
|
||||
const [fieldTriggerCronExpectedExecutions, setFieldTriggerCronExpectedExecutions] = useState<Date[]>([]);
|
||||
useEffect(() => {
|
||||
setFieldTriggerCronExpectedExecutions(getNextCronExecutions(fieldTriggerCron, 5));
|
||||
}, [fieldTriggerCron]);
|
||||
|
||||
const handleTriggerChange = (value: string) => {
|
||||
if (value === WORKFLOW_TRIGGERS.AUTO) {
|
||||
formInst.setFieldValue("triggerCron", formProps.initialValues?.triggerCron || initFormModel().triggerCron);
|
||||
} else {
|
||||
formInst.setFieldValue("triggerCron", undefined);
|
||||
}
|
||||
|
||||
onValuesChange?.(formInst.getFieldsValue(true));
|
||||
};
|
||||
|
||||
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
|
||||
onValuesChange?.(values as StartNodeConfigFormFieldValues);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
getFieldsValue: () => {
|
||||
return formInst.getFieldsValue(true);
|
||||
},
|
||||
resetFields: (fields) => {
|
||||
return formInst.resetFields(fields);
|
||||
},
|
||||
validateFields: (nameList, config) => {
|
||||
return formInst.validateFields(nameList, config);
|
||||
},
|
||||
} as StartNodeConfigFormInstance;
|
||||
});
|
||||
|
||||
return (
|
||||
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
|
||||
<Form.Item
|
||||
name="trigger"
|
||||
label={t("workflow_node.start.form.trigger.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger.tooltip") }}></span>}
|
||||
>
|
||||
<Radio.Group onChange={(e) => handleTriggerChange(e.target.value)}>
|
||||
<Radio value={WORKFLOW_TRIGGERS.AUTO}>{t("workflow_node.start.form.trigger.option.auto.label")}</Radio>
|
||||
<Radio value={WORKFLOW_TRIGGERS.MANUAL}>{t("workflow_node.start.form.trigger.option.manual.label")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="triggerCron"
|
||||
label={t("workflow_node.start.form.trigger_cron.label")}
|
||||
hidden={fieldTrigger !== WORKFLOW_TRIGGERS.AUTO}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger_cron.tooltip") }}></span>}
|
||||
extra={
|
||||
<Show when={fieldTriggerCronExpectedExecutions.length > 0}>
|
||||
<div>
|
||||
{t("workflow_node.start.form.trigger_cron.extra")}
|
||||
<br />
|
||||
{fieldTriggerCronExpectedExecutions.map((date, index) => (
|
||||
<span key={index}>
|
||||
{dayjs(date).format("YYYY-MM-DD HH:mm:ss")}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Input placeholder={t("workflow_node.start.form.trigger_cron.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Show when={fieldTrigger === WORKFLOW_TRIGGERS.AUTO}>
|
||||
<Form.Item>
|
||||
<Alert type="info" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger_cron_alert.content") }}></span>} />
|
||||
</Form.Item>
|
||||
</Show>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default memo(StartNodeConfigForm);
|
@ -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<WorkflowNodeConfigForStart>;
|
||||
|
||||
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<WorkflowTriggerType>("trigger", form);
|
||||
const fieldTriggerCron = Form.useWatch<string>("triggerCron", form);
|
||||
const [fieldTriggerCronExpectedExecutions, setFieldTriggerCronExpectedExecutions] = useState<Date[]>([]);
|
||||
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<typeof formSchema>) => {
|
||||
onValuesChange?.(values as StartNodeFormFieldValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
disabled={disabled}
|
||||
initialValues={initialValues}
|
||||
layout="vertical"
|
||||
name={formName}
|
||||
preserve={false}
|
||||
scrollToFirstError
|
||||
onValuesChange={handleFormChange}
|
||||
>
|
||||
<Form.Item
|
||||
name="trigger"
|
||||
label={t("workflow_node.start.form.trigger.label")}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger.tooltip") }}></span>}
|
||||
>
|
||||
<Radio.Group onChange={(e) => handleTriggerChange(e.target.value)}>
|
||||
<Radio value={WORKFLOW_TRIGGERS.AUTO}>{t("workflow_node.start.form.trigger.option.auto.label")}</Radio>
|
||||
<Radio value={WORKFLOW_TRIGGERS.MANUAL}>{t("workflow_node.start.form.trigger.option.manual.label")}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="triggerCron"
|
||||
label={t("workflow_node.start.form.trigger_cron.label")}
|
||||
hidden={fieldTrigger !== WORKFLOW_TRIGGERS.AUTO}
|
||||
rules={[formRule]}
|
||||
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger_cron.tooltip") }}></span>}
|
||||
extra={
|
||||
<Show when={fieldTriggerCronExpectedExecutions.length > 0}>
|
||||
<div>
|
||||
{t("workflow_node.start.form.trigger_cron.extra")}
|
||||
<br />
|
||||
{fieldTriggerCronExpectedExecutions.map((date, index) => (
|
||||
<span key={index}>
|
||||
{dayjs(date).format("YYYY-MM-DD HH:mm:ss")}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Input placeholder={t("workflow_node.start.form.trigger_cron.placeholder")} />
|
||||
</Form.Item>
|
||||
|
||||
<Show when={fieldTrigger === WORKFLOW_TRIGGERS.AUTO}>
|
||||
<Form.Item>
|
||||
<Alert type="info" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger_cron_alert.content") }}></span>} />
|
||||
</Form.Item>
|
||||
</Show>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(StartNodeForm);
|
201
ui/src/components/workflow/node/_SharedNode.tsx
Normal file
201
ui/src/components/workflow/node/_SharedNode.tsx
Normal file
@ -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<HTMLDivElement>) => {
|
||||
const oldName = node.name;
|
||||
const newName = e.target.innerText.trim();
|
||||
if (oldName === newName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateNode(
|
||||
produce(node, (draft) => {
|
||||
draft.name = newName;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
arrow={false}
|
||||
content={
|
||||
<Show when={node.type !== WorkflowNodeType.Start}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "delete",
|
||||
disabled: disabled,
|
||||
label: t("workflow_node.action.delete_node"),
|
||||
icon: <CloseCircleOutlinedIcon />,
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
if (disabled) return;
|
||||
|
||||
removeNode(node.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<Button color="primary" icon={<EllipsisOutlinedIcon />} variant="text" />
|
||||
</Dropdown>
|
||||
</Show>
|
||||
}
|
||||
overlayClassName="shadow-md"
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
placement="rightTop"
|
||||
>
|
||||
<Card className="relative w-[256px] overflow-hidden shadow-md" styles={{ body: { padding: 0 } }} hoverable>
|
||||
<div className="bg-primary flex h-[48px] flex-col items-center justify-center truncate px-4 py-2 text-white">
|
||||
<div
|
||||
className="focus:bg-background focus:text-foreground w-full overflow-hidden text-center outline-none focus:rounded-sm"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleNodeNameBlur}
|
||||
>
|
||||
{node.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex cursor-pointer flex-col justify-center px-4 py-2" onClick={handleNodeClick}>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Popover>
|
||||
|
||||
<AddNode node={node} disabled={disabled} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SharedNodeEditDrawerProps = SharedNodeProps & {
|
||||
children: React.ReactNode;
|
||||
footer?: boolean;
|
||||
loading?: boolean;
|
||||
open?: boolean;
|
||||
pending?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onConfirm: () => void | Promise<unknown>;
|
||||
getFormValues: () => NonNullable<unknown>;
|
||||
};
|
||||
|
||||
const SharedNodeConfigDrawer = ({
|
||||
children,
|
||||
node,
|
||||
disabled,
|
||||
footer = true,
|
||||
loading,
|
||||
pending,
|
||||
onConfirm,
|
||||
getFormValues,
|
||||
...props
|
||||
}: SharedNodeEditDrawerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [modalApi, ModelContextHolder] = Modal.useModal();
|
||||
|
||||
const [open, setOpen] = useControllableValue<boolean>(props, {
|
||||
valuePropName: "open",
|
||||
defaultValuePropName: "defaultOpen",
|
||||
trigger: "onOpenChange",
|
||||
});
|
||||
|
||||
const handleConfirmClick = async () => {
|
||||
await onConfirm();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
if (pending) return;
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (pending) return;
|
||||
|
||||
const oldValues = Object.fromEntries(Object.entries(node.config ?? {}).filter(([_, value]) => value !== null && value !== undefined));
|
||||
const newValues = Object.fromEntries(Object.entries(getFormValues()).filter(([_, value]) => value !== null && value !== undefined));
|
||||
const changed = !isEqual(oldValues, newValues);
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers();
|
||||
if (changed) {
|
||||
modalApi.confirm({
|
||||
title: t("common.text.operation_confirm"),
|
||||
content: t("workflow_node.unsaved_changes.confirm"),
|
||||
onOk: () => resolve(void 0),
|
||||
onCancel: () => reject(),
|
||||
});
|
||||
} else {
|
||||
resolve(void 0);
|
||||
}
|
||||
|
||||
promise.then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ModelContextHolder}
|
||||
|
||||
<Drawer
|
||||
afterOpenChange={(open) => setOpen(open)}
|
||||
destroyOnClose
|
||||
loading={loading}
|
||||
footer={
|
||||
!!footer && (
|
||||
<Space className="w-full justify-end">
|
||||
<Button onClick={handleCancelClick}>{t("common.button.cancel")}</Button>
|
||||
<Button disabled={disabled} loading={pending} type="primary" onClick={handleConfirmClick}>
|
||||
{t("common.button.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
open={open}
|
||||
width={640}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
Wrapper: memo(SharedNodeWrapper),
|
||||
ConfigDrawer: memo(SharedNodeConfigDrawer),
|
||||
};
|
@ -8,7 +8,7 @@ export interface UseAntdFormOptions<T extends NonNullable<unknown> = any> {
|
||||
form?: FormInstance<T>;
|
||||
initialValues?: Partial<T> | (() => Partial<T> | Promise<Partial<T>>);
|
||||
name?: string;
|
||||
onSubmit?: (values: T) => unknown;
|
||||
onSubmit?: (values: T) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface UseAntdFormReturns<T extends NonNullable<unknown> = any> {
|
||||
@ -95,7 +95,7 @@ const useAntdForm = <T extends NonNullable<unknown> = any>({ form, initialValues
|
||||
const formProps: FormProps = {
|
||||
form: formInst,
|
||||
initialValues: formInitialValues,
|
||||
name: options.name ? formName : undefined,
|
||||
name: formName,
|
||||
onFinish,
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useCreation } from "ahooks";
|
||||
import { type FormInstance } from "antd";
|
||||
import { nanoid } from "nanoid/non-secure";
|
||||
|
||||
export interface UseAntdFormNameOptions<T extends NonNullable<unknown> = any> {
|
||||
form: FormInstance<T>;
|
||||
@ -13,7 +14,7 @@ export interface UseAntdFormNameOptions<T extends NonNullable<unknown> = any> {
|
||||
* @returns {string}
|
||||
*/
|
||||
const useAntdFormName = <T extends NonNullable<unknown> = any>(options: UseAntdFormNameOptions<T>) => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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.<br><a href=\"https://go-acme.github.io/lego/usage/cli/options/index.html#dns-resolvers-and-challenge-verification\" target=\"_blank\">Learn more</a>.",
|
||||
"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)",
|
||||
|
@ -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 递归服务器。如果你不了解该选项的用途,保持默认即可。<br><a href=\"https://go-acme.github.io/lego/usage/cli/options/index.html#dns-resolvers-and-challenge-verification\" target=\"_blank\">点此了解更多</a>。",
|
||||
"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 传播检查超时时间(可选)",
|
||||
|
@ -31,6 +31,8 @@ const Login = () => {
|
||||
await navigage("/");
|
||||
} catch (err) {
|
||||
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -43,6 +43,8 @@ const SettingsAccount = () => {
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -47,6 +47,8 @@ const SettingsPassword = () => {
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@ -216,7 +216,7 @@ const WorkflowList = () => {
|
||||
<Tooltip title={t("workflow.action.delete")}>
|
||||
<Button
|
||||
color="danger"
|
||||
danger={true}
|
||||
danger
|
||||
icon={<DeleteOutlinedIcon />}
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
|
@ -73,10 +73,9 @@ const WorkflowNew = () => {
|
||||
workflow = await saveWorkflow(workflow);
|
||||
navigate(`/workflows/${workflow.id}`, { replace: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
|
||||
|
||||
return false;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -155,6 +154,7 @@ const WorkflowNew = () => {
|
||||
</div>
|
||||
|
||||
<ModalForm
|
||||
{...formProps}
|
||||
disabled={formPending}
|
||||
layout="vertical"
|
||||
form={formInst}
|
||||
@ -165,7 +165,6 @@ const WorkflowNew = () => {
|
||||
width={480}
|
||||
onFinish={handleModalFormFinish}
|
||||
onOpenChange={handleModalOpenChange}
|
||||
{...formProps}
|
||||
>
|
||||
<Form.Item name="name" label={t("workflow.new.modal.form.name.label")} rules={[formRule]}>
|
||||
<Input ref={(ref) => setTimeout(() => ref?.focus({ cursor: "end" }), 0)} placeholder={t("workflow.new.modal.form.name.placeholder")} />
|
||||
|
Loading…
x
Reference in New Issue
Block a user