From 350160833b71104e7d9e9d5beaaba5d2e25565b5 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Sun, 5 Jan 2025 22:53:12 +0800 Subject: [PATCH] feat(ui): new workflow node panel --- ui/src/components/workflow/Panel.tsx | 23 -- ui/src/components/workflow/PanelBody.tsx | 31 --- ui/src/components/workflow/PanelProvider.tsx | 43 ---- .../components/workflow/WorkflowElement.tsx | 186 ++------------ .../components/workflow/WorkflowElements.tsx | 15 +- .../components/workflow/WorkflowProvider.tsx | 4 +- ui/src/components/workflow/node/AddNode.tsx | 6 +- .../workflow/node/ApplyNodeForm.tsx | 117 ++++----- .../components/workflow/node/BranchNode.tsx | 12 +- .../components/workflow/node/CommonNode.tsx | 240 ++++++++++++++++++ .../workflow/node/ConditionNode.tsx | 3 +- .../workflow/node/DeployNodeForm.tsx | 92 ++++--- ui/src/components/workflow/node/EndNode.tsx | 3 +- .../components/workflow/node/NodeRender.tsx | 37 --- .../workflow/node/NotifyNodeForm.tsx | 63 ++--- .../workflow/node/StartNodeForm.tsx | 72 +++--- ui/src/components/workflow/panel/Panel.tsx | 41 +++ .../components/workflow/panel/PanelContext.ts | 34 +++ .../workflow/panel/PanelProvider.tsx | 90 +++++++ 19 files changed, 601 insertions(+), 511 deletions(-) delete mode 100644 ui/src/components/workflow/Panel.tsx delete mode 100644 ui/src/components/workflow/PanelBody.tsx delete mode 100644 ui/src/components/workflow/PanelProvider.tsx create mode 100644 ui/src/components/workflow/node/CommonNode.tsx delete mode 100644 ui/src/components/workflow/node/NodeRender.tsx create mode 100644 ui/src/components/workflow/panel/Panel.tsx create mode 100644 ui/src/components/workflow/panel/PanelContext.ts create mode 100644 ui/src/components/workflow/panel/PanelProvider.tsx diff --git a/ui/src/components/workflow/Panel.tsx b/ui/src/components/workflow/Panel.tsx deleted file mode 100644 index a3b2d55a..00000000 --- a/ui/src/components/workflow/Panel.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect } from "react"; -import { Drawer } from "antd"; - -type AddNodePanelProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - children: React.ReactNode; - name: string; -}; - -const Panel = ({ open, onOpenChange, children, name }: AddNodePanelProps) => { - useEffect(() => { - onOpenChange(open); - }, [open, onOpenChange]); - - return ( - onOpenChange(false)}> - {children} - - ); -}; - -export default Panel; diff --git a/ui/src/components/workflow/PanelBody.tsx b/ui/src/components/workflow/PanelBody.tsx deleted file mode 100644 index 3feefd67..00000000 --- a/ui/src/components/workflow/PanelBody.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; - -import ApplyNodeForm from "./node/ApplyNodeForm"; -import DeployNodeForm from "./node/DeployNodeForm"; -import NotifyNodeForm from "./node/NotifyNodeForm"; -import StartNodeForm from "./node/StartNodeForm"; - -type PanelBodyProps = { - data: WorkflowNode; -}; - -const PanelBody = ({ data }: PanelBodyProps) => { - const getBody = () => { - switch (data.type) { - case WorkflowNodeType.Start: - return ; - case WorkflowNodeType.Apply: - return ; - case WorkflowNodeType.Deploy: - return ; - case WorkflowNodeType.Notify: - return ; - default: - return <> ; - } - }; - - return <>{getBody()}; -}; - -export default PanelBody; diff --git a/ui/src/components/workflow/PanelProvider.tsx b/ui/src/components/workflow/PanelProvider.tsx deleted file mode 100644 index eb8ed998..00000000 --- a/ui/src/components/workflow/PanelProvider.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { createContext, useContext, useState } from "react"; - -import Panel from "./Panel"; - -type PanelContentProps = { name: string; children: React.ReactNode }; - -type PanelContextProps = { - open: boolean; - showPanel: ({ name, children }: PanelContentProps) => void; - hidePanel: () => void; -}; - -const PanelContext = createContext(undefined); - -export const PanelProvider = ({ children }: { children: React.ReactNode }) => { - const [open, setOpen] = useState(false); - const [panelContent, setPanelContent] = useState(null); - - const showPanel = (panelContent: PanelContentProps) => { - setOpen(true); - setPanelContent(panelContent); - }; - - const hidePanel = () => { - setOpen(false); - setPanelContent(null); - }; - - return ( - - {children} - - - ); -}; - -export const usePanel = () => { - const context = useContext(PanelContext); - if (!context) { - throw new Error("`usePanel` must be used within PanelProvider"); - } - return context; -}; diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index fdb4b99c..595ee4ce 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -1,176 +1,44 @@ -import { useTranslation } from "react-i18next"; -import { CloseCircleOutlined as CloseCircleOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon } from "@ant-design/icons"; -import { Avatar, Button, Card, Dropdown, Popover, Space, Typography } from "antd"; -import { produce } from "immer"; +import { memo, useMemo } from "react"; -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 { useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; +import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; -import PanelBody from "./PanelBody"; -import { usePanel } from "./PanelProvider"; -import AddNode from "./node/AddNode"; +import BranchNode from "./node/BranchNode"; +import CommonNode from "./node/CommonNode"; +import ConditionNode from "./node/ConditionNode"; +import EndNode from "./node/EndNode"; -export type NodeProps = { +export type WorkflowElementProps = { node: WorkflowNode; disabled?: boolean; + branchId?: string; + branchIndex?: number; }; -const WorkflowElement = ({ node, disabled }: NodeProps) => { - const { t } = useTranslation(); - - const { updateNode, removeNode } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode"])); - const { showPanel } = usePanel(); - - const renderNodeContent = () => { - if (!node.validated) { - return {t("workflow_node.action.configure_node")}; - } - +const WorkflowElement = ({ node, disabled, ...props }: WorkflowElementProps) => { + const nodeComponent = useMemo(() => { switch (node.type) { - case WorkflowNodeType.Start: { - const config = (node.config as WorkflowNodeConfigForStart) ?? {}; - return ( -
- - {config.trigger === WORKFLOW_TRIGGERS.AUTO - ? t("workflow.props.trigger.auto") - : config.trigger === WORKFLOW_TRIGGERS.MANUAL - ? t("workflow.props.trigger.manual") - : " "} - - - {config.trigger === WORKFLOW_TRIGGERS.AUTO ? config.triggerCron : ""} - -
- ); - } + case WorkflowNodeType.Start: + case WorkflowNodeType.Apply: + case WorkflowNodeType.Deploy: + case WorkflowNodeType.Notify: + return ; - case WorkflowNodeType.Apply: { - const config = (node.config as WorkflowNodeConfigForApply) ?? {}; - return {config.domains || " "}; - } + case WorkflowNodeType.Branch: + return ; - case WorkflowNodeType.Deploy: { - const config = (node.config as WorkflowNodeConfigForDeploy) ?? {}; - const provider = deployProvidersMap.get(config.provider); - return ( - - - {t(provider?.name ?? "")} - - ); - } + case WorkflowNodeType.Condition: + return ; - case WorkflowNodeType.Notify: { - const config = (node.config as WorkflowNodeConfigForNotify) ?? {}; - const channel = notifyChannelsMap.get(config.channel as string); - return ( -
- {t(channel?.name ?? " ")} - - {config.subject ?? ""} - -
- ); - } + case WorkflowNodeType.End: + return ; - default: { + default: + console.warn(`[certimate] unsupported workflow node type: ${node.type}`); return <>; - } } - }; + }, [node, disabled, props]); - const handleNodeNameBlur = (e: React.FocusEvent) => { - const oldName = node.name; - const newName = e.target.innerText.trim(); - if (oldName === newName) { - return; - } - - updateNode( - produce(node, (draft) => { - draft.name = newName; - }) - ); - }; - - const handleNodeClick = () => { - if (disabled) return; - - showPanel({ - name: node.name, - children: , - }); - }; - - return ( - <> - - , - danger: true, - onClick: () => { - if (disabled) return; - - removeNode(node.id); - }, - }, - ], - }} - trigger={["click"]} - > - } onFinish={(v) => { - setFieldDomains(v); - formInst.setFieldValue("domains", v); + form.setFieldValue("domains", v); }} /> @@ -173,7 +151,7 @@ const ApplyNodeForm = ({ node }: ApplyNodeFormProps) => { onSubmit={(record) => { const provider = accessProvidersMap.get(record.provider); if (ACCESS_USAGES.ALL === provider?.usage || ACCESS_USAGES.APPLY === provider?.usage) { - formInst.setFieldValue("providerAccessId", record.id); + form.setFieldValue("providerAccessId", record.id); } }} /> @@ -216,21 +194,22 @@ const ApplyNodeForm = ({ node }: ApplyNodeFormProps) => { { + form.setFieldValue("nameservers", e.target.value); + }} /> + } onFinish={(v) => { - setFieldNameservers(v); - formInst.setFieldValue("nameservers", v); + form.setFieldValue("nameservers", v); }} /> @@ -260,12 +239,6 @@ const ApplyNodeForm = ({ node }: ApplyNodeFormProps) => { > - - - - ); }; @@ -340,7 +313,7 @@ const FormFieldDomainsModalForm = ({ trigger, onFinish, }: { - data: string; + data?: string; disabled?: boolean; trigger?: React.ReactNode; onFinish?: (data: string) => void; @@ -353,7 +326,7 @@ const FormFieldDomainsModalForm = ({ }, t("common.errmsg.domain_invalid")), }); const formRule = createSchemaFieldRule(formSchema); - const [formInst] = Form.useForm>(); + const [form] = Form.useForm>(); const [model, setModel] = useState>>({ domains: data?.split(MULTIPLE_INPUT_DELIMITER) }); useEffect(() => { @@ -372,7 +345,7 @@ const FormFieldDomainsModalForm = ({ return ( void }) => { +const FormFieldNameserversModalForm = ({ data, trigger, onFinish }: { data?: string; trigger?: React.ReactNode; onFinish?: (data: string) => void }) => { const { t } = useTranslation(); const formSchema = z.object({ @@ -397,7 +370,7 @@ const FormFieldNameserversModalForm = ({ data, trigger, onFinish }: { data: stri }, t("common.errmsg.domain_invalid")), }); const formRule = createSchemaFieldRule(formSchema); - const [formInst] = Form.useForm>(); + const [form] = Form.useForm>(); const [model, setModel] = useState>>({ nameservers: data?.split(MULTIPLE_INPUT_DELIMITER) }); useEffect(() => { @@ -416,7 +389,7 @@ const FormFieldNameserversModalForm = ({ data, trigger, onFinish }: { data: stri return ( { const { addBranch } = useWorkflowStore(useZustandShallowSelector(["addBranch"])); - const renderNodes = (node: WorkflowNode, branchNodeId?: string, branchIndex?: number) => { + const renderBranch = (node: WorkflowNode, branchNodeId?: string, branchIndex?: number) => { const elements: JSX.Element[] = []; - let current = node as WorkflowNode | undefined; + let current = node as typeof node | undefined; while (current) { - elements.push(); + elements.push(); current = current.next; } @@ -47,12 +47,12 @@ const BranchNode = ({ node, disabled }: BrandNodeProps) => { {t("workflow_node.action.add_branch")} - {node.branches!.map((branch, index) => ( + {node.branches?.map((branch, index) => (
-
{renderNodes(branch, node.id, index)}
+
{renderBranch(branch, node.id, index)}
))} diff --git a/ui/src/components/workflow/node/CommonNode.tsx b/ui/src/components/workflow/node/CommonNode.tsx new file mode 100644 index 00000000..35c485ec --- /dev/null +++ b/ui/src/components/workflow/node/CommonNode.tsx @@ -0,0 +1,240 @@ +import { memo, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { CloseCircleOutlined as CloseCircleOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon } from "@ant-design/icons"; +import { Avatar, Button, Card, Dropdown, Popover, Space, Typography } from "antd"; +import { produce } from "immer"; + +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"; +import { usePanelContext } from "../panel/PanelContext"; + +export type CommonNodeProps = { + node: WorkflowNode; + disabled?: boolean; +}; + +const CommonNode = ({ node, disabled }: CommonNodeProps) => { + const { t } = useTranslation(); + + const { accesses } = useAccessesStore(useZustandShallowSelector("accesses")); + const { addEmail } = useContactEmailsStore(useZustandShallowSelector(["addEmail"])); + const { updateNode, removeNode } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode"])); + const { confirm: confirmPanel } = usePanelContext(); + + const { + form: formInst, + formPending, + formProps, + submit: submitForm, + } = useAntdForm({ + name: "workflowNodeForm", + onSubmit: async (values) => { + 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 nodeContentComponent = useMemo(() => { + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + switch (node.type) { + case WorkflowNodeType.Start: { + const config = (node.config as WorkflowNodeConfigForStart) ?? {}; + return ( +
+ + {config.trigger === WORKFLOW_TRIGGERS.AUTO + ? t("workflow.props.trigger.auto") + : config.trigger === WORKFLOW_TRIGGERS.MANUAL + ? t("workflow.props.trigger.manual") + : " "} + + + {config.trigger === WORKFLOW_TRIGGERS.AUTO ? config.triggerCron : ""} + +
+ ); + } + + case WorkflowNodeType.Apply: { + const config = (node.config as WorkflowNodeConfigForApply) ?? {}; + return {config.domains || " "}; + } + + case WorkflowNodeType.Deploy: { + const config = (node.config as WorkflowNodeConfigForDeploy) ?? {}; + const provider = deployProvidersMap.get(config.provider); + return ( + + + {t(provider?.name ?? "")} + + ); + } + + case WorkflowNodeType.Notify: { + const config = (node.config as WorkflowNodeConfigForNotify) ?? {}; + const channel = notifyChannelsMap.get(config.channel as string); + return ( +
+ {t(channel?.name ?? " ")} + + {config.subject ?? ""} + +
+ ); + } + + default: { + console.warn(`[certimate] unsupported workflow node type: ${node.type}`); + return <>; + } + } + }, [node]); + + const panelBodyComponent = useMemo(() => { + const nodeFormProps = { + form: formInst, + formName: formProps.name, + disabled: disabled || formPending, + workflowNode: node, + }; + + switch (node.type) { + case WorkflowNodeType.Start: + return ; + case WorkflowNodeType.Apply: + return ; + case WorkflowNodeType.Deploy: + return ; + case WorkflowNodeType.Notify: + return ; + default: + console.warn(`[certimate] unsupported workflow node type: ${node.type}`); + return <> ; + } + }, [node, disabled, formInst, formPending, formProps]); + + const handleNodeClick = () => { + confirmPanel({ + title: node.name, + children: panelBodyComponent, + okText: t("common.button.save"), + onOk: () => { + return submitForm(); + }, + }); + }; + + const handleNodeNameBlur = (e: React.FocusEvent) => { + const oldName = node.name; + const newName = e.target.innerText.trim(); + if (oldName === newName) { + return; + } + + updateNode( + produce(node, (draft) => { + draft.name = newName; + }) + ); + }; + + return ( + <> + + , + danger: true, + onClick: () => { + if (disabled) return; + + removeNode(node.id); + }, + }, + ], + }} + trigger={["click"]} + > + - ); diff --git a/ui/src/components/workflow/node/EndNode.tsx b/ui/src/components/workflow/node/EndNode.tsx index 9700a847..de419bcf 100644 --- a/ui/src/components/workflow/node/EndNode.tsx +++ b/ui/src/components/workflow/node/EndNode.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { useTranslation } from "react-i18next"; import { Typography } from "antd"; @@ -14,4 +15,4 @@ const EndNode = () => { ); }; -export default EndNode; +export default memo(EndNode); diff --git a/ui/src/components/workflow/node/NodeRender.tsx b/ui/src/components/workflow/node/NodeRender.tsx deleted file mode 100644 index d64648a4..00000000 --- a/ui/src/components/workflow/node/NodeRender.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { memo } from "react"; - -import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; - -import WorkflowElement from "../WorkflowElement"; -import BranchNode from "./BranchNode"; -import ConditionNode from "./ConditionNode"; -import EndNode from "./EndNode"; - -export type NodeRenderProps = { - node: WorkflowNode; - branchId?: string; - branchIndex?: number; - disabled?: boolean; -}; - -const NodeRender = ({ node: data, branchId, branchIndex, disabled }: NodeRenderProps) => { - const render = () => { - switch (data.type) { - case WorkflowNodeType.Start: - case WorkflowNodeType.Apply: - case WorkflowNodeType.Deploy: - case WorkflowNodeType.Notify: - return ; - case WorkflowNodeType.End: - return ; - case WorkflowNodeType.Branch: - return ; - case WorkflowNodeType.Condition: - return ; - } - }; - - return <>{render()}; -}; - -export default memo(NodeRender); diff --git a/ui/src/components/workflow/node/NotifyNodeForm.tsx b/ui/src/components/workflow/node/NotifyNodeForm.tsx index d1256079..aa8dcf6d 100644 --- a/ui/src/components/workflow/node/NotifyNodeForm.tsx +++ b/ui/src/components/workflow/node/NotifyNodeForm.tsx @@ -2,30 +2,33 @@ 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, Input, Select } from "antd"; +import { Button, Form, type FormInstance, Input, Select } from "antd"; import { createSchemaFieldRule } from "antd-zod"; -import { produce } from "immer"; import { z } from "zod"; import { notifyChannelsMap } from "@/domain/settings"; import { type WorkflowNode, type WorkflowNodeConfigForNotify } from "@/domain/workflow"; -import { useAntdForm, useZustandShallowSelector } from "@/hooks"; +import { useZustandShallowSelector } from "@/hooks"; import { useNotifyChannelsStore } from "@/stores/notify"; -import { useWorkflowStore } from "@/stores/workflow"; -import { usePanel } from "../PanelProvider"; + +type NotifyNodeFormFieldValues = Partial; export type NotifyNodeFormProps = { - node: WorkflowNode; + form: FormInstance; + formName?: string; + disabled?: boolean; + workflowNode: WorkflowNode; + onValuesChange?: (values: NotifyNodeFormFieldValues) => void; }; -const initFormModel = (): Partial => { +const initFormModel = (): NotifyNodeFormFieldValues => { return { subject: "Completed!", message: "Your workflow has been completed on Certimate.", }; }; -const NotifyNodeForm = ({ node }: NotifyNodeFormProps) => { +const NotifyNodeForm = ({ form, formName, disabled, workflowNode, onValuesChange }: NotifyNodeFormProps) => { const { t } = useTranslation(); const { @@ -37,9 +40,6 @@ const NotifyNodeForm = ({ node }: NotifyNodeFormProps) => { fetchChannels(); }, [fetchChannels]); - const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); - const { hidePanel } = usePanel(); - const formSchema = z.object({ subject: z .string({ message: t("workflow_node.notify.form.subject.placeholder") }) @@ -52,27 +52,24 @@ const NotifyNodeForm = ({ node }: NotifyNodeFormProps) => { 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, - formPending, - formProps, - } = useAntdForm>({ - name: "workflowNotifyNodeForm", - initialValues: (node?.config as WorkflowNodeConfigForNotify) ?? initFormModel(), - onSubmit: async (values) => { - await formInst.validateFields(); - await updateNode( - produce(node, (draft) => { - draft.config = { ...values }; - draft.validated = true; - }) - ); - hidePanel(); - }, - }); + + const initialValues: NotifyNodeFormFieldValues = (workflowNode.config as WorkflowNodeConfigForNotify) ?? initFormModel(); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as NotifyNodeFormFieldValues); + }; return ( -
+ @@ -108,12 +105,6 @@ const NotifyNodeForm = ({ node }: NotifyNodeFormProps) => { /> - - - -
); }; diff --git a/ui/src/components/workflow/node/StartNodeForm.tsx b/ui/src/components/workflow/node/StartNodeForm.tsx index a1dcdd71..1e8ba2b8 100644 --- a/ui/src/components/workflow/node/StartNodeForm.tsx +++ b/ui/src/components/workflow/node/StartNodeForm.tsx @@ -1,35 +1,34 @@ import { memo, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Alert, Button, Form, Input, Radio } from "antd"; +import { Alert, Form, type FormInstance, Input, Radio } from "antd"; import { createSchemaFieldRule } from "antd-zod"; import dayjs from "dayjs"; -import { produce } from "immer"; import { z } from "zod"; import Show from "@/components/Show"; -import { WORKFLOW_TRIGGERS, type WorkflowNode, type WorkflowNodeConfigForStart } from "@/domain/workflow"; -import { useAntdForm, useZustandShallowSelector } from "@/hooks"; -import { useWorkflowStore } from "@/stores/workflow"; +import { WORKFLOW_TRIGGERS, type WorkflowNode, type WorkflowNodeConfigForStart, type WorkflowTriggerType } from "@/domain/workflow"; import { getNextCronExecutions, validCronExpression } from "@/utils/cron"; -import { usePanel } from "../PanelProvider"; + +type StartNodeFormFieldValues = Partial; export type StartNodeFormProps = { - node: WorkflowNode; + form: FormInstance; + formName?: string; + disabled?: boolean; + workflowNode: WorkflowNode; + onValuesChange?: (values: StartNodeFormFieldValues) => void; }; -const initFormModel = (): WorkflowNodeConfigForStart => { +const initFormModel = (): StartNodeFormFieldValues => { return { trigger: WORKFLOW_TRIGGERS.AUTO, triggerCron: "0 0 * * *", }; }; -const StartNodeForm = ({ node }: StartNodeFormProps) => { +const StartNodeForm = ({ form, formName, disabled, workflowNode, onValuesChange }: StartNodeFormProps) => { const { t } = useTranslation(); - const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); - const { hidePanel } = usePanel(); - const formSchema = z .object({ trigger: z.string({ message: t("workflow_node.start.form.trigger.placeholder") }).min(1, t("workflow_node.start.form.trigger.placeholder")), @@ -49,27 +48,11 @@ const StartNodeForm = ({ node }: StartNodeFormProps) => { } }); const formRule = createSchemaFieldRule(formSchema); - const { - form: formInst, - formPending, - formProps, - } = useAntdForm>({ - name: "workflowStartNodeForm", - initialValues: (node?.config as WorkflowNodeConfigForStart) ?? initFormModel(), - onSubmit: async (values) => { - await formInst.validateFields(); - await updateNode( - produce(node, (draft) => { - draft.config = { ...values }; - draft.validated = true; - }) - ); - hidePanel(); - }, - }); - const fieldTrigger = Form.useWatch("trigger", formInst); - const fieldTriggerCron = Form.useWatch("triggerCron", formInst); + const initialValues: StartNodeFormFieldValues = (workflowNode.config as WorkflowNodeConfigForStart) ?? initFormModel(); + + const fieldTrigger = Form.useWatch("trigger", form); + const fieldTriggerCron = Form.useWatch("triggerCron", form); const [fieldTriggerCronExpectedExecutions, setFieldTriggerCronExpectedExecutions] = useState([]); useEffect(() => { setFieldTriggerCronExpectedExecutions(getNextCronExecutions(fieldTriggerCron, 5)); @@ -77,12 +60,27 @@ const StartNodeForm = ({ node }: StartNodeFormProps) => { const handleTriggerChange = (value: string) => { if (value === WORKFLOW_TRIGGERS.AUTO) { - formInst.setFieldValue("triggerCron", formInst.getFieldValue("triggerCron") || initFormModel().triggerCron); + form.setFieldValue("triggerCron", initialValues.triggerCron || initFormModel().triggerCron); + } else { + form.setFieldValue("triggerCron", undefined); } }; + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as StartNodeFormFieldValues); + }; + return ( -
+ { } /> - - - -
); }; diff --git a/ui/src/components/workflow/panel/Panel.tsx b/ui/src/components/workflow/panel/Panel.tsx new file mode 100644 index 00000000..dd6d3eb9 --- /dev/null +++ b/ui/src/components/workflow/panel/Panel.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from "react"; +import { useControllableValue } from "ahooks"; +import { Drawer } from "antd"; + +export type PanelProps = { + children: React.ReactNode; + defaultOpen?: boolean; + extra?: React.ReactNode; + footer?: React.ReactNode; + open?: boolean; + title?: React.ReactNode; + onClose?: () => void | Promise; + onOpenChange?: (open: boolean) => void; +}; + +const Panel = ({ children, extra, footer, title, onClose, ...props }: PanelProps) => { + const [open, setOpen] = useControllableValue(props, { + valuePropName: "open", + defaultValuePropName: "defaultOpen", + trigger: "onOpenChange", + }); + + const handleClose = async () => { + try { + const ret = await onClose?.(); + if (ret != null && !ret) return; + + setOpen(false); + } catch { + return; + } + }; + + return ( + + {children} + + ); +}; + +export default Panel; diff --git a/ui/src/components/workflow/panel/PanelContext.ts b/ui/src/components/workflow/panel/PanelContext.ts new file mode 100644 index 00000000..bc5a46a8 --- /dev/null +++ b/ui/src/components/workflow/panel/PanelContext.ts @@ -0,0 +1,34 @@ +import { createContext, useContext } from "react"; +import { type ButtonProps } from "antd"; + +import { type PanelProps } from "./Panel"; + +export type ShowPanelOptions = Omit; +export type ShowPanelWithConfirmOptions = Omit & { + cancelButtonProps?: ButtonProps; + cancelText?: React.ReactNode; + okButtonProps?: ButtonProps; + okText?: React.ReactNode; + onCancel?: () => void; + onOk?: () => void | Promise; +}; + +export type PanelContextProps = { + open: boolean; + show: (options: ShowPanelOptions) => void; + confirm: (options: ShowPanelWithConfirmOptions) => void; + hide: () => void; +}; + +const PanelContext = createContext(undefined); + +export const usePanelContext = () => { + const context = useContext(PanelContext); + if (!context) { + throw new Error("`usePanelContext` must be used within `PanelProvider`"); + } + + return context; +}; + +export default PanelContext; diff --git a/ui/src/components/workflow/panel/PanelProvider.tsx b/ui/src/components/workflow/panel/PanelProvider.tsx new file mode 100644 index 00000000..16a87c69 --- /dev/null +++ b/ui/src/components/workflow/panel/PanelProvider.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Space } from "antd"; + +import Panel from "./Panel"; +import PanelContext, { type ShowPanelOptions, type ShowPanelWithConfirmOptions } from "./PanelContext"; + +export const PanelProvider = ({ children }: { children: React.ReactNode }) => { + const { t } = useTranslation(); + + const [open, setOpen] = useState(false); + const [options, setOptions] = useState(); + + const showPanel = (options: ShowPanelOptions) => { + setOpen(true); + setOptions(options); + }; + + const showPanelWithConfirm = (options: ShowPanelWithConfirmOptions) => { + const updateOptionsFooter = (confirmLoading: boolean) => { + setOptions({ + ...options, + footer: ( + + + + + ), + onClose: () => Promise.resolve(!confirmLoading), + }); + }; + + showPanel(options); + updateOptionsFooter(false); + }; + + const hidePanel = () => { + setOpen(false); + setOptions(undefined); + }; + + const handleOpenChange = (open: boolean) => { + setOpen(open); + + if (!open) { + setOptions(undefined); + } + }; + + return ( + + {children} + + + {options?.children} + + + ); +};