feat(ui): new WorkflowApplyNodeForm using antd

This commit is contained in:
Fu Diwei
2024-12-26 03:06:15 +08:00
parent a9d918aa95
commit 8a816ba44f
10 changed files with 437 additions and 757 deletions

View File

@@ -0,0 +1,239 @@
import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Switch, Typography, type AutoCompleteProps } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import z from "zod";
import { Plus as PlusIcon } from "lucide-react";
import AccessEditModal from "@/components/access/AccessEditModal";
import AccessSelect from "@/components/access/AccessSelect";
import { usePanel } from "../PanelProvider";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { ACCESS_PROVIDER_USAGES, accessProvidersMap } from "@/domain/access";
import { type WorkflowNode, type WorkflowNodeConfig } from "@/domain/workflow";
import { useContactStore } from "@/stores/contact";
import { useWorkflowStore } from "@/stores/workflow";
import { validDomainName, validIPv4Address, validIPv6Address } from "@/utils/validators";
export type ApplyNodeFormProps = {
data: WorkflowNode;
};
const initFormModel = (): WorkflowNodeConfig => {
return {
domain: "",
keyAlgorithm: "RSA2048",
timeout: 60,
disableFollowCNAME: true,
};
};
const ApplyNodeForm = ({ data }: ApplyNodeFormProps) => {
const { t } = useTranslation();
const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"]));
const { hidePanel } = usePanel();
const formSchema = z.object({
domain: z.string({ message: t("workflow.nodes.apply.form.domain.placeholder") }).refine(
(str) => {
return String(str)
.split(";")
.every((e) => validDomainName(e, true));
},
{ message: t("common.errmsg.domain_invalid") }
),
email: z.string({ message: t("workflow.nodes.apply.form.email.placeholder") }).email("common.errmsg.email_invalid"),
access: z.string({ message: t("workflow.nodes.apply.form.access.placeholder") }).min(1, t("workflow.nodes.apply.form.access.placeholder")),
keyAlgorithm: z.string().nullish(),
nameservers: z
.string()
.refine(
(str) => {
if (!str) return true;
return String(str)
.split(";")
.every((e) => validDomainName(e) || validIPv4Address(e) || validIPv6Address(e));
},
{ message: t("common.errmsg.host_invalid") }
)
.nullish(),
timeout: z.number().gte(1, t("workflow.nodes.apply.form.timeout.placeholder")).nullish(),
disableFollowCNAME: z.boolean().nullish(),
});
const formRule = createSchemaFieldRule(formSchema);
const {
form: formInst,
formPending,
formProps,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: data?.config ?? initFormModel(),
onSubmit: async (values) => {
await updateNode({ ...data, config: { ...values }, validated: true });
hidePanel();
},
});
return (
<Form {...formProps} form={formInst} disabled={formPending} layout="vertical">
<Form.Item name="domain" label={t("workflow.nodes.apply.form.domain.label")} rules={[formRule]}>
<Input placeholder={t("workflow.nodes.apply.form.domain.placeholder")} />
</Form.Item>
<Form.Item name="email" label={t("workflow.nodes.apply.form.email.label")} rules={[formRule]}>
<ContactEmailSelect placeholder={t("workflow.nodes.apply.form.email.placeholder")} />
</Form.Item>
<Form.Item>
<label className="block mb-[2px]">
<div className="flex items-center justify-between gap-4 w-full overflow-hidden">
<div className="flex-grow max-w-full truncate">{t("workflow.nodes.apply.form.access.label")}</div>
<div className="text-right">
<AccessEditModal
preset="add"
trigger={
<Button className="p-0" type="link">
<PlusIcon size={14} />
{t("workflow.nodes.apply.form.access.button")}
</Button>
}
/>
</div>
</div>
</label>
<Form.Item name="access" rules={[formRule]}>
<AccessSelect
placeholder={t("workflow.nodes.apply.form.access.placeholder")}
filter={(record) => {
const provider = accessProvidersMap.get(record.configType);
return ACCESS_PROVIDER_USAGES.ALL === provider?.usage || ACCESS_PROVIDER_USAGES.APPLY === provider?.usage;
}}
/>
</Form.Item>
</Form.Item>
<Divider className="my-1">
<Typography.Text className="text-xs" type="secondary">
{t("workflow.nodes.apply.form.advanced_settings.label")}
</Typography.Text>
</Divider>
<Form.Item name="keyAlgorithm" label={t("workflow.nodes.apply.form.key_algorithm.label")} rules={[formRule]}>
<Select
options={["RSA2048", "RSA3072", "RSA4096", "RSA8192", "EC256", "EC384"].map((e) => ({
label: e,
value: e,
}))}
placeholder={t("workflow.nodes.apply.form.key_algorithm.placeholder")}
/>
</Form.Item>
<Form.Item
name="nameservers"
label={t("workflow.nodes.apply.form.nameservers.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow.nodes.apply.form.nameservers.tooltip") }}></span>}
>
<Input placeholder={t("workflow.nodes.apply.form.nameservers.placeholder")} />
</Form.Item>
<Form.Item
name="timeout"
label={t("workflow.nodes.apply.form.timeout.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow.nodes.apply.form.timeout.tooltip") }}></span>}
>
<InputNumber
className="w-full"
min={0}
max={3600}
placeholder={t("workflow.nodes.apply.form.timeout.placeholder")}
addonAfter={t("workflow.nodes.apply.form.timeout.suffix")}
/>
</Form.Item>
<Form.Item
name="disableFollowCNAME"
label={t("workflow.nodes.apply.form.disable_follow_cname.label")}
rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow.nodes.apply.form.disable_follow_cname.tooltip") }}></span>}
>
<Switch />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={formPending}>
{t("common.button.save")}
</Button>
</Form.Item>
</Form>
);
};
const ContactEmailSelect = ({
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 } = useContactStore();
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) {
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}
/>
);
};
export default memo(ApplyNodeForm);

