This commit is contained in:
yoan
2024-11-05 21:00:53 +08:00
parent 718cfccbea
commit 613b6839b8
23 changed files with 1597 additions and 21 deletions

View File

@@ -0,0 +1,95 @@
import { Plus } from "lucide-react";
import { BrandNodeProps, NodeProps } from "./types";
import { newWorkflowNode, workflowNodeDropdownList, WorkflowNodeType } from "@/domain/workflow";
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
import { useShallow } from "zustand/shallow";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import DropdownMenuItemIcon from "./DropdownMenuItemIcon";
import Show from "../Show";
const selectState = (state: WorkflowState) => ({
addNode: state.addNode,
});
const AddNode = ({ data }: NodeProps | BrandNodeProps) => {
const { addNode } = useWorkflowStore(useShallow(selectState));
const handleTypeSelected = (type: WorkflowNodeType, provider?: string) => {
const node = newWorkflowNode(type, {
providerType: provider,
});
addNode(node, data.id);
};
return (
<div className="before:content-[''] before:w-[2px] before:bg-stone-300 before:absolute before:h-full before:left-[50%] before:-translate-x-[50%] before:top-0 pt-6 pb-9 relative flex flex-col items-center">
<DropdownMenu>
<DropdownMenuTrigger className="">
<div className="bg-stone-400 hover:bg-stone-500 rounded-full z-10 relative outline-none">
<Plus size={18} className="text-white" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
{workflowNodeDropdownList.map((item) => (
<Show
key={item.type}
when={!!item.leaf}
fallback={
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex space-x-2">
<DropdownMenuItemIcon type={item.icon.type} name={item.icon.name} /> <div>{item.name}</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{item.children?.map((subItem) => {
return (
<DropdownMenuItem
key={subItem.providerType}
className="flex space-x-2"
onClick={() => {
handleTypeSelected(item.type, subItem.providerType);
}}
>
<DropdownMenuItemIcon type={subItem.icon.type} name={subItem.icon.name} /> <div>{subItem.name}</div>
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
}
>
<DropdownMenuItem
key={item.type}
className="flex space-x-2"
onClick={() => {
handleTypeSelected(item.type, item.providerType);
}}
>
<DropdownMenuItemIcon type={item.icon.type} name={item.icon.name} /> <div>{item.name}</div>
</DropdownMenuItem>
</Show>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
export default AddNode;

View File

@@ -0,0 +1,353 @@
import { memo } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronsUpDown, Plus, CircleHelp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
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 AccessEditDialog from "@/components/certimate/AccessEditDialog";
import EmailsEdit from "@/components/certimate/EmailsEdit";
import StringList from "@/components/certimate/StringList";
import { accessProvidersMap } from "@/domain/access";
import { EmailsSetting } from "@/domain/settings";
import { useConfigContext } from "@/providers/config";
import { Switch } from "@/components/ui/switch";
import { TooltipFast } from "@/components/ui/tooltip";
import { WorkflowNode, WorkflowNodeConfig } from "@/domain/workflow";
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
import { useShallow } from "zustand/shallow";
import { usePanel } from "./PanelProvider";
type ApplyFormProps = {
data: WorkflowNode;
};
const selectState = (state: WorkflowState) => ({
updateNode: state.updateNode,
});
const ApplyForm = ({ data }: ApplyFormProps) => {
const { updateNode } = useWorkflowStore(useShallow(selectState));
const {
config: { accesses, emails },
} = useConfigContext();
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 });
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">
<Plus size={14} />
{t("common.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.content as EmailsSetting).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>
<AccessEditDialog
trigger={
<div className="flex items-center font-normal cursor-pointer text-primary hover:underline">
<Plus size={14} />
{t("common.add")}
</div>
}
op="add"
/>
</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>
)}
/>
<div>
<hr />
<Collapsible>
<CollapsibleTrigger className="w-full my-4">
<div className="flex items-center justify-between space-x-4">
<span className="flex-1 text-sm text-left text-gray-600">{t("domain.application.form.advanced_settings.label")}</span>
<ChevronsUpDown className="w-4 h-4" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<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>
<TooltipFast
className="max-w-[20rem]"
contentView={
<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>
}
>
<CircleHelp size={14} />
</TooltipFast>
</div>
</FormLabel>
<FormControl>
<div>
<Switch
defaultChecked={field.value}
onCheckedChange={(value) => {
form.setValue(field.name, value);
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div className="flex justify-end">
<Button type="submit">{t("common.save")}</Button>
</div>
</form>
</Form>
</>
);
};
export default memo(ApplyForm);

View File

@@ -0,0 +1,67 @@
import { Button } from "@/components/ui/button";
import AddNode from "./AddNode";
import { WorkflowBranchNode, WorkflowNode } from "@/domain/workflow";
import NodeRender from "./NodeRender";
import { memo } from "react";
import { BrandNodeProps } from "./types";
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
import { useShallow } from "zustand/shallow";
const selectState = (state: WorkflowState) => ({
addBranch: state.addBranch,
});
const BranchNode = memo(({ data }: BrandNodeProps) => {
const { addBranch } = useWorkflowStore(useShallow(selectState));
const renderNodes = (node: WorkflowBranchNode | WorkflowNode | undefined, branchNodeId?: string, branchIndex?: number) => {
const elements: JSX.Element[] = [];
let current = node;
while (current) {
elements.push(<NodeRender data={current} branchId={branchNodeId} branchIndex={branchIndex} key={current.id} />);
current = current.next;
}
return elements;
};
return (
<>
<div className="border-t-[2px] border-b-[2px] relative flex gap-x-16 border-stone-300 bg-slate-50">
<Button
onClick={() => {
addBranch(data.id);
}}
size={"sm"}
variant={"outline"}
className="text-xs px-2 h-6 rounded-full absolute -top-3 left-[50%] -translate-x-1/2 z-10"
>
</Button>
{data.branches.map((branch, index) => (
<div
key={branch.id}
className="relative flex flex-col items-center before:content-[''] before:w-[2px] before:bg-stone-300 before:absolute before:h-full before:left-[50%] before:-translate-x-[50%] before:top-0"
>
{index == 0 && (
<>
<div className="w-[50%] h-2 absolute -top-1 bg-stone-50 -left-[1px]"></div>
<div className="w-[50%] h-2 absolute -bottom-1 bg-stone-50 -left-[1px]"></div>
</>
)}
{index == data.branches.length - 1 && (
<>
<div className="w-[50%] h-2 absolute -top-1 bg-stone-50 -right-[1px]"></div>
<div className="w-[50%] h-2 absolute -bottom-1 bg-stone-50 -right-[1px]"></div>
</>
)}
{/* 条件 1 */}
<div className="relative flex flex-col items-center">{renderNodes(branch, data.id, index)}</div>
</div>
))}
</div>
<AddNode data={data} />
</>
);
});
export default BranchNode;

View File

@@ -0,0 +1,47 @@
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
import AddNode from "./AddNode";
import { NodeProps } from "./types";
import { useShallow } from "zustand/shallow";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import { Ellipsis, Trash2 } from "lucide-react";
const selectState = (state: WorkflowState) => ({
updateNode: state.updateNode,
removeBranch: state.removeBranch,
});
const ConditionNode = ({ data, branchId, branchIndex }: NodeProps) => {
const { updateNode, removeBranch } = useWorkflowStore(useShallow(selectState));
const handleNameBlur = (e: React.FocusEvent<HTMLDivElement>) => {
updateNode({ ...data, name: e.target.innerText });
};
return (
<>
<div className="rounded-md shadow-md w-[261px] mt-10 relative z-10">
<DropdownMenu>
<DropdownMenuTrigger className="absolute right-2 top-1">
<Ellipsis size={17} className="text-stone-600" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex space-x-2 text-red-600"
onClick={() => {
removeBranch(branchId ?? "", branchIndex ?? 0);
}}
>
<Trash2 size={16} /> <div></div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-[261px] flex flex-col justify-center text-foreground rounded-md bg-white px-5 py-5">
<div contentEditable suppressContentEditableWarning onBlur={handleNameBlur} className="text-center outline-slate-200">
{data.name}
</div>
</div>
</div>
<AddNode data={data} />
</>
);
};
export default ConditionNode;

View File

@@ -0,0 +1,39 @@
import { accessProviders } from "@/domain/access";
import { WorkflowNode } from "@/domain/workflow";
import { memo } from "react";
import { useTranslation } from "react-i18next";
type DeployPanelBodyProps = {
data: WorkflowNode;
};
const DeployPanelBody = ({ data }: DeployPanelBodyProps) => {
const { t } = useTranslation();
return (
<>
{/* 默认展示服务商列表 */}
<div className="text-lg font-semibold text-gray-700"></div>
{accessProviders
.filter((provider) => provider[3] === "apply" || provider[3] === "all")
.reduce((acc: string[][][], provider, index) => {
if (index % 2 === 0) {
acc.push([provider]);
} else {
acc[acc.length - 1].push(provider);
}
return acc;
}, [])
.map((providerRow, rowIndex) => (
<div key={rowIndex} className="flex space-x-5">
{providerRow.map((provider, index) => (
<div key={index} className="flex space-x-2 w-1/3 items-center cursor-pointer hover:bg-slate-100 p-2 rounded-sm">
<img src={provider[2]} alt={provider[1]} className="w-8 h-8" />
<div className="text-muted-foreground">{t(provider[1])}</div>
</div>
))}
</div>
))}
</>
);
};
export default memo(DeployPanelBody);

View File

@@ -0,0 +1,23 @@
import { WorkflowwNodeDropdwonItemIcon, WorkflowwNodeDropdwonItemIconType } from "@/domain/workflow";
import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react";
const icons = new Map([
["NotebookPen", <NotebookPen size={16} />],
["CloudUpload", <CloudUpload size={16} />],
["GitFork", <GitFork size={16} />],
["Megaphone", <Megaphone size={16} />],
]);
const DropdownMenuItemIcon = ({ type, name }: WorkflowwNodeDropdwonItemIcon) => {
const getIcon = () => {
if (type === WorkflowwNodeDropdwonItemIconType.Icon) {
return icons.get(name);
} else {
return <img src={name} className="w-4" />;
}
};
return getIcon();
};
export default DropdownMenuItemIcon;

View File

@@ -0,0 +1,10 @@
const End = () => {
return (
<div className="flex flex-col items-center">
<div className="h-[18px] rounded-full w-[18px] bg-stone-400"></div>
<div className="text-sm text-stone-400 mt-2"></div>
</div>
);
};
export default End;

View File

@@ -0,0 +1,76 @@
import { WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
import AddNode from "./AddNode";
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
import { useShallow } from "zustand/shallow";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import { Ellipsis, Trash2 } from "lucide-react";
import { usePanel } from "./PanelProvider";
import PanelBody from "./PanelBody";
type NodeProps = {
data: WorkflowNode;
};
const selectState = (state: WorkflowState) => ({
updateNode: state.updateNode,
removeNode: state.removeNode,
});
const Node = ({ data }: NodeProps) => {
const { updateNode, removeNode } = useWorkflowStore(useShallow(selectState));
const handleNameBlur = (e: React.FocusEvent<HTMLDivElement>) => {
updateNode({ ...data, name: e.target.innerText });
};
const { showPanel } = usePanel();
const handleNodeSettingClick = () => {
showPanel({
name: data.name,
children: <PanelBody data={data} />,
});
};
return (
<>
<div className="rounded-md shadow-md w-[260px] relative">
{data.type != WorkflowNodeType.Start && (
<>
<DropdownMenu>
<DropdownMenuTrigger className="absolute right-2 top-1">
<Ellipsis className="text-white" size={17} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="flex space-x-2 text-red-600"
onClick={() => {
removeNode(data.id);
}}
>
<Trash2 size={16} /> <div></div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<div className="w-[260px] h-[60px] flex flex-col justify-center items-center bg-primary text-white rounded-t-md px-5">
<div
contentEditable
suppressContentEditableWarning
onBlur={handleNameBlur}
className="w-full text-center outline-none focus:bg-white focus:text-stone-600 focus:rounded-sm"
>
{data.name}
</div>
</div>
<div className="p-2 text-sm text-primary flex flex-col justify-center bg-white">
<div className="leading-7 text-primary cursor-pointer" onClick={handleNodeSettingClick}>
</div>
</div>
</div>
<AddNode data={data} />
</>
);
};
export default Node;

View File

@@ -0,0 +1,29 @@
import { memo } from "react";
import { WorkflowBranchNode, WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
import Node from "./Node";
import End from "./End";
import BranchNode from "./BranchNode";
import ConditionNode from "./ConditionNode";
import { NodeProps } from "./types";
const NodeRender = memo(({ data, branchId, branchIndex }: NodeProps) => {
const render = () => {
switch (data.type) {
case WorkflowNodeType.Start:
case WorkflowNodeType.Apply:
case WorkflowNodeType.Deploy:
case WorkflowNodeType.Notify:
return <Node data={data} />;
case WorkflowNodeType.End:
return <End />;
case WorkflowNodeType.Branch:
return <BranchNode data={data as WorkflowBranchNode} />;
case WorkflowNodeType.Condition:
return <ConditionNode data={data as WorkflowNode} branchId={branchId} branchIndex={branchIndex} />;
}
};
return <>{render()}</>;
});
export default NodeRender;

View File

@@ -0,0 +1,67 @@
import { WorkflowNodeType } from "@/domain/workflow";
import { CloudUpload, GitFork, Megaphone, NotebookPen } from "lucide-react";
type NodeTypesPanelProps = {
onTypeSelected: (type: WorkflowNodeType) => void;
};
const NodeTypesPanel = ({ onTypeSelected }: NodeTypesPanelProps) => {
return (
<>
<div className="flex space-x-2">
<div
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
onClick={() => {
onTypeSelected(WorkflowNodeType.Apply);
}}
>
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
<NotebookPen className="text-white" size={18} />
</div>
<div className="text-slate-600"></div>
</div>
<div
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
onClick={() => {
onTypeSelected(WorkflowNodeType.Deploy);
}}
>
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
<CloudUpload className="text-white" size={18} />
</div>
<div className="text-slate-600"></div>
</div>
</div>
<div className="flex space-x-2">
<div
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
onClick={() => {
onTypeSelected(WorkflowNodeType.Branch);
}}
>
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
<GitFork className="text-white" size={18} />
</div>
<div className="text-slate-600"></div>
</div>
<div
className="flex w-1/2 items-center space-x-2 hover:bg-stone-100 p-2 rounded-md"
onClick={() => {
onTypeSelected(WorkflowNodeType.Notify);
}}
>
<div className="bg-primary h-12 w-12 flex items-center justify-center rounded-full">
<Megaphone className="text-white" size={18} />
</div>
<div className="text-slate-600"></div>
</div>
</div>
</>
);
};
export default NodeTypesPanel;

View File

@@ -0,0 +1,23 @@
// components/AddNodePanel.tsx
import { Sheet, SheetContent, SheetTitle } from "../ui/sheet";
type AddNodePanelProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
name: string;
};
const Panel = ({ open, onOpenChange, children, name }: AddNodePanelProps) => {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="sm:max-w-[640px] p-0">
<SheetTitle className="bg-primary p-4 text-white">{name}</SheetTitle>
<div className="p-10 flex-col space-y-5">{children}</div>
</SheetContent>
</Sheet>
);
};
export default Panel;

View File

@@ -0,0 +1,30 @@
import { WorkflowNode, WorkflowNodeType } from "@/domain/workflow";
import StartForm from "./StartForm";
import DeployPanelBody from "./DeployPanelBody";
import ApplyForm from "./ApplyForm";
type PanelBodyProps = {
data: WorkflowNode;
};
const PanelBody = ({ data }: PanelBodyProps) => {
const getBody = () => {
switch (data.type) {
case WorkflowNodeType.Start:
return <StartForm data={data} />;
case WorkflowNodeType.Apply:
return <ApplyForm data={data} />;
case WorkflowNodeType.Notify:
return <DeployPanelBody data={data} />;
case WorkflowNodeType.Branch:
return <div></div>;
case WorkflowNodeType.Condition:
return <div></div>;
default:
return <> </>;
}
};
return <>{getBody()}</>;
};
export default PanelBody;

View File

@@ -0,0 +1,42 @@
// contexts/DialogContext.tsx
import { createContext, useContext, useState } from "react";
import Panel from "./Panel";
type PanelContentProps = { name: string; children: React.ReactNode };
type PanelContextType = {
open: boolean;
showPanel: ({ name, children }: PanelContentProps) => void;
hidePanel: () => void;
};
const PanelContext = createContext<PanelContextType | undefined>(undefined);
export const PanelProvider = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = useState(false);
const [panelContent, setPanelContent] = useState<PanelContentProps | null>(null);
const showPanel = (panelContent: PanelContentProps) => {
setOpen(true);
setPanelContent(panelContent);
};
const hidePanel = () => {
setOpen(false);
setPanelContent(null);
};
return (
<PanelContext.Provider value={{ open, showPanel, hidePanel }}>
{children}
<Panel open={open} onOpenChange={setOpen} children={panelContent?.children} name={panelContent?.name ?? ""} />
</PanelContext.Provider>
);
};
export const usePanel = () => {
const context = useContext(PanelContext);
if (!context) {
throw new Error("useDialog must be used within DialogProvider");
}
return context;
};

View File

@@ -0,0 +1,144 @@
import { WorkflowNode, WorkflowNodeConfig } from "@/domain/workflow";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "../ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { Input } from "../ui/input";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Label } from "../ui/label";
import { useTranslation } from "react-i18next";
import { parseExpression } from "cron-parser";
import { useWorkflowStore, WorkflowState } from "@/providers/workflow";
import { useShallow } from "zustand/shallow";
import { usePanel } from "./PanelProvider";
const formSchema = z
.object({
executionMethod: z.string().min(1, "executionMethod is required"),
crontab: z.string(),
})
.superRefine((data, ctx) => {
if (data.executionMethod != "auto") {
return;
}
try {
parseExpression(data.crontab);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "crontab is invalid",
path: ["crontab"],
});
}
});
type StartFormProps = {
data: WorkflowNode;
};
const selectState = (state: WorkflowState) => ({
updateNode: state.updateNode,
});
const StartForm = ({ data }: StartFormProps) => {
const { updateNode } = useWorkflowStore(useShallow(selectState));
const { hidePanel } = usePanel();
const { t } = useTranslation();
const [method, setMethod] = React.useState("auto");
useEffect(() => {
if (data.config && data.config.executionMethod) {
setMethod(data.config.executionMethod as string);
} else {
setMethod("auto");
}
}, [data]);
let config: WorkflowNodeConfig = {
executionMethod: "auto",
crontab: "0 0 * * *",
};
if (data) config = data.config ?? config;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
executionMethod: config.executionMethod as string,
crontab: config.crontab as string,
},
});
const onSubmit = async (config: z.infer<typeof formSchema>) => {
updateNode({ ...data, config: { ...config } });
hidePanel();
};
return (
<>
<Form {...form}>
<form
onSubmit={(e) => {
e.stopPropagation();
form.handleSubmit(onSubmit)(e);
}}
className="space-y-8"
>
<FormField
control={form.control}
name="executionMethod"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<RadioGroup
{...field}
value={method}
onValueChange={(val: string) => {
setMethod(val);
}}
className="flex space-x-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="option-one" />
<Label htmlFor="option-one"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="manual" id="option-two" />
<Label htmlFor="option-two"></Label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="crontab"
render={({ field }) => (
<FormItem hidden={method == "manual"}>
<FormLabel></FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit">{t("common.save")}</Button>
</div>
</form>
</Form>
</>
);
};
export default StartForm;

View File

@@ -0,0 +1,13 @@
import { ConfigProvider } from "@/providers/config";
import React from "react";
import { PanelProvider } from "./PanelProvider";
const WorkflowProvider = ({ children }: { children: React.ReactNode }) => {
return (
<ConfigProvider>
<PanelProvider>{children}</PanelProvider>
</ConfigProvider>
);
};
export default WorkflowProvider;

View File

@@ -0,0 +1,11 @@
import { WorkflowBranchNode, WorkflowNode } from "@/domain/workflow";
export type NodeProps = {
data: WorkflowNode | WorkflowBranchNode;
branchId?: string;
branchIndex?: number;
};
export type BrandNodeProps = {
data: WorkflowBranchNode;
};