From 619c8be4bde0242401d6424f4d81cdb135309ec9 Mon Sep 17 00:00:00 2001 From: Deon Sanchez <69873175+deon-sanchez@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:34:20 -0600 Subject: [PATCH] feat: Introduce CanvasControls and dropdown components for enhanced canvas interaction - Added CanvasControls component to manage canvas interactions and state based on flow lock status. - Implemented CanvasControlsDropdown and HelpDropdown for zoom and help functionalities, respectively. - Created DropdownControlButton for reusable dropdown actions with keyboard shortcuts. - Removed deprecated dropdowns and refactored related tests to align with new component structure. - Introduced utility functions for zoom percentage formatting and modifier key detection. --- .../components/FlowMenu/index.tsx | 8 +- .../CanvasControls.tsx | 44 +++ .../CanvasControlsDropdown.tsx | 146 ++++++++++ .../DropdownControlButton.tsx | 73 +++++ .../canvasControlsComponent/HelpDropdown.tsx | 99 +++++++ .../__tests__/CanvasControls.test.tsx | 15 +- .../__tests__/Dropdowns.test.tsx | 3 +- .../canvasControlsComponent/dropdowns.tsx | 272 ------------------ .../core/canvasControlsComponent/index.tsx | 143 --------- .../utils/canvasUtils.ts | 17 ++ .../PageComponent/MemoizedComponents.tsx | 2 +- 11 files changed, 396 insertions(+), 426 deletions(-) create mode 100644 src/frontend/src/components/core/canvasControlsComponent/CanvasControls.tsx create mode 100644 src/frontend/src/components/core/canvasControlsComponent/CanvasControlsDropdown.tsx create mode 100644 src/frontend/src/components/core/canvasControlsComponent/DropdownControlButton.tsx create mode 100644 src/frontend/src/components/core/canvasControlsComponent/HelpDropdown.tsx delete mode 100644 src/frontend/src/components/core/canvasControlsComponent/dropdowns.tsx delete mode 100644 src/frontend/src/components/core/canvasControlsComponent/index.tsx create mode 100644 src/frontend/src/components/core/canvasControlsComponent/utils/canvasUtils.ts diff --git a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx index ba1a7ba485..0f132eec2f 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx @@ -1,6 +1,3 @@ -import { memo, useMemo, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { useShallow } from "zustand/react/shallow"; import IconComponent from "@/components/common/genericIconComponent"; import ShadTooltip from "@/components/common/shadTooltipComponent"; import FlowSettingsComponent from "@/components/core/flowSettingsComponent"; @@ -23,8 +20,12 @@ import useFlowsManagerStore from "@/stores/flowsManagerStore"; import { useShortcutsStore } from "@/stores/shortcuts"; import { swatchColors } from "@/utils/styleUtils"; import { cn, getNumberFromString } from "@/utils/utils"; +import { memo, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useShallow } from "zustand/react/shallow"; export const MenuBar = memo((): JSX.Element => { + const currentFlow = useFlowStore((state) => state.currentFlow); const setSuccessData = useAlertStore((state) => state.setSuccessData); const saveLoading = useFlowsManagerStore((state) => state.saveLoading); const [openSettings, setOpenSettings] = useState(false); @@ -140,6 +141,7 @@ export const MenuBar = memo((): JSX.Element => { > {currentFlowName || "Untitled Flow"} + {currentFlow?.locked && } { + const reactFlowStoreApi = useStoreApi(); + const isFlowLocked = useFlowStore( + useShallow((state) => state.currentFlow?.locked), + ); + + useEffect(() => { + reactFlowStoreApi.setState({ + nodesDraggable: !isFlowLocked, + nodesConnectable: !isFlowLocked, + elementsSelectable: !isFlowLocked, + }); + }, [isFlowLocked, reactFlowStoreApi]); + + return ( + + {children} + {children && ( + + + + )} + + + + + + + ); +}; + +export default CanvasControls; diff --git a/src/frontend/src/components/core/canvasControlsComponent/CanvasControlsDropdown.tsx b/src/frontend/src/components/core/canvasControlsComponent/CanvasControlsDropdown.tsx new file mode 100644 index 0000000000..3a7855a6a7 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/CanvasControlsDropdown.tsx @@ -0,0 +1,146 @@ +import { useReactFlow, useStore } from "@xyflow/react"; +import { useCallback, useEffect, useState } from "react"; +import { shallow } from "zustand/shallow"; +import IconComponent from "@/components/common/genericIconComponent"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; +import DropdownControlButton from "./DropdownControlButton"; +import { formatZoomPercentage, reactFlowSelector } from "./utils/canvasUtils"; + +export const KEYBOARD_SHORTCUTS = { + ZOOM_IN: { key: "+", code: "Equal" }, + ZOOM_OUT: { key: "-", code: "Minus" }, + FIT_VIEW: { key: "1", code: "Digit1" }, + RESET_ZOOM: { key: "0", code: "Digit0" }, +} as const; + +const CanvasControlsDropdown = () => { + const [isOpen, setIsOpen] = useState(false); + const { fitView, zoomIn, zoomOut, zoomTo } = useReactFlow(); + + const { minZoomReached, maxZoomReached, zoom } = useStore( + reactFlowSelector, + shallow, + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const isModifierPressed = event.metaKey || event.ctrlKey; + + if (!isModifierPressed) return; + + switch (event.code) { + case KEYBOARD_SHORTCUTS.ZOOM_IN.code: + event.preventDefault(); + if (!maxZoomReached) { + zoomIn(); + } + break; + case KEYBOARD_SHORTCUTS.ZOOM_OUT.code: + event.preventDefault(); + if (minZoomReached || zoom <= 0.6) { + zoomTo(1); + } else { + zoomOut(); + } + break; + case KEYBOARD_SHORTCUTS.FIT_VIEW.code: + event.preventDefault(); + fitView(); + break; + case KEYBOARD_SHORTCUTS.RESET_ZOOM.code: + event.preventDefault(); + zoomTo(1); + break; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [zoomIn, zoomOut, fitView, zoomTo, maxZoomReached, minZoomReached, zoom]); + + const handleZoomIn = useCallback(() => { + zoomIn(); + }, [zoomIn]); + + const handleZoomOut = useCallback(() => { + zoomOut(); + }, [zoomOut]); + + const handleFitView = useCallback(() => { + fitView(); + }, [fitView]); + + const handleResetZoom = useCallback(() => { + zoomTo(1); + }, [zoomTo]); + + return ( + + + + + + {formatZoomPercentage(zoom)} + + + + + + + + + + + + + + ); +}; + +export default CanvasControlsDropdown; diff --git a/src/frontend/src/components/core/canvasControlsComponent/DropdownControlButton.tsx b/src/frontend/src/components/core/canvasControlsComponent/DropdownControlButton.tsx new file mode 100644 index 0000000000..727362c248 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/DropdownControlButton.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { ForwardedIconComponent } from "@/components/common/genericIconComponent"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/utils"; +import ToggleShadComponent from "../parameterRenderComponent/components/toggleShadComponent"; +import { getModifierKey } from "./utils/canvasUtils"; + +export type DropdownControlButtonProps = { + tooltipText?: string; + onClick?: () => void; + disabled?: boolean; + testId?: string; + label?: string; + shortcut?: string; + iconName?: string; + hasToogle?: boolean; + toggleValue?: boolean; +}; + +const DropdownControlButton: React.FC = ({ + tooltipText, + onClick = () => {}, + disabled, + testId, + label = "", + shortcut = "", + iconName, + hasToogle = false, + toggleValue = false, +}) => ( + + {iconName && ( + + )} + + + {label} + + + {shortcut && ( + <> + {getModifierKey()} + {shortcut} + > + )} + + + {hasToogle && ( + + )} + +); + +export default DropdownControlButton; diff --git a/src/frontend/src/components/core/canvasControlsComponent/HelpDropdown.tsx b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdown.tsx new file mode 100644 index 0000000000..4bb83edae9 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdown.tsx @@ -0,0 +1,99 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import IconComponent from "@/components/common/genericIconComponent"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; +import { + BUG_REPORT_URL, + DATASTAX_DOCS_URL, + DESKTOP_URL, + DOCS_URL, +} from "@/constants/constants"; +import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags"; +import DropdownControlButton from "./DropdownControlButton"; + +const HelpDropdown = () => { + const navigate = useNavigate(); + const [helperLineEnabled, setHelperLineEnabled] = useState(false); + const [isHelpMenuOpen, setIsHelpMenuOpen] = useState(false); + + const onToggleHelperLines = useCallback(() => { + setHelperLineEnabled(!helperLineEnabled); + }, [helperLineEnabled]); + + return ( + + + + + + + + { + window.open( + ENABLE_DATASTAX_LANGFLOW ? DATASTAX_DOCS_URL : DOCS_URL, + "_blank", + ); + }} + /> + { + navigate("/settings/shortcuts"); + }} + /> + { + window.open(BUG_REPORT_URL, "_blank"); + }} + /> + + { + window.open(DESKTOP_URL, "_blank"); + }} + /> + + + + ); +}; + +export default HelpDropdown; diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx index 456eb057ea..e8de65ff8e 100644 --- a/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import CanvasControls from "../index"; +import CanvasControls from "../CanvasControls"; // Capture flow functions for assertions const reactFlowFns = { @@ -38,11 +38,14 @@ jest.mock("@/components/ui/separator", () => ({ })); // Mock dropdowns to a simple render that exposes props for assertions -jest.mock("../dropdowns", () => ({ - CanvasControlsDropdown: (props: any) => ( - - ), - HelpDropdown: (props: any) => , +jest.mock("../CanvasControlsDropdown", () => ({ + __esModule: true, + default: (props: any) => , +})); + +jest.mock("../HelpDropdown", () => ({ + __esModule: true, + default: (props: any) => , })); describe("CanvasControls", () => { diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx index e7e9266b66..25ba6650a3 100644 --- a/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, useNavigate } from "react-router-dom"; -import { CanvasControlsDropdown, HelpDropdown } from "../dropdowns"; +import CanvasControlsDropdown from "../CanvasControlsDropdown"; +import HelpDropdown from "../HelpDropdown"; jest.mock("@/components/ui/button", () => ({ Button: ({ children, ...props }: any) => ( diff --git a/src/frontend/src/components/core/canvasControlsComponent/dropdowns.tsx b/src/frontend/src/components/core/canvasControlsComponent/dropdowns.tsx deleted file mode 100644 index a386a8c1fa..0000000000 --- a/src/frontend/src/components/core/canvasControlsComponent/dropdowns.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import IconComponent, { - ForwardedIconComponent, -} from "@/components/common/genericIconComponent"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Separator } from "@/components/ui/separator"; -import { - BUG_REPORT_URL, - DATASTAX_DOCS_URL, - DESKTOP_URL, - DOCS_URL, -} from "@/constants/constants"; -import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags"; -import { cn, getOS } from "@/utils/utils"; -import React, { useCallback, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import ToggleShadComponent from "../parameterRenderComponent/components/toggleShadComponent"; - -type DropdownControlButtonProps = { - tooltipText?: string; - onClick?: () => void; - disabled?: boolean; - testId?: string; - label?: string; - shortcut?: string; - iconName?: string; - hasToogle?: boolean; - toggleValue?: boolean; -}; - -const getModifierKey = (): string => { - const os = getOS(); - return os === "macos" ? "⌘" : "Ctrl"; -}; - -const DropdownControlButton: React.FC = ({ - tooltipText, - onClick = () => {}, - disabled, - testId, - label = "", - shortcut = "", - iconName, - hasToogle=false, - toggleValue = false, -}) => ( - - {iconName && ( - - )} - - - {label} - - - {shortcut && ( - <> - {getModifierKey()} - {shortcut} - > - )} - - - {hasToogle && ( - - )} - -); - -const formatZoomPercentage = (zoom: number): string => - `${Math.round(zoom * 100)}%`; - -export type CanvasControlsDropdownProps = { - zoom: number; - minZoomReached: boolean; - maxZoomReached: boolean; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onZoomIn: () => void; - onZoomOut: () => void; - onResetZoom: () => void; - onFitView: () => void; - shortcuts: { - ZOOM_IN: { key: string }; - ZOOM_OUT: { key: string }; - RESET_ZOOM: { key: string }; - FIT_VIEW: { key: string }; - }; -}; - -export const CanvasControlsDropdown: React.FC = ({ - zoom, - minZoomReached, - maxZoomReached, - isOpen, - onOpenChange, - onZoomIn, - onZoomOut, - onResetZoom, - onFitView, - shortcuts, -}) => ( - - - - - - {formatZoomPercentage(zoom)} - - - - - - - - - - - - - -); - -export type HelpDropdownProps = { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onSelectAction?: (action: "mock1" | "mock2" | "mock3") => void; -}; - -export const HelpDropdown: React.FC = ({ - isOpen, - onOpenChange, - onSelectAction, -}) => { - const navigate = useNavigate(); - const [helperLineEnabled, setHelperLineEnabled] = useState(false); - const onToggleHelperLines = useCallback(() => { - setHelperLineEnabled(!helperLineEnabled); - }, [helperLineEnabled]); - - return ( - - - - - - - - { - window.open( - ENABLE_DATASTAX_LANGFLOW ? DATASTAX_DOCS_URL : DOCS_URL, - "_blank", - ); - }} - /> - { - navigate("/settings/shortcuts"); - }} - /> - { - window.open(BUG_REPORT_URL, "_blank"); - }} - /> - - { - window.open(DESKTOP_URL, "_blank"); - }} - /> - - - - ); -}; diff --git a/src/frontend/src/components/core/canvasControlsComponent/index.tsx b/src/frontend/src/components/core/canvasControlsComponent/index.tsx deleted file mode 100644 index c44c2e20aa..0000000000 --- a/src/frontend/src/components/core/canvasControlsComponent/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Separator } from "@/components/ui/separator"; -import useFlowStore from "@/stores/flowStore"; -import { - Panel, - type ReactFlowState, - useReactFlow, - useStore, - useStoreApi, -} from "@xyflow/react"; -import { type ReactNode, useCallback, useEffect, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { shallow } from "zustand/shallow"; -import { CanvasControlsDropdown, HelpDropdown } from "./dropdowns"; - -const KEYBOARD_SHORTCUTS = { - ZOOM_IN: { key: "+", code: "Equal" }, - ZOOM_OUT: { key: "-", code: "Minus" }, - FIT_VIEW: { key: "1", code: "Digit1" }, - RESET_ZOOM: { key: "0", code: "Digit0" }, -} as const; - -const reactFlowSelector = (s: ReactFlowState) => ({ - isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, - minZoomReached: s.transform[2] <= s.minZoom, - maxZoomReached: s.transform[2] >= s.maxZoom, - zoom: s.transform[2], -}); - -const CanvasControls = ({ children }: { children?: ReactNode }) => { - const reactFlowStoreApi = useStoreApi(); - const { fitView, zoomIn, zoomOut, zoomTo } = useReactFlow(); - const { minZoomReached, maxZoomReached, zoom } = useStore( - reactFlowSelector, - shallow, - ); - const [isControlsMenuOpen, setIsControlsMenuOpen] = useState(false); - const [isHelpMenuOpen, setIsHelpMenuOpen] = useState(false); - const isFlowLocked = useFlowStore( - useShallow((state) => state.currentFlow?.locked), - ); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const isModifierPressed = event.metaKey || event.ctrlKey; - - if (!isModifierPressed) return; - - switch (event.code) { - case KEYBOARD_SHORTCUTS.ZOOM_IN.code: - event.preventDefault(); - if (!maxZoomReached) { - zoomIn(); - } - break; - case KEYBOARD_SHORTCUTS.ZOOM_OUT.code: - event.preventDefault(); - if (minZoomReached || zoom <= 0.6) { - zoomTo(1); - } else { - zoomOut(); - } - break; - case KEYBOARD_SHORTCUTS.FIT_VIEW.code: - event.preventDefault(); - fitView(); - break; - case KEYBOARD_SHORTCUTS.RESET_ZOOM.code: - event.preventDefault(); - zoomTo(1); - break; - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [zoomIn, zoomOut, fitView, zoomTo, maxZoomReached, minZoomReached]); - - useEffect(() => { - reactFlowStoreApi.setState({ - nodesDraggable: !isFlowLocked, - nodesConnectable: !isFlowLocked, - elementsSelectable: !isFlowLocked, - }); - }, [isFlowLocked, reactFlowStoreApi]); - - const handleZoomIn = useCallback(() => { - zoomIn(); - setIsControlsMenuOpen(false); - }, [zoomIn]); - - const handleZoomOut = useCallback(() => { - zoomOut(); - setIsControlsMenuOpen(false); - }, [zoomOut]); - - const handleFitView = useCallback(() => { - fitView(); - setIsControlsMenuOpen(false); - }, [fitView]); - - const handleResetZoom = useCallback(() => { - zoomTo(1); - setIsControlsMenuOpen(false); - }, [zoomTo]); - - return ( - - {children} - {children && ( - - - - )} - - - - - - setIsHelpMenuOpen(false)} - /> - - ); -}; - -export default CanvasControls; diff --git a/src/frontend/src/components/core/canvasControlsComponent/utils/canvasUtils.ts b/src/frontend/src/components/core/canvasControlsComponent/utils/canvasUtils.ts new file mode 100644 index 0000000000..7fc4649154 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/utils/canvasUtils.ts @@ -0,0 +1,17 @@ +import { ReactFlowState } from "@xyflow/react"; +import { getOS } from "@/utils/utils"; + +export const getModifierKey = (): string => { + const os = getOS(); + return os === "macos" ? "⌘" : "Ctrl"; +}; + +export const formatZoomPercentage = (zoom: number): string => + `${Math.round(zoom * 100)}%`; + +export const reactFlowSelector = (s: ReactFlowState) => ({ + isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, + minZoomReached: s.transform[2] <= s.minZoom, + maxZoomReached: s.transform[2] >= s.maxZoom, + zoom: s.transform[2], +}); diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/MemoizedComponents.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/MemoizedComponents.tsx index 23daffaa68..fc24ba214c 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/MemoizedComponents.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/MemoizedComponents.tsx @@ -4,7 +4,7 @@ import { default as ForwardedIconComponent, default as IconComponent, } from "@/components/common/genericIconComponent"; -import CanvasControls from "@/components/core/canvasControlsComponent"; +import CanvasControls from "@/components/core/canvasControlsComponent/CanvasControls"; import LogCanvasControls from "@/components/core/logCanvasControlsComponent"; import { Button } from "@/components/ui/button"; import { SidebarTrigger } from "@/components/ui/sidebar";