diff --git a/.cursor/settings.json b/.cursor/settings.json index ab4c98c2a..c2a49269e 100644 --- a/.cursor/settings.json +++ b/.cursor/settings.json @@ -1,3 +1,3 @@ { - "biome.configurationPath": "src/frontend/biome.json" + "biome.configurationPath": "src/frontend/biome.json" } \ No newline at end of file diff --git a/src/frontend/jest.setup.js b/src/frontend/jest.setup.js index 6b1b1f4b3..91547ea73 100644 --- a/src/frontend/jest.setup.js +++ b/src/frontend/jest.setup.js @@ -48,3 +48,59 @@ if (!Array.prototype.toSorted) { configurable: true, }); } + +// Global module stubs to avoid ESM/context issues in unit tests +jest.mock("@radix-ui/react-form", () => ({ + __esModule: true, + Field: (props) => props.children, + Label: (props) => props.children, + Control: (props) => props.children, + Message: (props) => props.children, + Submit: (props) => props.children, + Root: (props) => props.children, +})); + +jest.mock("react-markdown", () => ({ __esModule: true, default: () => null })); + +jest.mock("lucide-react/dynamicIconImports", () => ({}), { virtual: true }); + +// Avoid darkStore import in tests via genericIconComponent +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: () => null, +})); + +// Stub custom icon that uses JSX file to avoid transform issues in Jest +jest.mock("@/icons/BotMessageSquare", () => ({ + __esModule: true, + BotMessageSquareIcon: () => null, +})); + +// Provide a minimal import.meta.env shim used in some stores when running under Jest +if (typeof global.import === "undefined") { + global.import = { meta: { env: { CI: process.env.CI || false } } }; +} else if (!global.import.meta) { + global.import.meta = { env: { CI: process.env.CI || false } }; +} else if (!global.import.meta.env) { + global.import.meta.env = { CI: process.env.CI || false }; +} + +// Mock darkStore to avoid import.meta usage in modules during tests +jest.mock("@/stores/darkStore", () => ({ + __esModule: true, + useDarkStore: (selector) => + selector + ? selector({ + dark: false, + stars: 0, + version: "", + latestVersion: "", + discordCount: 0, + refreshLatestVersion: () => {}, + setDark: () => {}, + refreshVersion: () => {}, + refreshStars: () => {}, + refreshDiscordCount: () => {}, + }) + : {}, +})); diff --git a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/__tests__/FlowMenu.spec.tsx b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/__tests__/FlowMenu.spec.tsx new file mode 100644 index 000000000..66f57e843 --- /dev/null +++ b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/__tests__/FlowMenu.spec.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import MenuBar from "../index"; + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...rest }) => , +})); +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name }) => {name}, +})); +jest.mock("@/components/common/shadTooltipComponent", () => ({ + __esModule: true, + default: ({ children }) =>
{children}
, +})); +jest.mock("@/components/core/flowSettingsComponent", () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock( + "@/controllers/API/queries/flows/use-get-refresh-flows-query", + () => ({ __esModule: true, useGetRefreshFlowsQuery: () => ({}) }), +); +jest.mock("@/controllers/API/queries/folders/use-get-folders", () => ({ + __esModule: true, + useGetFoldersQuery: () => ({ + data: [{ id: "f1", name: "Folder" }], + isFetched: true, + }), +})); +const mockSave = jest.fn(() => Promise.resolve()); +jest.mock("@/hooks/flows/use-save-flow", () => ({ + __esModule: true, + default: () => mockSave, +})); +jest.mock("@/hooks/use-unsaved-changes", () => ({ + __esModule: true, + useUnsavedChanges: () => true, +})); +jest.mock("@/customization/hooks/use-custom-navigate", () => ({ + __esModule: true, + useCustomNavigate: () => jest.fn(), +})); +jest.mock("@/stores/flowsManagerStore", () => ({ + __esModule: true, + default: (sel) => + sel({ + autoSaving: false, + saveLoading: false, + currentFlow: { updated_at: new Date().toISOString() }, + }), +})); +jest.mock("@/stores/alertStore", () => ({ + __esModule: true, + default: (sel) => sel({ setSuccessData: jest.fn() }), +})); +jest.mock("@/stores/flowStore", () => ({ + __esModule: true, + default: (sel) => + sel({ + onFlowPage: true, + isBuilding: false, + currentFlow: { + id: "1", + name: "Flow", + folder_id: "f1", + icon: "Workflow", + gradient: "0", + locked: false, + }, + }), +})); +jest.mock("@/stores/shortcuts", () => ({ + __esModule: true, + useShortcutsStore: (sel) => sel({ changesSave: "mod+s" }), +})); + +// Avoid pulling utils that depend on darkStore +jest.mock("@/utils/utils", () => ({ + __esModule: true, + cn: (...args) => args.filter(Boolean).join(" "), + getNumberFromString: () => 0, +})); + +// styleUtils imports lucide dynamic icons; stub to avoid resolution +jest.mock("lucide-react/dynamicIconImports", () => ({}), { virtual: true }); + +describe("FlowMenu MenuBar", () => { + it("renders current folder and flow name, enables save", async () => { + render(); + expect(screen.getByTestId("menu_bar_wrapper")).toBeInTheDocument(); + expect(screen.getByText("Folder")).toBeInTheDocument(); + expect(screen.getByTestId("flow_name").textContent).toBe("Flow"); + + const saveBtn = screen.getByTestId("save-flow-button"); + expect(saveBtn).not.toBeDisabled(); + }); + + it("clicking save calls save flow", () => { + mockSave.mockClear(); + render(); + fireEvent.click(screen.getByTestId("save-flow-button")); + expect(mockSave).toHaveBeenCalled(); + }); +}); 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 15ab90fad..96daffc21 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx @@ -33,6 +33,7 @@ export const MenuBar = memo((): JSX.Element => { const saveFlow = useSaveFlow(); const autoSaving = useFlowsManagerStore((state) => state.autoSaving); const { + isFlowLocked, currentFlowName, currentFlowId, currentFlowFolderId, @@ -40,6 +41,7 @@ export const MenuBar = memo((): JSX.Element => { currentFlowGradient, } = useFlowStore( useShallow((state) => ({ + isFlowLocked: state.currentFlow?.locked, currentFlowName: state.currentFlow?.name, currentFlowId: state.currentFlow?.id, currentFlowFolderId: state.currentFlow?.folder_id, @@ -140,6 +142,9 @@ export const MenuBar = memo((): JSX.Element => { > {currentFlowName || "Untitled Flow"} + {isFlowLocked && ( + + )} { align="center" sideOffset={15} > - Flow Details setOpenSettings(false)} open={openSettings} diff --git a/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/__tests__/HeaderMenu.spec.tsx b/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/__tests__/HeaderMenu.spec.tsx new file mode 100644 index 000000000..b5cf2cbec --- /dev/null +++ b/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/__tests__/HeaderMenu.spec.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { + HeaderMenu, + HeaderMenuItemButton, + HeaderMenuItemLink, + HeaderMenuItems, + HeaderMenuItemsSection, + HeaderMenuItemsTitle, + HeaderMenuToggle, +} from "../index"; + +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }) =>
{children}
, + DropdownMenuTrigger: ({ children, ...rest }) => ( + + ), + DropdownMenuContent: ({ children, ...rest }) => ( +
+ {children} +
+ ), + DropdownMenuItem: ({ children, ...rest }) => ( +
+ {children} +
+ ), + DropdownMenuSeparator: (props) =>
, +})); +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name }) => {name}, +})); + +// Avoid pulling darkStore via utils +jest.mock("@/utils/utils", () => ({ + __esModule: true, + cn: (...args) => args.filter(Boolean).join(" "), +})); + +describe("HeaderMenu primitives", () => { + it("renders toggle and items with title and section", () => { + render( + + avatar + + Title + + + Docs + + {}}> + Logout + + + + , + ); + expect(screen.getByTestId("user_menu_button")).toBeInTheDocument(); + expect(screen.getByTestId("content")).toBeInTheDocument(); + expect(screen.getAllByRole("menuitem").length).toBeGreaterThan(0); + }); + + it("HeaderMenuItemLink renders anchor with icon when newPage", () => { + render( + + + + Link + + + , + ); + const link = screen.getByText("Link").closest("a")!; + expect(link).toHaveAttribute("target", "_blank"); + expect(screen.getByTestId("icon")).toBeInTheDocument(); + }); + + it("HeaderMenuItemButton invokes onClick", () => { + const onClick = jest.fn(); + render( + + + Click + + , + ); + fireEvent.click(screen.getByText("Click")); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx index ca990ece5..d10373ef5 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/HeaderMenu/index.tsx @@ -45,7 +45,7 @@ export const HeaderMenuItemLink = ({ {icon && ( )} diff --git a/src/frontend/src/components/core/canvasControlsComponent/CanvasControls.tsx b/src/frontend/src/components/core/canvasControlsComponent/CanvasControls.tsx new file mode 100644 index 000000000..2b7c32a86 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/CanvasControls.tsx @@ -0,0 +1,44 @@ +import { Panel, useStoreApi } from "@xyflow/react"; +import { type ReactNode, useEffect } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { Separator } from "@/components/ui/separator"; +import useFlowStore from "@/stores/flowStore"; +import CanvasControlsDropdown from "./CanvasControlsDropdown"; +import HelpDropdown from "./HelpDropdown"; + +const CanvasControls = ({ children }: { children?: ReactNode }) => { + 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 000000000..3a7855a6a --- /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 ( + + + + + + + + + + + + + ); +}; + +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 000000000..4fcf13782 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/DropdownControlButton.tsx @@ -0,0 +1,86 @@ +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; + externalLink?: boolean; +}; + +const DropdownControlButton: React.FC = ({ + tooltipText, + onClick = () => {}, + disabled, + testId, + label = "", + shortcut = "", + iconName, + hasToogle = false, + toggleValue = false, + externalLink = false, +}) => ( + +); + +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 000000000..3a125b1b1 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdown.tsx @@ -0,0 +1,40 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { HelpDropdownView } from "@/components/core/canvasControlsComponent/HelpDropdownView"; +import { + BUG_REPORT_URL, + DATASTAX_DOCS_URL, + DESKTOP_URL, + DOCS_URL, +} from "@/constants/constants"; +import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags"; +import useFlowStore from "@/stores/flowStore"; + +const HelpDropdown = () => { + const navigate = useNavigate(); + const [isHelpMenuOpen, setIsHelpMenuOpen] = useState(false); + const helperLineEnabled = useFlowStore((state) => state.helperLineEnabled); + const setHelperLineEnabled = useFlowStore( + (state) => state.setHelperLineEnabled, + ); + + const onToggleHelperLines = useCallback(() => { + setHelperLineEnabled(!helperLineEnabled); + }, [helperLineEnabled]); + + const docsUrl = ENABLE_DATASTAX_LANGFLOW ? DATASTAX_DOCS_URL : DOCS_URL; + + return ( + navigate(path)} + openLink={(url) => window.open(url, "_blank")} + urls={{ docs: docsUrl, bugReport: BUG_REPORT_URL, desktop: DESKTOP_URL }} + /> + ); +}; + +export default HelpDropdown; diff --git a/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx new file mode 100644 index 000000000..7b351752c --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx @@ -0,0 +1,96 @@ +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"; + +export type HelpDropdownViewProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + helperLineEnabled: boolean; + onToggleHelperLines: () => void; + navigateTo: (path: string) => void; + openLink: (url: string) => void; + urls: { + docs: string; + bugReport: string; + desktop: string; + }; +}; + +export const HelpDropdownView = ({ + isOpen, + onOpenChange, + helperLineEnabled, + onToggleHelperLines, + navigateTo, + openLink, + urls, +}: HelpDropdownViewProps) => { + return ( + + + + + + openLink(urls.docs)} + /> + navigateTo("/settings/shortcuts")} + /> + openLink(urls.bugReport)} + /> + + openLink(urls.desktop)} + /> + + + + ); +}; + +export default HelpDropdownView; diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx new file mode 100644 index 000000000..57c3af6f3 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControls.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import CanvasControls from "../CanvasControls"; + +// Capture flow functions for assertions +const reactFlowFns = { + fitView: jest.fn(), + zoomIn: jest.fn(), + zoomOut: jest.fn(), + zoomTo: jest.fn(), +}; + +// Mocks for external dependencies used internally +jest.mock("@xyflow/react", () => ({ + Panel: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + useReactFlow: () => reactFlowFns, + useStore: (_selector: any) => ({ + isInteractive: true, + minZoomReached: false, + maxZoomReached: false, + zoom: 1, + }), + useStoreApi: () => ({ setState: jest.fn() }), +})); + +jest.mock("@/stores/flowStore", () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +jest.mock("@/components/ui/separator", () => ({ + Separator: ({ orientation }: { orientation: "vertical" | "horizontal" }) => ( +
+ ), +})); + +// Mock dropdowns to a simple render that exposes props for assertions +jest.mock("../CanvasControlsDropdown", () => ({ + __esModule: true, + default: (props: any) =>
, +})); + +jest.mock("../HelpDropdown", () => ({ + __esModule: true, + default: (props: any) =>
, +})); + +describe("CanvasControls", () => { + it("renders panel and separators when children present", () => { + render( + +
child
+
, + ); + expect(screen.getByTestId("main_canvas_controls")).toBeInTheDocument(); + const seps = screen.getAllByTestId("separator-vertical"); + expect(seps.length).toBeGreaterThanOrEqual(1); + expect(screen.getByTestId("controls-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("help-dropdown")).toBeInTheDocument(); + }); + + it("updates reactFlow state based on flow lock status", () => { + render(); + + // The component should set up state through useStoreApi + // This test verifies the component renders and doesn't throw + expect(screen.getByTestId("main_canvas_controls")).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControlsDropdown.spec.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControlsDropdown.spec.tsx new file mode 100644 index 000000000..018e02425 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControlsDropdown.spec.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import CanvasControlsDropdown, { + KEYBOARD_SHORTCUTS, +} from "../CanvasControlsDropdown"; + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...rest }) => , +})); +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children, open, onOpenChange }) => ( +
+ +
+ ), + DropdownMenuContent: ({ children }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }) => ( +
{children}
+ ), +})); +jest.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); +jest.mock("../DropdownControlButton", () => ({ + __esModule: true, + default: ({ label, onClick, disabled, testId, shortcut }) => ( + + ), +})); +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name }) => {name}, +})); + +// Minimize utils import surface (prevents pulling stores/darkStore via utils.ts) +jest.mock("@/utils/utils", () => ({ + __esModule: true, + getOS: () => "macos", + cn: (...args) => args.filter(Boolean).join(" "), +})); + +const fitView = jest.fn(); +const zoomIn = jest.fn(); +const zoomOut = jest.fn(); +const zoomTo = jest.fn(); + +jest.mock("@xyflow/react", () => ({ + useReactFlow: () => ({ fitView, zoomIn, zoomOut, zoomTo }), + useStore: (selector) => + selector({ + nodesDraggable: true, + nodesConnectable: true, + elementsSelectable: true, + transform: [0, 0, 1], + minZoom: 0.2, + maxZoom: 2, + }), +})); + +describe("CanvasControlsDropdown", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders current zoom percentage and toggles menu", () => { + render(); + expect(screen.getByText("100%")); + fireEvent.click(screen.getByTestId("canvas_controls_dropdown")); + expect(screen.getByTestId("dropdown-content")).toBeInTheDocument(); + }); + + it("handles zoom in/out, fit and reset via click", () => { + render(); + fireEvent.click(screen.getByTestId("canvas_controls_dropdown")); + + fireEvent.click(screen.getByTestId("zoom_in")); + expect(zoomIn).toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("zoom_out")); + expect(zoomOut).toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("fit_view")); + expect(fitView).toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("reset_zoom")); + expect(zoomTo).toHaveBeenCalledWith(1); + }); + + it("handles keyboard shortcuts with modifier", () => { + render(); + + const keydown = (code: string) => + document.dispatchEvent( + new KeyboardEvent("keydown", { code, metaKey: true }), + ); + + keydown(KEYBOARD_SHORTCUTS.ZOOM_IN.code); + expect(zoomIn).toHaveBeenCalled(); + + keydown(KEYBOARD_SHORTCUTS.ZOOM_OUT.code); + expect(zoomOut).toHaveBeenCalled(); + + keydown(KEYBOARD_SHORTCUTS.FIT_VIEW.code); + expect(fitView).toHaveBeenCalled(); + + keydown(KEYBOARD_SHORTCUTS.RESET_ZOOM.code); + expect(zoomTo).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControlsDropdown.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControlsDropdown.test.tsx new file mode 100644 index 000000000..749b9bbc5 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/CanvasControlsDropdown.test.tsx @@ -0,0 +1,226 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import CanvasControlsDropdown, { + KEYBOARD_SHORTCUTS, +} from "../CanvasControlsDropdown"; + +// Mock React Flow hooks +const mockReactFlowFns = { + fitView: jest.fn(), + zoomIn: jest.fn(), + zoomOut: jest.fn(), + zoomTo: jest.fn(), +}; + +let mockStoreValues = { + isInteractive: true, + minZoomReached: false, + maxZoomReached: false, + zoom: 1, +}; + +jest.mock("@xyflow/react", () => ({ + useReactFlow: () => mockReactFlowFns, + useStore: (selector: any) => selector(mockStoreValues), +})); + +// Mock dependencies +jest.mock("zustand/shallow", () => ({ + shallow: jest.fn((fn) => fn), +})); + +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name }: { name: string }) => ( + {name} + ), +})); + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => ( + + ), +})); + +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DropdownMenuTrigger: ({ children, asChild, ...props }: any) => ( +
+ {children} +
+ ), + DropdownMenuContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +jest.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +jest.mock("../DropdownControlButton", () => ({ + __esModule: true, + default: ({ testId, onClick, disabled, label, shortcut }: any) => ( + + ), +})); + +jest.mock("../utils/canvasUtils", () => ({ + formatZoomPercentage: jest.fn((zoom: number) => `${Math.round(zoom * 100)}%`), + reactFlowSelector: jest.fn((state: any) => state), +})); + +describe("CanvasControlsDropdown", () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset mock store values + mockStoreValues = { + isInteractive: true, + minZoomReached: false, + maxZoomReached: false, + zoom: 1, + }; + + // Mock addEventListener and removeEventListener + jest.spyOn(document, "addEventListener"); + jest.spyOn(document, "removeEventListener"); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("renders dropdown trigger with zoom percentage", () => { + render(); + + expect(screen.getByTestId("canvas_controls_dropdown")).toBeInTheDocument(); + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + + it("renders chevron icon", () => { + render(); + + // Should have one of the chevron icons (the actual logic depends on internal state) + const chevronUp = screen.queryByTestId("icon-ChevronUp"); + const chevronDown = screen.queryByTestId("icon-ChevronDown"); + + expect(chevronUp || chevronDown).toBeInTheDocument(); + }); + + it("renders all control buttons with correct props", () => { + render(); + + expect(screen.getByTestId("zoom_in_dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("zoom_out_dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("reset_zoom_dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("fit_view_dropdown")).toBeInTheDocument(); + + // Check shortcuts are passed correctly + expect(screen.getByTestId("zoom_in_dropdown")).toHaveAttribute( + "data-shortcut", + KEYBOARD_SHORTCUTS.ZOOM_IN.key, + ); + expect(screen.getByTestId("zoom_out_dropdown")).toHaveAttribute( + "data-shortcut", + KEYBOARD_SHORTCUTS.ZOOM_OUT.key, + ); + }); + + it("handles zoom in button click", () => { + render(); + + fireEvent.click(screen.getByTestId("zoom_in_dropdown")); + expect(mockReactFlowFns.zoomIn).toHaveBeenCalledTimes(1); + }); + + it("handles zoom out button click", () => { + render(); + + fireEvent.click(screen.getByTestId("zoom_out_dropdown")); + expect(mockReactFlowFns.zoomOut).toHaveBeenCalledTimes(1); + }); + + it("handles fit view button click", () => { + render(); + + fireEvent.click(screen.getByTestId("fit_view_dropdown")); + expect(mockReactFlowFns.fitView).toHaveBeenCalledTimes(1); + }); + + it("handles reset zoom button click", () => { + render(); + + fireEvent.click(screen.getByTestId("reset_zoom_dropdown")); + expect(mockReactFlowFns.zoomTo).toHaveBeenCalledWith(1); + }); + + it("disables zoom in when maxZoomReached is true", () => { + mockStoreValues.maxZoomReached = true; + + render(); + + expect(screen.getByTestId("zoom_in_dropdown")).toBeDisabled(); + }); + + it("disables zoom out when minZoomReached is true", () => { + mockStoreValues.minZoomReached = true; + + render(); + + expect(screen.getByTestId("zoom_out_dropdown")).toBeDisabled(); + }); + + describe("Keyboard shortcuts", () => { + it("sets up keyboard event listeners on mount", () => { + render(); + + expect(document.addEventListener).toHaveBeenCalledWith( + "keydown", + expect.any(Function), + ); + }); + }); + + describe("Event listener management", () => { + it("removes keydown event listener on unmount", () => { + const { unmount } = render(); + + unmount(); + + expect(document.removeEventListener).toHaveBeenCalledWith( + "keydown", + expect.any(Function), + ); + }); + }); + + describe("Dynamic zoom display", () => { + it("displays zoom percentage correctly", () => { + render(); + + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + + it("handles fractional zoom values correctly", () => { + mockStoreValues.zoom = 0.75; + + render(); + + expect(screen.getByText("75%")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/DropdownControlButton.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/DropdownControlButton.test.tsx new file mode 100644 index 000000000..345667279 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/DropdownControlButton.test.tsx @@ -0,0 +1,181 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import DropdownControlButton from "../DropdownControlButton"; + +// Mock dependencies +jest.mock("@/components/common/genericIconComponent", () => ({ + ForwardedIconComponent: ({ + name, + className, + }: { + name: string; + className: string; + }) => , +})); + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => ( + + ), +})); + +jest.mock("@/utils/utils", () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(" "), +})); + +jest.mock( + "../../parameterRenderComponent/components/toggleShadComponent", + () => ({ + __esModule: true, + default: ({ value, handleOnNewValue, id }: any) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleOnNewValue(); + } + }} + /> + ), + }), +); + +jest.mock("../utils/canvasUtils", () => ({ + getModifierKey: jest.fn(() => "⌘"), +})); + +describe("DropdownControlButton", () => { + const defaultProps = { + testId: "test-button", + label: "Test Button", + onClick: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders basic button with label", () => { + render(); + + expect(screen.getByTestId("test-button")).toBeInTheDocument(); + expect(screen.getByText("Test Button")).toBeInTheDocument(); + }); + + it("calls onClick handler when clicked", () => { + const mockOnClick = jest.fn(); + render(); + + fireEvent.click(screen.getByTestId("test-button")); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it("renders with icon when iconName is provided", () => { + render(); + + expect(screen.getByTestId("icon-test-icon")).toBeInTheDocument(); + }); + + it("displays shortcut with modifier key", () => { + render(); + + expect(screen.getByText("⌘")).toBeInTheDocument(); + expect(screen.getByText("+")).toBeInTheDocument(); + }); + + it("applies disabled state correctly", () => { + render(); + + const button = screen.getByTestId("test-button"); + expect(button).toBeDisabled(); + }); + + it("sets tooltip text as title attribute", () => { + const tooltipText = "This is a tooltip"; + render( + , + ); + + const button = screen.getByTestId("test-button"); + expect(button).toHaveAttribute("title", tooltipText); + }); + + it("renders toggle component when hasToogle is true", () => { + render( + , + ); + + expect(screen.getByTestId("toggle-helper_lines")).toBeInTheDocument(); + expect(screen.getByTestId("toggle-helper_lines")).toHaveAttribute( + "data-value", + "true", + ); + }); + + it("passes toggle value correctly to toggle component", () => { + render( + , + ); + + expect(screen.getByTestId("toggle-helper_lines")).toHaveAttribute( + "data-value", + "false", + ); + }); + + it("handles toggle click through onClick prop", () => { + const mockOnClick = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId("toggle-helper_lines")); + expect(mockOnClick).toHaveBeenCalled(); + }); + + it("renders without shortcut when not provided", () => { + render(); + + expect(screen.queryByText("⌘")).not.toBeInTheDocument(); + }); + + it("uses default onClick when not provided", () => { + render(); + + // Should not throw error when clicked + fireEvent.click(screen.getByTestId("test-button")); + }); + + it("applies correct CSS classes for disabled state", () => { + render(); + + const button = screen.getByTestId("test-button"); + expect(button.className).toContain("cursor-not-allowed opacity-50"); + }); + + it("renders empty label by default", () => { + render(); + + const button = screen.getByTestId("test-button"); + expect(button).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx new file mode 100644 index 000000000..a3023dd64 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useNavigate } from "react-router-dom"; +import HelpDropdown from "../HelpDropdown"; + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => ( + + ), +})); + +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DropdownMenuTrigger: ({ children, ...props }: any) => ( +
+ {children} +
+ ), + DropdownMenuContent: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +jest.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: () => , + ForwardedIconComponent: ({ name }: { name: string }) => ( + + ), +})); + +jest.mock("@/constants/constants", () => ({ + __esModule: true, + DATASTAX_DOCS_URL: "https://docs.datastax.com", + DOCS_URL: "https://docs.langflow.org", + DESKTOP_URL: "https://desktop.langflow.org", +})); + +jest.mock("@/customization/feature-flags", () => ({ + ENABLE_DATASTAX_LANGFLOW: false, +})); + +jest.mock("@/utils/utils", () => ({ + cn: (...args: any[]) => args.filter(Boolean).join(" "), + getOS: () => "macos", +})); + +jest.mock("react-router-dom", () => { + const actual = jest.requireActual("react-router-dom"); + return { + ...actual, + useNavigate: jest.fn(), + }; +}); + +jest.mock("@/stores/darkStore", () => ({ + useDarkStore: () => ({ + dark: false, + setDark: jest.fn(), + }), +})); + +jest.mock("@/stores/flowStore", () => ({ + __esModule: true, + default: () => ({ + helperLineEnabled: false, + setHelperLineEnabled: jest.fn(), + }), +})); + +// Mock window.open +Object.defineProperty(window, "open", { + writable: true, + value: jest.fn(), +}); + +describe("HelpDropdown", () => { + beforeEach(() => { + (window.open as jest.Mock).mockClear(); + }); + + it("opens docs in new tab and navigates to shortcuts", () => { + const mockNavigate = jest.fn(); + (useNavigate as unknown as jest.Mock).mockReturnValue(mockNavigate); + + render( + + {}} /> + , + ); + + fireEvent.click(screen.getByTestId("canvas_controls_dropdown_docs")); + expect(window.open).toHaveBeenCalledWith( + "https://docs.langflow.org", + "_blank", + ); + + fireEvent.click(screen.getByTestId("canvas_controls_dropdown_shortcuts")); + expect(mockNavigate).toHaveBeenCalledWith("/settings/shortcuts"); + + fireEvent.click( + screen.getByTestId("canvas_controls_dropdown_get_langflow_desktop"), + ); + expect(window.open).toHaveBeenCalledWith( + "https://desktop.langflow.org", + "_blank", + ); + }); +}); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/HelpDropdown.spec.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/HelpDropdown.spec.tsx new file mode 100644 index 000000000..557e624f1 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/HelpDropdown.spec.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { HelpDropdownView } from "../HelpDropdownView"; + +jest.mock("@/components/ui/button", () => ({ + Button: ({ children, ...rest }) => , +})); + +jest.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children, open, onOpenChange }) => ( +
+ +
+ ), + DropdownMenuContent: ({ children }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }) => ( +
{children}
+ ), +})); + +jest.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +jest.mock("../DropdownControlButton", () => ({ + __esModule: true, + default: ({ label, onClick, disabled, testId }) => ( + + ), +})); + +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name }) => {name}, +})); + +// Minimize utils import surface (prevents pulling heavy modules) +jest.mock("@/utils/utils", () => ({ + __esModule: true, + getOS: () => "macos", + cn: (...args: Array) => args.filter(Boolean).join(" "), +})); + +describe("HelpDropdownView", () => { + it("calls provided handlers for each menu item", () => { + const onOpenChange = jest.fn(); + const onToggleHelperLines = jest.fn(); + const navigateTo = jest.fn(); + const openLink = jest.fn(); + const urls = { + docs: "https://docs", + bugReport: "https://bugs", + desktop: "https://desktop", + }; + + render( + , + ); + + fireEvent.click(screen.getByTestId("canvas_controls_dropdown_docs")); + expect(openLink).toHaveBeenCalledWith("https://docs"); + + fireEvent.click(screen.getByTestId("canvas_controls_dropdown_shortcuts")); + expect(navigateTo).toHaveBeenCalledWith("/settings/shortcuts"); + + fireEvent.click( + screen.getByTestId("canvas_controls_dropdown_report_a_bug"), + ); + expect(openLink).toHaveBeenCalledWith("https://bugs"); + + fireEvent.click( + screen.getByTestId("canvas_controls_dropdown_get_langflow_desktop"), + ); + expect(openLink).toHaveBeenCalledWith("https://desktop"); + + fireEvent.click( + screen.getByTestId("canvas_controls_dropdown_enable_smart_guides"), + ); + expect(onToggleHelperLines).toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/canvasUtils.test.ts b/src/frontend/src/components/core/canvasControlsComponent/__tests__/canvasUtils.test.ts new file mode 100644 index 000000000..eb5335771 --- /dev/null +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/canvasUtils.test.ts @@ -0,0 +1,257 @@ +import { ReactFlowState } from "@xyflow/react"; +import { + formatZoomPercentage, + getModifierKey, + reactFlowSelector, +} from "../utils/canvasUtils"; + +// Mock the getOS utility +jest.mock("@/utils/utils", () => ({ + getOS: jest.fn(), +})); + +import { getOS } from "@/utils/utils"; + +const mockGetOS = getOS as jest.MockedFunction; + +describe("canvasUtils", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getModifierKey", () => { + it("returns ⌘ for macOS", () => { + mockGetOS.mockReturnValue("macos"); + expect(getModifierKey()).toBe("⌘"); + }); + + it("returns Ctrl for Windows", () => { + mockGetOS.mockReturnValue("windows"); + expect(getModifierKey()).toBe("Ctrl"); + }); + + it("returns Ctrl for Linux", () => { + mockGetOS.mockReturnValue("linux"); + expect(getModifierKey()).toBe("Ctrl"); + }); + + it("returns Ctrl for unknown OS", () => { + mockGetOS.mockReturnValue("unknown"); + expect(getModifierKey()).toBe("Ctrl"); + }); + }); + + describe("formatZoomPercentage", () => { + it("formats zoom level 1 as 100%", () => { + expect(formatZoomPercentage(1)).toBe("100%"); + }); + + it("formats zoom level 0.5 as 50%", () => { + expect(formatZoomPercentage(0.5)).toBe("50%"); + }); + + it("formats zoom level 1.5 as 150%", () => { + expect(formatZoomPercentage(1.5)).toBe("150%"); + }); + + it("formats zoom level 0.25 as 25%", () => { + expect(formatZoomPercentage(0.25)).toBe("25%"); + }); + + it("formats zoom level 2.789 as 279%", () => { + expect(formatZoomPercentage(2.789)).toBe("279%"); + }); + + it("rounds decimal values correctly", () => { + expect(formatZoomPercentage(0.333)).toBe("33%"); + expect(formatZoomPercentage(0.666)).toBe("67%"); + expect(formatZoomPercentage(1.234)).toBe("123%"); + }); + + it("handles zero zoom level", () => { + expect(formatZoomPercentage(0)).toBe("0%"); + }); + + it("handles very small zoom levels", () => { + expect(formatZoomPercentage(0.001)).toBe("0%"); + expect(formatZoomPercentage(0.005)).toBe("1%"); + }); + + it("handles very large zoom levels", () => { + expect(formatZoomPercentage(10)).toBe("1000%"); + expect(formatZoomPercentage(50.5)).toBe("5050%"); + }); + }); + + describe("reactFlowSelector", () => { + const createMockReactFlowState = ( + overrides: Partial = {}, + ): ReactFlowState => + ({ + nodesDraggable: true, + nodesConnectable: true, + elementsSelectable: true, + transform: [0, 0, 1], // x, y, zoom + minZoom: 0.1, + maxZoom: 5, + ...overrides, + }) as ReactFlowState; + + it("returns correct isInteractive when all interaction modes are enabled", () => { + const state = createMockReactFlowState({ + nodesDraggable: true, + nodesConnectable: true, + elementsSelectable: true, + }); + + const result = reactFlowSelector(state); + expect(result.isInteractive).toBe(true); + }); + + it("returns correct isInteractive when only nodesDraggable is enabled", () => { + const state = createMockReactFlowState({ + nodesDraggable: true, + nodesConnectable: false, + elementsSelectable: false, + }); + + const result = reactFlowSelector(state); + expect(result.isInteractive).toBe(true); + }); + + it("returns correct isInteractive when only nodesConnectable is enabled", () => { + const state = createMockReactFlowState({ + nodesDraggable: false, + nodesConnectable: true, + elementsSelectable: false, + }); + + const result = reactFlowSelector(state); + expect(result.isInteractive).toBe(true); + }); + + it("returns correct isInteractive when only elementsSelectable is enabled", () => { + const state = createMockReactFlowState({ + nodesDraggable: false, + nodesConnectable: false, + elementsSelectable: true, + }); + + const result = reactFlowSelector(state); + expect(result.isInteractive).toBe(true); + }); + + it("returns false for isInteractive when all interaction modes are disabled", () => { + const state = createMockReactFlowState({ + nodesDraggable: false, + nodesConnectable: false, + elementsSelectable: false, + }); + + const result = reactFlowSelector(state); + expect(result.isInteractive).toBe(false); + }); + + it("returns correct minZoomReached when zoom is at minimum", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 0.1], // zoom at minZoom + minZoom: 0.1, + }); + + const result = reactFlowSelector(state); + expect(result.minZoomReached).toBe(true); + }); + + it("returns correct minZoomReached when zoom is below minimum", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 0.05], // zoom below minZoom + minZoom: 0.1, + }); + + const result = reactFlowSelector(state); + expect(result.minZoomReached).toBe(true); + }); + + it("returns correct minZoomReached when zoom is above minimum", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 0.5], // zoom above minZoom + minZoom: 0.1, + }); + + const result = reactFlowSelector(state); + expect(result.minZoomReached).toBe(false); + }); + + it("returns correct maxZoomReached when zoom is at maximum", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 5], // zoom at maxZoom + maxZoom: 5, + }); + + const result = reactFlowSelector(state); + expect(result.maxZoomReached).toBe(true); + }); + + it("returns correct maxZoomReached when zoom is above maximum", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 10], // zoom above maxZoom + maxZoom: 5, + }); + + const result = reactFlowSelector(state); + expect(result.maxZoomReached).toBe(true); + }); + + it("returns correct maxZoomReached when zoom is below maximum", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 2], // zoom below maxZoom + maxZoom: 5, + }); + + const result = reactFlowSelector(state); + expect(result.maxZoomReached).toBe(false); + }); + + it("returns correct zoom level from transform", () => { + const state = createMockReactFlowState({ + transform: [100, 200, 1.5], // x, y, zoom + }); + + const result = reactFlowSelector(state); + expect(result.zoom).toBe(1.5); + }); + + it("handles edge case where zoom equals both min and max", () => { + const state = createMockReactFlowState({ + transform: [0, 0, 1], + minZoom: 1, + maxZoom: 1, + }); + + const result = reactFlowSelector(state); + expect(result.minZoomReached).toBe(true); + expect(result.maxZoomReached).toBe(true); + expect(result.zoom).toBe(1); + }); + + it("returns complete selector object with all properties", () => { + const state = createMockReactFlowState({ + nodesDraggable: true, + nodesConnectable: false, + elementsSelectable: true, + transform: [0, 0, 2], + minZoom: 0.5, + maxZoom: 4, + }); + + const result = reactFlowSelector(state); + + expect(result).toEqual({ + isInteractive: true, + minZoomReached: false, + maxZoomReached: false, + zoom: 2, + }); + }); + }); +}); 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 e4129a994..000000000 --- a/src/frontend/src/components/core/canvasControlsComponent/index.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { - ControlButton, - Panel, - type ReactFlowState, - useReactFlow, - useStore, - useStoreApi, -} from "@xyflow/react"; -import { cloneDeep } from "lodash"; -import { useCallback, useEffect } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { shallow } from "zustand/shallow"; -import IconComponent from "@/components/common/genericIconComponent"; -import ShadTooltip from "@/components/common/shadTooltipComponent"; -import useSaveFlow from "@/hooks/flows/use-save-flow"; -import useFlowStore from "@/stores/flowStore"; -import useFlowsManagerStore from "@/stores/flowsManagerStore"; -import { cn } from "@/utils/utils"; - -type CustomControlButtonProps = { - iconName: string; - tooltipText: string; - onClick: () => void; - disabled?: boolean; - backgroundClasses?: string; - iconClasses?: string; - testId?: string; -}; - -export const CustomControlButton = ({ - iconName, - tooltipText, - onClick, - disabled, - backgroundClasses, - iconClasses, - testId, -}: CustomControlButtonProps): JSX.Element => { - return ( - - -
-
-
-
- ); -}; - -const selector = (s: ReactFlowState) => ({ - isInteractive: s.nodesDraggable || s.nodesConnectable || s.elementsSelectable, - minZoomReached: s.transform[2] <= s.minZoom, - maxZoomReached: s.transform[2] >= s.maxZoom, -}); - -const CanvasControls = ({ children }) => { - const store = useStoreApi(); - const { fitView, zoomIn, zoomOut } = useReactFlow(); - const { isInteractive, minZoomReached, maxZoomReached } = useStore( - selector, - shallow, - ); - const saveFlow = useSaveFlow(); - const isLocked = useFlowStore( - useShallow((state) => state.currentFlow?.locked), - ); - const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow); - const autoSaving = useFlowsManagerStore((state) => state.autoSaving); - const setHelperLineEnabled = useFlowStore( - (state) => state.setHelperLineEnabled, - ); - const helperLineEnabled = useFlowStore((state) => state.helperLineEnabled); - - useEffect(() => { - store.setState({ - nodesDraggable: !isLocked, - nodesConnectable: !isLocked, - elementsSelectable: !isLocked, - }); - }, [isLocked]); - - const handleSaveFlow = useCallback(() => { - const currentFlow = useFlowStore.getState().currentFlow; - if (!currentFlow) return; - const newFlow = cloneDeep(currentFlow); - newFlow.locked = isInteractive; - if (autoSaving) { - saveFlow(newFlow); - } else { - setCurrentFlow(newFlow); - } - }, [isInteractive, autoSaving, saveFlow, setCurrentFlow]); - - const onToggleInteractivity = useCallback(() => { - store.setState({ - nodesDraggable: !isInteractive, - nodesConnectable: !isInteractive, - elementsSelectable: !isInteractive, - }); - handleSaveFlow(); - }, [isInteractive, store, handleSaveFlow]); - - const onToggleHelperLines = useCallback(() => { - setHelperLineEnabled(!helperLineEnabled); - }, [setHelperLineEnabled, helperLineEnabled]); - - return ( - - {/* Zoom In */} - - {/* Zoom Out */} - - {/* Zoom To Fit */} - - {children} - {/* Lock/Unlock */} - - {/* Display Helper Lines */} - - - ); -}; - -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 000000000..7fc464915 --- /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/components/core/editFlowSettingsComponent/__tests__/EditFlowSettings.spec.tsx b/src/frontend/src/components/core/editFlowSettingsComponent/__tests__/EditFlowSettings.spec.tsx new file mode 100644 index 000000000..56010b8ac --- /dev/null +++ b/src/frontend/src/components/core/editFlowSettingsComponent/__tests__/EditFlowSettings.spec.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import EditFlowSettings from "../index"; + +jest.mock("@/components/ui/input", () => ({ + Input: ({ ...props }) => , +})); +jest.mock("@/components/ui/textarea", () => ({ + Textarea: ({ ...props }) =>