import { memo, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { CloseCircleOutlined as CloseCircleOutlinedIcon, EllipsisOutlined as EllipsisOutlinedIcon, FormOutlined as FormOutlinedIcon, MoreOutlined as MoreOutlinedIcon, } from "@ant-design/icons"; import { useControllableValue } from "ahooks"; import { Button, Card, Drawer, Dropdown, Input, type InputRef, type MenuProps, Modal, Popover, Space } from "antd"; import { produce } from "immer"; import { isEqual } from "radash"; import { type WorkflowNode, WorkflowNodeType } from "@/domain/workflow"; import { useZustandShallowSelector } from "@/hooks"; import { useWorkflowStore } from "@/stores/workflow"; import AddNode from "./AddNode"; export type SharedNodeProps = { node: WorkflowNode; disabled?: boolean; }; // #region Title type SharedNodeTitleProps = SharedNodeProps & { className?: string; style?: React.CSSProperties; }; const SharedNodeTitle = ({ className, style, node, disabled }: SharedNodeTitleProps) => { const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); const handleBlur = (e: React.FocusEvent) => { const oldName = node.name; const newName = e.target.innerText.trim().substring(0, 64) || oldName; if (oldName === newName) { return; } updateNode( produce(node, (draft) => { draft.name = newName; }) ); }; return (
{node.name}
); }; // #endregion // #region Menu type SharedNodeMenuProps = SharedNodeProps & { branchId?: string; branchIndex?: number; menus?: Array<"rename" | "duplicate" | "remove">; trigger: React.ReactNode; afterUpdate?: () => void; afterDelete?: () => void; }; const isNodeBranchLike = (node: WorkflowNode) => { return ( node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition || node.type === WorkflowNodeType.ExecuteResultBranch || node.type === WorkflowNodeType.ExecuteSuccess || node.type === WorkflowNodeType.ExecuteFailure ); }; const isNodeReadOnly = (node: WorkflowNode) => { return node.type === WorkflowNodeType.Start || node.type === WorkflowNodeType.End; }; const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, afterUpdate, afterDelete }: SharedNodeMenuProps) => { const { t } = useTranslation(); const { updateNode, removeNode, removeBranch } = useWorkflowStore(useZustandShallowSelector(["updateNode", "removeNode", "removeBranch"])); const [modalApi, ModelContextHolder] = Modal.useModal(); const nameInputRef = useRef(null); const nameRef = useRef(); const handleRenameConfirm = async () => { const oldName = node.name; const newName = nameRef.current?.trim()?.substring(0, 64) || oldName; if (oldName === newName) { return; } await updateNode( produce(node, (draft) => { draft.name = newName; }) ); afterUpdate?.(); }; const handleDeleteClick = async () => { if (isNodeBranchLike(node)) { await removeBranch(branchId!, branchIndex!); } else { await removeNode(node.id); } afterDelete?.(); }; const menuItems = useMemo(() => { let temp = [ { key: "rename", disabled: disabled, label: isNodeBranchLike(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"), icon: , onClick: () => { nameRef.current = node.name; const dialog = modalApi.confirm({ title: isNodeBranchLike(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"), content: (
(nameRef.current = e.target.value)} onPressEnter={async () => { await handleRenameConfirm(); dialog.destroy(); }} />
), icon: null, okText: t("common.button.save"), onOk: handleRenameConfirm, }); setTimeout(() => nameInputRef.current?.focus(), 1); }, }, { type: "divider", }, { key: "remove", disabled: disabled || isNodeReadOnly(node), label: isNodeBranchLike(node) ? t("workflow_node.action.remove_branch") : t("workflow_node.action.remove_node"), icon: , danger: true, onClick: handleDeleteClick, }, ] satisfies MenuProps["items"]; if (menus) { temp = temp.filter((item) => item.type === "divider" || menus.includes(item.key as "rename" | "remove")); temp = temp.filter((item, index, array) => { if (item.type !== "divider") return true; return index === 0 || array[index - 1].type !== "divider"; }); if (temp[0]?.type === "divider") { temp.shift(); } if (temp[temp.length - 1]?.type === "divider") { temp.pop(); } } return temp; }, [disabled, node]); return ( <> {ModelContextHolder} {trigger} ); }; // #endregion // #region Wrapper type SharedNodeBlockProps = SharedNodeProps & { children: React.ReactNode; onClick?: (e: React.MouseEvent) => void; }; const SharedNodeBlock = ({ children, node, disabled, onClick }: SharedNodeBlockProps) => { const handleNodeClick = (e: React.MouseEvent) => { onClick?.(e); }; return ( <> } variant="text" />} />} placement="rightTop" >
{children}
); }; // #endregion // #region EditDrawer type SharedNodeEditDrawerProps = SharedNodeProps & { children: React.ReactNode; footer?: boolean; loading?: boolean; open?: boolean; pending?: boolean; onOpenChange?: (open: boolean) => void; onConfirm: () => void | Promise; getFormValues: () => NonNullable; }; const SharedNodeConfigDrawer = ({ children, node, disabled, footer = true, loading, pending, onConfirm, getFormValues, ...props }: SharedNodeEditDrawerProps) => { const { t } = useTranslation(); const [modalApi, ModelContextHolder] = Modal.useModal(); const [open, setOpen] = useControllableValue(props, { valuePropName: "open", defaultValuePropName: "defaultOpen", trigger: "onOpenChange", }); const handleConfirmClick = async () => { await onConfirm(); setOpen(false); }; const handleCancelClick = () => { if (pending) return; setOpen(false); }; const handleClose = () => { if (pending) return; const oldValues = JSON.parse(JSON.stringify(node.config ?? {})); const newValues = JSON.parse(JSON.stringify(getFormValues())); const changed = !isEqual(oldValues, {}) && !isEqual(oldValues, newValues); const { promise, resolve, reject } = Promise.withResolvers(); if (changed) { modalApi.confirm({ title: t("common.text.operation_confirm"), content: t("workflow_node.unsaved_changes.confirm"), onOk: () => resolve(void 0), onCancel: () => reject(), }); } else { resolve(void 0); } promise.then(() => setOpen(false)); }; return ( <> {ModelContextHolder} } type="text" />} afterDelete={() => { setOpen(false); }} /> } footer={ !!footer && ( ) } loading={loading} maskClosable={!pending} open={open} title={
{node.name}
} width={720} onClose={handleClose} > {children}
); }; // #endregion export default { Title: memo(SharedNodeTitle), Menu: memo(SharedNodeMenu), Block: memo(SharedNodeBlock), ConfigDrawer: memo(SharedNodeConfigDrawer), };