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,110 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("4yzbv8urny5ja1e")
if err != nil {
return err
}
// update
edit_configType := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "hwy7m03o",
"name": "configType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"acmehttpreq",
"aliyun",
"aws",
"baiducloud",
"byteplus",
"cloudflare",
"dogecloud",
"godaddy",
"huaweicloud",
"k8s",
"local",
"namedotcom",
"namesilo",
"powerdns",
"qiniu",
"ssh",
"tencentcloud",
"volcengine",
"webhook"
]
}
}`), edit_configType); err != nil {
return err
}
collection.Schema.AddField(edit_configType)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("4yzbv8urny5ja1e")
if err != nil {
return err
}
// update
edit_configType := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "hwy7m03o",
"name": "configType",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"aliyun",
"tencent",
"huaweicloud",
"qiniu",
"aws",
"cloudflare",
"namesilo",
"godaddy",
"pdns",
"httpreq",
"local",
"ssh",
"webhook",
"k8s",
"baiducloud",
"dogecloud",
"volcengine",
"byteplus",
"namedotcom"
]
}
}`), edit_configType); err != nil {
return err
}
collection.Schema.AddField(edit_configType)
return dao.SaveCollection(collection)
})
}

View File

@ -1,114 +0,0 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ClientResponseError } from "pocketbase";
import { cn } from "@/components/ui/utils";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { type PbErrorData } from "@/domain/base";
import { useContactStore } from "@/stores/contact";
type EmailsEditProps = {
className?: string;
trigger: React.ReactNode;
};
const EmailsEdit = ({ className, trigger }: EmailsEditProps) => {
const { emails, setEmails, fetchEmails } = useContactStore();
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const formSchema = z.object({
email: z.string().email("common.errmsg.email_invalid"),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});
useEffect(() => {
fetchEmails();
}, []);
const onSubmit = async (data: z.infer<typeof formSchema>) => {
if (emails.includes(data.email)) {
form.setError("email", {
message: "common.errmsg.email_duplicate",
});
return;
}
try {
await setEmails([...emails, data.email]);
form.reset();
form.clearErrors();
setOpen(false);
} catch (e) {
const err = e as ClientResponseError;
Object.entries(err.response.data as PbErrorData).forEach(([key, value]) => {
form.setError(key as keyof z.infer<typeof formSchema>, {
type: "manual",
message: value.message,
});
});
}
};
return (
<Dialog onOpenChange={setOpen} open={open}>
<DialogTrigger asChild className={cn(className)}>
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] w-full dark:text-stone-200">
<DialogHeader>
<DialogTitle>{t("domain.application.form.email.add")}</DialogTitle>
</DialogHeader>
<div className="container py-3">
<Form {...form}>
<form
onSubmit={(e) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain.application.form.email.label")}</FormLabel>
<FormControl>
<Input placeholder={t("common.errmsg.email_empty")} {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">{t("common.button.save")}</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};
export default EmailsEdit;

View File

@ -1,217 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { Edit, Plus, Trash2 } from "lucide-react";
import { cn } from "@/components/ui/utils";
import Show from "@/components/Show";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
type StringListProps = {
className?: string;
value: string;
valueType?: ValueType;
onValueChange: (value: string) => void;
};
const titles: Record<string, string> = {
domain: "common.text.domain",
ip: "common.text.ip",
dns: "common.text.dns",
};
const StringList = ({ value, className, onValueChange, valueType = "domain" }: StringListProps) => {
const [list, setList] = useState<string[]>([]);
const { t } = useTranslation();
useMemo(() => {
if (value) {
setList(value.split(";"));
}
}, [value]);
useEffect(() => {
const changeList = () => {
onValueChange(list.join(";"));
};
changeList();
}, [list]);
const addVal = (val: string) => {
if (list.includes(val)) {
return;
}
setList([...list, val]);
};
const editVal = (index: number, val: string) => {
const newList = [...list];
newList[index] = val;
setList(newList);
};
const onRemoveClick = (index: number) => {
const newList = [...list];
newList.splice(index, 1);
setList(newList);
};
return (
<>
<div className={cn(className)}>
<FormItem>
<FormLabel className="flex justify-between items-center">
<div>{t(titles[valueType])}</div>
<Show when={list.length > 0}>
<StringEdit
op="add"
onValueChange={(val: string) => {
addVal(val);
}}
valueType={valueType}
value={""}
trigger={
<div className="flex items-center text-primary">
<Plus size={16} className="cursor-pointer " />
<div className="text-sm ">{t("common.button.add")}</div>
</div>
}
/>
</Show>
</FormLabel>
<FormControl>
<Show
when={list.length > 0}
fallback={
<div className="border rounded-md p-3 text-sm flex flex-col items-center">
<div className="text-muted-foreground">{t("common.text." + valueType + ".empty")}</div>
<StringEdit value={""} trigger={t("common.button.add")} onValueChange={addVal} valueType={valueType} />
</div>
}
>
<div className="border rounded-md p-3 text-sm text-gray-700 space-y-2 dark:text-white dark:border-stone-700 dark:bg-stone-950">
{list.map((item, index) => (
<div key={index} className="flex justify-between items-center">
<div>{item}</div>
<div className="flex space-x-2">
<StringEdit
op="edit"
valueType={valueType}
trigger={<Edit size={16} className="cursor-pointer text-gray-600 dark:text-white" />}
value={item}
onValueChange={(val: string) => {
editVal(index, val);
}}
/>
<Trash2
size={16}
className="cursor-pointer"
onClick={() => {
onRemoveClick(index);
}}
/>
</div>
</div>
))}
</div>
</Show>
</FormControl>
</FormItem>
</div>
</>
);
};
export default StringList;
type ValueType = "domain" | "dns" | "host";
type StringEditProps = {
value: string;
trigger: React.ReactNode;
onValueChange: (value: string) => void;
valueType: ValueType;
op?: "add" | "edit";
};
const StringEdit = ({ trigger, value, onValueChange, op = "add", valueType }: StringEditProps) => {
const [currentValue, setCurrentValue] = useState<string>("");
const [open, setOpen] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const { t } = useTranslation();
useEffect(() => {
setCurrentValue(value);
}, [value]);
const domainSchema = z.string().regex(/^(?:\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, {
message: t("common.errmsg.domain_invalid"),
});
const ipSchema = z.string().ip({ message: t("common.errmsg.ip_invalid") });
const schedules: Record<ValueType, z.ZodString> = {
domain: domainSchema,
dns: ipSchema,
host: ipSchema,
};
const onSaveClick = useCallback(() => {
const schema = schedules[valueType];
const resp = schema.safeParse(currentValue);
if (!resp.success) {
setError(JSON.parse(resp.error.message)[0].message);
return;
}
setCurrentValue("");
setOpen(false);
setError("");
onValueChange(currentValue);
}, [currentValue]);
return (
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
>
<DialogTrigger className="text-primary">{trigger}</DialogTrigger>
<DialogContent className="dark:text-white">
<DialogHeader>
<DialogTitle className="dark:text-white">{t(titles[valueType])}</DialogTitle>
</DialogHeader>
<Input
value={currentValue}
className="dark:text-white"
onChange={(e) => {
setCurrentValue(e.target.value);
}}
/>
<Show when={error.length > 0}>
<div className="text-red-500 text-sm">{error}</div>
</Show>
<DialogFooter>
<Button
onClick={() => {
onSaveClick();
}}
>
{op === "add" ? t("common.button.add") : t("common.button.ok")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,356 +0,0 @@
import { memo, useEffect } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Collapse, Divider, Switch, Tooltip, Typography } from "antd";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronsUpDown as ChevronsUpDownIcon, Plus as PlusIcon, CircleHelp as CircleHelpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import AccessEditModal from "@/components/access/AccessEditModal";
import EmailsEdit from "@/components/certimate/EmailsEdit";
import StringList from "@/components/certimate/StringList";
import { accessProvidersMap } from "@/domain/access";
import { useZustandShallowSelector } from "@/hooks";
import { useAccessStore } from "@/stores/access";
import { useContactStore } from "@/stores/contact";
import { WorkflowNode, WorkflowNodeConfig } from "@/domain/workflow";
import { useWorkflowStore } from "@/stores/workflow";
import { usePanel } from "./PanelProvider";
type ApplyFormProps = {
data: WorkflowNode;
};
const ApplyForm = ({ data }: ApplyFormProps) => {
const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"]));
const { accesses } = useAccessStore();
const { emails, fetchEmails } = useContactStore();
useEffect(() => {
fetchEmails();
}, []);
const { t } = useTranslation();
const { hidePanel } = usePanel();
const formSchema = z.object({
domain: z.string().min(1, {
message: "common.errmsg.domain_invalid",
}),
email: z.string().email("common.errmsg.email_invalid").optional(),
access: z.string().regex(/^[a-zA-Z0-9]+$/, {
message: "domain.application.form.access.placeholder",
}),
keyAlgorithm: z.string().optional(),
nameservers: z.string().optional(),
timeout: z.number().optional(),
disableFollowCNAME: z.boolean().optional(),
});
let config: WorkflowNodeConfig = {
domain: "",
email: "",
access: "",
keyAlgorithm: "RSA2048",
nameservers: "",
timeout: 60,
disableFollowCNAME: true,
};
if (data) config = data.config ?? config;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
domain: config.domain as string,
email: config.email as string,
access: config.access as string,
keyAlgorithm: config.keyAlgorithm as string,
nameservers: config.nameservers as string,
timeout: config.timeout as number,
disableFollowCNAME: config.disableFollowCNAME as boolean,
},
});
const onSubmit = async (config: z.infer<typeof formSchema>) => {
updateNode({ ...data, config, validated: true });
hidePanel();
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 dark:text-stone-200">
{/* 域名 */}
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<>
<StringList
value={field.value}
valueType="domain"
onValueChange={(domain: string) => {
form.setValue("domain", domain);
}}
/>
</>
<FormMessage />
</FormItem>
)}
/>
{/* 邮箱 */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex justify-between w-full">
<div>{t("domain.application.form.email.label") + " " + t("domain.application.form.email.tips")}</div>
<EmailsEdit
trigger={
<div className="flex items-center font-normal cursor-pointer text-primary hover:underline">
<PlusIcon size={14} />
{t("common.button.add")}
</div>
}
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={(value) => {
form.setValue("email", value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.application.form.email.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t("domain.application.form.email.list")}</SelectLabel>
{emails.map((item) => (
<SelectItem key={item} value={item}>
<div>{item}</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* DNS 服务商授权 */}
<FormField
control={form.control}
name="access"
render={({ field }) => (
<FormItem>
<FormLabel className="flex justify-between w-full">
<div>{t("domain.application.form.access.label")}</div>
<AccessEditModal
preset="add"
trigger={
<div className="flex items-center font-normal cursor-pointer text-primary hover:underline">
<PlusIcon size={14} />
{t("common.button.add")}
</div>
}
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value}
onValueChange={(value) => {
form.setValue("access", value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.application.form.access.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t("domain.application.form.access.list")}</SelectLabel>
{accesses
.filter((item) => item.usage != "deploy")
.map((item) => (
<SelectItem key={item.id} value={item.id}>
<div className="flex items-center space-x-2">
<img className="w-6" src={accessProvidersMap.get(item.configType)?.icon} />
<div>{item.name}</div>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Divider />
<Collapse
bordered={false}
ghost={true}
items={[
{
key: "advanced",
styles: {
header: { paddingLeft: 0, paddingRight: 0 },
body: { paddingLeft: 0, paddingRight: 0 },
},
label: <Typography.Text type="secondary">{t("domain.application.form.advanced_settings.label")}</Typography.Text>,
children: (
<div className="flex flex-col space-y-8">
{/* 证书算法 */}
<FormField
control={form.control}
name="keyAlgorithm"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain.application.form.key_algorithm.label")}</FormLabel>
<Select
{...field}
value={field.value}
onValueChange={(value) => {
form.setValue("keyAlgorithm", value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("domain.application.form.key_algorithm.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="RSA2048">RSA2048</SelectItem>
<SelectItem value="RSA3072">RSA3072</SelectItem>
<SelectItem value="RSA4096">RSA4096</SelectItem>
<SelectItem value="RSA8192">RSA8192</SelectItem>
<SelectItem value="EC256">EC256</SelectItem>
<SelectItem value="EC384">EC384</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* DNS */}
<FormField
control={form.control}
name="nameservers"
render={({ field }) => (
<FormItem>
<StringList
value={field.value ?? ""}
onValueChange={(val: string) => {
form.setValue("nameservers", val);
}}
valueType="dns"
></StringList>
<FormMessage />
</FormItem>
)}
/>
{/* DNS 超时时间 */}
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain.application.form.timeout.label")}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t("domain.application.form.timeout.placeholder")}
{...field}
value={field.value}
onChange={(e) => {
form.setValue("timeout", parseInt(e.target.value));
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 禁用 CNAME 跟随 */}
<FormField
control={form.control}
name="disableFollowCNAME"
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex">
<span className="mr-1">{t("domain.application.form.disable_follow_cname.label")} </span>
<Tooltip
title={
<p>
{t("domain.application.form.disable_follow_cname.tips")}
<a
className="text-primary"
target="_blank"
href="https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname"
>
{t("domain.application.form.disable_follow_cname.tips_link")}
</a>
</p>
}
>
<CircleHelpIcon size={14} />
</Tooltip>
</div>
</FormLabel>
<FormControl>
<div>
<Switch
defaultChecked={field.value}
onChange={(value) => {
form.setValue(field.name, value);
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
),
extra: <ChevronsUpDownIcon size={14} />,
forceRender: true,
showArrow: false,
},
]}
/>
<div className="flex justify-end">
<Button type="submit">{t("common.button.save")}</Button>
</div>
</form>
</Form>
</>
);
};
export default memo(ApplyForm);

View File

@ -1,7 +1,7 @@
import { WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
import StartNodeForm from "./node/StartNodeForm";
import DeployPanelBody from "./DeployPanelBody";
import ApplyForm from "./ApplyForm";
import ApplyNodeForm from "./node/ApplyNodeForm";
import NotifyNodeForm from "./node/NotifyNodeForm";
type PanelBodyProps = {
@ -13,7 +13,7 @@ const PanelBody = ({ data }: PanelBodyProps) => {
case WorkflowNodeType.Start:
return <StartNodeForm data={data} />;
case WorkflowNodeType.Apply:
return <ApplyForm data={data} />;
return <ApplyNodeForm data={data} />;
case WorkflowNodeType.Deploy:
return <DeployPanelBody data={data} />;
case WorkflowNodeType.Notify:

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,6 +83,7 @@ const NotifyNodeForm = ({ data }: NotifyNodeFormProps) => {
</div>
</div>
</label>
<Form.Item name="channel" rules={[formRule]}>
<Select
loading={!channelsLoadedAtOnce}
options={Object.entries(channels)
@ -105,6 +95,7 @@ const NotifyNodeForm = ({ data }: NotifyNodeFormProps) => {
placeholder={t("workflow.nodes.notify.form.channel.placeholder")}
/>
</Form.Item>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={formPending}>

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>
))}
</div>
) : (
<></>
)

View File

@ -40,6 +40,25 @@
"workflow.nodes.start.form.trigger_cron.tooltip": "Time zone is based on the server.",
"workflow.nodes.start.form.trigger_cron.extra": "Expected execution time for the last 5 times:",
"workflow.nodes.start.form.trigger_cron_alert.content": "Tips: If you have multiple workflows, it is recommended to set them to run at multiple times of the day instead of always running at specific times.<br><br>Reference links:<br>1. <a href=\"https://letsencrypt.org/docs/rate-limits/\" target=\"_blank\">Lets Encrypt rate limits</a><br>2. <a href=\"https://letsencrypt.org/docs/faq/#why-should-my-let-s-encrypt-acme-client-run-at-a-random-time\" target=\"_blank\">Why should my Lets Encrypt (ACME) client run at a random time?</a>",
"workflow.nodes.apply.form.domain.label": "Domain (wildcard domain is supported)",
"workflow.nodes.apply.form.domain.placeholder": "Please enter domain",
"workflow.nodes.apply.form.email.label": "Contact Email",
"workflow.nodes.apply.form.email.placeholder": "Please enter contact email",
"workflow.nodes.apply.form.access.label": "DNS Provider Authorization",
"workflow.nodes.apply.form.access.placeholder": "Please select an authorization of DNS provider",
"workflow.nodes.apply.form.access.button": "Create",
"workflow.nodes.apply.form.advanced_settings.label": "Advanced Settings",
"workflow.nodes.apply.form.key_algorithm.label": "Certificate Key Algorithm",
"workflow.nodes.apply.form.key_algorithm.placeholder": "Please select certificate key algorithm",
"workflow.nodes.apply.form.nameservers.label": "DNS Recursive Nameservers",
"workflow.nodes.apply.form.nameservers.placeholder": "Please enter DNS recursive nameservers",
"workflow.nodes.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.nodes.apply.form.timeout.label": "DNS Propagation Timeout",
"workflow.nodes.apply.form.timeout.placeholder": "Please enter DNS propagation timeout",
"workflow.nodes.apply.form.timeout.suffix": "Seconds",
"workflow.nodes.apply.form.timeout.tooltip": "It determines the maximum waiting time for DNS propagation checks during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.",
"workflow.nodes.apply.form.disable_follow_cname.label": "Disable CNAME following",
"workflow.nodes.apply.form.disable_follow_cname.tooltip": "It determines whether to disable CNAME following during ACME DNS-01 authentication. If you don't understand this option, just keep it by default.<br><a href=\"https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname\" target=\"_blank\">Learn more</a>.",
"workflow.nodes.notify.form.subject.label": "Subject",
"workflow.nodes.notify.form.subject.placeholder": "Please enter subject",
"workflow.nodes.notify.form.message.label": "Message",

View File

@ -40,6 +40,25 @@
"workflow.nodes.start.form.trigger_cron.tooltip": "时区以服务器设置为准。",
"workflow.nodes.start.form.trigger_cron.extra": "预计最近 5 次执行时间:",
"workflow.nodes.start.form.trigger_cron_alert.content": "小贴士:如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。<br><br>参考链接:<br>1. <a href=\"https://letsencrypt.org/zh-cn/docs/rate-limits/\" target=\"_blank\">Lets Encrypt 速率限制</a><br>2. <a href=\"https://letsencrypt.org/zh-cn/docs/faq/#%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E7%9A%84-let-s-encrypt-acme-%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%90%AF%E5%8A%A8%E6%97%B6%E9%97%B4%E5%BA%94%E5%BD%93%E9%9A%8F%E6%9C%BA\" target=\"_blank\">为什么我的 Lets Encrypt (ACME) 客户端启动时间应当随机?</a>",
"workflow.nodes.apply.form.domain.label": "域名(支持泛域名)",
"workflow.nodes.apply.form.domain.placeholder": "请输入域名",
"workflow.nodes.apply.form.email.label": "联系邮箱",
"workflow.nodes.apply.form.email.placeholder": "请输入联系邮箱",
"workflow.nodes.apply.form.access.label": "DNS 提供商授权",
"workflow.nodes.apply.form.access.placeholder": "请选择 DNS 提供商授权",
"workflow.nodes.apply.form.access.button": "新建",
"workflow.nodes.apply.form.advanced_settings.label": "高级设置",
"workflow.nodes.apply.form.key_algorithm.label": "数字证书算法",
"workflow.nodes.apply.form.key_algorithm.placeholder": "请选择数字证书算法",
"workflow.nodes.apply.form.nameservers.label": "DNS 递归服务器",
"workflow.nodes.apply.form.nameservers.placeholder": "请输入 DNS 递归服务器",
"workflow.nodes.apply.form.nameservers.tooltip": "在 ACME DNS-01 认证时使用自定义的 DNS 递归服务器。如果你不了解该选项的用途,保持默认即可。",
"workflow.nodes.apply.form.timeout.label": "DNS 传播检查超时时间",
"workflow.nodes.apply.form.timeout.placeholder": "请输入 DNS 传播检查超时时间",
"workflow.nodes.apply.form.timeout.suffix": "秒",
"workflow.nodes.apply.form.timeout.tooltip": "在 ACME DNS-01 认证时等待 DNS 传播检查的最长时间。如果你不了解此选项的用途,保持默认即可。",
"workflow.nodes.apply.form.disable_follow_cname.label": "禁止 CNAME 跟随",
"workflow.nodes.apply.form.disable_follow_cname.tooltip": "在 ACME DNS-01 认证时是否禁止 CNAME 跟随。如果你不了解该选项的用途,保持默认即可。<br><a href=\"https://letsencrypt.org/2019/10/09/onboarding-your-customers-with-lets-encrypt-and-acme/#the-advantages-of-a-cname\" target=\"_blank\">点此了解更多</a>。",
"workflow.nodes.notify.form.subject.label": "通知主题",
"workflow.nodes.notify.form.subject.placeholder": "请输入通知主题",
"workflow.nodes.notify.form.message.label": "通知内容",