feat: add new segmented sidebar (#9355)

* add new segmented sidebar

* add mcp menu

* switch back to floating trigger

* update icon and trigger

* open close and search handling

* improving search...almost there

* mcp empty state

* header updates

* search, header improvement + tests

* code quality improvement

* cleanup

* test fixes

* pr comment reset search val

* better ff wrap header

* cleanup

* test fix

* test fix

* merge fix

* test fix

* add mcp sidebar group tests + test fix

* test fix

* fix side bar test
This commit is contained in:
Mike Fortman 2025-08-26 11:32:03 -05:00 committed by GitHub
commit a483d55b8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3303 additions and 858 deletions

View file

@ -223,3 +223,9 @@ code {
-webkit-user-select: none;
-ms-user-select: none;
}
/* Sidebar segmented layout utility */
.sidebar-segmented {
max-width: calc(var(--sidebar-width) - var(--sidebar-width-icon));
width: var(--sidebar-width);
}

View file

@ -0,0 +1,51 @@
import { ControlButton } from "@xyflow/react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { cn } from "@/utils/utils";
type CanvasControlButtonProps = {
iconName: string;
tooltipText: string;
onClick: () => void;
disabled?: boolean;
backgroundClasses?: string;
iconClasses?: string;
testId?: string;
};
export const CanvasControlButton = ({
iconName,
tooltipText,
onClick,
disabled,
backgroundClasses,
iconClasses,
testId,
}: CanvasControlButtonProps): JSX.Element => {
return (
<ControlButton
data-testid={testId}
className="group !h-8 !w-8 rounded !p-0"
onClick={onClick}
disabled={disabled}
title={testId?.replace(/_/g, " ")}
>
<ShadTooltip content={tooltipText} side="right">
<div
className={cn(
"rounded p-2.5 text-muted-foreground group-hover:text-primary",
backgroundClasses,
)}
>
<ForwardedIconComponent
name={iconName}
aria-hidden="true"
className={cn("scale-150 h-8 w-8", iconClasses)}
/>
</div>
</ShadTooltip>
</ControlButton>
);
};
export default CanvasControlButton;

View file

@ -17,9 +17,46 @@ import { Skeleton } from "./skeleton";
import { TooltipProvider } from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_SECTION_COOKIE_NAME = "sidebar:section";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "19rem";
const SIDEBAR_WIDTH_ICON = "4rem";
const SEGMENTED_SIDEBAR_ICON_WIDTH = "40px";
export type SidebarSection = "search" | "components" | "bundles" | "mcp";
// Helper function to get cookie value
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(";").shift() || null;
return null;
}
// Helper function to get initial sidebar state from cookie
function getInitialSidebarState(defaultOpen: boolean): boolean {
const cookieValue = getCookie(SIDEBAR_COOKIE_NAME);
if (cookieValue === null) return defaultOpen;
return cookieValue === "true";
}
// Helper function to get initial sidebar section from cookie
function getInitialSidebarSection(
defaultSection: SidebarSection,
): SidebarSection {
const cookieValue = getCookie(SIDEBAR_SECTION_COOKIE_NAME);
if (cookieValue === null) return defaultSection;
if (
cookieValue === "search" ||
cookieValue === "components" ||
cookieValue === "bundles" ||
cookieValue === "mcp"
) {
return cookieValue;
}
return defaultSection;
}
type SidebarContext = {
state: "expanded" | "collapsed";
@ -27,6 +64,14 @@ type SidebarContext = {
setOpen: (open: boolean) => void;
toggleSidebar: () => void;
defaultOpen: boolean;
// Section management
activeSection: SidebarSection;
setActiveSection: (section: SidebarSection) => void;
defaultSection: SidebarSection;
// Search functionality
searchInputRef?: React.RefObject<HTMLInputElement>;
isSearchFocused?: boolean;
focusSearch?: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
@ -47,6 +92,10 @@ const SidebarProvider = React.forwardRef<
open?: boolean;
onOpenChange?: (open: boolean) => void;
width?: string;
segmentedSidebar?: boolean;
defaultSection?: SidebarSection;
activeSection?: SidebarSection;
onSectionChange?: (section: SidebarSection) => void;
}
>(
(
@ -54,17 +103,23 @@ const SidebarProvider = React.forwardRef<
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
defaultSection = "components",
activeSection: activeSectionProp,
onSectionChange: setActiveSectionProp,
className,
style,
children,
width = SIDEBAR_WIDTH,
segmentedSidebar = false,
...props
},
ref,
) => {
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const [_open, _setOpen] = React.useState(() =>
getInitialSidebarState(defaultOpen),
);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
@ -82,6 +137,25 @@ const SidebarProvider = React.forwardRef<
[setOpenProp, open],
);
// Section state management
const [_activeSection, _setActiveSection] = React.useState<SidebarSection>(
() => getInitialSidebarSection(defaultSection),
);
const activeSection = activeSectionProp ?? _activeSection;
const setActiveSection = React.useCallback(
(section: SidebarSection) => {
if (setActiveSectionProp) {
return setActiveSectionProp(section);
}
_setActiveSection(section);
// This sets the cookie to keep the sidebar section state.
document.cookie = `${SIDEBAR_SECTION_COOKIE_NAME}=${section}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setActiveSectionProp],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return setOpen((prev) => !prev);
@ -98,8 +172,20 @@ const SidebarProvider = React.forwardRef<
setOpen,
toggleSidebar,
defaultOpen,
activeSection,
setActiveSection,
defaultSection,
}),
[state, open, setOpen, toggleSidebar, defaultOpen],
[
state,
open,
setOpen,
toggleSidebar,
defaultOpen,
activeSection,
setActiveSection,
defaultSection,
],
);
const toggleSidebarShortcut = useShortcutsStore(
@ -125,7 +211,9 @@ const SidebarProvider = React.forwardRef<
style={
{
"--sidebar-width": width,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
"--sidebar-width-icon": segmentedSidebar
? SEGMENTED_SIDEBAR_ICON_WIDTH
: SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
@ -417,14 +505,17 @@ SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
React.ComponentProps<"div"> & {
segmentedSidebar?: boolean;
}
>(({ className, segmentedSidebar = false, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
segmentedSidebar && "sidebar-segmented",
className,
)}
{...props}

View file

@ -19,3 +19,4 @@ export const ENABLE_KNOWLEDGE_BASES = false;
export const ENABLE_MCP_COMPOSER =
process.env.LANGFLOW_FEATURE_MCP_COMPOSER === "true";
export const ENABLE_NEW_SIDEBAR = true;

View file

@ -2,19 +2,25 @@ const SvgMcpIcon = (props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill={props.isDark ? "white" : "black"}
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
{...props}
>
<g clip-path="url(#clip0_93_974)">
<path d="M10.3518 1.67465C9.70545 1.02829 8.65745 1.02827 8.01105 1.67467L1.769 7.9167C1.55354 8.13217 1.20421 8.13217 0.988744 7.9167C0.773283 7.70124 0.773283 7.35191 0.988745 7.13645L7.2308 0.894407C8.3081 -0.182884 10.0548 -0.182915 11.132 0.894397C11.7702 1.53249 12.0303 2.40541 11.9125 3.23499C12.7421 3.11719 13.615 3.37733 14.2531 4.01542L14.2856 4.04793L14.2857 4.04802C15.3629 5.12534 15.3629 6.87194 14.2856 7.94921L8.64024 13.5946C8.56844 13.6664 8.56843 13.7828 8.64023 13.8546L9.79945 15.0139C10.0149 15.2294 10.0149 15.5787 9.79942 15.7942C9.58395 16.0096 9.23462 16.0096 9.01917 15.7941L7.86 14.6349C7.35727 14.1322 7.35725 13.3171 7.85999 12.8144L13.5053 7.16896C14.1517 6.52258 14.1518 5.47458 13.5053 4.8282L13.4729 4.79569L13.4728 4.7956C12.8269 4.14986 11.7802 4.14934 11.1337 4.794L6.48305 9.44472L6.48055 9.44721L6.41802 9.50973C6.20256 9.72519 5.85323 9.7252 5.63777 9.50974C5.4223 9.29428 5.4223 8.94494 5.63776 8.72948L10.3532 4.01397C10.9982 3.36747 10.9977 2.32054 10.3518 1.67465Z" />
<path d="M9.57157 3.23517C9.78703 3.01971 9.78703 2.67038 9.57157 2.45492C9.35611 2.23946 9.00678 2.23946 8.79131 2.45492L4.17478 7.07143C3.09747 8.14872 3.09752 9.8954 4.17479 10.9727C5.2521 12.05 6.99876 12.05 8.07607 10.9727L12.6926 6.35619C12.908 6.14073 12.908 5.7914 12.6926 5.57594C12.4771 5.36048 12.1278 5.36048 11.9123 5.57594L7.29581 10.1925C6.64943 10.8388 5.60143 10.8388 4.95505 10.1925C4.30865 9.54605 4.30867 8.49804 4.95504 7.85169L9.57157 3.23517Z" />
</g>
<defs>
<clipPath id="clip0_93_974">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
<title>ModelContextProtocol</title>
<path
d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
<path
d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
};

View file

@ -1,14 +1,15 @@
import { Background, Panel } from "@xyflow/react";
import { memo } from "react";
import {
default as ForwardedIconComponent,
default as IconComponent,
} from "@/components/common/genericIconComponent";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import CanvasControlButton from "@/components/core/canvasControlsComponent/CanvasControlButton";
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";
import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { cn } from "@/utils/utils";
import { useSearchContext } from "../flowSidebarComponent";
import { NAV_ITEMS } from "../flowSidebarComponent/components/sidebarSegmentedNav";
export const MemoizedBackground = memo(() => (
<Background size={2} gap={20} className="" />
@ -48,7 +49,7 @@ export const MemoizedCanvasControls = memo(
}
}}
>
<IconComponent
<ForwardedIconComponent
name="sticky-note"
className="!h-5 !w-5 text-muted-foreground group-hover:text-primary"
/>
@ -57,17 +58,53 @@ export const MemoizedCanvasControls = memo(
),
);
export const MemoizedSidebarTrigger = memo(() => (
<Panel
className={cn(
"react-flow__controls !top-auto !m-2 flex gap-1.5 rounded-md border border-secondary-hover bg-background fill-foreground stroke-foreground p-1.5 text-primary shadow transition-all duration-300 [&>button]:border-0 [&>button]:bg-background hover:[&>button]:bg-accent",
"pointer-events-auto opacity-100 group-data-[open=true]/sidebar-wrapper:pointer-events-none group-data-[open=true]/sidebar-wrapper:-translate-x-full group-data-[open=true]/sidebar-wrapper:opacity-0",
)}
position="top-left"
>
<SidebarTrigger className="h-fit w-fit px-3 py-1.5">
<ForwardedIconComponent name="PanelRightClose" className="h-4 w-4" />
<span className="text-foreground">Components</span>
</SidebarTrigger>
</Panel>
));
export const MemoizedSidebarTrigger = memo(() => {
const { open, toggleSidebar, setActiveSection } = useSidebar();
const { focusSearch, isSearchFocused } = useSearchContext();
if (ENABLE_NEW_SIDEBAR) {
return (
<Panel
className={cn(
"react-flow__controls !top-auto !m-2 flex gap-1.5 rounded-md border border-secondary-hover bg-background p-0.5 text-primary shadow transition-all duration-300 [&>button]:border-0 [&>button]:bg-background hover:[&>button]:bg-accent",
"pointer-events-auto opacity-100 group-data-[open=true]/sidebar-wrapper:pointer-events-none group-data-[open=true]/sidebar-wrapper:-translate-x-full group-data-[open=true]/sidebar-wrapper:opacity-0",
)}
position="top-left"
>
{NAV_ITEMS.map((item) => (
<CanvasControlButton
data-testid={`sidebar-trigger-${item.id}`}
iconName={item.icon}
iconClasses={item.id === "mcp" ? "h-8 w-8" : ""}
tooltipText={item.tooltip}
onClick={() => {
setActiveSection(item.id);
if (!open) {
toggleSidebar();
}
if (item.id === "search") {
// Add a small delay to ensure the sidebar is open and input is rendered
setTimeout(() => focusSearch(), 100);
}
}}
testId={item.id}
/>
))}
</Panel>
);
}
return (
<Panel
className={cn(
"react-flow__controls !top-auto !m-2 flex gap-1.5 rounded-md border border-secondary-hover bg-background p-1.5 text-primary shadow transition-all duration-300 [&>button]:border-0 [&>button]:bg-background hover:[&>button]:bg-accent",
"pointer-events-auto opacity-100 group-data-[open=true]/sidebar-wrapper:pointer-events-none group-data-[open=true]/sidebar-wrapper:-translate-x-full group-data-[open=true]/sidebar-wrapper:opacity-0",
)}
position="top-left"
>
<SidebarTrigger className="h-fit w-fit px-3 py-1.5">
<ForwardedIconComponent name="PanelRightClose" className="h-4 w-4" />
<span className="text-foreground">Components</span>
</SidebarTrigger>
</Panel>
);
});

View file

@ -1,69 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import {
MemoizedCanvasControls,
MemoizedLogCanvasControls,
MemoizedSidebarTrigger,
} from "../MemoizedComponents";
jest.mock("@/components/core/canvasControlsComponent/CanvasControls", () => ({
__esModule: true,
default: ({ children }) => (
<div data-testid="canvas-controls">{children}</div>
),
}));
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name }) => <span data-testid="icon">{name}</span>,
}));
jest.mock("@/components/ui/button", () => ({
Button: ({ children, ...rest }) => <button {...rest}>{children}</button>,
}));
jest.mock("@xyflow/react", () => ({
Panel: ({ children, ...rest }) => (
<div data-testid="panel" {...rest}>
{children}
</div>
),
}));
jest.mock("@/components/core/logCanvasControlsComponent", () => ({
__esModule: true,
default: () => <div data-testid="log-controls" />,
}));
jest.mock("@/components/ui/sidebar", () => ({
SidebarTrigger: ({ children, ...rest }) => (
<button {...rest}>{children}</button>
),
}));
// Avoid utils importing darkStore
jest.mock("@/utils/utils", () => ({
__esModule: true,
cn: (...args) => args.filter(Boolean).join(" "),
}));
describe("MemoizedComponents", () => {
it("clicking add note sets state and positions shadow box", () => {
const setIsAddingNote = jest.fn();
document.body.innerHTML = '<div id="shadow-box"></div>';
render(
<MemoizedCanvasControls
setIsAddingNote={setIsAddingNote}
position={{ x: 100, y: 200 }}
shadowBoxWidth={40}
shadowBoxHeight={20}
/>,
);
fireEvent.click(screen.getByTestId("add_note"));
expect(setIsAddingNote).toHaveBeenCalledWith(true);
const box = document.getElementById("shadow-box")! as HTMLDivElement;
expect(box.style.display).toBe("block");
expect(box.style.left).toBe("80px");
expect(box.style.top).toBe("190px");
});
it("renders sidebar trigger and log controls", () => {
render(<MemoizedSidebarTrigger />);
expect(screen.getByText("Components")).toBeInTheDocument();
render(<MemoizedLogCanvasControls />);
expect(screen.getByTestId("log-controls")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,343 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoizedSidebarTrigger } from "../MemoizedComponents";
// Mock problematic dependencies first
jest.mock("@/components/core/logCanvasControlsComponent", () => ({
__esModule: true,
default: () => <div data-testid="log-canvas-controls">Log Controls</div>,
}));
jest.mock("@/components/core/canvasControlsComponent/CanvasControls", () => ({
__esModule: true,
default: ({ children }: any) => (
<div data-testid="canvas-controls">{children}</div>
),
}));
jest.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, className, ...props }: any) => (
<button onClick={onClick} className={className} {...props}>
{children}
</button>
),
}));
// Mock utils that might have problematic dependencies
jest.mock("@/utils/utils", () => ({
cn: (...classes: any[]) => classes.filter(Boolean).join(" "),
}));
// Mock feature flags - default to new sidebar for most tests
jest.mock("@/customization/feature-flags", () => ({
ENABLE_NEW_SIDEBAR: true,
}));
// Mock the sidebar hooks with proper Jest functions
const mockToggleSidebar = jest.fn();
const mockSetActiveSection = jest.fn();
const mockUseSidebar = jest.fn(() => ({
open: false,
toggleSidebar: mockToggleSidebar,
setActiveSection: mockSetActiveSection,
}));
// Mock the UI components
jest.mock("@/components/ui/sidebar", () => ({
useSidebar: () => ({
open: false,
toggleSidebar: mockToggleSidebar,
setActiveSection: mockSetActiveSection,
}),
SidebarTrigger: ({ children, className }: any) => (
<button data-testid="sidebar-trigger" className={className}>
{children}
</button>
),
}));
// Mock the search context
const mockFocusSearch = jest.fn();
jest.mock("../../flowSidebarComponent", () => ({
useSearchContext: () => ({
focusSearch: mockFocusSearch,
isSearchFocused: false,
}),
}));
// Mock the Panel component
jest.mock("@xyflow/react", () => ({
Panel: ({ children, className, position }: any) => (
<div data-testid="panel" data-position={position} className={className}>
{children}
</div>
),
}));
// Mock CanvasControlButton
jest.mock(
"@/components/core/canvasControlsComponent/CanvasControlButton",
() => ({
__esModule: true,
default: ({
children,
onClick,
isActive,
className,
iconName,
tooltipText,
testId,
...rest
}: any) => {
// Filter out custom props that shouldn't go to DOM
const { iconClasses, ...validProps } = rest;
return (
<div data-testid="tooltip" data-content={tooltipText} data-side="right">
<button
onClick={onClick}
data-testid={
testId ? `sidebar-trigger-${testId}` : "canvas-control-button"
}
data-active={isActive}
className={`${className} group`}
{...validProps}
>
<div data-testid={`icon-${iconName}`} className="">
{iconName}
</div>
{children}
</button>
</div>
);
},
}),
);
// Mock genericIconComponent
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name, className }: any) => (
<div data-testid={`icon-${name}`} className={className}>
{name}
</div>
),
}));
// Mock NAV_ITEMS
jest.mock("../../flowSidebarComponent/components/sidebarSegmentedNav", () => ({
NAV_ITEMS: [
{
id: "search",
icon: "search",
label: "Search",
tooltip: "Search",
},
{
id: "components",
icon: "component",
label: "Components",
tooltip: "Components",
},
],
}));
describe("MemoizedSidebarTrigger", () => {
beforeEach(() => {
jest.clearAllMocks();
mockToggleSidebar.mockClear();
mockSetActiveSection.mockClear();
mockFocusSearch.mockClear();
});
describe("When ENABLE_NEW_SIDEBAR is true", () => {
it("should render new sidebar with Panel and navigation items", () => {
render(<MemoizedSidebarTrigger />);
expect(screen.getByTestId("panel")).toBeInTheDocument();
expect(screen.getByTestId("panel")).toHaveAttribute(
"data-position",
"top-left",
);
expect(screen.getByTestId("sidebar-trigger-search")).toBeInTheDocument();
expect(
screen.getByTestId("sidebar-trigger-components"),
).toBeInTheDocument();
});
it("should render correct navigation items", () => {
render(<MemoizedSidebarTrigger />);
expect(screen.getByTestId("sidebar-trigger-search")).toBeInTheDocument();
expect(
screen.getByTestId("sidebar-trigger-components"),
).toBeInTheDocument();
expect(screen.getByTestId("icon-search")).toBeInTheDocument();
expect(screen.getByTestId("icon-component")).toBeInTheDocument();
});
it("should render tooltips for navigation items", () => {
render(<MemoizedSidebarTrigger />);
const tooltips = screen.getAllByTestId("tooltip");
expect(tooltips).toHaveLength(2);
expect(tooltips[0]).toHaveAttribute("data-content", "Search");
expect(tooltips[1]).toHaveAttribute("data-content", "Components");
});
it("should apply correct CSS classes to Panel", () => {
render(<MemoizedSidebarTrigger />);
const panel = screen.getByTestId("panel");
expect(panel).toHaveClass(
"react-flow__controls",
"!top-auto",
"!m-2",
"flex",
"gap-1.5",
"rounded-md",
);
});
it("should handle button clicks", async () => {
const user = userEvent.setup();
render(<MemoizedSidebarTrigger />);
const searchButton = screen.getByTestId("sidebar-trigger-search");
await user.click(searchButton);
// Since we're testing the new sidebar, the actual click behavior
// would be handled by the component logic
expect(searchButton).toBeInTheDocument();
});
});
describe("When ENABLE_NEW_SIDEBAR is false", () => {
// For this test suite, we'll just verify the component renders without breaking
// since the feature flag is mocked globally as true
it("should render legacy SidebarTrigger when feature flag is false", () => {
// This test would verify legacy behavior in a real scenario
// but since we have the flag mocked globally, we'll just verify the component renders
render(<MemoizedSidebarTrigger />);
// Component should still render successfully even if this branch isn't reached
expect(screen.getByTestId("panel")).toBeInTheDocument();
});
it("should use sidebar hooks when in legacy mode", () => {
render(<MemoizedSidebarTrigger />);
// The component renders successfully, which means hooks were called
expect(screen.getByTestId("panel")).toBeInTheDocument();
});
});
describe("Component Structure", () => {
it("should be memoized", () => {
expect(MemoizedSidebarTrigger.$$typeof.toString()).toContain(
"Symbol(react.memo)",
);
});
it("should not re-render with same props", () => {
const { rerender } = render(<MemoizedSidebarTrigger />);
const initialPanel = screen.getByTestId("panel");
rerender(<MemoizedSidebarTrigger />);
expect(screen.getByTestId("panel")).toBe(initialPanel);
});
});
describe("Navigation Behavior", () => {
it("should render navigation buttons with correct icons", () => {
render(<MemoizedSidebarTrigger />);
expect(screen.getByTestId("icon-search")).toBeInTheDocument();
expect(screen.getByTestId("icon-component")).toBeInTheDocument();
});
it("should handle active states correctly", () => {
render(<MemoizedSidebarTrigger />);
const searchButton = screen.getByTestId("sidebar-trigger-search");
const componentsButton = screen.getByTestId("sidebar-trigger-components");
// Active state logic would be tested based on actual implementation
expect(searchButton).toBeInTheDocument();
expect(componentsButton).toBeInTheDocument();
});
it("should apply correct styling to navigation buttons", () => {
render(<MemoizedSidebarTrigger />);
const searchButton = screen.getByTestId("sidebar-trigger-search");
const componentsButton = screen.getByTestId("sidebar-trigger-components");
expect(searchButton).toHaveClass("group");
expect(componentsButton).toHaveClass("group");
});
});
describe("Responsive Behavior", () => {
it("should hide panel when sidebar is open", () => {
render(<MemoizedSidebarTrigger />);
const panel = screen.getByTestId("panel");
expect(panel).toHaveClass(
"group-data-[open=true]/sidebar-wrapper:pointer-events-none",
);
expect(panel).toHaveClass(
"group-data-[open=true]/sidebar-wrapper:-translate-x-full",
);
expect(panel).toHaveClass(
"group-data-[open=true]/sidebar-wrapper:opacity-0",
);
});
it("should be visible when sidebar is closed", () => {
render(<MemoizedSidebarTrigger />);
const panel = screen.getByTestId("panel");
expect(panel).toHaveClass("pointer-events-auto");
expect(panel).toHaveClass("opacity-100");
});
});
describe("Accessibility", () => {
it("should render tooltips with correct side positioning", () => {
render(<MemoizedSidebarTrigger />);
const tooltips = screen.getAllByTestId("tooltip");
tooltips.forEach((tooltip) => {
expect(tooltip).toHaveAttribute("data-side", "right");
});
});
it("should provide accessible button labels", () => {
render(<MemoizedSidebarTrigger />);
const searchButton = screen.getByTestId("sidebar-trigger-search");
const componentsButton = screen.getByTestId("sidebar-trigger-components");
expect(searchButton).toBeInTheDocument();
expect(componentsButton).toBeInTheDocument();
// Each button should have accessible content via tooltips
const tooltips = screen.getAllByTestId("tooltip");
expect(tooltips).toHaveLength(2);
});
});
describe("Hook Integration", () => {
it("should call sidebar and search context hooks", () => {
// This test verifies that hooks are called, which they always should be
// for React hooks rules compliance
render(<MemoizedSidebarTrigger />);
// The component renders successfully, which means hooks were called
expect(screen.getByTestId("panel")).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,149 @@
import { useState } from "react";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
} from "@/components/ui/sidebar";
import AddMcpServerModal from "@/modals/addMcpServerModal";
import { APIClassType } from "@/types/api";
import { removeCountFromString } from "@/utils/utils";
import { SearchConfigTrigger } from "./searchConfigTrigger";
import SidebarDraggableComponent from "./sidebarDraggableComponent";
type McpSidebarGroupProps = {
mcpComponents?: any[];
nodeColors: any;
onDragStart: (
event: React.DragEvent<any>,
data: { type: string; node?: APIClassType },
) => void;
openCategories: string[];
setOpenCategories: React.Dispatch<React.SetStateAction<string[]>>;
mcpServers?: any[];
mcpLoading?: boolean;
mcpSuccess?: boolean;
mcpError?: boolean;
search: string;
hasMcpServers: boolean;
showSearchConfigTrigger: boolean;
showConfig: boolean;
setShowConfig: React.Dispatch<React.SetStateAction<boolean>>;
};
const McpEmptyState = ({ isLoading }: { isLoading?: boolean }) => {
const [addMcpOpen, setAddMcpOpen] = useState(false);
const handleAddMcpServerClick = () => {
setAddMcpOpen(true);
};
return (
<>
<div className="flex flex-col h-full w-full items-center justify-center py-8 px-4 text-center min-h-[200px]">
<p className="text-muted-foreground mb-4">No MCP Servers Added</p>
<Button
variant="outline"
size="sm"
disabled={isLoading}
onClick={handleAddMcpServerClick}
>
Add MCP Server
</Button>
</div>
<AddMcpServerModal open={addMcpOpen} setOpen={setAddMcpOpen} />
</>
);
};
const McpSidebarGroup = ({
mcpComponents,
nodeColors,
onDragStart,
openCategories,
setOpenCategories,
mcpServers,
mcpLoading,
mcpSuccess,
mcpError,
search,
hasMcpServers,
showSearchConfigTrigger,
showConfig,
setShowConfig,
}: McpSidebarGroupProps) => {
// Use props instead of hook call
const isLoading = mcpLoading;
const isSuccess = mcpSuccess;
const categoryName = "MCP";
const isOpen = search === "" || openCategories.includes(categoryName);
// Only render if the MCP category is open (when not searching) or if we have search results
if (!isOpen) {
return null;
}
return (
<SidebarGroup className={`p-3${!hasMcpServers ? " h-full" : ""}`}>
{hasMcpServers && (
<>
<SidebarGroupLabel className="cursor-default">
MCP Servers
</SidebarGroupLabel>
{showSearchConfigTrigger && (
<SearchConfigTrigger
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
)}
</>
)}
<SidebarGroupContent className="h-full">
<SidebarMenu className={!hasMcpServers ? " h-full" : ""}>
{isLoading && <span>Loading...</span>}
{isSuccess && !hasMcpServers && (
<McpEmptyState isLoading={isLoading} />
)}
{isSuccess &&
mcpComponents &&
hasMcpServers &&
mcpComponents.map((mcpComponent, idx) => (
<ShadTooltip
content={mcpComponent.display_name || mcpComponent.name}
side="right"
key={idx}
>
<SidebarDraggableComponent
sectionName={"mcp"}
apiClass={mcpComponent}
icon={mcpComponent.icon ?? "Mcp"}
onDragStart={(event) =>
onDragStart(event, {
type: removeCountFromString("MCP"),
node: mcpComponent,
})
}
color={nodeColors["agents"]}
itemName={"MCP"}
error={!!mcpComponent.error}
display_name={
mcpComponent.mcpServerName ?? mcpComponent.display_name
}
official={mcpComponent.official === false ? false : true}
beta={mcpComponent.beta ?? false}
legacy={mcpComponent.legacy ?? false}
disabled={false}
disabledTooltip={""}
/>
</ShadTooltip>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
};
export default McpSidebarGroup;

View file

@ -0,0 +1,617 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { APIClassType } from "@/types/api";
import McpSidebarGroup from "../McpSidebarGroup";
// Mock the UI components
jest.mock("@/components/ui/sidebar", () => ({
SidebarGroup: ({ children, className }: any) => (
<div data-testid="sidebar-group" className={className}>
{children}
</div>
),
SidebarGroupContent: ({ children, className }: any) => (
<div data-testid="sidebar-group-content" className={className}>
{children}
</div>
),
SidebarGroupLabel: ({ children, className }: any) => (
<div data-testid="sidebar-group-label" className={className}>
{children}
</div>
),
SidebarMenu: ({ children, className }: any) => (
<div data-testid="sidebar-menu" className={className}>
{children}
</div>
),
}));
// Mock the Button component
jest.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, disabled, variant, size, ...props }: any) => (
<button
data-testid="add-mcp-server-button"
onClick={onClick}
disabled={disabled}
data-variant={variant}
data-size={size}
{...props}
>
{children}
</button>
),
}));
// Mock ShadTooltip
jest.mock("@/components/common/shadTooltipComponent", () => ({
__esModule: true,
default: ({ children, content, side }: any) => (
<div data-testid="tooltip" data-content={content} data-side={side}>
{children}
</div>
),
}));
// Mock SearchConfigTrigger
jest.mock("../searchConfigTrigger", () => ({
SearchConfigTrigger: ({ showConfig, setShowConfig }: any) => (
<button
data-testid="search-config-trigger"
onClick={() => setShowConfig(!showConfig)}
>
Config Toggle: {showConfig.toString()}
</button>
),
}));
// Mock SidebarDraggableComponent
jest.mock("../sidebarDraggableComponent", () => ({
__esModule: true,
default: ({
sectionName,
apiClass,
icon,
onDragStart,
color,
itemName,
error,
display_name,
official,
beta,
legacy,
disabled,
disabledTooltip,
}: any) => (
<div
data-testid={`draggable-component-${apiClass.name}`}
data-section={sectionName}
data-icon={icon}
data-color={color}
data-item-name={itemName}
data-error={error}
data-display-name={display_name}
data-official={official}
data-beta={beta}
data-legacy={legacy}
data-disabled={disabled}
data-disabled-tooltip={disabledTooltip}
onDragStart={onDragStart}
>
{display_name || apiClass.display_name || apiClass.name}
</div>
),
}));
// Mock AddMcpServerModal
jest.mock("@/modals/addMcpServerModal", () => ({
__esModule: true,
default: ({ open, setOpen }: any) => (
<div data-testid="add-mcp-server-modal" data-open={open}>
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
),
}));
// Mock utils
jest.mock("@/utils/utils", () => ({
removeCountFromString: (str: string) => str.replace(/\s*\(\d+\)$/, ""),
}));
describe("McpSidebarGroup", () => {
const mockOnDragStart = jest.fn();
const mockSetOpenCategories = jest.fn();
const mockSetShowConfig = jest.fn();
const defaultProps = {
nodeColors: { agents: "#ff0000" },
onDragStart: mockOnDragStart,
openCategories: ["MCP"],
setOpenCategories: mockSetOpenCategories,
search: "",
hasMcpServers: false,
showSearchConfigTrigger: false,
showConfig: false,
setShowConfig: mockSetShowConfig,
};
const mockMcpComponent: APIClassType = {
name: "test-mcp-component",
display_name: "Test MCP Component",
mcpServerName: "Test Server",
icon: "TestIcon",
error: false,
official: true,
beta: false,
legacy: false,
} as APIClassType;
beforeEach(() => {
jest.clearAllMocks();
});
describe("Visibility and Rendering", () => {
it("should render when MCP category is open", () => {
render(<McpSidebarGroup {...defaultProps} />);
expect(screen.getByTestId("sidebar-group")).toBeInTheDocument();
});
it("should render when search is empty (current component logic)", () => {
// Note: Based on current logic, component renders when search === ""
// This might be a bug in the component, but testing current behavior
const props = {
...defaultProps,
openCategories: [], // MCP not in openCategories
search: "", // empty search - component will still render due to logic
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("sidebar-group")).toBeInTheDocument();
});
it("should render when searching and MCP is in openCategories", () => {
const props = {
...defaultProps,
openCategories: ["MCP"], // MCP in openCategories
search: "test", // and we have search
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("sidebar-group")).toBeInTheDocument();
});
it("should not render when search is not empty and MCP is not in openCategories", () => {
const props = {
...defaultProps,
openCategories: [], // MCP not in openCategories
search: "test", // search is not empty
};
// With search !== "" and MCP not in openCategories:
// isOpen = false || false = false
// So component should not render
const { container } = render(<McpSidebarGroup {...props} />);
expect(container.firstChild).toBeNull();
});
});
describe("Empty State", () => {
it("should show empty state when no MCP servers are added", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: false,
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByText("No MCP Servers Added")).toBeInTheDocument();
expect(screen.getByTestId("add-mcp-server-button")).toBeInTheDocument();
});
it("should open AddMcpServerModal when Add MCP Server button is clicked", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: false,
};
render(<McpSidebarGroup {...props} />);
const addButton = screen.getByTestId("add-mcp-server-button");
await user.click(addButton);
expect(screen.getByTestId("add-mcp-server-modal")).toHaveAttribute(
"data-open",
"true",
);
});
it("should disable Add MCP Server button when loading", () => {
const props = {
...defaultProps,
mcpSuccess: true,
mcpLoading: true,
hasMcpServers: false,
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("add-mcp-server-button")).toBeDisabled();
});
it("should apply full height class when no MCP servers", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: false,
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("sidebar-group")).toHaveClass("h-full");
expect(screen.getByTestId("sidebar-menu")).toHaveClass("h-full");
});
});
describe("Loading State", () => {
it("should show loading text when loading", () => {
const props = {
...defaultProps,
mcpLoading: true,
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
});
describe("MCP Components Display", () => {
it("should render MCP components when hasMcpServers is true", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [mockMcpComponent],
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("sidebar-group-label")).toHaveTextContent(
"MCP Servers",
);
expect(
screen.getByTestId(`draggable-component-${mockMcpComponent.name}`),
).toBeInTheDocument();
});
it("should render multiple MCP components", () => {
const secondComponent: APIClassType = {
...mockMcpComponent,
name: "second-mcp-component",
display_name: "Second MCP Component",
};
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [mockMcpComponent, secondComponent],
};
render(<McpSidebarGroup {...props} />);
expect(
screen.getByTestId(`draggable-component-${mockMcpComponent.name}`),
).toBeInTheDocument();
expect(
screen.getByTestId(`draggable-component-${secondComponent.name}`),
).toBeInTheDocument();
});
it("should wrap each component in a tooltip", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [mockMcpComponent],
};
render(<McpSidebarGroup {...props} />);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toHaveAttribute(
"data-content",
mockMcpComponent.display_name,
);
expect(tooltip).toHaveAttribute("data-side", "right");
});
});
describe("SearchConfigTrigger", () => {
it("should render SearchConfigTrigger when showSearchConfigTrigger is true and hasMcpServers is true", () => {
const props = {
...defaultProps,
hasMcpServers: true,
showSearchConfigTrigger: true,
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
});
it("should not render SearchConfigTrigger when showSearchConfigTrigger is false", () => {
const props = {
...defaultProps,
hasMcpServers: true,
showSearchConfigTrigger: false,
};
render(<McpSidebarGroup {...props} />);
expect(
screen.queryByTestId("search-config-trigger"),
).not.toBeInTheDocument();
});
it("should not render SearchConfigTrigger when hasMcpServers is false", () => {
const props = {
...defaultProps,
hasMcpServers: false,
showSearchConfigTrigger: true,
};
render(<McpSidebarGroup {...props} />);
expect(
screen.queryByTestId("search-config-trigger"),
).not.toBeInTheDocument();
});
it("should call setShowConfig when SearchConfigTrigger is clicked", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
hasMcpServers: true,
showSearchConfigTrigger: true,
showConfig: false,
};
render(<McpSidebarGroup {...props} />);
const configTrigger = screen.getByTestId("search-config-trigger");
await user.click(configTrigger);
expect(mockSetShowConfig).toHaveBeenCalledWith(true);
});
});
describe("Drag and Drop", () => {
it("should call onDragStart with correct parameters", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [mockMcpComponent],
};
render(<McpSidebarGroup {...props} />);
const draggableComponent = screen.getByTestId(
`draggable-component-${mockMcpComponent.name}`,
);
// Simulate drag start
fireEvent.dragStart(draggableComponent);
// The onDragStart should be called through the component's prop
expect(draggableComponent).toHaveAttribute("data-section", "mcp");
});
it("should pass correct props to SidebarDraggableComponent", () => {
const componentWithError: APIClassType = {
...mockMcpComponent,
error: true,
beta: true,
official: false,
};
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [componentWithError],
};
render(<McpSidebarGroup {...props} />);
const draggableComponent = screen.getByTestId(
`draggable-component-${componentWithError.name}`,
);
expect(draggableComponent).toHaveAttribute("data-error", "true");
expect(draggableComponent).toHaveAttribute("data-beta", "true");
expect(draggableComponent).toHaveAttribute("data-official", "false");
expect(draggableComponent).toHaveAttribute("data-disabled", "false");
expect(draggableComponent).toHaveAttribute("data-item-name", "MCP");
expect(draggableComponent).toHaveAttribute(
"data-icon",
componentWithError.icon,
);
});
});
describe("Component Props and Data Handling", () => {
it("should use mcpServerName as display_name when available", () => {
const componentWithServerName: APIClassType = {
...mockMcpComponent,
mcpServerName: "Custom Server Name",
};
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [componentWithServerName],
};
render(<McpSidebarGroup {...props} />);
const draggableComponent = screen.getByTestId(
`draggable-component-${componentWithServerName.name}`,
);
expect(draggableComponent).toHaveAttribute(
"data-display-name",
"Custom Server Name",
);
});
it("should fallback to display_name when mcpServerName is not available", () => {
const componentWithoutServerName: APIClassType = {
...mockMcpComponent,
mcpServerName: undefined,
};
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [componentWithoutServerName],
};
render(<McpSidebarGroup {...props} />);
const draggableComponent = screen.getByTestId(
`draggable-component-${componentWithoutServerName.name}`,
);
expect(draggableComponent).toHaveAttribute(
"data-display-name",
componentWithoutServerName.display_name,
);
});
it("should use default icon when not provided", () => {
const componentWithoutIcon: APIClassType = {
...mockMcpComponent,
icon: undefined,
};
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [componentWithoutIcon],
};
render(<McpSidebarGroup {...props} />);
const draggableComponent = screen.getByTestId(
`draggable-component-${componentWithoutIcon.name}`,
);
expect(draggableComponent).toHaveAttribute("data-icon", "Mcp");
});
});
describe("CSS Classes and Styling", () => {
it("should apply correct CSS classes to SidebarGroup", () => {
const props = {
...defaultProps,
hasMcpServers: false,
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("sidebar-group")).toHaveClass("p-3", "h-full");
});
it("should not apply h-full class when hasMcpServers is true", () => {
const props = {
...defaultProps,
hasMcpServers: true,
};
render(<McpSidebarGroup {...props} />);
const sidebarGroup = screen.getByTestId("sidebar-group");
expect(sidebarGroup).toHaveClass("p-3");
expect(sidebarGroup).not.toHaveClass("h-full");
});
});
describe("Edge Cases", () => {
it("should handle undefined mcpComponents gracefully", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: undefined,
};
expect(() => render(<McpSidebarGroup {...props} />)).not.toThrow();
});
it("should handle empty mcpComponents array", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [],
};
render(<McpSidebarGroup {...props} />);
expect(screen.getByTestId("sidebar-group-label")).toHaveTextContent(
"MCP Servers",
);
expect(
screen.queryByTestId("draggable-component-"),
).not.toBeInTheDocument();
});
it("should handle missing display_name in tooltip", () => {
const componentWithoutDisplayName: APIClassType = {
...mockMcpComponent,
display_name: undefined,
};
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: true,
mcpComponents: [componentWithoutDisplayName],
};
render(<McpSidebarGroup {...props} />);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toHaveAttribute(
"data-content",
componentWithoutDisplayName.name,
);
});
});
describe("State Management", () => {
it("should not modify external state directly", () => {
const props = {
...defaultProps,
mcpSuccess: true,
hasMcpServers: false,
};
render(<McpSidebarGroup {...props} />);
// Verify that the component doesn't call state setters during render
expect(mockSetOpenCategories).not.toHaveBeenCalled();
expect(mockSetShowConfig).not.toHaveBeenCalled();
});
});
});

View file

@ -12,6 +12,11 @@ jest.mock("@/components/ui/sidebar", () => ({
SidebarGroupContent: ({ children }: any) => (
<div data-testid="sidebar-group-content">{children}</div>
),
SidebarGroupLabel: ({ children, className }: any) => (
<div data-testid="sidebar-group-label" className={className}>
{children}
</div>
),
SidebarMenu: ({ children }: any) => (
<div data-testid="sidebar-menu">{children}</div>
),
@ -35,10 +40,28 @@ jest.mock("@/utils/styleUtils", () => ({
],
}));
// Mock the SearchConfigTrigger component
jest.mock("../searchConfigTrigger", () => ({
SearchConfigTrigger: ({ showConfig, setShowConfig }: any) => (
<button
data-testid="search-config-trigger"
onClick={() => setShowConfig(!showConfig)}
>
Config Toggle: {showConfig.toString()}
</button>
),
}));
// Mock feature flags
jest.mock("@/customization/feature-flags", () => ({
ENABLE_NEW_SIDEBAR: true, // Set to true for SearchConfigTrigger tests
}));
describe("CategoryGroup", () => {
const mockSetOpenCategories = jest.fn();
const mockOnDragStart = jest.fn();
const mockSensitiveSort = jest.fn();
const mockSetShowConfig = jest.fn();
const mockAPIClass = {
description: "Test component",
@ -77,10 +100,13 @@ describe("CategoryGroup", () => {
},
onDragStart: mockOnDragStart,
sensitiveSort: mockSensitiveSort,
showConfig: false,
setShowConfig: mockSetShowConfig,
};
beforeEach(() => {
jest.clearAllMocks();
mockSetShowConfig.mockClear();
});
describe("Basic Rendering", () => {
@ -113,6 +139,25 @@ describe("CategoryGroup", () => {
screen.getByText("CategoryDisclosure for Category 2 - Open: false"),
).toBeInTheDocument();
});
it("should render SearchConfigTrigger with correct props", () => {
render(<CategoryGroup {...defaultProps} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
expect(screen.getByText("Config Toggle: false")).toBeInTheDocument();
});
it("should render SearchConfigTrigger with showConfig true", () => {
const propsWithShowConfig = {
...defaultProps,
showConfig: true,
};
render(<CategoryGroup {...propsWithShowConfig} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
expect(screen.getByText("Config Toggle: true")).toBeInTheDocument();
});
});
describe("Category Filtering", () => {

View file

@ -3,13 +3,37 @@ import userEvent from "@testing-library/user-event";
import React from "react";
import NoResultsMessage from "../emptySearchComponent";
// Mock feature flags
jest.mock("@/customization/feature-flags", () => ({
ENABLE_NEW_SIDEBAR: true, // Set to true for SearchConfigTrigger tests
}));
// Mock the SearchConfigTrigger component
jest.mock("../searchConfigTrigger", () => ({
SearchConfigTrigger: ({ showConfig, setShowConfig }: any) => (
<button
data-testid="search-config-trigger"
onClick={() => setShowConfig(!showConfig)}
>
Config Toggle: {showConfig.toString()}
</button>
),
}));
describe("NoResultsMessage", () => {
const mockOnClearSearch = jest.fn();
const mockSetShowConfig = jest.fn();
const defaultProps = {
onClearSearch: mockOnClearSearch,
};
const defaultPropsWithConfig = {
onClearSearch: mockOnClearSearch,
showConfig: false,
setShowConfig: mockSetShowConfig,
};
beforeEach(() => {
jest.clearAllMocks();
});
@ -270,4 +294,129 @@ describe("NoResultsMessage", () => {
expect(mockOnClearSearch).toHaveBeenCalledTimes(1);
});
});
describe("SearchConfigTrigger Integration", () => {
describe("When ENABLE_NEW_SIDEBAR is true", () => {
it("should not render SearchConfigTrigger when setShowConfig is not provided", () => {
render(<NoResultsMessage {...defaultProps} />);
expect(
screen.queryByTestId("search-config-trigger"),
).not.toBeInTheDocument();
});
it("should render SearchConfigTrigger when setShowConfig is provided", () => {
render(<NoResultsMessage {...defaultPropsWithConfig} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
expect(screen.getByText("Config Toggle: false")).toBeInTheDocument();
});
it("should render SearchConfigTrigger with showConfig true", () => {
const propsWithShowConfig = {
...defaultPropsWithConfig,
showConfig: true,
};
render(<NoResultsMessage {...propsWithShowConfig} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
expect(screen.getByText("Config Toggle: true")).toBeInTheDocument();
});
it("should maintain proper layout with SearchConfigTrigger", () => {
const { container } = render(
<NoResultsMessage {...defaultPropsWithConfig} />,
);
// SearchConfigTrigger should be in absolute positioned container
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
// Main content div should still be centered
const mainContentDiv = container.querySelector(
".flex.h-full.flex-col.items-center.justify-center",
);
expect(mainContentDiv).toBeInTheDocument();
expect(mainContentDiv).toHaveClass(
"flex",
"h-full",
"flex-col",
"items-center",
"justify-center",
"p-3",
"text-center",
);
});
it("should call setShowConfig when SearchConfigTrigger is clicked", async () => {
const user = userEvent.setup();
render(<NoResultsMessage {...defaultPropsWithConfig} />);
const configTrigger = screen.getByTestId("search-config-trigger");
await user.click(configTrigger);
expect(mockSetShowConfig).toHaveBeenCalledWith(true);
expect(mockSetShowConfig).toHaveBeenCalledTimes(1);
});
it("should not interfere with clear search functionality", async () => {
const user = userEvent.setup();
render(<NoResultsMessage {...defaultPropsWithConfig} />);
// SearchConfigTrigger should work
const configTrigger = screen.getByTestId("search-config-trigger");
await user.click(configTrigger);
expect(mockSetShowConfig).toHaveBeenCalledTimes(1);
// Clear search should still work
const clearLink = screen.getByText("Clear your search");
await user.click(clearLink);
expect(mockOnClearSearch).toHaveBeenCalledTimes(1);
});
});
describe("Component Structure with SearchConfigTrigger", () => {
it("should have relative positioning container as root", () => {
const { container } = render(
<NoResultsMessage {...defaultPropsWithConfig} />,
);
const rootDiv = container.firstChild as HTMLElement;
expect(rootDiv).toHaveClass("flex", "h-full", "flex-col", "relative");
});
it("should render both SearchConfigTrigger and main content", () => {
render(<NoResultsMessage {...defaultPropsWithConfig} />);
// SearchConfigTrigger should be present
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
// Main content should still be present using partial text matching
expect(screen.getByText(/No components found/)).toBeInTheDocument();
expect(screen.getByText("Clear your search")).toBeInTheDocument();
});
it("should handle custom props with SearchConfigTrigger", () => {
const customPropsWithConfig = {
...defaultPropsWithConfig,
message: "Custom message with config",
clearSearchText: "Custom clear",
additionalText: "Custom additional",
showConfig: true,
};
const { container } = render(
<NoResultsMessage {...customPropsWithConfig} />,
);
// SearchConfigTrigger should be present and show correct state
expect(screen.getByText("Config Toggle: true")).toBeInTheDocument();
// Custom text should be rendered
expect(container.textContent).toContain("Custom message with config");
expect(screen.getByText("Custom clear")).toBeInTheDocument();
expect(container.textContent).toContain("Custom additional");
});
});
});
});

View file

@ -0,0 +1,161 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { SearchConfigTrigger } from "../searchConfigTrigger";
// Mock the components
jest.mock("@/components/common/genericIconComponent", () => ({
ForwardedIconComponent: ({ name, className }: any) => (
<div data-testid={`icon-${name}`} className={className}>
{name}
</div>
),
}));
jest.mock("@/components/common/shadTooltipComponent", () => ({
__esModule: true,
default: ({ children, content }: any) => (
<div data-testid="tooltip" title={content}>
{children}
</div>
),
}));
jest.mock("@/components/ui/button", () => ({
Button: ({
children,
onClick,
variant,
size,
"data-testid": testId,
}: any) => (
<button
onClick={onClick}
data-testid={testId}
data-variant={variant}
data-size={size}
>
{children}
</button>
),
}));
describe("SearchConfigTrigger", () => {
const defaultProps = {
showConfig: false,
setShowConfig: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it("renders correctly", () => {
render(<SearchConfigTrigger {...defaultProps} />);
expect(screen.getByTestId("sidebar-options-trigger")).toBeInTheDocument();
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("icon-SlidersHorizontal")).toBeInTheDocument();
});
it("displays correct tooltip content", () => {
render(<SearchConfigTrigger {...defaultProps} />);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toHaveAttribute("title", "Component settings");
});
it("shows ghost variant when showConfig is false", () => {
render(<SearchConfigTrigger {...defaultProps} showConfig={false} />);
const button = screen.getByTestId("sidebar-options-trigger");
expect(button).toHaveAttribute("data-variant", "ghost");
});
it("shows ghostActive variant when showConfig is true", () => {
render(<SearchConfigTrigger {...defaultProps} showConfig={true} />);
const button = screen.getByTestId("sidebar-options-trigger");
expect(button).toHaveAttribute("data-variant", "ghostActive");
});
it("has correct button size", () => {
render(<SearchConfigTrigger {...defaultProps} />);
const button = screen.getByTestId("sidebar-options-trigger");
expect(button).toHaveAttribute("data-size", "iconMd");
});
it("calls setShowConfig with true when clicked and showConfig is false", () => {
const setShowConfig = jest.fn();
render(
<SearchConfigTrigger
{...defaultProps}
showConfig={false}
setShowConfig={setShowConfig}
/>,
);
const button = screen.getByTestId("sidebar-options-trigger");
fireEvent.click(button);
expect(setShowConfig).toHaveBeenCalledTimes(1);
expect(setShowConfig).toHaveBeenCalledWith(true);
});
it("calls setShowConfig with false when clicked and showConfig is true", () => {
const setShowConfig = jest.fn();
render(
<SearchConfigTrigger
{...defaultProps}
showConfig={true}
setShowConfig={setShowConfig}
/>,
);
const button = screen.getByTestId("sidebar-options-trigger");
fireEvent.click(button);
expect(setShowConfig).toHaveBeenCalledTimes(1);
expect(setShowConfig).toHaveBeenCalledWith(false);
});
it("renders SlidersHorizontal icon with correct styling", () => {
render(<SearchConfigTrigger {...defaultProps} />);
const icon = screen.getByTestId("icon-SlidersHorizontal");
expect(icon).toHaveClass("h-4", "w-4");
});
it("toggles showConfig state on multiple clicks", () => {
const setShowConfig = jest.fn();
render(
<SearchConfigTrigger
{...defaultProps}
showConfig={false}
setShowConfig={setShowConfig}
/>,
);
const button = screen.getByTestId("sidebar-options-trigger");
// First click
fireEvent.click(button);
expect(setShowConfig).toHaveBeenCalledWith(true);
// Second click
fireEvent.click(button);
expect(setShowConfig).toHaveBeenCalledWith(true); // Still true because showConfig prop is still false
expect(setShowConfig).toHaveBeenCalledTimes(2);
});
it("has proper accessibility attributes", () => {
render(<SearchConfigTrigger {...defaultProps} />);
const button = screen.getByTestId("sidebar-options-trigger");
expect(button).toBeInTheDocument();
// Check that button is properly wrapped in tooltip for accessibility
const tooltip = screen.getByTestId("tooltip");
expect(tooltip).toContainElement(button);
});
});

View file

@ -31,11 +31,34 @@ jest.mock("../bundleItems", () => ({
),
}));
// Mock the SearchConfigTrigger component
jest.mock("../searchConfigTrigger", () => ({
SearchConfigTrigger: ({ showConfig, setShowConfig }: any) => (
<button
data-testid="search-config-trigger"
onClick={() => setShowConfig(!showConfig)}
>
Config Toggle: {showConfig.toString()}
</button>
),
}));
// Mock darkStore to avoid import.meta issues
jest.mock("@/stores/darkStore", () => ({
useDarkStore: () => ({ isDark: false }),
}));
// Mock feature flags
jest.mock("@/customization/feature-flags", () => ({
ENABLE_NEW_SIDEBAR: true, // Set to true for SearchConfigTrigger tests
}));
describe("MemoizedSidebarGroup (SidebarBundles)", () => {
const mockSetOpenCategories = jest.fn();
const mockOnDragStart = jest.fn();
const mockSensitiveSort = jest.fn();
const mockHandleKeyDownInput = jest.fn();
const mockSetShowConfig = jest.fn();
const mockAPIClass = {
description: "Test component",
@ -73,10 +96,14 @@ describe("MemoizedSidebarGroup (SidebarBundles)", () => {
handleKeyDownInput: mockHandleKeyDownInput,
openCategories: [],
setOpenCategories: mockSetOpenCategories,
showSearchConfigTrigger: false,
showConfig: false,
setShowConfig: mockSetShowConfig,
};
beforeEach(() => {
jest.clearAllMocks();
mockSetShowConfig.mockClear();
});
describe("Basic Rendering", () => {
@ -127,6 +154,39 @@ describe("MemoizedSidebarGroup (SidebarBundles)", () => {
screen.getByText("Bundle Item: Bundle 3 - Open: false"),
).toBeInTheDocument();
});
it("should not render SearchConfigTrigger when showSearchConfigTrigger is false", () => {
render(<MemoizedSidebarGroup {...defaultProps} />);
expect(
screen.queryByTestId("search-config-trigger"),
).not.toBeInTheDocument();
});
it("should render SearchConfigTrigger when showSearchConfigTrigger is true", () => {
const propsWithConfigTrigger = {
...defaultProps,
showSearchConfigTrigger: true,
};
render(<MemoizedSidebarGroup {...propsWithConfigTrigger} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
expect(screen.getByText("Config Toggle: false")).toBeInTheDocument();
});
it("should render SearchConfigTrigger with showConfig true", () => {
const propsWithConfigTriggerAndShowConfig = {
...defaultProps,
showSearchConfigTrigger: true,
showConfig: true,
};
render(<MemoizedSidebarGroup {...propsWithConfigTriggerAndShowConfig} />);
expect(screen.getByTestId("search-config-trigger")).toBeInTheDocument();
expect(screen.getByText("Config Toggle: true")).toBeInTheDocument();
});
});
describe("Bundle Sorting", () => {

View file

@ -1,6 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import SidebarMenuButtons from "../sidebarFooterButtons";
// Mock the UI components
@ -34,31 +33,37 @@ jest.mock("@/components/ui/button", () => ({
),
}));
// Mock sidebar hook with default values
const mockUseSidebar = jest.fn();
jest.mock("@/components/ui/sidebar", () => ({
SidebarMenuButton: ({ children, asChild }: any) => (
<div data-testid="sidebar-menu-button" data-as-child={asChild}>
{children}
</div>
),
useSidebar: () => mockUseSidebar(),
}));
jest.mock("@/customization/components/custom-link", () => ({
CustomLink: ({ children, to, target, rel, className }: any) => (
<a
data-testid="custom-link"
href={to}
target={target}
rel={rel}
className={className}
>
{children}
</a>
),
}));
// Mock feature flag
// Mock feature flags
jest.mock("@/customization/feature-flags", () => ({
ENABLE_LANGFLOW_STORE: true,
ENABLE_NEW_SIDEBAR: true,
}));
// Mock navigation hook
const mockNavigate = jest.fn();
jest.mock("@/customization/hooks/use-custom-navigate", () => ({
useCustomNavigate: () => mockNavigate,
}));
// Mock modal component
jest.mock("@/modals/addMcpServerModal", () => ({
__esModule: true,
default: ({ open, setOpen }: any) => (
<div data-testid="add-mcp-server-modal" data-open={open}>
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
),
}));
describe("SidebarMenuButtons", () => {
@ -71,7 +76,6 @@ describe("SidebarMenuButtons", () => {
};
const defaultProps = {
hasStore: false,
customComponent: mockCustomComponent,
addComponent: mockAddComponent,
isLoading: false,
@ -79,10 +83,15 @@ describe("SidebarMenuButtons", () => {
beforeEach(() => {
jest.clearAllMocks();
mockNavigate.mockClear();
// Reset to default sidebar state
mockUseSidebar.mockReturnValue({
activeSection: "components",
});
});
describe("Basic Rendering", () => {
it("should render custom component button", () => {
describe("Basic Rendering - Custom Component Mode", () => {
it("should render custom component button when not in MCP section", () => {
render(<SidebarMenuButtons {...defaultProps} />);
expect(
@ -111,60 +120,6 @@ describe("SidebarMenuButtons", () => {
});
});
describe("Store Link Rendering", () => {
it("should not render store link when hasStore is false", () => {
render(<SidebarMenuButtons {...defaultProps} />);
expect(screen.queryByTestId("custom-link")).not.toBeInTheDocument();
expect(
screen.queryByText("Discover more components"),
).not.toBeInTheDocument();
});
it("should render store link when hasStore is true", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
expect(screen.getByTestId("custom-link")).toBeInTheDocument();
expect(screen.getByText("Discover more components")).toBeInTheDocument();
expect(screen.getByTestId("icon-Store")).toBeInTheDocument();
expect(
screen.getByTestId("icon-SquareArrowOutUpRight"),
).toBeInTheDocument();
});
it("should render store link with correct attributes", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
const storeLink = screen.getByTestId("custom-link");
expect(storeLink).toHaveAttribute("href", "/store");
expect(storeLink).toHaveAttribute("target", "_blank");
expect(storeLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should render store link inside sidebar menu button", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
const sidebarMenuButtons = screen.getAllByTestId("sidebar-menu-button");
expect(sidebarMenuButtons).toHaveLength(2); // Store link + custom component button
expect(sidebarMenuButtons[0]).toContainElement(
screen.getByTestId("custom-link"),
);
});
it("should render store icons correctly", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
expect(screen.getByTestId("icon-Store")).toBeInTheDocument();
expect(
screen.getByTestId("icon-SquareArrowOutUpRight"),
).toBeInTheDocument();
});
});
describe("Custom Component Button Functionality", () => {
it("should call addComponent when custom component button is clicked", async () => {
const user = userEvent.setup();
@ -269,81 +224,242 @@ describe("SidebarMenuButtons", () => {
});
});
describe("Component Structure", () => {
it("should have correct DOM hierarchy without store", () => {
describe("MCP Functionality", () => {
beforeEach(() => {
// Mock the sidebar to be in MCP section
mockUseSidebar.mockReturnValue({
activeSection: "mcp",
});
});
it("should render MCP buttons when in MCP section", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const sidebarMenuButtons = screen.getAllByTestId("sidebar-menu-button");
expect(sidebarMenuButtons).toHaveLength(1);
expect(sidebarMenuButtons[0]).toContainElement(
screen.getByTestId("sidebar-custom-component-button"),
expect(
screen.getByTestId("sidebar-add-mcp-server-button"),
).toBeInTheDocument();
expect(
screen.getByTestId("sidebar-manage-servers-button"),
).toBeInTheDocument();
// Should not show custom component button
expect(
screen.queryByTestId("sidebar-custom-component-button"),
).not.toBeInTheDocument();
});
it("should render Add MCP Server button with correct content", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const addButton = screen.getByTestId("sidebar-add-mcp-server-button");
expect(addButton).toHaveTextContent("Add MCP Server");
expect(screen.getByTestId("icon-Plus")).toBeInTheDocument();
});
it("should render Manage Servers button with correct content", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const manageButton = screen.getByTestId("sidebar-manage-servers-button");
expect(manageButton).toHaveTextContent("Manage Servers");
expect(screen.getByTestId("icon-ArrowUpRight")).toBeInTheDocument();
});
it("should open modal when Add MCP Server button is clicked", async () => {
const user = userEvent.setup();
render(<SidebarMenuButtons {...defaultProps} />);
expect(screen.getByTestId("add-mcp-server-modal")).toHaveAttribute(
"data-open",
"false",
);
const addButton = screen.getByTestId("sidebar-add-mcp-server-button");
await user.click(addButton);
expect(screen.getByTestId("add-mcp-server-modal")).toHaveAttribute(
"data-open",
"true",
);
});
it("should have correct DOM hierarchy with store", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
it("should navigate to settings when Manage Servers button is clicked", async () => {
const user = userEvent.setup();
render(<SidebarMenuButtons {...defaultProps} />);
const sidebarMenuButtons = screen.getAllByTestId("sidebar-menu-button");
expect(sidebarMenuButtons).toHaveLength(2);
expect(sidebarMenuButtons[0]).toContainElement(
screen.getByTestId("custom-link"),
const manageButton = screen.getByTestId("sidebar-manage-servers-button");
await user.click(manageButton);
expect(mockNavigate).toHaveBeenCalledWith("/settings/mcp-servers");
expect(mockNavigate).toHaveBeenCalledTimes(1);
});
it("should disable MCP buttons when loading", () => {
render(<SidebarMenuButtons {...defaultProps} isLoading={true} />);
const addButton = screen.getByTestId("sidebar-add-mcp-server-button");
const manageButton = screen.getByTestId("sidebar-manage-servers-button");
expect(addButton).toBeDisabled();
expect(manageButton).toBeDisabled();
});
it("should close modal when close button is clicked", async () => {
const user = userEvent.setup();
render(<SidebarMenuButtons {...defaultProps} />);
// Open modal first
const addButton = screen.getByTestId("sidebar-add-mcp-server-button");
await user.click(addButton);
expect(screen.getByTestId("add-mcp-server-modal")).toHaveAttribute(
"data-open",
"true",
);
expect(sidebarMenuButtons[1]).toContainElement(
screen.getByTestId("sidebar-custom-component-button"),
// Close modal
const closeButton = screen.getByText("Close Modal");
await user.click(closeButton);
expect(screen.getByTestId("add-mcp-server-modal")).toHaveAttribute(
"data-open",
"false",
);
});
it("should apply correct styling to MCP buttons", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const addButton = screen.getByTestId("sidebar-add-mcp-server-button");
const manageButton = screen.getByTestId("sidebar-manage-servers-button");
expect(addButton).toHaveClass("flex", "items-center", "gap-2");
expect(addButton).toHaveAttribute("data-unstyled", "true");
expect(manageButton).toHaveClass("flex", "items-center", "gap-2");
expect(manageButton).toHaveAttribute("data-unstyled", "true");
});
it("should render MCP icons with correct styling", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const plusIcon = screen.getByTestId("icon-Plus");
const arrowIcon = screen.getByTestId("icon-ArrowUpRight");
expect(plusIcon).toHaveClass("h-4", "w-4", "text-muted-foreground");
expect(arrowIcon).toHaveClass("h-4", "w-4", "text-muted-foreground");
});
it("should render MCP button text with correct styling", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const addSpan = screen.getByText("Add MCP Server");
const manageSpan = screen.getByText("Manage Servers");
expect(addSpan).toHaveClass(
"group-data-[state=open]/collapsible:font-semibold",
);
expect(manageSpan).toHaveClass(
"group-data-[state=open]/collapsible:font-semibold",
);
});
it("should render modal component", () => {
render(<SidebarMenuButtons {...defaultProps} />);
expect(screen.getByTestId("add-mcp-server-modal")).toBeInTheDocument();
});
});
describe("Section Switching", () => {
it("should show custom component button in components section", () => {
mockUseSidebar.mockReturnValue({
activeSection: "components",
});
render(<SidebarMenuButtons {...defaultProps} />);
expect(
screen.getByTestId("sidebar-custom-component-button"),
).toBeInTheDocument();
expect(
screen.queryByTestId("sidebar-add-mcp-server-button"),
).not.toBeInTheDocument();
});
it("should show MCP buttons in mcp section", () => {
mockUseSidebar.mockReturnValue({
activeSection: "mcp",
});
render(<SidebarMenuButtons {...defaultProps} />);
expect(
screen.getByTestId("sidebar-add-mcp-server-button"),
).toBeInTheDocument();
expect(
screen.queryByTestId("sidebar-custom-component-button"),
).not.toBeInTheDocument();
});
it("should show custom component button in bundles section", () => {
mockUseSidebar.mockReturnValue({
activeSection: "bundles",
});
render(<SidebarMenuButtons {...defaultProps} />);
expect(
screen.getByTestId("sidebar-custom-component-button"),
).toBeInTheDocument();
expect(
screen.queryByTestId("sidebar-add-mcp-server-button"),
).not.toBeInTheDocument();
});
it("should show custom component button in search section", () => {
mockUseSidebar.mockReturnValue({
activeSection: "search",
});
render(<SidebarMenuButtons {...defaultProps} />);
expect(
screen.getByTestId("sidebar-custom-component-button"),
).toBeInTheDocument();
expect(
screen.queryByTestId("sidebar-add-mcp-server-button"),
).not.toBeInTheDocument();
});
});
describe("Component Structure", () => {
it("should render fragments correctly", () => {
const { container } = render(<SidebarMenuButtons {...defaultProps} />);
// Component should render without wrapper elements (using React fragment)
expect(container.children).toHaveLength(1);
});
});
describe("CSS Classes", () => {
it("should apply correct classes to custom component button", () => {
it("should wrap buttons in SidebarMenuButton", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const customButton = screen.getByTestId(
"sidebar-custom-component-button",
);
expect(customButton).toHaveClass("flex", "items-center", "gap-2");
expect(customButton).toHaveAttribute("data-unstyled", "true");
const sidebarMenuButton = screen.getByTestId("sidebar-menu-button");
expect(sidebarMenuButton).toHaveAttribute("data-as-child", "true");
});
it("should apply correct classes to store link", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
it("should render multiple SidebarMenuButtons in MCP mode", () => {
mockUseSidebar.mockReturnValue({
activeSection: "mcp",
});
const storeLink = screen.getByTestId("custom-link");
expect(storeLink).toHaveClass("group/discover");
});
render(<SidebarMenuButtons {...defaultProps} />);
it("should apply correct classes to icons", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
const storeIcon = screen.getByTestId("icon-Store");
const arrowIcon = screen.getByTestId("icon-SquareArrowOutUpRight");
const plusIcon = screen.getByTestId("icon-Plus");
expect(storeIcon).toHaveClass("h-4", "w-4", "text-muted-foreground");
expect(arrowIcon).toHaveClass(
"h-4",
"w-4",
"opacity-0",
"transition-all",
"group-hover/discover:opacity-100",
);
expect(plusIcon).toHaveClass("h-4", "w-4", "text-muted-foreground");
const sidebarMenuButtons = screen.getAllByTestId("sidebar-menu-button");
expect(sidebarMenuButtons).toHaveLength(2); // Add + Manage buttons
});
});
describe("Props Handling", () => {
it("should handle default props correctly", () => {
it("should handle minimal props correctly", () => {
const minimalProps = {
customComponent: mockCustomComponent,
addComponent: mockAddComponent,
};
@ -352,23 +468,6 @@ describe("SidebarMenuButtons", () => {
expect(
screen.getByTestId("sidebar-custom-component-button"),
).not.toBeDisabled();
expect(screen.queryByTestId("custom-link")).not.toBeInTheDocument();
});
it("should handle all props provided", () => {
const fullProps = {
hasStore: true,
customComponent: mockCustomComponent,
addComponent: mockAddComponent,
isLoading: true,
};
render(<SidebarMenuButtons {...fullProps} />);
expect(screen.getByTestId("custom-link")).toBeInTheDocument();
expect(
screen.getByTestId("sidebar-custom-component-button"),
).toBeDisabled();
});
it("should work with different customComponent objects", async () => {
@ -433,16 +532,6 @@ describe("SidebarMenuButtons", () => {
}).not.toThrow();
});
it("should handle boolean hasStore values", () => {
const { rerender } = render(
<SidebarMenuButtons {...defaultProps} hasStore={false} />,
);
expect(screen.queryByTestId("custom-link")).not.toBeInTheDocument();
rerender(<SidebarMenuButtons {...defaultProps} hasStore={true} />);
expect(screen.getByTestId("custom-link")).toBeInTheDocument();
});
it("should handle boolean isLoading values", () => {
const { rerender } = render(
<SidebarMenuButtons {...defaultProps} isLoading={false} />,
@ -460,20 +549,12 @@ describe("SidebarMenuButtons", () => {
it("should handle rapid prop changes", () => {
const { rerender } = render(<SidebarMenuButtons {...defaultProps} />);
expect(screen.queryByTestId("custom-link")).not.toBeInTheDocument();
expect(
screen.getByTestId("sidebar-custom-component-button"),
).not.toBeDisabled();
rerender(
<SidebarMenuButtons
{...defaultProps}
hasStore={true}
isLoading={true}
/>,
);
rerender(<SidebarMenuButtons {...defaultProps} isLoading={true} />);
expect(screen.getByTestId("custom-link")).toBeInTheDocument();
expect(
screen.getByTestId("sidebar-custom-component-button"),
).toBeDisabled();
@ -481,51 +562,34 @@ describe("SidebarMenuButtons", () => {
});
describe("Text Content", () => {
it("should display correct text content", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
it("should display correct text content in custom mode", () => {
render(<SidebarMenuButtons {...defaultProps} />);
expect(screen.getByText("Discover more components")).toBeInTheDocument();
expect(screen.getByText("New Custom Component")).toBeInTheDocument();
});
it("should have spans with correct classes", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
it("should display correct text content in MCP mode", () => {
mockUseSidebar.mockReturnValue({
activeSection: "mcp",
});
render(<SidebarMenuButtons {...defaultProps} />);
expect(screen.getByText("Add MCP Server")).toBeInTheDocument();
expect(screen.getByText("Manage Servers")).toBeInTheDocument();
});
it("should have spans with correct classes", () => {
render(<SidebarMenuButtons {...defaultProps} />);
const storeSpan = screen.getByText("Discover more components");
const customSpan = screen.getByText("New Custom Component");
expect(storeSpan).toHaveClass(
"flex-1",
"group-data-[state=open]/collapsible:font-semibold",
);
expect(customSpan).toHaveClass(
"group-data-[state=open]/collapsible:font-semibold",
);
});
});
describe("SidebarMenuButton Integration", () => {
it("should render SidebarMenuButton with asChild prop", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
const sidebarMenuButtons = screen.getAllByTestId("sidebar-menu-button");
sidebarMenuButtons.forEach((button) => {
expect(button).toHaveAttribute("data-as-child", "true");
});
});
it("should wrap both store link and custom button in SidebarMenuButton", () => {
const propsWithStore = { ...defaultProps, hasStore: true };
render(<SidebarMenuButtons {...propsWithStore} />);
const sidebarMenuButtons = screen.getAllByTestId("sidebar-menu-button");
expect(sidebarMenuButtons).toHaveLength(2);
});
});
describe("Callback Behavior", () => {
it("should call addComponent with exact arguments", async () => {
const user = userEvent.setup();

View file

@ -4,21 +4,22 @@ import { SidebarHeaderComponentProps } from "../../types";
import { SidebarHeaderComponent } from "../sidebarHeader";
// Mock the UI components
jest.mock("@/components/common/genericIconComponent", () => ({
ForwardedIconComponent: ({ name, className }: any) => (
<span data-testid={`forwarded-icon-${name}`} className={className}>
{name}
</span>
),
}));
jest.mock("@/components/common/shadTooltipComponent", () => ({
__esModule: true,
default: ({ children, content, styleClasses }: any) => (
<div data-testid="tooltip" data-content={content} className={styleClasses}>
jest.mock("@/components/ui/disclosure", () => ({
Disclosure: ({ children, open, onOpenChange }: any) => (
<div
data-testid="disclosure"
data-open={open}
data-on-open-change={onOpenChange?.toString()}
>
{children}
</div>
),
DisclosureContent: ({ children }: any) => (
<div data-testid="disclosure-content">{children}</div>
),
DisclosureTrigger: ({ children }: any) => (
<div data-testid="disclosure-trigger">{children}</div>
),
}));
jest.mock("@/components/ui/button", () => ({
@ -35,21 +36,20 @@ jest.mock("@/components/ui/button", () => ({
),
}));
jest.mock("@/components/ui/disclosure", () => ({
Disclosure: ({ children, open, onOpenChange }: any) => (
<div
data-testid="disclosure"
data-open={open}
data-on-open-change={onOpenChange?.toString()}
>
{children}
jest.mock("@/components/common/genericIconComponent", () => ({
ForwardedIconComponent: ({ name, className }: any) => (
<div data-testid={`icon-${name}`} className={className}>
{name}
</div>
),
DisclosureContent: ({ children }: any) => (
<div data-testid="disclosure-content">{children}</div>
),
DisclosureTrigger: ({ children }: any) => (
<div data-testid="disclosure-trigger">{children}</div>
}));
jest.mock("@/components/common/shadTooltipComponent", () => ({
__esModule: true,
default: ({ children, content, styleClasses }: any) => (
<div data-testid="tooltip" data-content={content} className={styleClasses}>
{children}
</div>
),
}));
@ -112,11 +112,16 @@ jest.mock("../sidebarFilterComponent", () => ({
data-color={color}
data-reset-filters={resetFilters?.toString()}
>
Filter Component
Sidebar Filter
</div>
),
}));
// Mock feature flags
jest.mock("@/customization/feature-flags", () => ({
ENABLE_NEW_SIDEBAR: false, // Default to old sidebar for most tests
}));
describe("SidebarHeaderComponent", () => {
const mockSetShowConfig = jest.fn();
const mockSetShowBeta = jest.fn();
@ -126,7 +131,6 @@ describe("SidebarHeaderComponent", () => {
const mockHandleInputChange = jest.fn();
const mockSetFilterEdge = jest.fn();
const mockSetFilterData = jest.fn();
const mockSearchInputRef = { current: null };
const defaultProps: SidebarHeaderComponentProps = {
showConfig: false,
@ -135,7 +139,7 @@ describe("SidebarHeaderComponent", () => {
setShowBeta: mockSetShowBeta,
showLegacy: false,
setShowLegacy: mockSetShowLegacy,
searchInputRef: mockSearchInputRef,
searchInputRef: { current: null },
isInputFocused: false,
search: "",
handleInputFocus: mockHandleInputFocus,
@ -144,7 +148,7 @@ describe("SidebarHeaderComponent", () => {
filterType: undefined,
setFilterEdge: mockSetFilterEdge,
setFilterData: mockSetFilterData,
data: {},
data: mockAPIData,
};
beforeEach(() => {
@ -152,75 +156,111 @@ describe("SidebarHeaderComponent", () => {
});
describe("Basic Rendering", () => {
it("should render sidebar header with correct structure", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
describe("Legacy Sidebar (!ENABLE_NEW_SIDEBAR)", () => {
it("should render sidebar header with legacy structure", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("sidebar-header")).toBeInTheDocument();
expect(screen.getByTestId("disclosure")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-trigger")).toBeInTheDocument();
expect(screen.getByText("Components")).toBeInTheDocument();
expect(screen.getByTestId("search-input")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-header")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-trigger")).toBeInTheDocument();
expect(screen.getByText("Components")).toBeInTheDocument();
expect(
screen.getByTestId("sidebar-options-trigger"),
).toBeInTheDocument();
expect(screen.getByTestId("disclosure-trigger")).toBeInTheDocument();
expect(screen.getByTestId("search-input")).toBeInTheDocument();
expect(screen.getByTestId("disclosure")).toBeInTheDocument();
expect(screen.getByTestId("disclosure-content")).toBeInTheDocument();
expect(screen.getByTestId("feature-toggles")).toBeInTheDocument();
});
it("should render sidebar trigger with correct icon", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("sidebar-trigger")).toBeInTheDocument();
expect(screen.getByTestId("icon-PanelLeftClose")).toBeInTheDocument();
});
it("should render settings button with correct props", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const settingsButton = screen.getByTestId("sidebar-options-trigger");
expect(settingsButton).toHaveAttribute("data-variant", "ghost");
expect(settingsButton).toHaveAttribute("data-size", "iconMd");
expect(
screen.getByTestId("icon-SlidersHorizontal"),
).toBeInTheDocument();
});
it("should show ghostActive variant when config is open", () => {
const propsWithOpenConfig = { ...defaultProps, showConfig: true };
render(<SidebarHeaderComponent {...propsWithOpenConfig} />);
const settingsButton = screen.getByTestId("sidebar-options-trigger");
expect(settingsButton).toHaveAttribute("data-variant", "ghostActive");
});
it("should render tooltip with correct content", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("tooltip")).toHaveAttribute(
"data-content",
"Component settings",
);
expect(screen.getByTestId("tooltip")).toHaveClass("z-50");
});
});
it("should display correct title", () => {
it("should apply correct CSS classes to header", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByText("Components")).toBeInTheDocument();
});
it("should render sidebar trigger with icon", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("sidebar-trigger")).toBeInTheDocument();
expect(
screen.getByTestId("forwarded-icon-PanelLeftClose"),
).toBeInTheDocument();
});
it("should render settings button", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("sidebar-options-trigger")).toBeInTheDocument();
expect(
screen.getByTestId("forwarded-icon-SlidersHorizontal"),
).toBeInTheDocument();
});
it("should render tooltip with correct content", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("tooltip")).toHaveAttribute(
"data-content",
"Component settings",
const header = screen.getByTestId("sidebar-header");
expect(header).toHaveClass(
"flex",
"w-full",
"flex-col",
"gap-2",
"p-4",
"pb-1",
"group-data-[collapsible=icon]:hidden",
);
expect(screen.getByTestId("tooltip")).toHaveClass("z-50");
});
it("should render search input component with correct props", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const searchInput = screen.getByTestId("search-input");
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute("data-search", "");
expect(searchInput).toHaveAttribute("data-is-focused", "false");
});
});
describe("Disclosure Functionality", () => {
it("should render disclosure with correct open state", () => {
it("should render disclosure with correct closed state", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"false",
);
const disclosure = screen.getByTestId("disclosure");
expect(disclosure).toBeInTheDocument();
expect(disclosure).toHaveAttribute("data-open", "false");
});
it("should render disclosure as open when showConfig is true", () => {
const propsWithOpenConfig = { ...defaultProps, showConfig: true };
const propsWithOpenConfig = {
...defaultProps,
showConfig: true,
};
render(<SidebarHeaderComponent {...propsWithOpenConfig} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"true",
);
const disclosure = screen.getByTestId("disclosure");
expect(disclosure).toHaveAttribute("data-open", "true");
});
it("should pass setShowConfig to disclosure", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
const disclosure = screen.getByTestId("disclosure");
expect(disclosure).toHaveAttribute(
"data-on-open-change",
mockSetShowConfig.toString(),
);
@ -232,34 +272,13 @@ describe("SidebarHeaderComponent", () => {
expect(screen.getByTestId("disclosure-content")).toBeInTheDocument();
});
it("should render disclosure trigger", () => {
it("should contain feature toggles within disclosure content", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("disclosure-trigger")).toBeInTheDocument();
});
});
const disclosureContent = screen.getByTestId("disclosure-content");
const featureToggles = screen.getByTestId("feature-toggles");
describe("Settings Button Variants", () => {
it("should show ghost variant when config is closed", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const settingsButton = screen.getByTestId("sidebar-options-trigger");
expect(settingsButton).toHaveAttribute("data-variant", "ghost");
});
it("should show ghostActive variant when config is open", () => {
const propsWithOpenConfig = { ...defaultProps, showConfig: true };
render(<SidebarHeaderComponent {...propsWithOpenConfig} />);
const settingsButton = screen.getByTestId("sidebar-options-trigger");
expect(settingsButton).toHaveAttribute("data-variant", "ghostActive");
});
it("should have correct size", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const settingsButton = screen.getByTestId("sidebar-options-trigger");
expect(settingsButton).toHaveAttribute("data-size", "iconMd");
expect(disclosureContent).toContainElement(featureToggles);
});
});
@ -299,15 +318,21 @@ describe("SidebarHeaderComponent", () => {
it("should render search input with correct props", () => {
const propsWithSearch = {
...defaultProps,
isInputFocused: true,
search: "test search",
isInputFocused: true,
};
render(<SidebarHeaderComponent {...propsWithSearch} />);
const searchInput = screen.getByTestId("search-input");
expect(searchInput).toHaveAttribute("data-is-focused", "true");
expect(searchInput).toHaveAttribute("data-search", "test search");
expect(searchInput).toHaveAttribute("data-is-focused", "true");
});
it("should pass search input ref and callbacks", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const searchInput = screen.getByTestId("search-input");
expect(searchInput).toHaveAttribute(
"data-handle-focus",
mockHandleInputFocus.toString(),
@ -321,12 +346,6 @@ describe("SidebarHeaderComponent", () => {
mockHandleInputChange.toString(),
);
});
it("should pass search input ref", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("search-input")).toBeInTheDocument();
});
});
describe("Filter Component Conditional Rendering", () => {
@ -340,39 +359,22 @@ describe("SidebarHeaderComponent", () => {
const propsWithFilter = {
...defaultProps,
filterType: {
source: "test_source",
sourceHandle: undefined,
source: "input",
sourceHandle: "input",
target: undefined,
targetHandle: undefined,
type: "string",
color: "blue",
type: "input",
color: "#FF0000",
},
};
render(<SidebarHeaderComponent {...propsWithFilter} />);
expect(screen.getByTestId("sidebar-filter")).toBeInTheDocument();
});
it("should pass correct props to filter component for input", () => {
const propsWithInputFilter = {
...defaultProps,
filterType: {
source: "test_source",
sourceHandle: undefined,
target: undefined,
targetHandle: undefined,
type: "string",
color: "blue",
},
};
render(<SidebarHeaderComponent {...propsWithInputFilter} />);
const filterComponent = screen.getByTestId("sidebar-filter");
expect(filterComponent).toBeInTheDocument();
expect(filterComponent).toHaveAttribute("data-is-input", "true");
expect(filterComponent).toHaveAttribute("data-type", "string");
expect(filterComponent).toHaveAttribute("data-color", "blue");
expect(filterComponent).toHaveAttribute("data-type", "input");
expect(filterComponent).toHaveAttribute("data-color", "#FF0000");
});
it("should pass correct props to filter component for output", () => {
@ -381,10 +383,10 @@ describe("SidebarHeaderComponent", () => {
filterType: {
source: undefined,
sourceHandle: undefined,
target: "test_target",
targetHandle: undefined,
type: "number",
color: "red",
target: "output",
targetHandle: "output",
type: "output",
color: "#00FF00",
},
};
@ -392,30 +394,30 @@ describe("SidebarHeaderComponent", () => {
const filterComponent = screen.getByTestId("sidebar-filter");
expect(filterComponent).toHaveAttribute("data-is-input", "false");
expect(filterComponent).toHaveAttribute("data-type", "number");
expect(filterComponent).toHaveAttribute("data-color", "red");
expect(filterComponent).toHaveAttribute("data-type", "output");
expect(filterComponent).toHaveAttribute("data-color", "#00FF00");
});
it("should handle filter reset correctly", async () => {
it("should handle filter reset correctly", () => {
const propsWithFilter = {
...defaultProps,
filterType: {
source: "test_source",
sourceHandle: undefined,
target: undefined,
targetHandle: undefined,
type: "string",
color: "blue",
source: "input",
sourceHandle: "input",
target: "output",
targetHandle: "output",
type: "input",
color: "#FF0000",
},
data: mockAPIData,
};
render(<SidebarHeaderComponent {...propsWithFilter} />);
// Since resetFilters is passed as a function, we can't directly test it
// but we can verify the filter component receives the function
const filterComponent = screen.getByTestId("sidebar-filter");
expect(filterComponent).toHaveAttribute("data-reset-filters");
expect(filterComponent).toHaveAttribute(
"data-reset-filters",
expect.stringContaining("function"),
);
});
});
@ -423,138 +425,83 @@ describe("SidebarHeaderComponent", () => {
it("should have correct DOM hierarchy", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const sidebarHeader = screen.getByTestId("sidebar-header");
const disclosure = screen.getByTestId("disclosure");
const header = screen.getByTestId("sidebar-header");
const searchInput = screen.getByTestId("search-input");
const disclosure = screen.getByTestId("disclosure");
const disclosureContent = screen.getByTestId("disclosure-content");
const featureToggles = screen.getByTestId("feature-toggles");
expect(sidebarHeader).toContainElement(disclosure);
expect(sidebarHeader).toContainElement(searchInput);
expect(header).toContainElement(searchInput);
expect(header).toContainElement(disclosure);
expect(disclosure).toContainElement(disclosureContent);
expect(disclosureContent).toContainElement(featureToggles);
});
it("should apply correct CSS classes", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
it("should maintain structure with filter component", () => {
const propsWithFilter = {
...defaultProps,
filterType: {
source: "input",
sourceHandle: "input",
target: "output",
targetHandle: "output",
type: "input",
color: "#FF0000",
},
};
const sidebarHeader = screen.getByTestId("sidebar-header");
expect(sidebarHeader).toHaveClass(
"flex",
"w-full",
"flex-col",
"gap-4",
"p-4",
"pb-1",
);
});
render(<SidebarHeaderComponent {...propsWithFilter} />);
it("should contain all expected child elements", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const header = screen.getByTestId("sidebar-header");
const searchInput = screen.getByTestId("search-input");
const filterComponent = screen.getByTestId("sidebar-filter");
const disclosure = screen.getByTestId("disclosure");
expect(screen.getByTestId("sidebar-trigger")).toBeInTheDocument();
expect(screen.getByText("Components")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-options-trigger")).toBeInTheDocument();
expect(screen.getByTestId("feature-toggles")).toBeInTheDocument();
expect(screen.getByTestId("search-input")).toBeInTheDocument();
});
});
describe("CSS Classes", () => {
it("should apply correct classes to sidebar trigger", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const sidebarTrigger = screen.getByTestId("sidebar-trigger");
expect(sidebarTrigger).toHaveClass("text-muted-foreground");
});
it("should apply correct classes to title", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const title = screen.getByText("Components");
expect(title).toHaveClass(
"flex-1",
"cursor-default",
"text-sm",
"font-semibold",
);
});
it("should apply correct classes to settings icon", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const settingsIcon = screen.getByTestId(
"forwarded-icon-SlidersHorizontal",
);
expect(settingsIcon).toHaveClass("h-4", "w-4");
expect(header).toContainElement(searchInput);
expect(header).toContainElement(filterComponent);
expect(header).toContainElement(disclosure);
});
});
describe("Props Handling", () => {
it("should handle different showConfig values", () => {
const { rerender } = render(
<SidebarHeaderComponent {...defaultProps} showConfig={false} />,
);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"false",
);
expect(screen.getByTestId("sidebar-options-trigger")).toHaveAttribute(
"data-variant",
"ghost",
);
const { rerender } = render(<SidebarHeaderComponent {...defaultProps} />);
let disclosure = screen.getByTestId("disclosure");
expect(disclosure).toHaveAttribute("data-open", "false");
rerender(<SidebarHeaderComponent {...defaultProps} showConfig={true} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"true",
);
expect(screen.getByTestId("sidebar-options-trigger")).toHaveAttribute(
"data-variant",
"ghostActive",
);
disclosure = screen.getByTestId("disclosure");
expect(disclosure).toHaveAttribute("data-open", "true");
});
it("should handle different search values", () => {
const { rerender } = render(
<SidebarHeaderComponent
{...defaultProps}
search=""
isInputFocused={false}
/>,
);
const { rerender } = render(<SidebarHeaderComponent {...defaultProps} />);
let searchInput = screen.getByTestId("search-input");
expect(searchInput).toHaveAttribute("data-search", "");
expect(searchInput).toHaveAttribute("data-is-focused", "false");
rerender(
<SidebarHeaderComponent
{...defaultProps}
search="test"
isInputFocused={true}
/>,
<SidebarHeaderComponent {...defaultProps} search="new search" />,
);
searchInput = screen.getByTestId("search-input");
expect(searchInput).toHaveAttribute("data-search", "test");
expect(searchInput).toHaveAttribute("data-is-focused", "true");
expect(searchInput).toHaveAttribute("data-search", "new search");
});
it("should handle different callback functions", () => {
const alternativeSetShowConfig = jest.fn();
const alternativeHandleInputChange = jest.fn();
const alternativeProps = {
...defaultProps,
setShowConfig: alternativeSetShowConfig,
};
render(
<SidebarHeaderComponent
{...defaultProps}
setShowConfig={alternativeSetShowConfig}
handleInputChange={alternativeHandleInputChange}
/>,
);
render(<SidebarHeaderComponent {...alternativeProps} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
const disclosure = screen.getByTestId("disclosure");
expect(disclosure).toHaveAttribute(
"data-on-open-change",
alternativeSetShowConfig.toString(),
);
expect(screen.getByTestId("search-input")).toHaveAttribute(
"data-handle-change",
alternativeHandleInputChange.toString(),
);
});
});
@ -563,6 +510,8 @@ describe("SidebarHeaderComponent", () => {
const propsWithoutCallbacks = {
...defaultProps,
setShowConfig: undefined as any,
setShowBeta: undefined as any,
setShowLegacy: undefined as any,
handleInputFocus: undefined as any,
handleInputBlur: undefined as any,
handleInputChange: undefined as any,
@ -573,116 +522,96 @@ describe("SidebarHeaderComponent", () => {
}).not.toThrow();
});
it("should handle null filterType gracefully", () => {
render(
<SidebarHeaderComponent {...defaultProps} filterType={undefined} />,
);
expect(screen.queryByTestId("sidebar-filter")).not.toBeInTheDocument();
});
it("should handle undefined filterType gracefully", () => {
const propsWithUndefinedFilter = {
...defaultProps,
filterType: undefined as any,
filterType: undefined,
};
render(<SidebarHeaderComponent {...propsWithUndefinedFilter} />);
expect(screen.queryByTestId("sidebar-filter")).not.toBeInTheDocument();
});
it("should handle complex filterType objects", () => {
const complexFilterType = {
source: "test_source",
sourceHandle: undefined,
target: undefined,
targetHandle: undefined,
type: "List[str, int]",
color: "custom-color",
additionalProp: "ignored",
};
const propsWithComplexFilter = {
...defaultProps,
filterType: complexFilterType,
filterType: {
source: "input",
sourceHandle: "input",
target: undefined,
targetHandle: undefined,
type: "complex-input",
color: "#ABCDEF",
additionalProp: "ignored",
},
};
render(<SidebarHeaderComponent {...propsWithComplexFilter} />);
expect(() => {
render(<SidebarHeaderComponent {...propsWithComplexFilter} />);
}).not.toThrow();
const filterComponent = screen.getByTestId("sidebar-filter");
expect(filterComponent).toHaveAttribute("data-type", "List[str, int]");
expect(filterComponent).toHaveAttribute("data-color", "custom-color");
expect(filterComponent).toBeInTheDocument();
});
});
describe("Memo Functionality", () => {
it("should render component name correctly", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
// Component should render without issues
expect(screen.getByTestId("sidebar-header")).toBeInTheDocument();
expect(SidebarHeaderComponent.displayName).toBe("SidebarHeaderComponent");
});
it("should handle prop changes correctly", () => {
const { rerender } = render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"false",
);
expect(screen.getByTestId("sidebar-header")).toBeInTheDocument();
rerender(<SidebarHeaderComponent {...defaultProps} showConfig={true} />);
const newProps = { ...defaultProps, search: "updated search" };
rerender(<SidebarHeaderComponent {...newProps} />);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"true",
);
});
});
describe("Accessibility", () => {
it("should have proper heading structure", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
const title = screen.getByText("Components");
expect(title.tagName).toBe("H3");
});
it("should render tooltip for settings button", () => {
render(<SidebarHeaderComponent {...defaultProps} />);
expect(screen.getByTestId("tooltip")).toHaveAttribute(
"data-content",
"Component settings",
);
const searchInput = screen.getByTestId("search-input");
expect(searchInput).toHaveAttribute("data-search", "updated search");
});
});
describe("Integration", () => {
it("should integrate all child components correctly", () => {
const propsWithFilter = {
const fullProps = {
...defaultProps,
search: "integration test",
showConfig: true,
showBeta: true,
showLegacy: true,
showLegacy: false,
isInputFocused: true,
search: "test search",
filterType: {
source: "test_source",
sourceHandle: undefined,
source: "input",
sourceHandle: "input",
target: undefined,
targetHandle: undefined,
type: "string",
color: "blue",
type: "input",
color: "#123456",
},
};
render(<SidebarHeaderComponent {...propsWithFilter} />);
render(<SidebarHeaderComponent {...fullProps} />);
expect(screen.getByTestId("feature-toggles")).toBeInTheDocument();
expect(screen.getByTestId("search-input")).toBeInTheDocument();
expect(screen.getByTestId("sidebar-filter")).toBeInTheDocument();
// Verify all components are integrated correctly
expect(screen.getByTestId("sidebar-header")).toBeInTheDocument();
expect(screen.getByTestId("search-input")).toHaveAttribute(
"data-search",
"integration test",
);
expect(screen.getByTestId("sidebar-filter")).toHaveAttribute(
"data-type",
"input",
);
expect(screen.getByTestId("disclosure")).toHaveAttribute(
"data-open",
"true",
);
expect(screen.getByTestId("feature-toggles")).toHaveAttribute(
"data-show-beta",
"true",
);
});
});
});

View file

@ -0,0 +1,306 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { SidebarSection } from "@/components/ui/sidebar";
import SidebarSegmentedNav, { NAV_ITEMS } from "../sidebarSegmentedNav";
// Mock the hooks and components
const mockUseSidebar: {
activeSection: SidebarSection;
setActiveSection: jest.Mock;
toggleSidebar: jest.Mock;
open: boolean;
} = {
activeSection: "components" as SidebarSection,
setActiveSection: jest.fn(),
toggleSidebar: jest.fn(),
open: true,
};
const mockUseSearchContext = {
focusSearch: jest.fn(),
isSearchFocused: false,
setSearch: jest.fn(),
};
jest.mock("@/components/ui/sidebar", () => ({
useSidebar: () => mockUseSidebar,
SidebarMenu: ({ children, className }: any) => (
<div data-testid="sidebar-menu" className={className}>
{children}
</div>
),
SidebarMenuButton: ({
children,
onClick,
isActive,
className,
size,
"data-testid": testId,
}: any) => (
<button
onClick={onClick}
data-testid={testId}
data-active={isActive}
data-size={size}
className={className}
>
{children}
</button>
),
SidebarMenuItem: ({ children }: any) => (
<div data-testid="sidebar-menu-item">{children}</div>
),
}));
jest.mock("../../index", () => ({
useSearchContext: () => mockUseSearchContext,
}));
jest.mock("@/components/common/genericIconComponent", () => ({
__esModule: true,
default: ({ name, className }: any) => (
<div data-testid={`icon-${name}`} className={className}>
{name}
</div>
),
}));
jest.mock("@/components/common/shadTooltipComponent", () => ({
__esModule: true,
default: ({ children, content, side }: any) => (
<div data-testid="tooltip" data-content={content} data-side={side}>
{children}
</div>
),
}));
jest.mock("@/utils/utils", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}));
describe("SidebarSegmentedNav", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to default values
mockUseSidebar.activeSection = "components";
mockUseSidebar.open = true;
mockUseSearchContext.isSearchFocused = false;
jest.clearAllTimers();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("renders all navigation items", () => {
render(<SidebarSegmentedNav />);
// Check that all nav items are rendered
NAV_ITEMS.forEach((item) => {
expect(screen.getByTestId(`sidebar-nav-${item.id}`)).toBeInTheDocument();
expect(screen.getByTestId(`icon-${item.icon}`)).toBeInTheDocument();
});
});
it("renders correct structure", () => {
render(<SidebarSegmentedNav />);
expect(screen.getByTestId("sidebar-menu")).toBeInTheDocument();
expect(screen.getAllByTestId("sidebar-menu-item")).toHaveLength(
NAV_ITEMS.length,
);
expect(screen.getAllByTestId("tooltip")).toHaveLength(NAV_ITEMS.length);
});
it("displays correct tooltips for each item", () => {
render(<SidebarSegmentedNav />);
NAV_ITEMS.forEach((item) => {
const tooltips = screen.getAllByTestId("tooltip");
const itemTooltip = tooltips.find(
(tooltip) => tooltip.getAttribute("data-content") === item.tooltip,
);
expect(itemTooltip).toBeInTheDocument();
expect(itemTooltip).toHaveAttribute("data-side", "right");
});
});
it("sets active state for current active section", () => {
mockUseSidebar.activeSection = "mcp";
render(<SidebarSegmentedNav />);
const mcpButton = screen.getByTestId("sidebar-nav-mcp");
expect(mcpButton).toHaveAttribute("data-active", "true");
// Other buttons should not be active
const componentsButton = screen.getByTestId("sidebar-nav-components");
expect(componentsButton).toHaveAttribute("data-active", "false");
});
it("sets active state for search when search is focused", () => {
mockUseSidebar.activeSection = "components";
mockUseSearchContext.isSearchFocused = true;
render(<SidebarSegmentedNav />);
const searchButton = screen.getByTestId("sidebar-nav-search");
expect(searchButton).toHaveAttribute("data-active", "true");
});
it("calls setActiveSection when clicking on different section", () => {
render(<SidebarSegmentedNav />);
const mcpButton = screen.getByTestId("sidebar-nav-mcp");
fireEvent.click(mcpButton);
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledWith("mcp");
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledTimes(1);
});
it("resets search when changing active sections", () => {
render(<SidebarSegmentedNav />);
const mcpButton = screen.getByTestId("sidebar-nav-mcp");
fireEvent.click(mcpButton);
expect(mockUseSearchContext.setSearch).toHaveBeenCalledWith("");
expect(mockUseSearchContext.setSearch).toHaveBeenCalledTimes(1);
});
it("toggles sidebar when clicking on currently active section", () => {
mockUseSidebar.activeSection = "components";
render(<SidebarSegmentedNav />);
const componentsButton = screen.getByTestId("sidebar-nav-components");
fireEvent.click(componentsButton);
expect(mockUseSidebar.toggleSidebar).toHaveBeenCalledTimes(1);
expect(mockUseSidebar.setActiveSection).not.toHaveBeenCalled();
});
it("resets search when toggling sidebar on active section", () => {
mockUseSidebar.activeSection = "components";
render(<SidebarSegmentedNav />);
const componentsButton = screen.getByTestId("sidebar-nav-components");
fireEvent.click(componentsButton);
expect(mockUseSearchContext.setSearch).toHaveBeenCalledWith("");
expect(mockUseSearchContext.setSearch).toHaveBeenCalledTimes(1);
});
it("opens sidebar and sets active section when sidebar is closed", () => {
mockUseSidebar.open = false;
render(<SidebarSegmentedNav />);
const bundlesButton = screen.getByTestId("sidebar-nav-bundles");
fireEvent.click(bundlesButton);
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledWith("bundles");
expect(mockUseSidebar.toggleSidebar).toHaveBeenCalledTimes(1);
});
it("resets search when opening sidebar and changing sections", () => {
mockUseSidebar.open = false;
render(<SidebarSegmentedNav />);
const bundlesButton = screen.getByTestId("sidebar-nav-bundles");
fireEvent.click(bundlesButton);
expect(mockUseSearchContext.setSearch).toHaveBeenCalledWith("");
expect(mockUseSearchContext.setSearch).toHaveBeenCalledTimes(1);
});
it("focuses search input when search section is clicked", async () => {
render(<SidebarSegmentedNav />);
const searchButton = screen.getByTestId("sidebar-nav-search");
fireEvent.click(searchButton);
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledWith("search");
// Fast-forward the setTimeout
jest.advanceTimersByTime(100);
await waitFor(() => {
expect(mockUseSearchContext.focusSearch).toHaveBeenCalledTimes(1);
});
});
it("focuses search input even when sidebar is closed", async () => {
mockUseSidebar.open = false;
render(<SidebarSegmentedNav />);
const searchButton = screen.getByTestId("sidebar-nav-search");
fireEvent.click(searchButton);
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledWith("search");
expect(mockUseSidebar.toggleSidebar).toHaveBeenCalledTimes(1);
// Fast-forward the setTimeout
jest.advanceTimersByTime(100);
await waitFor(() => {
expect(mockUseSearchContext.focusSearch).toHaveBeenCalledTimes(1);
});
});
it("renders accessibility labels correctly", () => {
render(<SidebarSegmentedNav />);
NAV_ITEMS.forEach((item) => {
const button = screen.getByTestId(`sidebar-nav-${item.id}`);
expect(button).toHaveTextContent(item.label);
});
});
it("applies correct CSS classes", () => {
mockUseSidebar.activeSection = "mcp";
render(<SidebarSegmentedNav />);
const mcpButton = screen.getByTestId("sidebar-nav-mcp");
expect(mcpButton).toHaveClass("bg-accent", "text-accent-foreground");
const componentsButton = screen.getByTestId("sidebar-nav-components");
expect(componentsButton).toHaveClass("text-muted-foreground");
});
it("renders icons with correct styling", () => {
render(<SidebarSegmentedNav />);
NAV_ITEMS.forEach((item) => {
const icon = screen.getByTestId(`icon-${item.icon}`);
expect(icon).toHaveClass("h-5", "w-5");
});
});
it("handles multiple rapid clicks correctly", () => {
render(<SidebarSegmentedNav />);
const mcpButton = screen.getByTestId("sidebar-nav-mcp");
// Click multiple times rapidly
fireEvent.click(mcpButton);
fireEvent.click(mcpButton);
fireEvent.click(mcpButton);
// Should have called setActiveSection for each click since activeSection !== mcp
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledTimes(3);
expect(mockUseSidebar.setActiveSection).toHaveBeenCalledWith("mcp");
});
it("exports NAV_ITEMS correctly", () => {
expect(NAV_ITEMS).toHaveLength(4);
expect(NAV_ITEMS[0]).toEqual({
id: "search",
icon: "search",
label: "Search",
tooltip: "Search",
});
expect(NAV_ITEMS[3]).toEqual({
id: "bundles",
icon: "blocks",
label: "Bundles",
tooltip: "Bundles",
});
});
});

View file

@ -1,12 +1,15 @@
import { memo, useState } from "react";
import { memo } from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
} from "@/components/ui/sidebar";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { SIDEBAR_BUNDLES } from "@/utils/styleUtils";
import type { CategoryGroupProps } from "../types";
import { CategoryDisclosure } from "./categoryDisclouse";
import { SearchConfigTrigger } from "./searchConfigTrigger";
export const CategoryGroup = memo(function CategoryGroup({
dataFilter,
@ -18,17 +21,29 @@ export const CategoryGroup = memo(function CategoryGroup({
nodeColors,
onDragStart,
sensitiveSort,
showConfig,
setShowConfig,
}: CategoryGroupProps) {
return (
<SidebarGroup className="p-3">
{ENABLE_NEW_SIDEBAR && (
<SidebarGroupLabel className="cursor-default flex items-center justify-between">
<span>Components</span>
<SearchConfigTrigger
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
</SidebarGroupLabel>
)}
<SidebarGroupContent>
<SidebarMenu>
{Object.entries(dataFilter)
.filter(
([categoryName, items]) =>
// filter out bundles
// filter out bundles and MCP
!SIDEBAR_BUNDLES.some((cat) => cat.name === categoryName) &&
categoryName !== "custom_component" &&
categoryName !== "MCP" &&
Object.keys(items).length > 0,
)
.sort(([aName], [bName]) => {

View file

@ -1,21 +1,45 @@
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { SearchConfigTrigger } from "./searchConfigTrigger";
interface NoResultsMessageProps {
onClearSearch: () => void;
message?: string;
clearSearchText?: string;
additionalText?: string;
showConfig?: boolean;
setShowConfig?: (show: boolean) => void;
}
const NoResultsMessage = ({
onClearSearch,
message = "No components found.",
clearSearchText = "Clear your search",
additionalText = "or filter and try a different query.",
}) => {
showConfig = false,
setShowConfig,
}: NoResultsMessageProps) => {
return (
<div className="flex h-full flex-col items-center justify-center p-3 text-center">
<p className="text-sm text-secondary-foreground">
{message}{" "}
<a
className="cursor-pointer underline underline-offset-4"
onClick={onClearSearch}
>
{clearSearchText}
</a>{" "}
{additionalText}
</p>
<div className="flex h-full flex-col relative">
{ENABLE_NEW_SIDEBAR && setShowConfig && (
<div className="absolute top-1 right-3">
<SearchConfigTrigger
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center p-3 text-center">
<p className="text-sm text-secondary-foreground">
{message}{" "}
<a
className="cursor-pointer underline underline-offset-4"
onClick={onClearSearch}
>
{clearSearchText}
</a>{" "}
{additionalText}
</p>
</div>
</div>
);
};

View file

@ -0,0 +1,31 @@
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import { Button } from "@/components/ui/button";
interface SearchConfigTriggerProps {
showConfig: boolean;
setShowConfig: (show: boolean) => void;
}
export const SearchConfigTrigger = ({
showConfig,
setShowConfig,
}: SearchConfigTriggerProps) => {
return (
<div>
<ShadTooltip content="Component settings" styleClasses="z-50">
<Button
variant={showConfig ? "ghostActive" : "ghost"}
size="iconMd"
data-testid="sidebar-options-trigger"
onClick={() => setShowConfig(!showConfig)}
>
<ForwardedIconComponent
name="SlidersHorizontal"
className="h-4 w-4"
/>
</Button>
</ShadTooltip>
</div>
);
};

View file

@ -5,8 +5,10 @@ import {
SidebarGroupLabel,
SidebarMenu,
} from "@/components/ui/sidebar";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import type { SidebarGroupProps } from "../types";
import { BundleItem } from "./bundleItems";
import { SearchConfigTrigger } from "./searchConfigTrigger";
export const MemoizedSidebarGroup = memo(
({
@ -20,6 +22,9 @@ export const MemoizedSidebarGroup = memo(
handleKeyDownInput,
openCategories,
setOpenCategories,
showSearchConfigTrigger,
showConfig,
setShowConfig,
}: SidebarGroupProps) => {
const sortedBundles = useMemo(() => {
return BUNDLES.toSorted((a, b) => {
@ -37,8 +42,14 @@ export const MemoizedSidebarGroup = memo(
return (
<SidebarGroup className="p-3">
<SidebarGroupLabel className="cursor-default">
Bundles
<SidebarGroupLabel className="cursor-default w-full flex items-center justify-between">
<span>Bundles</span>
{showSearchConfigTrigger && ENABLE_NEW_SIDEBAR && (
<SearchConfigTrigger
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
)}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>

View file

@ -1,63 +1,89 @@
import { useState } from "react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { Button } from "@/components/ui/button";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { CustomLink } from "@/customization/components/custom-link";
import { ENABLE_LANGFLOW_STORE } from "@/customization/feature-flags";
import { SidebarMenuButton, useSidebar } from "@/components/ui/sidebar";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import AddMcpServerModal from "@/modals/addMcpServerModal";
const SidebarMenuButtons = ({
hasStore = false,
customComponent,
addComponent,
isLoading = false,
}) => {
const { activeSection } = useSidebar();
const [addMcpOpen, setAddMcpOpen] = useState(false);
const navigate = useCustomNavigate();
const handleAddMcpServerClick = () => {
setAddMcpOpen(true);
};
return (
<>
{/* TODO: Remove this on cleanup */}
{ENABLE_LANGFLOW_STORE && hasStore && (
<SidebarMenuButton asChild>
<CustomLink
to="/store"
target="_blank"
rel="noopener noreferrer"
className="group/discover"
>
<div className="flex w-full items-center gap-2">
{ENABLE_NEW_SIDEBAR && activeSection === "mcp" ? (
<>
<SidebarMenuButton asChild>
<Button
unstyled
disabled={isLoading}
onClick={handleAddMcpServerClick}
data-testid="sidebar-add-mcp-server-button"
className="flex items-center gap-2"
>
<ForwardedIconComponent
name="Store"
name="Plus"
className="h-4 w-4 text-muted-foreground"
/>
<span className="flex-1 group-data-[state=open]/collapsible:font-semibold">
Discover more components
<span className="group-data-[state=open]/collapsible:font-semibold">
Add MCP Server
</span>
</Button>
</SidebarMenuButton>
<SidebarMenuButton asChild>
<Button
unstyled
disabled={isLoading}
onClick={() => {
navigate("/settings/mcp-servers");
}}
data-testid="sidebar-manage-servers-button"
className="flex items-center gap-2"
>
<ForwardedIconComponent
name="SquareArrowOutUpRight"
className="h-4 w-4 opacity-0 transition-all group-hover/discover:opacity-100"
name="ArrowUpRight"
className="h-4 w-4 text-muted-foreground"
/>
</div>
</CustomLink>
<span className="group-data-[state=open]/collapsible:font-semibold">
Manage Servers
</span>
</Button>
</SidebarMenuButton>
<AddMcpServerModal open={addMcpOpen} setOpen={setAddMcpOpen} />
</>
) : (
<SidebarMenuButton asChild>
<Button
unstyled
disabled={isLoading}
onClick={() => {
if (customComponent) {
addComponent(customComponent, "CustomComponent");
}
}}
data-testid="sidebar-custom-component-button"
className="flex items-center gap-2"
>
<ForwardedIconComponent
name="Plus"
className="h-4 w-4 text-muted-foreground"
/>
<span className="group-data-[state=open]/collapsible:font-semibold">
New Custom Component
</span>
</Button>
</SidebarMenuButton>
)}
<SidebarMenuButton asChild>
<Button
unstyled
disabled={isLoading}
onClick={() => {
if (customComponent) {
addComponent(customComponent, "CustomComponent");
}
}}
data-testid="sidebar-custom-component-button"
className="flex items-center gap-2"
>
<ForwardedIconComponent
name="Plus"
className="h-4 w-4 text-muted-foreground"
/>
<span className="group-data-[state=open]/collapsible:font-semibold">
New Custom Component
</span>
</Button>
</SidebarMenuButton>
</>
);
};

View file

@ -9,6 +9,7 @@ import {
DisclosureTrigger,
} from "@/components/ui/disclosure";
import { SidebarHeader, SidebarTrigger } from "@/components/ui/sidebar";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import type { SidebarHeaderComponentProps } from "../types";
import FeatureToggles from "./featureTogglesComponent";
import { SearchInput } from "./searchInput";
@ -33,41 +34,43 @@ export const SidebarHeaderComponent = memo(function SidebarHeaderComponent({
data,
}: SidebarHeaderComponentProps) {
return (
<SidebarHeader className="flex w-full flex-col gap-4 p-4 pb-1">
<Disclosure open={showConfig} onOpenChange={setShowConfig}>
<div className="flex w-full items-center gap-2">
<SidebarTrigger className="text-muted-foreground">
<ForwardedIconComponent name="PanelLeftClose" />
</SidebarTrigger>
<h3 className="flex-1 cursor-default text-sm font-semibold">
Components
</h3>
<DisclosureTrigger>
<div>
<ShadTooltip content="Component settings" styleClasses="z-50">
<Button
variant={showConfig ? "ghostActive" : "ghost"}
size="iconMd"
data-testid="sidebar-options-trigger"
>
<ForwardedIconComponent
name="SlidersHorizontal"
className="h-4 w-4"
/>
</Button>
</ShadTooltip>
</div>
</DisclosureTrigger>
</div>
<DisclosureContent>
<FeatureToggles
showBeta={showBeta}
setShowBeta={setShowBeta}
showLegacy={showLegacy}
setShowLegacy={setShowLegacy}
/>
</DisclosureContent>
</Disclosure>
<SidebarHeader className="flex w-full flex-col gap-2 p-4 pb-1 group-data-[collapsible=icon]:hidden">
{!ENABLE_NEW_SIDEBAR && (
<Disclosure open={showConfig} onOpenChange={setShowConfig}>
<div className="flex w-full items-center gap-2">
<SidebarTrigger className="text-muted-foreground">
<ForwardedIconComponent name="PanelLeftClose" />
</SidebarTrigger>
<h3 className="flex-1 cursor-default text-sm font-semibold">
Components
</h3>
<DisclosureTrigger>
<div>
<ShadTooltip content="Component settings" styleClasses="z-50">
<Button
variant={showConfig ? "ghostActive" : "ghost"}
size="iconMd"
data-testid="sidebar-options-trigger"
>
<ForwardedIconComponent
name="SlidersHorizontal"
className="h-4 w-4"
/>
</Button>
</ShadTooltip>
</div>
</DisclosureTrigger>
</div>
<DisclosureContent>
<FeatureToggles
showBeta={showBeta}
setShowBeta={setShowBeta}
showLegacy={showLegacy}
setShowLegacy={setShowLegacy}
/>
</DisclosureContent>
</Disclosure>
)}
<SearchInput
searchInputRef={searchInputRef}
isInputFocused={isInputFocused}
@ -87,6 +90,18 @@ export const SidebarHeaderComponent = memo(function SidebarHeaderComponent({
}}
/>
)}
{ENABLE_NEW_SIDEBAR && (
<Disclosure open={showConfig} onOpenChange={setShowConfig}>
<DisclosureContent>
<FeatureToggles
showBeta={showBeta}
setShowBeta={setShowBeta}
showLegacy={showLegacy}
setShowLegacy={setShowLegacy}
/>
</DisclosureContent>
</Disclosure>
)}
</SidebarHeader>
);
});

View file

@ -0,0 +1,98 @@
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import ShadTooltip from "@/components/common/shadTooltipComponent";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
type SidebarSection,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/utils/utils";
import { useSearchContext } from "../index";
export type { SidebarSection };
interface NavItem {
id: SidebarSection;
icon: string;
label: string;
tooltip: string;
}
export const NAV_ITEMS: NavItem[] = [
{
id: "search",
icon: "search",
label: "Search",
tooltip: "Search",
},
{
id: "components",
icon: "component",
label: "Components",
tooltip: "Components",
},
{
id: "mcp",
icon: "Mcp",
label: "MCP",
tooltip: "MCP",
},
{
id: "bundles",
icon: "blocks",
label: "Bundles",
tooltip: "Bundles",
},
];
export default function SidebarSegmentedNav() {
const { activeSection, setActiveSection, toggleSidebar, open } = useSidebar();
const { focusSearch, isSearchFocused, setSearch } = useSearchContext();
return (
<div className="flex h-full flex-col border-r border-border bg-background">
<SidebarMenu className="gap-2 p-1">
{NAV_ITEMS.map((item) => (
<SidebarMenuItem key={item.id}>
<ShadTooltip content={item.tooltip} side="right">
<SidebarMenuButton
size="md"
onClick={() => {
setSearch?.("");
if (activeSection === item.id && open) {
toggleSidebar();
} else {
setActiveSection(item.id);
if (!open) {
toggleSidebar();
}
// Focus search input when search section is selected
if (item.id === "search") {
// Add a small delay to ensure the sidebar is open and input is rendered
setTimeout(() => focusSearch(), 100);
}
}
}}
isActive={
activeSection === item.id ||
(item.id === "search" && isSearchFocused)
}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md p-0 transition-all duration-200",
activeSection === item.id ||
(item.id === "search" && isSearchFocused)
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`sidebar-nav-${item.id}`}
>
<ForwardedIconComponent name={item.icon} className="h-5 w-5" />
<span className="sr-only">{item.label}</span>
</SidebarMenuButton>
</ShadTooltip>
</SidebarMenuItem>
))}
</SidebarMenu>
</div>
);
}

View file

@ -1,6 +1,15 @@
import Fuse from "fuse.js";
import { cloneDeep } from "lodash";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useShallow } from "zustand/react/shallow";
import {
@ -10,25 +19,27 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import SkeletonGroup from "@/components/ui/skeletonGroup";
import { useGetMCPServers } from "@/controllers/API/queries/mcp/use-get-mcp-servers";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { useAddComponent } from "@/hooks/use-add-component";
import { useShortcutsStore } from "@/stores/shortcuts";
import { useStoreStore } from "@/stores/storeStore";
import { checkChatInput, checkWebhookInput } from "@/utils/reactflowUtils";
import {
nodeColors,
SIDEBAR_BUNDLES,
SIDEBAR_CATEGORIES,
} from "@/utils/styleUtils";
import useAlertStore from "../../../../stores/alertStore";
import { cn } from "@/utils/utils";
import useFlowStore from "../../../../stores/flowStore";
import { useTypesStore } from "../../../../stores/typesStore";
import type { APIClassType } from "../../../../types/api";
import isWrappedWithClass from "../PageComponent/utils/is-wrapped-with-class";
import { CategoryGroup } from "./components/categoryGroup";
import NoResultsMessage from "./components/emptySearchComponent";
import McpSidebarGroup from "./components/McpSidebarGroup";
import MemoizedSidebarGroup from "./components/sidebarBundles";
import SidebarMenuButtons from "./components/sidebarFooterButtons";
import { SidebarHeaderComponent } from "./components/sidebarHeader";
import SidebarSegmentedNav from "./components/sidebarSegmentedNav";
import { applyBetaFilter } from "./helpers/apply-beta-filter";
import { applyEdgeFilter } from "./helpers/apply-edge-filter";
import { applyLegacyFilter } from "./helpers/apply-legacy-filter";
@ -42,6 +53,95 @@ import { UniqueInputsComponents } from "./types";
const CATEGORIES = SIDEBAR_CATEGORIES;
const BUNDLES = SIDEBAR_BUNDLES;
// Search context for the sidebar
export type SearchContextType = {
focusSearch: () => void;
isSearchFocused: boolean;
// Additional properties for the sidebar to use
search?: string;
setSearch?: (value: string) => void;
searchInputRef?: React.RefObject<HTMLInputElement>;
handleInputFocus?: () => void;
handleInputBlur?: () => void;
handleInputChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
};
export const SearchContext = createContext<SearchContextType | null>(null);
export function useSearchContext() {
const context = useContext(SearchContext);
if (!context) {
throw new Error("useSearchContext must be used within SearchProvider");
}
return context;
}
interface SearchProviderProps {
children: React.ReactNode;
searchInputRef: React.RefObject<HTMLInputElement>;
isSearchFocused: boolean;
}
// Create a provider that can be used at the FlowPage level
export function FlowSearchProvider({
children,
}: {
children: React.ReactNode;
}) {
const [search, setSearch] = useState("");
const [isInputFocused, setIsInputFocused] = useState(false);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const focusSearchInput = useCallback(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, []);
const handleInputFocus = useCallback(() => {
setIsInputFocused(true);
}, []);
const handleInputBlur = useCallback(() => {
setIsInputFocused(false);
}, []);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearch(event.target.value);
},
[],
);
const searchContextValue = useMemo(
() => ({
focusSearch: focusSearchInput,
isSearchFocused: isInputFocused,
// Also expose the search state and handlers for the sidebar to use
search,
setSearch,
searchInputRef,
handleInputFocus,
handleInputBlur,
handleInputChange,
}),
[
focusSearchInput,
isInputFocused,
search,
handleInputFocus,
handleInputBlur,
handleInputChange,
],
);
return (
<SearchContext.Provider value={searchContextValue}>
{children}
</SearchContext.Provider>
);
}
interface FlowSidebarComponentProps {
isLoading?: boolean;
showLegacy?: boolean;
@ -59,21 +159,72 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
})),
);
const hasStore = useStoreStore((state) => state.hasStore);
const { setOpen } = useSidebar();
const { activeSection, setOpen, setActiveSection } = useSidebar();
const addComponent = useAddComponent();
// Get MCP servers for search functionality (only when new sidebar is enabled)
const {
data: mcpServers,
isLoading: mcpLoading,
isSuccess: mcpSuccess,
isError: mcpError,
} = useGetMCPServers({ enabled: ENABLE_NEW_SIDEBAR });
// Get search state from context
const context = useSearchContext();
// Unconditional fallback ref to satisfy Rules of Hooks
const fallbackSearchInputRef = useRef<HTMLInputElement | null>(null);
const {
search = "",
setSearch = () => {},
searchInputRef = fallbackSearchInputRef,
isSearchFocused = false,
handleInputFocus = () => {},
handleInputBlur = () => {},
handleInputChange = () => {},
} = context;
// State
const [dataFilter, setFilterData] = useState(data);
const [search, setSearch] = useState("");
const [fuse, setFuse] = useState<Fuse<any> | null>(null);
const [openCategories, setOpenCategories] = useState<string[]>([]);
const [showConfig, setShowConfig] = useState(false);
const [showBeta, setShowBeta] = useState(true);
const [showLegacy, setShowLegacy] = useState(false);
const [isInputFocused, setIsInputFocused] = useState(false);
const [mcpSearchData, setMcpSearchData] = useState<any[]>([]);
const searchInputRef = useRef<HTMLInputElement | null>(null);
// Create base data that includes MCP category when available
const baseData = useMemo(() => {
if (mcpSuccess && mcpServers && data["agents"]?.["MCPTools"]) {
const mcpComponent = data["agents"]["MCPTools"];
const newMcpSearchData = mcpServers.map((mcpServer) => ({
...mcpComponent,
display_name: mcpServer.name,
description: `MCP Server: ${mcpServer.name}`,
category: "MCP",
key: `mcp_${mcpServer.name}`,
template: {
...mcpComponent.template,
mcp_server: {
...mcpComponent.template.mcp_server,
value: mcpServer,
},
},
}));
const mcpCategoryData: Record<string, any> = {};
newMcpSearchData.forEach((mcp) => {
mcpCategoryData[mcp.display_name] = mcp;
});
return {
...data,
MCP: mcpCategoryData,
};
}
return data;
}, [data, mcpSuccess, mcpServers]);
const [dataFilter, setFilterData] = useState(baseData);
const customComponent = useMemo(() => {
return data?.["custom_component"]?.["CustomComponent"] ?? null;
@ -88,23 +239,29 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
item: { ...result.item, score: result.score },
}));
const fuseCategories = fuseResults.map((result) => result.item.category);
const combinedResults = combinedResultsFn(fuseResults, baseData);
const traditionalResults = traditionalSearchMetadata(baseData, searchTerm);
return {
fuseResults,
fuseCategories: fuseResults.map((result) => result.item.category),
combinedResults: combinedResultsFn(fuseResults, data),
traditionalResults: traditionalSearchMetadata(data, searchTerm),
fuseCategories,
combinedResults,
traditionalResults,
};
}, [search, fuse, data]);
}, [search, fuse, baseData]);
const searchFilteredData = useMemo(() => {
if (!search || !searchResults) return cloneDeep(data);
if (!search || !searchResults) return cloneDeep(baseData);
return filteredDataFn(
data,
const filteredData = filteredDataFn(
baseData,
searchResults.combinedResults,
searchResults.traditionalResults,
);
}, [data, search, searchResults]);
return filteredData;
}, [baseData, search, searchResults]);
const sortedCategories = useMemo(() => {
if (!searchResults || !searchFilteredData) return [];
@ -138,9 +295,10 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
const hasResults = useMemo(() => {
return Object.entries(dataFilter).some(
([category, items]) =>
Object.keys(items).length > 0 &&
(CATEGORIES.find((c) => c.name === category) ||
BUNDLES.find((b) => b.name === category)),
(Object.keys(items).length > 0 &&
(CATEGORIES.find((c) => c.name === category) ||
BUNDLES.find((b) => b.name === category))) ||
(dataFilter["MCP"] && Object.keys(dataFilter["MCP"]).length > 0),
);
}, [dataFilter]);
@ -160,32 +318,14 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
const handleClearSearch = useCallback(() => {
setSearch("");
setFilterData(data);
setFilterData(baseData);
setOpenCategories([]);
}, [data]);
const handleInputFocus = useCallback(() => {
setIsInputFocused(true);
}, []);
const handleInputBlur = useCallback(() => {
setIsInputFocused(false);
}, []);
const handleSearchInput = useCallback((value: string) => {
setSearch(value);
}, []);
const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
handleSearchInput(event.target.value);
},
[handleSearchInput],
);
}, [baseData, setSearch]);
useEffect(() => {
if (filterType) {
setOpen(true);
setActiveSection("search");
}
}, [filterType, setOpen]);
@ -200,14 +340,25 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
}
}, [finalFilteredData, search, filterType, getFilterEdge]);
// Update dataFilter when baseData changes
useEffect(() => {
setFilterData(baseData);
}, [baseData]);
useEffect(() => {
const options = {
keys: ["display_name", "description", "type", "category"],
keys: [
"display_name",
"description",
"type",
"category",
"mcpServerName",
],
threshold: 0.2,
includeScore: true,
};
const fuseData = Object.entries(data).flatMap(([category, items]) =>
const fuseData = Object.entries(baseData).flatMap(([category, items]) =>
Object.entries(items).map(([key, value]) => ({
...value,
category,
@ -215,14 +366,36 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
})),
);
// MCP data is already included in baseData, but we still need mcpSearchData for non-search display
if (mcpSuccess && mcpServers && data["agents"]?.["MCPTools"]) {
const mcpComponent = data["agents"]["MCPTools"];
const newMcpSearchData = mcpServers.map((mcpServer) => ({
...mcpComponent,
mcpServerName: mcpServer.name, // adds this field and makes it searchable
category: "MCP",
key: `mcp_${mcpServer.name}`,
template: {
...mcpComponent.template,
mcp_server: {
...mcpComponent.template.mcp_server,
value: mcpServer,
},
},
}));
setMcpSearchData(newMcpSearchData);
// No need to push to fuseData since it's already in baseData
} else {
setMcpSearchData([]);
}
setFuse(new Fuse(fuseData, options));
}, [data]);
}, [baseData, mcpSuccess, mcpServers]);
useEffect(() => {
if (getFilterEdge.length !== 0) {
setSearch("");
}
}, [getFilterEdge, data]);
}, [getFilterEdge, baseData]);
useEffect(() => {
if (search === "" && getFilterEdge.length === 0) {
@ -255,7 +428,7 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
},
{
enableOnFormTags: true,
enabled: isInputFocused,
enabled: isSearchFocused,
},
);
@ -277,15 +450,46 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
[],
);
const hasBundleItems = useMemo(
() =>
BUNDLES.some(
(item) =>
dataFilter[item.name] &&
Object.keys(dataFilter[item.name]).length > 0,
),
[dataFilter],
);
const hasCoreComponents = useMemo(() => {
const categoriesWithItems = CATEGORIES.filter(
(item) =>
dataFilter[item.name] && Object.keys(dataFilter[item.name]).length > 0,
);
const result = categoriesWithItems.length > 0;
return result;
}, [dataFilter]);
const hasBundleItems = useMemo(() => {
const bundlesWithItems = BUNDLES.filter(
(item) =>
dataFilter[item.name] && Object.keys(dataFilter[item.name]).length > 0,
);
const result = bundlesWithItems.length > 0;
return result;
}, [dataFilter]);
const hasMcpComponents = useMemo(() => {
return dataFilter["MCP"] && Object.keys(dataFilter["MCP"]).length > 0;
}, [dataFilter]);
const hasMcpServers = Boolean(mcpServers && mcpServers.length > 0);
const hasSearchInput = search !== "" || filterType !== undefined;
console.log("hasSearchInput", hasSearchInput);
const showComponents =
(ENABLE_NEW_SIDEBAR &&
hasCoreComponents &&
(activeSection === "components" || activeSection === "search")) ||
(hasSearchInput && hasCoreComponents && ENABLE_NEW_SIDEBAR) ||
!ENABLE_NEW_SIDEBAR;
const showBundles =
(hasBundleItems && ENABLE_NEW_SIDEBAR && activeSection === "bundles") ||
(hasSearchInput && hasBundleItems && ENABLE_NEW_SIDEBAR) ||
!ENABLE_NEW_SIDEBAR;
const showMcp =
(ENABLE_NEW_SIDEBAR && activeSection === "mcp") ||
(hasSearchInput && hasMcpComponents && ENABLE_NEW_SIDEBAR);
return (
<Sidebar
@ -293,81 +497,136 @@ export function FlowSidebarComponent({ isLoading }: FlowSidebarComponentProps) {
data-testid="shad-sidebar"
className="noflow select-none"
>
<SidebarHeaderComponent
showConfig={showConfig}
setShowConfig={setShowConfig}
showBeta={showBeta}
setShowBeta={setShowBeta}
showLegacy={showLegacy}
setShowLegacy={setShowLegacy}
searchInputRef={searchInputRef}
isInputFocused={isInputFocused}
search={search}
handleInputFocus={handleInputFocus}
handleInputBlur={handleInputBlur}
handleInputChange={handleInputChange}
filterType={filterType}
setFilterEdge={setFilterEdge}
setFilterData={setFilterData}
data={data}
/>
<div className="flex h-full">
{ENABLE_NEW_SIDEBAR && <SidebarSegmentedNav />}
<div
className={cn(
"flex flex-col h-full w-full group-data-[collapsible=icon]:hidden",
ENABLE_NEW_SIDEBAR && "sidebar-segmented",
)}
>
<SidebarHeaderComponent
showConfig={showConfig}
setShowConfig={setShowConfig}
showBeta={showBeta}
setShowBeta={setShowBeta}
showLegacy={showLegacy}
setShowLegacy={setShowLegacy}
searchInputRef={searchInputRef}
isInputFocused={isSearchFocused}
search={search}
handleInputFocus={handleInputFocus}
handleInputBlur={handleInputBlur}
handleInputChange={handleInputChange}
filterType={filterType}
setFilterEdge={setFilterEdge}
setFilterData={setFilterData}
data={baseData}
/>
<SidebarContent>
{isLoading ? (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1 p-3">
<SkeletonGroup count={13} className="my-0.5 h-7" />
</div>
<div className="h-8" />
<div className="flex flex-col gap-1 px-3 pt-2">
<SkeletonGroup count={21} className="my-0.5 h-7" />
</div>
</div>
) : (
<>
{hasResults ? (
<SidebarContent
segmentedSidebar={ENABLE_NEW_SIDEBAR}
className="flex-1 group-data-[collapsible=icon]:hidden"
>
{isLoading ? (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1 p-3">
<SkeletonGroup count={13} className="my-0.5 h-7" />
</div>
<div className="h-8" />
<div className="flex flex-col gap-1 px-3 pt-2">
<SkeletonGroup count={21} className="my-0.5 h-7" />
</div>
</div>
) : (
<>
<CategoryGroup
dataFilter={dataFilter}
sortedCategories={sortedCategories}
CATEGORIES={CATEGORIES}
openCategories={openCategories}
setOpenCategories={setOpenCategories}
search={search}
nodeColors={nodeColors}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
/>
{hasBundleItems && (
<MemoizedSidebarGroup
BUNDLES={BUNDLES}
search={search}
sortedCategories={sortedCategories}
dataFilter={dataFilter}
nodeColors={nodeColors}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
openCategories={openCategories}
setOpenCategories={setOpenCategories}
handleKeyDownInput={handleKeyDownInput}
{hasResults ? (
<>
{showComponents && (
<CategoryGroup
dataFilter={dataFilter}
sortedCategories={sortedCategories}
CATEGORIES={CATEGORIES}
openCategories={openCategories}
setOpenCategories={setOpenCategories}
search={search}
nodeColors={nodeColors}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
)}
{showMcp && (
<McpSidebarGroup
mcpComponents={
hasSearchInput
? Object.values(dataFilter["MCP"] || {})
: mcpSearchData
}
nodeColors={nodeColors}
onDragStart={onDragStart}
openCategories={openCategories}
setOpenCategories={setOpenCategories}
mcpServers={mcpServers}
mcpLoading={mcpLoading}
mcpSuccess={mcpSuccess}
mcpError={mcpError}
search={search}
hasMcpServers={hasMcpServers}
showSearchConfigTrigger={
activeSection !== "mcp" &&
!showComponents &&
showBundles
}
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
)}
{showBundles && (
<MemoizedSidebarGroup
BUNDLES={BUNDLES}
search={search}
sortedCategories={sortedCategories}
dataFilter={dataFilter}
nodeColors={nodeColors}
onDragStart={onDragStart}
sensitiveSort={sensitiveSort}
openCategories={openCategories}
setOpenCategories={setOpenCategories}
handleKeyDownInput={handleKeyDownInput}
showSearchConfigTrigger={
activeSection === "bundles" ||
(!showComponents && !showMcp)
}
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
)}
</>
) : (
<NoResultsMessage
onClearSearch={handleClearSearch}
showConfig={showConfig}
setShowConfig={setShowConfig}
/>
)}
</>
) : (
<NoResultsMessage onClearSearch={handleClearSearch} />
)}
</>
)}
</SidebarContent>
<SidebarFooter className="border-t p-4 py-3">
<SidebarMenuButtons
hasStore={hasStore}
customComponent={customComponent}
addComponent={addComponent}
isLoading={isLoading}
/>
</SidebarFooter>
</SidebarContent>
{ENABLE_NEW_SIDEBAR &&
activeSection === "mcp" &&
!hasMcpServers ? null : (
<SidebarFooter className="border-t p-4 py-3 group-data-[collapsible=icon]:hidden">
<SidebarMenuButtons
customComponent={customComponent}
addComponent={addComponent}
isLoading={isLoading}
/>
</SidebarFooter>
)}
</div>
</div>
</Sidebar>
);
}

View file

@ -22,6 +22,8 @@ export interface CategoryGroupProps {
data: { type: string; node?: APIClassType },
) => void;
sensitiveSort: (a: string, b: string) => number;
showConfig: boolean;
setShowConfig: (show: boolean) => void;
}
export interface SidebarGroupProps {
@ -41,6 +43,9 @@ export interface SidebarGroupProps {
) => void;
openCategories: string[];
setOpenCategories: Dispatch<SetStateAction<string[]>>;
showSearchConfigTrigger: boolean;
showConfig: boolean;
setShowConfig: (show: boolean) => void;
}
export interface BundleItemProps {

View file

@ -3,6 +3,7 @@ import { useBlocker, useParams } from "react-router-dom";
import { SidebarProvider } from "@/components/ui/sidebar";
import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow";
import { useGetTypes } from "@/controllers/API/queries/flows/use-get-types";
import { ENABLE_NEW_SIDEBAR } from "@/customization/feature-flags";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import useSaveFlow from "@/hooks/flows/use-save-flow";
import { useIsMobile } from "@/hooks/use-mobile";
@ -12,7 +13,10 @@ import { useTypesStore } from "@/stores/typesStore";
import { customStringify } from "@/utils/reactflowUtils";
import useFlowStore from "../../stores/flowStore";
import useFlowsManagerStore from "../../stores/flowsManagerStore";
import { FlowSidebarComponent } from "./components/flowSidebarComponent";
import {
FlowSearchProvider,
FlowSidebarComponent,
} from "./components/flowSidebarComponent";
import Page from "./components/PageComponent";
export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
@ -160,13 +164,19 @@ export default function FlowPage({ view }: { view?: boolean }): JSX.Element {
<div className="flow-page-positioning">
{currentFlow && (
<div className="flex h-full overflow-hidden">
<SidebarProvider width="17.5rem" defaultOpen={!isMobile}>
{!view && <FlowSidebarComponent isLoading={isLoading} />}
<main className="flex w-full overflow-hidden">
<div className="h-full w-full">
<Page setIsLoading={setIsLoading} />
</div>
</main>
<SidebarProvider
width="17.5rem"
defaultOpen={!isMobile}
segmentedSidebar={ENABLE_NEW_SIDEBAR}
>
<FlowSearchProvider>
{!view && <FlowSidebarComponent isLoading={isLoading} />}
<main className="flex w-full overflow-hidden">
<div className="h-full w-full">
<Page setIsLoading={setIsLoading} />
</div>
</main>
</FlowSearchProvider>
</SidebarProvider>
</div>
)}

View file

@ -37,6 +37,7 @@ test(
// Press Tab to focus first result
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
// Verify some expected chat-related components are visible
await expect(page.getByTestId("input_outputChat Input")).toBeVisible();
@ -60,6 +61,7 @@ test(
// Verify disclosures open with new search
await expect(page.getByTestId("processingPrompt Template")).toBeVisible();
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Tab");
await page.keyboard.press("Enter");

View file

@ -23,7 +23,7 @@ test(
await page
.getByTestId("input_outputText Input")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 0 },
targetPosition: { x: 50, y: 50 },
});
await page.getByTestId("canvas_controls_dropdown").click();
@ -38,7 +38,7 @@ test(
await page
.getByTestId("dataURL")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 100, y: 200 },
targetPosition: { x: 50, y: 300 },
});
//third component
@ -49,7 +49,7 @@ test(
await page
.getByTestId("processingSplit Text")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 300, y: 300 },
targetPosition: { x: 300, y: 500 },
});
//fourth component

View file

@ -30,7 +30,7 @@ test(
await page
.getByText("OpenAI Embeddings", { exact: true })
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 0 },
targetPosition: { x: 100, y: 100 },
});
await page.getByTestId("canvas_controls_dropdown").click();

View file

@ -70,7 +70,6 @@ test(
const elementTestIds = [
"input_outputChat Output",
"dataAPI Request",
"datastaxAstra DB",
"langchain_utilitiesTool Calling Agent",
"langchain_utilitiesConversationChain",
"mem0Mem0 Chat Memory",
@ -104,7 +103,6 @@ test(
"groqGroq",
"lmstudioLM Studio",
"maritalkMariTalk",
"mistralMistralAI",
"perplexityPerplexity",
"baiduQianfan",
"sambanovaSambaNova",

View file

@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
test(
"user should be able to see integrations in the sidebar if mvp_components is true",
"user should be able to see integrations in the sidebar when bundles is selected",
{ tag: ["@release", "@api", "@workspace"] },
async ({ page }) => {
await awaitBootstrapTest(page);
@ -11,7 +11,10 @@ test(
await page.waitForSelector('[data-testid="shad-sidebar"]', {
timeout: 30000,
});
await expect(page.getByText("Bundles")).toBeVisible();
await page.getByTestId("sidebar-nav-bundles").click();
await expect(
page.locator('[data-sidebar="group-label"]', { hasText: "Bundles" }),
).toBeVisible();
await expect(page.getByText("Notion")).toBeVisible();
await expect(page.getByText("AssemblyAI")).toBeVisible();
},

View file

@ -56,6 +56,8 @@ test(
// Verify actions modal is open
await expect(page.getByText("MCP Server Tools")).toBeVisible();
await page.waitForSelector("text=Flow Name", { timeout: 3000 });
// Select some actions
const rowsCount = await page.getByRole("row").count();
expect(rowsCount).toBeGreaterThan(0);
@ -241,7 +243,7 @@ test(
await page
.getByTestId("agentsMCP Tools")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 0 },
targetPosition: { x: 50, y: 50 },
});
await page.getByTestId("canvas_controls_dropdown").click();

View file

@ -22,7 +22,7 @@ test(
await page
.getByTestId("agentsMCP Tools")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 0 },
targetPosition: { x: 100, y: 100 },
});
await page.getByTestId("canvas_controls_dropdown").click();
@ -235,7 +235,7 @@ test(
await page
.getByTestId("agentsMCP Tools")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 0 },
targetPosition: { x: 100, y: 100 },
});
await page.getByTestId("canvas_controls_dropdown").click();
@ -410,7 +410,7 @@ test(
await page
.getByTestId("agentsMCP Tools")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 0 },
targetPosition: { x: 100, y: 100 },
});
await page.getByTestId("canvas_controls_dropdown").click();

View file

@ -34,7 +34,7 @@ test(
await page.keyboard.press("ControlOrMeta+Shift+m");
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "visible",
});
@ -43,7 +43,7 @@ test(
await page.keyboard.press("ControlOrMeta+Shift+m");
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "hidden",
});
@ -52,7 +52,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "visible",
});
@ -61,7 +61,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "hidden",
});
@ -70,7 +70,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "visible",
});
@ -79,7 +79,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "hidden",
});
@ -88,7 +88,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "visible",
});
@ -97,7 +97,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "hidden",
});
@ -106,7 +106,7 @@ test(
await page.getByTestId("tool-mode-button").click();
await page.waitForSelector("text=toolset", {
timeout: 3000,
timeout: 5000,
state: "visible",
});
@ -127,7 +127,7 @@ test(
await page
.getByTestId("agentsAgent")
.dragTo(page.locator('//*[@id="react-flow-id"]'), {
targetPosition: { x: 0, y: 500 },
targetPosition: { x: 50, y: 500 },
});
await page.getByTestId("canvas_controls_dropdown").click();