View File

@@ -1,14 +1,13 @@
import { memo, useEffect, useState } from "react";
import { memo, useEffect } from "react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { useDeepCompareEffect } from "ahooks";
import { Button, Form, Input, Select } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import { ChevronRight as ChevronRightIcon } from "lucide-react";
import { usePanel } from "../PanelProvider";
import { useZustandShallowSelector } from "@/hooks";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { notifyChannelsMap } from "@/domain/settings";
import { type WorkflowNode, type WorkflowNodeConfig } from "@/domain/workflow";
import { useNotifyChannelStore } from "@/stores/notify";
@@ -20,8 +19,8 @@ export type NotifyNodeFormProps = {
const initFormModel = (): WorkflowNodeConfig => {
return {
subject: "",
message: "",
subject: "Completed!",
message: "Your workflow has been completed on Certimate.",
};
};
@@ -48,40 +47,30 @@ const NotifyNodeForm = ({ data }: NotifyNodeFormProps) => {
channel: z.string({ message: t("workflow.nodes.notify.form.channel.placeholder") }).min(1, t("workflow.nodes.notify.form.channel.placeholder")),
});
const formRule = createSchemaFieldRule(formSchema);
const [formInst] = Form.useForm<z.infer<typeof formSchema>>();
const [formPending, setFormPending] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<z.infer<typeof formSchema>>>(
(data?.config as Partial<z.infer<typeof formSchema>>) ?? initFormModel()
);
useDeepCompareEffect(() => {
setInitialValues((data?.config as Partial<z.infer<typeof formSchema>>) ?? initFormModel());
}, [data?.config]);
const handleFormFinish = async (values: z.infer<typeof formSchema>) => {
setFormPending(true);
try {
const {
form: formInst,
formPending,
formProps,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: data?.config ?? initFormModel(),
onSubmit: async (values) => {
await updateNode({ ...data, config: { ...values }, validated: true });
hidePanel();
} finally {
setFormPending(false);
}
};
},
});
return (
<Form form={formInst} disabled={formPending} initialValues={initialValues} layout="vertical" onFinish={handleFormFinish}>
<Form {...formProps} form={formInst} disabled={formPending} layout="vertical">
<Form.Item name="subject" label={t("workflow.nodes.notify.form.subject.label")} rules={[formRule]}>
<Input placeholder={t("workflow.nodes.notify.form.subject.placeholder")} />
</Form.Item>
<Form.Item name="message" label={t("workflow.nodes.notify.form.message.label")} rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 5 }} placeholder={t("workflow.nodes.notify.form.message.placeholder")} />
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow.nodes.notify.form.message.placeholder")} />
</Form.Item>
<Form.Item name="channel" rules={[formRule]}>
<label className="block mb-1">
<Form.Item>
<label className="block mb-[2px]">
<div className="flex items-center justify-between gap-4 w-full overflow-hidden">
<div className="flex-grow max-w-full truncate">{t("workflow.nodes.notify.form.channel.label")}</div>
<div className="text-right">
@@ -94,16 +83,18 @@ const NotifyNodeForm = ({ data }: NotifyNodeFormProps) => {
</div>
</div>
</label>
<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.nodes.notify.form.channel.placeholder")}
/>
<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.nodes.notify.form.channel.placeholder")}
/>
</Form.Item>
</Form.Item>
<Form.Item>

View File

@@ -1,13 +1,12 @@
import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDeepCompareEffect } from "ahooks";
import { Alert, Button, Form, Input, Radio } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import dayjs from "dayjs";
import { z } from "zod";
import { usePanel } from "../PanelProvider";
import { useZustandShallowSelector } from "@/hooks";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { type WorkflowNode, type WorkflowNodeConfig } from "@/domain/workflow";
import { useWorkflowStore } from "@/stores/workflow";
import { validCronExpression, getNextCronExecutions } from "@/utils/cron";
@@ -48,15 +47,17 @@ const StartNodeForm = ({ data }: StartNodeFormProps) => {
}
});
const formRule = createSchemaFieldRule(formSchema);
const [formInst] = Form.useForm<z.infer<typeof formSchema>>();
const [formPending, setFormPending] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<z.infer<typeof formSchema>>>(
(data?.config as Partial<z.infer<typeof formSchema>>) ?? initFormModel()
);
useDeepCompareEffect(() => {
setInitialValues((data?.config as Partial<z.infer<typeof formSchema>>) ?? initFormModel());
}, [data?.config]);
const {
form: formInst,
formPending,
formProps,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: data?.config ?? initFormModel(),
onSubmit: async (values) => {
await updateNode({ ...data, config: { ...values }, validated: true });
hidePanel();
},
});
const [triggerType, setTriggerType] = useState(data?.config?.executionMethod);
const [triggerCronLastExecutions, setTriggerCronExecutions] = useState<Date[]>([]);
@@ -77,20 +78,8 @@ const StartNodeForm = ({ data }: StartNodeFormProps) => {
setTriggerCronExecutions(getNextCronExecutions(value, 5));
};
const handleFormFinish = async (values: z.infer<typeof formSchema>) => {
setFormPending(true);
try {
await updateNode({ ...data, config: { ...values }, validated: true });
hidePanel();
} finally {
setFormPending(false);
}
};
return (
<Form form={formInst} disabled={formPending} initialValues={initialValues} layout="vertical" onFinish={handleFormFinish}>
<Form {...formProps} form={formInst} disabled={formPending} layout="vertical">
<Form.Item
name="executionMethod"
label={t("workflow.nodes.start.form.trigger.label")}
@@ -111,16 +100,16 @@ const StartNodeForm = ({ data }: StartNodeFormProps) => {
tooltip={<span dangerouslySetInnerHTML={{ __html: t("workflow.nodes.start.form.trigger_cron.tooltip") }}></span>}
extra={
triggerCronLastExecutions.length > 0 ? (
<span>
<div>
{t("workflow.nodes.start.form.trigger_cron.extra")}
<br />
{triggerCronLastExecutions.map((d) => (
<>
{dayjs(d).format("YYYY-MM-DD HH:mm:ss")}
{triggerCronLastExecutions.map((date, index) => (
<span key={index}>
{dayjs(date).format("YYYY-MM-DD HH:mm:ss")}
<br />
</>
</span>
))}
</span>
</div>
) : (
<></>
)