fix(ui): antd nested form bugs

This commit is contained in:
Fu Diwei 2025-01-06 19:10:29 +08:00
parent 4ba7237326
commit 87e1749553
60 changed files with 1694 additions and 1358 deletions

View File

@ -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 = () => {
const mergedDrawerProps: DrawerProps = {
...drawerProps,
afterOpenChange: (open) => {
if (!open && !mergedFormProps.preserve) {
formInst.resetFields();
}
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>

View File

@ -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>

View File

@ -48,7 +48,7 @@ const MultipleInput = ({
});
const handleCreate = () => {
const newValue = produce(value, (draft) => {
const newValue = produce(value ?? [], (draft) => {
draft.push("");
});
setValue(newValue);

View File

@ -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>
);

View File

@ -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"} />

View File

@ -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(() => {

View File

@ -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>
);

View File

@ -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;
}
},
});

View File

@ -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);

View File

@ -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();

View 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);

View 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);

View File

@ -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);

View File

@ -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();

View File

@ -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);

View File

@ -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"]));

View 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);

View 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);

View File

@ -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);

View 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);

View 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);

View File

@ -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);

View 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);

View 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);

View File

@ -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);

View 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),
};

View File

@ -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,
};

View File

@ -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;
};

View File

@ -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);
},

View File

@ -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)",

View File

@ -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 传播检查超时时间(可选)",

View File

@ -31,6 +31,8 @@ const Login = () => {
await navigage("/");
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
}
},
});

View File

@ -43,6 +43,8 @@ const SettingsAccount = () => {
}, 500);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
}
},
});

View File

@ -47,6 +47,8 @@ const SettingsPassword = () => {
}, 500);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
}
},
});

View File

@ -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;
}
},
});

View File

@ -216,7 +216,7 @@ const WorkflowList = () => {
<Tooltip title={t("workflow.action.delete")}>
<Button
color="danger"
danger={true}
danger
icon={<DeleteOutlinedIcon />}
variant="text"
onClick={() => {

View File

@ -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")} />