mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
feat(ui): redesign app sidebar navigation (#11994)
This commit is contained in:
@@ -13,10 +13,12 @@ import {
|
||||
import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat";
|
||||
import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading";
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import {
|
||||
APP_SIDEBAR_MODE,
|
||||
AppSidebarModeSync,
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { Chat } from "@/components/lighthouse-v1";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync";
|
||||
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
@@ -78,7 +80,7 @@ export default async function AIChatbot({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Lighthouse AI" icon={<LighthouseIcon />}>
|
||||
<SidebarNavigationModeSync mode={SIDEBAR_NAVIGATION_MODE.CHAT} />
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.CHAT} />
|
||||
{/* [contain:layout] traps streamdown's fixed fullscreen overlay inside
|
||||
the chat area so it never covers the sidebar or navbar. */}
|
||||
<div className="h-[calc(100dvh-6.5rem)] min-h-0 [contain:layout]">
|
||||
|
||||
@@ -5,9 +5,11 @@ import { getAllProviders } from "@/actions/providers";
|
||||
import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import {
|
||||
APP_SIDEBAR_MODE,
|
||||
AppSidebarModeSync,
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync";
|
||||
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -55,7 +57,7 @@ export default async function Home({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<SidebarNavigationModeSync mode={SIDEBAR_NAVIGATION_MODE.BROWSE} />
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderAccountSelectors providers={providersData?.data ?? []} />
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import { AppSidebarContent } from "./app-sidebar-content";
|
||||
import { useAppSidebarMode } from "./app-sidebar-mode-store";
|
||||
import { APP_SIDEBAR_MODE } from "./types";
|
||||
|
||||
const {
|
||||
openCloudUpgradeMock,
|
||||
openLaunchScanModalMock,
|
||||
pathnameValue,
|
||||
pushMock,
|
||||
} = vi.hoisted(() => ({
|
||||
openCloudUpgradeMock: vi.fn(),
|
||||
openLaunchScanModalMock: vi.fn(),
|
||||
pathnameValue: { current: "/findings" },
|
||||
pushMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameValue.current,
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks", () => ({
|
||||
useAuth: () => ({ permissions: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-runtime-config", () => ({
|
||||
useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
useScansStore: (
|
||||
selector: (state: {
|
||||
openLaunchScanModal: typeof openLaunchScanModalMock;
|
||||
}) => unknown,
|
||||
) => selector({ openLaunchScanModal: openLaunchScanModalMock }),
|
||||
useCloudUpgradeStore: (
|
||||
selector: (state: {
|
||||
openCloudUpgrade: typeof openCloudUpgradeMock;
|
||||
}) => unknown,
|
||||
) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({
|
||||
LighthouseV2SidebarChat: () => <div data-testid="lighthouse-chat-sidebar" />,
|
||||
}));
|
||||
|
||||
describe("AppSidebarContent", () => {
|
||||
beforeEach(() => {
|
||||
pathnameValue.current = "/findings";
|
||||
pushMock.mockClear();
|
||||
openCloudUpgradeMock.mockClear();
|
||||
openLaunchScanModalMock.mockClear();
|
||||
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0");
|
||||
const user = userEvent.setup();
|
||||
|
||||
// When
|
||||
render(<AppSidebarContent />);
|
||||
|
||||
// Then
|
||||
const homeLink = screen.getByRole("link", { name: "Prowler home" });
|
||||
expect(homeLink).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Launch Scan" })).toHaveAttribute(
|
||||
"href",
|
||||
"/scans?launchScan=true",
|
||||
);
|
||||
expect(screen.getByText("5.8.0")).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Chat" }));
|
||||
expect(openCloudUpgradeMock).toHaveBeenCalledWith(
|
||||
CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI,
|
||||
undefined,
|
||||
);
|
||||
expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
|
||||
|
||||
// When
|
||||
render(<AppSidebarContent />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Service status" }),
|
||||
).toHaveAttribute("href", "https://status.prowler.com");
|
||||
expect(
|
||||
screen.queryByText("All systems operational"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the current scan modal instead of navigating from the scans route", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
pathnameValue.current = "/scans";
|
||||
const user = userEvent.setup();
|
||||
|
||||
// When
|
||||
render(<AppSidebarContent />);
|
||||
await user.click(screen.getByRole("button", { name: "Launch Scan" }));
|
||||
|
||||
// Then
|
||||
expect(openLaunchScanModalMock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Launch Scan" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation";
|
||||
import { ProwlerBrand } from "@/components/icons";
|
||||
import { useAuth } from "@/hooks";
|
||||
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
import { useAppSidebarMode } from "./app-sidebar-mode-store";
|
||||
import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle";
|
||||
import { LaunchScanAction } from "./launch-scan-action";
|
||||
import { getNavigationConfig } from "./navigation-config";
|
||||
import { SidebarFooter } from "./sidebar-footer";
|
||||
import { SidebarNavigation } from "./sidebar-navigation";
|
||||
import { APP_SIDEBAR_MODE, type AppSidebarSelectionHandler } from "./types";
|
||||
|
||||
interface AppSidebarContentProps {
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
export function AppSidebarContent({ onSelect }: AppSidebarContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
const { apiDocsUrl } = useRuntimeConfig();
|
||||
const mode = useAppSidebarMode((state) => state.mode);
|
||||
const isCloudEnvironment = isCloud();
|
||||
const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions });
|
||||
const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 px-5 pt-6 pb-7">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Prowler home"
|
||||
className="focus-visible:ring-button-primary/50 flex h-8 items-center rounded-md focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<ProwlerBrand />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-3 px-3 pb-1">
|
||||
<LaunchScanAction onSelect={onSelect} />
|
||||
<AppSidebarModeToggle
|
||||
chatEnabled={isCloudEnvironment}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{showChat ? (
|
||||
<LighthouseV2SidebarChat isOpen />
|
||||
) : (
|
||||
<SidebarNavigation sections={sections} onSelect={onSelect} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SidebarFooter
|
||||
isCloudEnvironment={isCloudEnvironment}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
migrateAppSidebarState,
|
||||
useAppSidebarMode,
|
||||
} from "./app-sidebar-mode-store";
|
||||
import { APP_SIDEBAR_MODE } from "./types";
|
||||
|
||||
describe("app sidebar mode store", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE });
|
||||
});
|
||||
|
||||
it("keeps the persisted chat mode while discarding legacy sidebar state", () => {
|
||||
// Given
|
||||
const legacyState = {
|
||||
isOpen: false,
|
||||
isHover: true,
|
||||
navigationMode: APP_SIDEBAR_MODE.CHAT,
|
||||
settings: { disabled: false, isHoverOpen: false },
|
||||
};
|
||||
|
||||
// When
|
||||
const migrated = migrateAppSidebarState(legacyState);
|
||||
|
||||
// Then
|
||||
expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.CHAT });
|
||||
expect(migrated).not.toHaveProperty("isOpen");
|
||||
expect(migrated).not.toHaveProperty("settings");
|
||||
});
|
||||
|
||||
it("falls back to browse for an invalid persisted mode", () => {
|
||||
// Given / When
|
||||
const migrated = migrateAppSidebarState({ navigationMode: "invalid" });
|
||||
|
||||
// Then
|
||||
expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.BROWSE });
|
||||
});
|
||||
|
||||
it("rehydrates the legacy payload under the existing sidebar key", async () => {
|
||||
// Given
|
||||
localStorage.setItem(
|
||||
"sidebar",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
navigationMode: APP_SIDEBAR_MODE.CHAT,
|
||||
isOpen: false,
|
||||
isHover: true,
|
||||
settings: { disabled: true },
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await useAppSidebarMode.persist.rehydrate();
|
||||
|
||||
// Then
|
||||
expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT);
|
||||
expect(useAppSidebarMode.getState()).not.toEqual(
|
||||
expect.objectContaining({ isOpen: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the current navigation mode", () => {
|
||||
// Given / When
|
||||
useAppSidebarMode.getState().setMode(APP_SIDEBAR_MODE.CHAT);
|
||||
|
||||
// Then
|
||||
expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
import { APP_SIDEBAR_MODE, type AppSidebarMode } from "./types";
|
||||
|
||||
interface PersistedAppSidebarState {
|
||||
mode: AppSidebarMode;
|
||||
}
|
||||
|
||||
interface AppSidebarModeStore extends PersistedAppSidebarState {
|
||||
setMode: (mode: AppSidebarMode) => void;
|
||||
}
|
||||
|
||||
function isAppSidebarMode(value: unknown): value is AppSidebarMode {
|
||||
return Object.values(APP_SIDEBAR_MODE).some((mode) => mode === value);
|
||||
}
|
||||
|
||||
export function migrateAppSidebarState(
|
||||
persistedState: unknown,
|
||||
): PersistedAppSidebarState {
|
||||
if (typeof persistedState !== "object" || persistedState === null) {
|
||||
return { mode: APP_SIDEBAR_MODE.BROWSE };
|
||||
}
|
||||
|
||||
if ("mode" in persistedState && isAppSidebarMode(persistedState.mode)) {
|
||||
return { mode: persistedState.mode };
|
||||
}
|
||||
|
||||
if (
|
||||
"navigationMode" in persistedState &&
|
||||
isAppSidebarMode(persistedState.navigationMode)
|
||||
) {
|
||||
return { mode: persistedState.navigationMode };
|
||||
}
|
||||
|
||||
return { mode: APP_SIDEBAR_MODE.BROWSE };
|
||||
}
|
||||
|
||||
export const useAppSidebarMode = create<AppSidebarModeStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: APP_SIDEBAR_MODE.BROWSE,
|
||||
setMode: (mode) => set({ mode }),
|
||||
}),
|
||||
{
|
||||
name: "sidebar",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...migrateAppSidebarState(persistedState),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useAppSidebarMode } from "./app-sidebar-mode-store";
|
||||
import { AppSidebarModeSync } from "./app-sidebar-mode-sync";
|
||||
import { APP_SIDEBAR_MODE } from "./types";
|
||||
|
||||
describe("AppSidebarModeSync", () => {
|
||||
beforeEach(() => {
|
||||
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT });
|
||||
});
|
||||
|
||||
it("restores the requested sidebar mode when a route mounts", () => {
|
||||
// Given / When
|
||||
render(<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />);
|
||||
|
||||
// Then
|
||||
expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
|
||||
import { useAppSidebarMode } from "./app-sidebar-mode-store";
|
||||
import type { AppSidebarMode } from "./types";
|
||||
|
||||
interface AppSidebarModeSyncProps {
|
||||
mode: AppSidebarMode;
|
||||
}
|
||||
|
||||
export function AppSidebarModeSync({ mode }: AppSidebarModeSyncProps) {
|
||||
const setMode = useAppSidebarMode((state) => state.setMode);
|
||||
|
||||
useMountEffect(() => {
|
||||
setMode(mode);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Home } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { NavigationButton } from "@/components/shadcn/navigation-button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import { useAppSidebarMode } from "./app-sidebar-mode-store";
|
||||
import {
|
||||
APP_SIDEBAR_MODE,
|
||||
type AppSidebarMode,
|
||||
type AppSidebarSelectionHandler,
|
||||
} from "./types";
|
||||
|
||||
interface AppSidebarModeToggleProps {
|
||||
chatEnabled: boolean;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
value: APP_SIDEBAR_MODE.BROWSE,
|
||||
label: "Home",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
value: APP_SIDEBAR_MODE.CHAT,
|
||||
label: "Chat",
|
||||
icon: LighthouseIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AppSidebarModeToggle({
|
||||
chatEnabled,
|
||||
onSelect,
|
||||
}: AppSidebarModeToggleProps) {
|
||||
const router = useRouter();
|
||||
const mode = useAppSidebarMode((state) => state.mode);
|
||||
const setMode = useAppSidebarMode((state) => state.setMode);
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
const selectMode = (nextMode: AppSidebarMode) => {
|
||||
const isChatUpsell = nextMode === APP_SIDEBAR_MODE.CHAT && !chatEnabled;
|
||||
|
||||
if (isChatUpsell) {
|
||||
openCloudUpgrade(
|
||||
CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI,
|
||||
onSelect?.() ?? undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setMode(nextMode);
|
||||
onSelect?.();
|
||||
|
||||
if (nextMode === APP_SIDEBAR_MODE.CHAT) {
|
||||
router.push("/lighthouse");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Sidebar view"
|
||||
className="border-border-sidebar-toggle bg-bg-sidebar-toggle grid grid-cols-2 gap-1 rounded-xl border p-1"
|
||||
>
|
||||
{MODES.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.value === mode;
|
||||
const isCloudUpsell =
|
||||
item.value === APP_SIDEBAR_MODE.CHAT && !chatEnabled;
|
||||
const button = (
|
||||
<NavigationButton
|
||||
key={item.value}
|
||||
variant="toggle"
|
||||
active={isActive}
|
||||
aria-label={item.label}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => selectMode(item.value)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
{isCloudUpsell && (
|
||||
<Badge variant="cloud" size="sm">
|
||||
Cloud
|
||||
</Badge>
|
||||
)}
|
||||
</NavigationButton>
|
||||
);
|
||||
|
||||
if (!isCloudUpsell) return button;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.value} delayDuration={100}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Available in Prowler Cloud
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppSidebarContent } from "./app-sidebar-content";
|
||||
|
||||
export function AppSidebar() {
|
||||
return (
|
||||
<aside className="border-border-neutral-secondary bg-bg-neutral-primary fixed inset-y-0 left-0 z-20 hidden w-[264px] overflow-hidden border-r lg:block">
|
||||
<div aria-hidden="true" className="app-sidebar-halo" />
|
||||
<AppSidebarContent />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { AppSidebar } from "./app-sidebar";
|
||||
export { AppSidebarModeSync } from "./app-sidebar-mode-sync";
|
||||
export { MobileAppSidebar } from "./mobile-app-sidebar";
|
||||
export type { AppSidebarMode } from "./types";
|
||||
export { APP_SIDEBAR_MODE } from "./types";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { ScanLine } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
|
||||
import { useScansStore } from "@/store";
|
||||
|
||||
import type { AppSidebarSelectionHandler } from "./types";
|
||||
|
||||
interface LaunchScanActionProps {
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
function LaunchScanContent() {
|
||||
return (
|
||||
<>
|
||||
<ScanLine aria-hidden="true" className="size-5" />
|
||||
<span>Launch Scan</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
|
||||
const pathname = usePathname();
|
||||
const openLaunchScanModal = useScansStore(
|
||||
(state) => state.openLaunchScanModal,
|
||||
);
|
||||
const isScansPage = pathname.startsWith("/scans");
|
||||
|
||||
if (isScansPage) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
aria-label="Launch Scan"
|
||||
onClick={() => {
|
||||
openLaunchScanModal();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<LaunchScanContent />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button asChild size="lg" className="w-full">
|
||||
<Link href={LAUNCH_SCAN_HREF} aria-label="Launch Scan" onClick={onSelect}>
|
||||
<LaunchScanContent />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./app-sidebar-content", () => ({
|
||||
AppSidebarContent: ({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect?: () => HTMLElement | null;
|
||||
}) => (
|
||||
<button type="button" onClick={onSelect}>
|
||||
Alerts
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MobileAppSidebar } from "./mobile-app-sidebar";
|
||||
|
||||
describe("MobileAppSidebar", () => {
|
||||
it("replaces the open hamburger with a viewport X while the overlay is visible", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<MobileAppSidebar />);
|
||||
|
||||
// When
|
||||
const openButton = screen.getByRole("button", { name: "Open menu" });
|
||||
await user.click(openButton);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog", { name: "App sidebar" })).toBeVisible();
|
||||
const closeButton = screen.getByRole("button", { name: "Close menu" });
|
||||
expect(closeButton).toBeVisible();
|
||||
expect(closeButton.querySelector(".lucide-x")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Close" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.click(closeButton);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "App sidebar" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(openButton).toHaveFocus();
|
||||
});
|
||||
|
||||
it("closes after selecting an item from the shared sidebar content", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<MobileAppSidebar />);
|
||||
await user.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Alerts" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "App sidebar" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { MenuIcon, X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/shadcn/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AppSidebarContent } from "./app-sidebar-content";
|
||||
|
||||
export function MobileAppSidebar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleSelect = () => {
|
||||
setOpen(false);
|
||||
return triggerRef.current;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="bare"
|
||||
size="icon-sm"
|
||||
aria-label="Open menu"
|
||||
className={cn("lg:hidden", open && "invisible")}
|
||||
>
|
||||
<MenuIcon aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" variant="navigation" showCloseButton={false}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>App sidebar</SheetTitle>
|
||||
<SheetDescription>Primary application navigation</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Close menu"
|
||||
className="fixed top-4 right-4 z-[60]"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<AppSidebarContent onSelect={handleSelect} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import type { RolePermissionAttributes } from "@/types/users";
|
||||
|
||||
import {
|
||||
filterNavigationByPermissions,
|
||||
getNavigationConfig,
|
||||
} from "./navigation-config";
|
||||
import { NAVIGATION_ITEM_KIND } from "./types";
|
||||
|
||||
const getItem = (label: string) =>
|
||||
getNavigationConfig({ pathname: "/alerts", apiDocsUrl: null })
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === label);
|
||||
|
||||
const getConfigurationChildren = () => {
|
||||
const configuration = getItem("Configuration");
|
||||
|
||||
if (configuration?.kind !== NAVIGATION_ITEM_KIND.COLLAPSIBLE) {
|
||||
throw new Error("Configuration must be a collapsible navigation item");
|
||||
}
|
||||
|
||||
return configuration.children;
|
||||
};
|
||||
|
||||
describe("getNavigationConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("groups the Local Server navigation without losing available features", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
const sections = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: "https://local.example/api/v1/docs",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(sections.map((section) => section.label ?? null)).toEqual([
|
||||
null,
|
||||
"SECURITY",
|
||||
"SETTINGS",
|
||||
"HELP",
|
||||
]);
|
||||
expect(sections[0]?.items.map((item) => item.label)).toEqual([
|
||||
"Overview",
|
||||
"Lighthouse AI",
|
||||
]);
|
||||
expect(sections[1]?.items.map((item) => item.label)).toEqual([
|
||||
"Compliance",
|
||||
"Findings",
|
||||
"Attack Paths",
|
||||
"Scans",
|
||||
"Resources",
|
||||
]);
|
||||
expect(sections[2]?.items.map((item) => item.label)).toEqual([
|
||||
"Configuration",
|
||||
"Organization",
|
||||
]);
|
||||
expect(sections[3]?.items.map((item) => item.label)).toEqual([
|
||||
"Documentation",
|
||||
"API Reference",
|
||||
"Community Support",
|
||||
"Prowler Hub",
|
||||
]);
|
||||
});
|
||||
|
||||
it("models Local Server Cloud features as contextual upgrade actions", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
const children = getConfigurationChildren();
|
||||
|
||||
// Then
|
||||
expect(children.map((item) => item.label)).toEqual([
|
||||
"Providers",
|
||||
"Alerts",
|
||||
"Mutelist",
|
||||
"Scans",
|
||||
"CLI Import",
|
||||
"Integrations",
|
||||
"Lighthouse AI",
|
||||
]);
|
||||
expect(children.find((item) => item.label === "Alerts")).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
}),
|
||||
);
|
||||
expect(children.find((item) => item.label === "Scans")).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
|
||||
}),
|
||||
);
|
||||
expect(children.find((item) => item.label === "CLI Import")).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Cloud destinations and current New badges", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
// When
|
||||
const sections = getNavigationConfig({
|
||||
pathname: "/scans/config",
|
||||
apiDocsUrl: "https://ignored.example/docs",
|
||||
});
|
||||
const items = sections.flatMap((section) => section.items);
|
||||
const configuration = items.find((item) => item.label === "Configuration");
|
||||
|
||||
if (configuration?.kind !== NAVIGATION_ITEM_KIND.COLLAPSIBLE) {
|
||||
throw new Error("Configuration must be a collapsible navigation item");
|
||||
}
|
||||
|
||||
// Then
|
||||
expect(sections[0]?.items.map((item) => item.label)).toEqual(["Overview"]);
|
||||
expect(configuration.children).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: "CLI Import" }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
configuration.children.find((item) => item.label === "Alerts"),
|
||||
).toEqual(expect.objectContaining({ highlight: true }));
|
||||
expect(
|
||||
configuration.children.find((item) => item.label === "Scans"),
|
||||
).toEqual(expect.objectContaining({ active: true, highlight: true }));
|
||||
expect(items.find((item) => item.label === "Attack Paths")).not.toEqual(
|
||||
expect.objectContaining({ highlight: true }),
|
||||
);
|
||||
expect(sections[3]?.items.map((item) => item.label)).toEqual([
|
||||
"Documentation",
|
||||
"API Reference",
|
||||
"Support Desk",
|
||||
"Prowler Hub",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the Cloud Billing destination for users with billing permission", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const permissions = {
|
||||
manage_billing: true,
|
||||
} as RolePermissionAttributes;
|
||||
|
||||
// When
|
||||
const billing = getNavigationConfig({
|
||||
pathname: "/billing",
|
||||
apiDocsUrl: null,
|
||||
permissions,
|
||||
})
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === "Billing");
|
||||
|
||||
// Then
|
||||
expect(billing).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/billing",
|
||||
active: true,
|
||||
requiredPermission: "manage_billing",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides Billing without permission and in Local Server", () => {
|
||||
// Given
|
||||
const permissions = {
|
||||
manage_billing: false,
|
||||
} as RolePermissionAttributes;
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
// When
|
||||
const cloudItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
permissions,
|
||||
}).flatMap((section) => section.items);
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
const localItems = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
permissions: { ...permissions, manage_billing: true },
|
||||
}).flatMap((section) => section.items);
|
||||
|
||||
// Then
|
||||
expect(cloudItems.find((item) => item.label === "Billing")).toBeUndefined();
|
||||
expect(localItems.find((item) => item.label === "Billing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps environment-specific API documentation destinations", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
const localApiReference = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: "https://local.example/api/v1/docs",
|
||||
})
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === "API Reference");
|
||||
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const cloudApiReference = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: "https://ignored.example/docs",
|
||||
})
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === "API Reference");
|
||||
|
||||
// Then
|
||||
expect(localApiReference).toEqual(
|
||||
expect.objectContaining({ href: "https://local.example/api/v1/docs" }),
|
||||
);
|
||||
expect(cloudApiReference).toEqual(
|
||||
expect.objectContaining({ href: "https://api.prowler.com/api/v1/docs" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the Local Server API reference when no URL is configured", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
const items = getNavigationConfig({
|
||||
pathname: "/",
|
||||
apiDocsUrl: null,
|
||||
}).flatMap((section) => section.items);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
items.find((item) => item.label === "API Reference"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters navigation by required permission after visible copy changes", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const sections = getNavigationConfig({
|
||||
pathname: "/integrations",
|
||||
apiDocsUrl: null,
|
||||
}).map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE
|
||||
? {
|
||||
...item,
|
||||
children: item.children.map((child) =>
|
||||
child.label === "Integrations"
|
||||
? { ...child, label: "Connected apps" }
|
||||
: child,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
const permissions = {
|
||||
manage_integrations: false,
|
||||
} as RolePermissionAttributes;
|
||||
|
||||
// When
|
||||
const filtered = filterNavigationByPermissions(sections, permissions);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
filtered
|
||||
.flatMap((section) => section.items)
|
||||
.filter((item) => item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE)
|
||||
.flatMap((item) => item.children)
|
||||
.find((item) => item.label === "Connected apps"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps navigation when the required permission is granted", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const permissions = {
|
||||
manage_integrations: true,
|
||||
} as RolePermissionAttributes;
|
||||
|
||||
// When
|
||||
const configuration = getNavigationConfig({
|
||||
pathname: "/integrations",
|
||||
apiDocsUrl: null,
|
||||
permissions,
|
||||
})
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === "Configuration");
|
||||
|
||||
// Then
|
||||
expect(configuration).toEqual(
|
||||
expect.objectContaining({
|
||||
children: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Integrations" }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches complete route segments without stealing nested settings routes", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
const scanDetails = getNavigationConfig({
|
||||
pathname: "/scans/scan-123",
|
||||
apiDocsUrl: null,
|
||||
});
|
||||
const scanSettings = getNavigationConfig({
|
||||
pathname: "/scans/config/edit",
|
||||
apiDocsUrl: null,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
scanDetails
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === "Scans"),
|
||||
).toEqual(expect.objectContaining({ active: true }));
|
||||
expect(
|
||||
scanSettings
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.label === "Scans"),
|
||||
).toEqual(expect.objectContaining({ active: false }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
Code,
|
||||
CreditCard,
|
||||
FileText,
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
MessageCircleQuestion,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SquareChartGantt,
|
||||
Tag,
|
||||
Timer,
|
||||
Users,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { CloudUpgradeFeature } from "@/types/cloud-upgrade";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import type { RolePermissionAttributes } from "@/types/users";
|
||||
|
||||
import {
|
||||
NAVIGATION_ITEM_KIND,
|
||||
NAVIGATION_PERMISSION,
|
||||
type NavigationChild,
|
||||
type NavigationItem,
|
||||
type NavigationSection,
|
||||
} from "./types";
|
||||
|
||||
interface NavigationConfigOptions {
|
||||
pathname: string;
|
||||
apiDocsUrl?: string | null;
|
||||
permissions?: RolePermissionAttributes;
|
||||
}
|
||||
|
||||
interface CloudFeatureOptions {
|
||||
isCloudEnvironment: boolean;
|
||||
href: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
feature: CloudUpgradeFeature;
|
||||
}
|
||||
|
||||
function getCloudFeature({
|
||||
isCloudEnvironment,
|
||||
href,
|
||||
label,
|
||||
active,
|
||||
feature,
|
||||
}: CloudFeatureOptions): NavigationChild {
|
||||
if (!isCloudEnvironment) {
|
||||
return {
|
||||
kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
|
||||
label,
|
||||
cloudUpgradeFeature: feature,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href,
|
||||
label,
|
||||
active,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isRouteActive(pathname: string, href: string) {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function hasRequiredPermission(
|
||||
item: NavigationItem | NavigationChild,
|
||||
permissions?: RolePermissionAttributes,
|
||||
) {
|
||||
return (
|
||||
item.requiredPermission === undefined ||
|
||||
permissions?.[item.requiredPermission] !== false
|
||||
);
|
||||
}
|
||||
|
||||
export function filterNavigationByPermissions(
|
||||
sections: NavigationSection[],
|
||||
permissions?: RolePermissionAttributes,
|
||||
) {
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items
|
||||
.filter((item) => hasRequiredPermission(item, permissions))
|
||||
.map((item) =>
|
||||
item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE
|
||||
? {
|
||||
...item,
|
||||
children: item.children.filter((child) =>
|
||||
hasRequiredPermission(child, permissions),
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
export function getNavigationConfig({
|
||||
pathname,
|
||||
apiDocsUrl = null,
|
||||
permissions,
|
||||
}: NavigationConfigOptions): NavigationSection[] {
|
||||
const isCloudEnvironment = isCloud();
|
||||
const apiReferenceUrl = isCloudEnvironment
|
||||
? "https://api.prowler.com/api/v1/docs"
|
||||
: apiDocsUrl;
|
||||
|
||||
const sections: NavigationSection[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/",
|
||||
label: "Overview",
|
||||
icon: SquareChartGantt,
|
||||
active: pathname === "/",
|
||||
},
|
||||
...(!isCloudEnvironment
|
||||
? [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/lighthouse",
|
||||
label: "Lighthouse AI",
|
||||
icon: LighthouseIcon,
|
||||
active:
|
||||
isRouteActive(pathname, "/lighthouse") &&
|
||||
!isRouteActive(pathname, "/lighthouse/settings"),
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "SECURITY",
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/compliance",
|
||||
label: "Compliance",
|
||||
icon: ShieldCheck,
|
||||
active: isRouteActive(pathname, "/compliance"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/findings?filter[muted]=false&filter[status__in]=FAIL",
|
||||
label: "Findings",
|
||||
icon: Tag,
|
||||
active: isRouteActive(pathname, "/findings"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/attack-paths",
|
||||
label: "Attack Paths",
|
||||
icon: GitBranch,
|
||||
active: isRouteActive(pathname, "/attack-paths"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/scans",
|
||||
label: "Scans",
|
||||
icon: Timer,
|
||||
active:
|
||||
isRouteActive(pathname, "/scans") &&
|
||||
!isRouteActive(pathname, "/scans/config"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/resources",
|
||||
label: "Resources",
|
||||
icon: Warehouse,
|
||||
active: isRouteActive(pathname, "/resources"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "SETTINGS",
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.COLLAPSIBLE,
|
||||
label: "Configuration",
|
||||
icon: Settings,
|
||||
defaultOpen: true,
|
||||
children: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/providers",
|
||||
label: "Providers",
|
||||
active: isRouteActive(pathname, "/providers"),
|
||||
},
|
||||
getCloudFeature({
|
||||
isCloudEnvironment,
|
||||
href: "/alerts",
|
||||
label: "Alerts",
|
||||
active: isRouteActive(pathname, "/alerts"),
|
||||
feature: CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
}),
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/mutelist",
|
||||
label: "Mutelist",
|
||||
active: pathname === "/mutelist",
|
||||
},
|
||||
getCloudFeature({
|
||||
isCloudEnvironment,
|
||||
href: "/scans/config",
|
||||
label: "Scans",
|
||||
active: isRouteActive(pathname, "/scans/config"),
|
||||
feature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
|
||||
}),
|
||||
...(!isCloudEnvironment
|
||||
? [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
|
||||
label: "CLI Import",
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/integrations",
|
||||
label: "Integrations",
|
||||
requiredPermission: NAVIGATION_PERMISSION.MANAGE_INTEGRATIONS,
|
||||
active: isRouteActive(pathname, "/integrations"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/lighthouse/settings",
|
||||
label: "Lighthouse AI",
|
||||
active: isRouteActive(pathname, "/lighthouse/settings"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.COLLAPSIBLE,
|
||||
label: "Organization",
|
||||
icon: Users,
|
||||
defaultOpen: false,
|
||||
children: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/users",
|
||||
label: "Users",
|
||||
active: isRouteActive(pathname, "/users"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/invitations",
|
||||
label: "Invitations",
|
||||
active: isRouteActive(pathname, "/invitations"),
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/roles",
|
||||
label: "Roles",
|
||||
active: isRouteActive(pathname, "/roles"),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCloudEnvironment
|
||||
? [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/billing",
|
||||
label: "Billing",
|
||||
icon: CreditCard,
|
||||
requiredPermission: NAVIGATION_PERMISSION.MANAGE_BILLING,
|
||||
active: isRouteActive(pathname, "/billing"),
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "HELP",
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "https://docs.prowler.com/",
|
||||
label: "Documentation",
|
||||
icon: FileText,
|
||||
target: "_blank",
|
||||
},
|
||||
...(apiReferenceUrl
|
||||
? [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: apiReferenceUrl,
|
||||
label: "API Reference",
|
||||
icon: Code,
|
||||
target: "_blank",
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: isCloudEnvironment
|
||||
? "https://customer.support.prowler.com/servicedesk/customer/portal/9/create/102"
|
||||
: "https://github.com/prowler-cloud/prowler/issues",
|
||||
label: isCloudEnvironment ? "Support Desk" : "Community Support",
|
||||
icon: MessageCircleQuestion,
|
||||
target: "_blank",
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "https://hub.prowler.com/",
|
||||
label: "Prowler Hub",
|
||||
icon: LayoutGrid,
|
||||
target: "_blank",
|
||||
tooltip: "Looking for all available checks? learn more.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return filterNavigationByPermissions(sections, permissions);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import type { AppSidebarSelectionHandler } from "./types";
|
||||
|
||||
interface SidebarFooterProps {
|
||||
isCloudEnvironment: boolean;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
export function SidebarFooter({
|
||||
isCloudEnvironment,
|
||||
onSelect,
|
||||
}: SidebarFooterProps) {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
const version = process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-3 pb-4">
|
||||
{!isCloudEnvironment && (
|
||||
<div className="pt-4 pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
openCloudUpgrade(
|
||||
CLOUD_UPGRADE_FEATURE.GENERAL,
|
||||
onSelect?.() ?? undefined,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Cloud aria-hidden="true" className="size-4" />
|
||||
Explore Prowler Cloud
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-border-neutral-secondary text-text-neutral-tertiary flex min-h-9 items-center border-t pt-3 text-[11px]">
|
||||
{isCloudEnvironment && (
|
||||
<Link
|
||||
href="https://status.prowler.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-text-neutral-primary min-w-0 flex-1 transition-colors"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="truncate">Service status</span>
|
||||
</Link>
|
||||
)}
|
||||
<span className="ml-auto font-mono">{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FileText, Settings, ShieldCheck } from "lucide-react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
import { SidebarNavigation } from "./sidebar-navigation";
|
||||
import { NAVIGATION_ITEM_KIND, type NavigationSection } from "./types";
|
||||
|
||||
const { openCloudUpgradeMock } = vi.hoisted(() => ({
|
||||
openCloudUpgradeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
useCloudUpgradeStore: (
|
||||
selector: (state: {
|
||||
openCloudUpgrade: typeof openCloudUpgradeMock;
|
||||
}) => unknown,
|
||||
) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
|
||||
}));
|
||||
|
||||
const sections: NavigationSection[] = [
|
||||
{
|
||||
label: "SECURITY",
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/compliance",
|
||||
label: "Compliance",
|
||||
icon: ShieldCheck,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "SETTINGS",
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.COLLAPSIBLE,
|
||||
label: "Configuration",
|
||||
icon: Settings,
|
||||
defaultOpen: false,
|
||||
children: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "/providers",
|
||||
label: "Providers",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.CLOUD_UPGRADE,
|
||||
label: "Alerts",
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "HELP",
|
||||
items: [
|
||||
{
|
||||
kind: NAVIGATION_ITEM_KIND.LINK,
|
||||
href: "https://docs.prowler.com/",
|
||||
label: "Documentation",
|
||||
icon: FileText,
|
||||
target: "_blank",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function setProviderActive(active: boolean): NavigationSection[] {
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.map((item) =>
|
||||
item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE
|
||||
? {
|
||||
...item,
|
||||
children: item.children.map((child) =>
|
||||
child.kind === NAVIGATION_ITEM_KIND.LINK &&
|
||||
child.label === "Providers"
|
||||
? { ...child, active }
|
||||
: child,
|
||||
),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("SidebarNavigation", () => {
|
||||
beforeEach(() => {
|
||||
openCloudUpgradeMock.mockClear();
|
||||
});
|
||||
|
||||
it("renders grouped semantic navigation with accessible active destinations", () => {
|
||||
// Given / When
|
||||
render(<SidebarNavigation sections={sections} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Main navigation" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("SECURITY")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Compliance" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Providers" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
const activeParent = screen.getByRole("button", {
|
||||
name: "Configuration",
|
||||
});
|
||||
expect(activeParent).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("allows manually collapsing a section with an active destination", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<SidebarNavigation sections={sections} />);
|
||||
const activeParent = screen.getByRole("button", {
|
||||
name: "Configuration",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(activeParent);
|
||||
|
||||
// Then
|
||||
expect(activeParent).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("expands a collapsed section when one of its destinations becomes active", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<SidebarNavigation sections={setProviderActive(false)} />,
|
||||
);
|
||||
const configuration = screen.getByRole("button", {
|
||||
name: "Configuration",
|
||||
});
|
||||
expect(configuration).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// When
|
||||
rerender(<SidebarNavigation sections={setProviderActive(true)} />);
|
||||
|
||||
// Then
|
||||
const activeConfiguration = screen.getByRole("button", {
|
||||
name: "Configuration",
|
||||
});
|
||||
expect(activeConfiguration).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
// When
|
||||
await user.click(activeConfiguration);
|
||||
|
||||
// Then
|
||||
expect(activeConfiguration).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
// When
|
||||
rerender(<SidebarNavigation sections={setProviderActive(true)} />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Configuration" }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("marks external links and opens contextual Cloud upgrades", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const returnFocusElement = document.createElement("button");
|
||||
const onSelect = vi.fn(() => returnFocusElement);
|
||||
render(<SidebarNavigation sections={sections} onSelect={onSelect} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /alerts/i }));
|
||||
|
||||
// Then
|
||||
expect(openCloudUpgradeMock).toHaveBeenCalledWith(
|
||||
CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
returnFocusElement,
|
||||
);
|
||||
expect(screen.getByText("Cloud")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "Documentation" })).toHaveAttribute(
|
||||
"target",
|
||||
"_blank",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Documentation" })).toHaveAttribute(
|
||||
"rel",
|
||||
"noopener noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight, ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/shadcn/collapsible";
|
||||
import { NavigationButton } from "@/components/shadcn/navigation-button";
|
||||
import { ScrollArea } from "@/components/shadcn/scroll-area/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
|
||||
import {
|
||||
type AppSidebarSelectionHandler,
|
||||
NAVIGATION_ITEM_KIND,
|
||||
type NavigationChild,
|
||||
type NavigationChildLink,
|
||||
type NavigationCloudUpgrade,
|
||||
type NavigationCollapsible,
|
||||
type NavigationLink,
|
||||
type NavigationSection,
|
||||
} from "./types";
|
||||
|
||||
interface SidebarNavigationProps {
|
||||
sections: NavigationSection[];
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
interface NavigationLinkProps {
|
||||
item: NavigationLink;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
interface NavigationCollapsibleProps {
|
||||
item: NavigationCollapsible;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
interface NavigationChildProps {
|
||||
item: NavigationChild;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}
|
||||
|
||||
function getCollapsibleActivationKey(item: NavigationCollapsible) {
|
||||
const activeChild = item.children.find(
|
||||
(child) =>
|
||||
child.kind === NAVIGATION_ITEM_KIND.LINK && child.active === true,
|
||||
);
|
||||
|
||||
return `${item.label}-${activeChild?.label ?? "inactive"}`;
|
||||
}
|
||||
|
||||
function TopLevelLink({ item, onSelect }: NavigationLinkProps) {
|
||||
const Icon = item.icon;
|
||||
const isExternal = item.target === "_blank";
|
||||
const link = (
|
||||
<NavigationButton asChild active={item.active}>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.target}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{item.active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-sidebar-active-bar absolute top-2 bottom-2 -left-px w-0.5 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-[18px] shrink-0",
|
||||
item.active && "text-sidebar-active-icon",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.highlight && (
|
||||
<Badge variant="new" size="sm">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
{isExternal && <ArrowUpRight aria-hidden="true" className="size-4" />}
|
||||
</Link>
|
||||
</NavigationButton>
|
||||
);
|
||||
|
||||
if (!item.tooltip) return link;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{item.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudUpgradeChild({
|
||||
item,
|
||||
onSelect,
|
||||
}: {
|
||||
item: NavigationCloudUpgrade;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}) {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
return (
|
||||
<NavigationButton
|
||||
variant="subitem"
|
||||
onClick={() => {
|
||||
openCloudUpgrade(item.cloudUpgradeFeature, onSelect?.() ?? undefined);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<Badge variant="cloud" size="sm">
|
||||
Cloud
|
||||
</Badge>
|
||||
</NavigationButton>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkChild({
|
||||
item,
|
||||
onSelect,
|
||||
}: {
|
||||
item: NavigationChildLink;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}) {
|
||||
const isExternal = item.target === "_blank";
|
||||
|
||||
if (item.disabled) {
|
||||
return (
|
||||
<NavigationButton asChild variant="subitem" disabledState>
|
||||
<span aria-disabled="true">{item.label}</span>
|
||||
</NavigationButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationButton asChild variant="subitem" active={item.active}>
|
||||
<Link
|
||||
href={item.href}
|
||||
target={item.target}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.highlight && (
|
||||
<Badge variant="new" size="sm">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
{isExternal && <ArrowUpRight aria-hidden="true" className="size-3.5" />}
|
||||
</Link>
|
||||
</NavigationButton>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationChildItem({ item, onSelect }: NavigationChildProps) {
|
||||
if (item.kind === NAVIGATION_ITEM_KIND.CLOUD_UPGRADE) {
|
||||
return <CloudUpgradeChild item={item} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
return <LinkChild item={item} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
function CollapsibleNavigationItem({
|
||||
item,
|
||||
onSelect,
|
||||
}: NavigationCollapsibleProps) {
|
||||
const hasActiveChild = item.children.some(
|
||||
(child) =>
|
||||
child.kind === NAVIGATION_ITEM_KIND.LINK && child.active === true,
|
||||
);
|
||||
const [expanded, setExpanded] = useState(item.defaultOpen || hasActiveChild);
|
||||
const isOpen = expanded;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<NavigationButton active={hasActiveChild}>
|
||||
{hasActiveChild && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-sidebar-active-bar absolute top-2 bottom-2 -left-px w-0.5 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-[18px] shrink-0",
|
||||
hasActiveChild && "text-sidebar-active-icon",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</NavigationButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden">
|
||||
<ul className="border-sidebar-guide mt-1 ml-[21px] space-y-0.5 border-l pl-3">
|
||||
{item.children.map((child) => (
|
||||
<li key={child.label}>
|
||||
<NavigationChildItem item={child} onSelect={onSelect} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationSectionList({
|
||||
section,
|
||||
onSelect,
|
||||
}: {
|
||||
section: NavigationSection;
|
||||
onSelect?: AppSidebarSelectionHandler;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={section.label ? `sidebar-${section.label}` : undefined}
|
||||
>
|
||||
{section.label && (
|
||||
<h2
|
||||
id={`sidebar-${section.label}`}
|
||||
className="text-text-neutral-tertiary mb-1.5 px-3 text-[10px] font-semibold tracking-[0.14em]"
|
||||
>
|
||||
{section.label}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.label}>
|
||||
{item.kind === NAVIGATION_ITEM_KIND.COLLAPSIBLE ? (
|
||||
<CollapsibleNavigationItem
|
||||
key={getCollapsibleActivationKey(item)}
|
||||
item={item}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<TopLevelLink item={item} onSelect={onSelect} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNavigation({
|
||||
sections,
|
||||
onSelect,
|
||||
}: SidebarNavigationProps) {
|
||||
return (
|
||||
<ScrollArea className="h-full [&>div>div[style]]:block!">
|
||||
<nav aria-label="Main navigation" className="space-y-5 px-3 py-4">
|
||||
{sections.map((section, index) => (
|
||||
<NavigationSectionList
|
||||
key={section.label ?? `primary-${index}`}
|
||||
section={section}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { IconComponent } from "@/types";
|
||||
import type { CloudUpgradeFeature } from "@/types/cloud-upgrade";
|
||||
|
||||
export const APP_SIDEBAR_MODE = {
|
||||
BROWSE: "browse",
|
||||
CHAT: "chat",
|
||||
} as const;
|
||||
|
||||
export type AppSidebarMode =
|
||||
(typeof APP_SIDEBAR_MODE)[keyof typeof APP_SIDEBAR_MODE];
|
||||
|
||||
export const NAVIGATION_ITEM_KIND = {
|
||||
LINK: "link",
|
||||
COLLAPSIBLE: "collapsible",
|
||||
CLOUD_UPGRADE: "cloud_upgrade",
|
||||
} as const;
|
||||
|
||||
export const NAVIGATION_PERMISSION = {
|
||||
MANAGE_BILLING: "manage_billing",
|
||||
MANAGE_INTEGRATIONS: "manage_integrations",
|
||||
} as const;
|
||||
|
||||
export type NavigationPermission =
|
||||
(typeof NAVIGATION_PERMISSION)[keyof typeof NAVIGATION_PERMISSION];
|
||||
|
||||
interface NavigationLabel {
|
||||
label: string;
|
||||
requiredPermission?: NavigationPermission;
|
||||
}
|
||||
|
||||
export interface NavigationLink extends NavigationLabel {
|
||||
kind: typeof NAVIGATION_ITEM_KIND.LINK;
|
||||
href: string;
|
||||
icon: IconComponent;
|
||||
active?: boolean;
|
||||
highlight?: boolean;
|
||||
target?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface NavigationChildLink extends NavigationLabel {
|
||||
kind: typeof NAVIGATION_ITEM_KIND.LINK;
|
||||
href: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export interface NavigationCloudUpgrade extends NavigationLabel {
|
||||
kind: typeof NAVIGATION_ITEM_KIND.CLOUD_UPGRADE;
|
||||
cloudUpgradeFeature: CloudUpgradeFeature;
|
||||
}
|
||||
|
||||
export type NavigationChild = NavigationChildLink | NavigationCloudUpgrade;
|
||||
|
||||
export interface NavigationCollapsible extends NavigationLabel {
|
||||
kind: typeof NAVIGATION_ITEM_KIND.COLLAPSIBLE;
|
||||
icon: IconComponent;
|
||||
children: NavigationChild[];
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
|
||||
export type NavigationItem = NavigationLink | NavigationCollapsible;
|
||||
|
||||
export interface NavigationSection {
|
||||
label?: string;
|
||||
items: NavigationItem[];
|
||||
}
|
||||
|
||||
export type AppSidebarSelectionHandler = () => HTMLElement | null;
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./app-sidebar";
|
||||
export * from "./main-layout";
|
||||
export * from "./nav-bar";
|
||||
export * from "./sidebar";
|
||||
export * from "./user-nav";
|
||||
|
||||
@@ -3,19 +3,8 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import MainLayout from "./main-layout";
|
||||
|
||||
vi.mock("@/hooks/use-sidebar", () => ({
|
||||
useSidebar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-store", () => ({
|
||||
useStore: () => ({
|
||||
getOpenState: () => true,
|
||||
settings: { disabled: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../sidebar/sidebar", () => ({
|
||||
Sidebar: () => <aside data-testid="sidebar" />,
|
||||
vi.mock("@/components/layout/app-sidebar", () => ({
|
||||
AppSidebar: () => <aside data-testid="sidebar" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/cloud-upgrade-modal", () => ({
|
||||
@@ -31,6 +20,8 @@ describe("MainLayout", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("cloud-upgrade-modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Page content")).toBeVisible();
|
||||
expect(screen.getByRole("main")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,49 +1,15 @@
|
||||
"use client";
|
||||
import { type ReactNode, Suspense } from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
|
||||
import { useSidebar } from "@/hooks/use-sidebar";
|
||||
import { useStore } from "@/hooks/use-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Sidebar } from "../sidebar/sidebar";
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const sidebar = useStore(useSidebar, (x) => x);
|
||||
if (!sidebar) return null;
|
||||
const { getOpenState, settings } = sidebar;
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative flex h-dvh items-center justify-center overflow-hidden">
|
||||
{/* Top-left gradient halo */}
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 left-0 z-0 h-[120%] w-[160%] opacity-[7%] blur-3xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom-right gradient halo */}
|
||||
<div
|
||||
className="pointer-events-none fixed right-0 bottom-0 z-0 h-[50%] w-[50%] opacity-[7%] blur-3xl"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #31E59F 0%, #60E0EC 100%)",
|
||||
transform: "translate(50%, 50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Sidebar />
|
||||
<AppSidebar />
|
||||
<CloudUpgradeModal />
|
||||
<main
|
||||
className={cn(
|
||||
"no-scrollbar relative z-10 mb-auto h-full flex-1 flex-col overflow-y-auto transition-[margin-left] duration-300 ease-in-out",
|
||||
!settings.disabled &&
|
||||
(!getOpenState() ? "lg:ml-[90px]" : "lg:ml-[248px]"),
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<main className="no-scrollbar relative z-10 mb-auto ml-4 h-full flex-1 flex-col overflow-y-auto lg:ml-[280px]">
|
||||
<Suspense fallback={null}>{children}</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,10 +30,6 @@ vi.mock("@/store/onboarding-replay", () => {
|
||||
return { useOnboardingReplayStore: hook };
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-sidebar", () => ({
|
||||
useSidebar: () => ({ isOpen: true, toggleOpen: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ThemeSwitch", () => ({
|
||||
ThemeSwitch: () => <button type="button">Theme switch</button>,
|
||||
}));
|
||||
@@ -54,12 +50,8 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../sidebar/sheet-menu", () => ({
|
||||
SheetMenu: () => <button type="button">Open menu</button>,
|
||||
}));
|
||||
|
||||
vi.mock("../sidebar/sidebar-toggle", () => ({
|
||||
SidebarToggle: () => <button type="button">Toggle sidebar</button>,
|
||||
vi.mock("@/components/layout/app-sidebar", () => ({
|
||||
MobileAppSidebar: () => <button type="button">Open menu</button>,
|
||||
}));
|
||||
|
||||
vi.mock("../user-nav/user-nav", () => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BellRing, Info } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { MobileAppSidebar } from "@/components/layout/app-sidebar";
|
||||
import {
|
||||
BreadcrumbNavigation,
|
||||
Button,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { ThemeSwitch } from "@/components/ThemeSwitch";
|
||||
import { useSidebar } from "@/hooks/use-sidebar";
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { useTourCompletion } from "@/lib/tours/use-tour-completion";
|
||||
@@ -20,8 +20,6 @@ import { cn } from "@/lib/utils";
|
||||
import { useOnboardingReplayStore } from "@/store/onboarding-replay";
|
||||
import { usePageReadyStore } from "@/store/page-ready";
|
||||
|
||||
import { SheetMenu } from "../sidebar/sheet-menu";
|
||||
import { SidebarToggle } from "../sidebar/sidebar-toggle";
|
||||
import { UserNav } from "../user-nav/user-nav";
|
||||
|
||||
export interface OnboardingActionConfig {
|
||||
@@ -43,7 +41,6 @@ export function NavbarClient({
|
||||
onboardingAction,
|
||||
feedsSlot,
|
||||
}: NavbarClientProps) {
|
||||
const { isOpen, toggleOpen } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const requestReplay = useOnboardingReplayStore(
|
||||
@@ -85,10 +82,7 @@ export function NavbarClient({
|
||||
<header className="sticky top-0 z-10 w-full pt-4 backdrop-blur-sm">
|
||||
<div className="flex h-14 items-center pr-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<SheetMenu />
|
||||
<div className="hidden lg:block">
|
||||
<SidebarToggle isOpen={isOpen} setIsOpen={toggleOpen} />
|
||||
</div>
|
||||
<MobileAppSidebar />
|
||||
{/* Suspense contains the useSearchParams() CSR bailout in BreadcrumbNavigation
|
||||
so statically prerendered pages don't fail the build. */}
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SubmenuItem } from "@/components/layout/sidebar/submenu-item";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/shadcn/collapsible";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconComponent,
|
||||
type MenuSelectionHandler,
|
||||
SUBMENU_KIND,
|
||||
SubmenuProps,
|
||||
} from "@/types";
|
||||
|
||||
interface CollapsibleMenuProps {
|
||||
icon: IconComponent;
|
||||
label: string;
|
||||
submenus: SubmenuProps[];
|
||||
defaultOpen?: boolean;
|
||||
isOpen: boolean;
|
||||
onSelect?: MenuSelectionHandler;
|
||||
}
|
||||
|
||||
export const CollapsibleMenu = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
submenus,
|
||||
defaultOpen = false,
|
||||
isOpen: isSidebarOpen,
|
||||
onSelect,
|
||||
}: CollapsibleMenuProps) => {
|
||||
const pathname = usePathname();
|
||||
const isSubmenuActive = submenus.some(
|
||||
(submenu) =>
|
||||
submenu.kind !== SUBMENU_KIND.CLOUD_UPGRADE &&
|
||||
(submenu.active === undefined
|
||||
? submenu.href === pathname
|
||||
: submenu.active),
|
||||
);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
isSubmenuActive || defaultOpen,
|
||||
);
|
||||
const isOpen = isSidebarOpen && isCollapsed;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsCollapsed}
|
||||
defaultOpen={defaultOpen}
|
||||
className="group mb-1 w-full"
|
||||
>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant={isSubmenuActive ? "menu-active" : "menu-inactive"}
|
||||
className={cn(
|
||||
isSidebarOpen ? "w-full justify-start" : "w-14 justify-center",
|
||||
)}
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-4">
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<p className="max-w-[150px] truncate">{label}</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
className="transition-transform duration-200 group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Icon size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</TooltipTrigger>
|
||||
{!isSidebarOpen && (
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down flex flex-col items-end overflow-hidden">
|
||||
{submenus.map((submenu, index) => (
|
||||
<SubmenuItem key={index} {...submenu} onSelect={onSelect} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from "./collapsible-menu";
|
||||
export * from "./menu";
|
||||
export * from "./menu-item";
|
||||
export * from "./sheet-menu";
|
||||
export * from "./sidebar";
|
||||
export * from "./sidebar-toggle";
|
||||
export * from "./submenu-item";
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconComponent, type MenuSelectionHandler } from "@/types";
|
||||
|
||||
interface MenuItemProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: IconComponent;
|
||||
active?: boolean;
|
||||
target?: string;
|
||||
tooltip?: string;
|
||||
isOpen: boolean;
|
||||
highlight?: boolean;
|
||||
onSelect?: MenuSelectionHandler;
|
||||
}
|
||||
|
||||
export const MenuItem = ({
|
||||
href,
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
target,
|
||||
tooltip,
|
||||
isOpen,
|
||||
highlight,
|
||||
onSelect,
|
||||
}: MenuItemProps) => {
|
||||
const pathname = usePathname();
|
||||
// Extract only the pathname from href (without query parameters) for comparison
|
||||
const hrefPathname = href.split("?")[0];
|
||||
const isActive =
|
||||
active !== undefined ? active : pathname.startsWith(hrefPathname);
|
||||
|
||||
// Show tooltip always for Prowler Hub, or when sidebar is collapsed
|
||||
const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isActive ? "menu-active" : "menu-inactive"}
|
||||
className={cn(
|
||||
isOpen ? "w-full justify-start" : "w-14 justify-center",
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href={href} target={target} onClick={onSelect}>
|
||||
<div className="flex items-center">
|
||||
<span className={cn(isOpen ? "mr-4" : "")}>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
{isOpen && (
|
||||
<p className="flex max-w-[200px] items-center truncate">
|
||||
<span>{label}</span>
|
||||
{highlight && (
|
||||
<Badge variant="new" size="sm" className="ml-2">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent side="right">{tooltip || label}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,302 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
|
||||
|
||||
const {
|
||||
openLaunchScanModalMock,
|
||||
openCloudUpgradeMock,
|
||||
pathnameValue,
|
||||
pushMock,
|
||||
navigationModeValue,
|
||||
setNavigationModeMock,
|
||||
} = vi.hoisted(() => ({
|
||||
openLaunchScanModalMock: vi.fn(),
|
||||
openCloudUpgradeMock: vi.fn(),
|
||||
pathnameValue: { current: "/findings" },
|
||||
pushMock: vi.fn(),
|
||||
navigationModeValue: { current: "browse" },
|
||||
setNavigationModeMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => pathnameValue.current,
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({
|
||||
data: { user: { permissions: {} } },
|
||||
status: "authenticated",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks", () => ({
|
||||
useAuth: () => ({
|
||||
permissions: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/menu-list", () => ({
|
||||
getMenuList: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({
|
||||
LighthouseV2SidebarChat: () => <div data-testid="lighthouse-chat-sidebar" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/store", () => ({
|
||||
useScansStore: (
|
||||
selector: (state: { openLaunchScanModal: () => void }) => unknown,
|
||||
) => selector({ openLaunchScanModal: openLaunchScanModalMock }),
|
||||
useCloudUpgradeStore: (
|
||||
selector: (state: {
|
||||
openCloudUpgrade: (feature: string) => void;
|
||||
}) => unknown,
|
||||
) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-sidebar", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/hooks/use-sidebar")>();
|
||||
return {
|
||||
...actual,
|
||||
useSidebar: (
|
||||
selector: (state: {
|
||||
navigationMode: string;
|
||||
setNavigationMode: (mode: string) => void;
|
||||
}) => unknown,
|
||||
) =>
|
||||
selector({
|
||||
navigationMode: navigationModeValue.current,
|
||||
setNavigationMode: setNavigationModeMock,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let MenuComponent: typeof import("./menu").Menu;
|
||||
let SidebarNavigationModeToggleComponent: typeof import("@/components/sidebar/navigation-mode-toggle").SidebarNavigationModeToggle;
|
||||
|
||||
const expectLastCloudUpgrade = (feature: string) => {
|
||||
expect(openCloudUpgradeMock.mock.calls.at(-1)?.[0]).toBe(feature);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
MenuComponent = (await import("./menu")).Menu;
|
||||
SidebarNavigationModeToggleComponent = (
|
||||
await import("@/components/sidebar/navigation-mode-toggle")
|
||||
).SidebarNavigationModeToggle;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
navigationModeValue.current = "browse";
|
||||
openCloudUpgradeMock.mockClear();
|
||||
});
|
||||
|
||||
describe("Menu", () => {
|
||||
it("links scan to the scans page with the modal open", () => {
|
||||
pathnameValue.current = "/findings";
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
const launchScanLink = screen.getByRole("link", { name: /launch scan/i });
|
||||
const launchScanWrapper = launchScanLink.closest("div.flex.shrink-0");
|
||||
|
||||
expect(launchScanLink).toHaveAttribute("href", "/scans?launchScan=true");
|
||||
expect(launchScanWrapper).toHaveClass("flex", "justify-center");
|
||||
expect(launchScanLink).toHaveClass("h-14", "w-full", "p-1");
|
||||
expect(launchScanLink).not.toHaveClass("h-8", "h-9", "h-10");
|
||||
expect(screen.getByText("Scan")).toHaveClass("text-xl", "leading-8");
|
||||
expect(screen.getByText("Scan")).not.toHaveClass("text-2xl", "font-bold");
|
||||
expect(
|
||||
launchScanLink.querySelector('svg[viewBox="0 0 432.08 396.77"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the launch scan modal without navigation when already on scans", async () => {
|
||||
pathnameValue.current = "/scans";
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
await screen.getByRole("button", { name: /launch scan/i }).click();
|
||||
|
||||
expect(openLaunchScanModalMock).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /launch scan/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Prowler icon when the menu is collapsed", () => {
|
||||
pathnameValue.current = "/findings";
|
||||
|
||||
render(<MenuComponent isOpen={false} />);
|
||||
|
||||
const launchScanLink = screen.getByRole("link", { name: /launch scan/i });
|
||||
|
||||
expect(launchScanLink).toHaveClass("h-9", "w-14");
|
||||
expect(launchScanLink).not.toHaveClass("h-14");
|
||||
expect(
|
||||
launchScanLink.querySelector('svg[viewBox="0 0 432.08 396.77"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("swaps to the Lighthouse chat sidebar in cloud CHAT mode", () => {
|
||||
pathnameValue.current = "/lighthouse";
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
navigationModeValue.current = SIDEBAR_NAVIGATION_MODE.CHAT;
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the navigation menu in cloud BROWSE mode", () => {
|
||||
pathnameValue.current = "/findings";
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
navigationModeValue.current = SIDEBAR_NAVIGATION_MODE.BROWSE;
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("lighthouse-chat-sidebar"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the managed Lighthouse upgrade from Chat in Local Server", async () => {
|
||||
pathnameValue.current = "/findings";
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
|
||||
const chatButton = screen.getByRole("button", { name: "Chat" });
|
||||
expect(chatButton).not.toHaveAttribute("aria-disabled");
|
||||
|
||||
await userEvent.click(chatButton);
|
||||
|
||||
expectLastCloudUpgrade("lighthouse_ai");
|
||||
});
|
||||
|
||||
it("shows a persistent Cloud upgrade action only in Local Server", async () => {
|
||||
pathnameValue.current = "/findings";
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
const upgradeButton = screen.getByRole("button", {
|
||||
name: "Explore Prowler Cloud",
|
||||
});
|
||||
expect(upgradeButton).toHaveClass("bg-button-primary");
|
||||
|
||||
await userEvent.click(upgradeButton);
|
||||
|
||||
expectLastCloudUpgrade("general");
|
||||
});
|
||||
|
||||
it("separates the persistent Cloud upgrade action from the navigation", () => {
|
||||
// Given
|
||||
pathnameValue.current = "/findings";
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
// When
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Explore Prowler Cloud" })
|
||||
.parentElement,
|
||||
).toHaveClass("pt-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SidebarNavigationModeToggle", () => {
|
||||
beforeEach(() => {
|
||||
pushMock.mockClear();
|
||||
});
|
||||
|
||||
it("navigates to Lighthouse when Chat mode is selected", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<SidebarNavigationModeToggleComponent
|
||||
isOpen
|
||||
value={SIDEBAR_NAVIGATION_MODE.BROWSE}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Chat" }));
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(SIDEBAR_NAVIGATION_MODE.CHAT);
|
||||
expect(pushMock).toHaveBeenCalledWith("/lighthouse");
|
||||
});
|
||||
|
||||
it("does not navigate when Home mode is selected", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<SidebarNavigationModeToggleComponent
|
||||
isOpen
|
||||
value={SIDEBAR_NAVIGATION_MODE.CHAT}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Home" }));
|
||||
|
||||
// Then
|
||||
expect(onChange).toHaveBeenCalledWith(SIDEBAR_NAVIGATION_MODE.BROWSE);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the Cloud upgrade and shows the cloud tooltip when chat is unavailable", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<SidebarNavigationModeToggleComponent
|
||||
isOpen
|
||||
value={SIDEBAR_NAVIGATION_MODE.BROWSE}
|
||||
onChange={onChange}
|
||||
chatEnabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const chatButton = screen.getByRole("button", { name: "Chat" });
|
||||
await user.hover(chatButton);
|
||||
|
||||
// Then
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("Available in Prowler Cloud");
|
||||
|
||||
// When
|
||||
await user.click(chatButton);
|
||||
|
||||
// Then
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
expectLastCloudUpgrade("lighthouse_ai");
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Cloud } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation";
|
||||
import { InfoIcon, ProwlerShort } from "@/components/icons";
|
||||
import { CollapsibleMenu } from "@/components/layout/sidebar/collapsible-menu";
|
||||
import { MenuItem } from "@/components/layout/sidebar/menu-item";
|
||||
import { Separator } from "@/components/shadcn";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { ScrollArea } from "@/components/shadcn/scroll-area/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { SidebarNavigationModeToggle } from "@/components/sidebar/navigation-mode-toggle";
|
||||
import { useAuth } from "@/hooks";
|
||||
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
|
||||
import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
|
||||
import { getMenuList } from "@/lib/menu-list";
|
||||
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCloudUpgradeStore, useScansStore } from "@/store";
|
||||
import { GroupProps, type MenuSelectionHandler } from "@/types";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { RolePermissionAttributes } from "@/types/users";
|
||||
|
||||
interface MenuHideRule {
|
||||
label: string;
|
||||
condition: (permissions: RolePermissionAttributes) => boolean;
|
||||
}
|
||||
|
||||
const MENU_HIDE_RULES: MenuHideRule[] = [
|
||||
{
|
||||
label: "Billing",
|
||||
condition: (permissions) => permissions?.manage_billing === false,
|
||||
},
|
||||
{
|
||||
label: "Integrations",
|
||||
condition: (permissions) => permissions?.manage_integrations === false,
|
||||
},
|
||||
];
|
||||
|
||||
const filterMenus = (menuGroups: GroupProps[], labelsToHide: string[]) => {
|
||||
return menuGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
menus: group.menus
|
||||
.filter((menu) => !labelsToHide.includes(menu.label))
|
||||
.map((menu) => ({
|
||||
...menu,
|
||||
submenus: menu.submenus?.filter(
|
||||
(submenu) => !labelsToHide.includes(submenu.label),
|
||||
),
|
||||
})),
|
||||
}))
|
||||
.filter((group) => group.menus.length > 0);
|
||||
};
|
||||
|
||||
interface SidebarMenuProps {
|
||||
isOpen: boolean;
|
||||
onSelect?: MenuSelectionHandler;
|
||||
}
|
||||
|
||||
export const Menu = ({ isOpen, onSelect }: SidebarMenuProps) => {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
const openLaunchScanModal = useScansStore(
|
||||
(state) => state.openLaunchScanModal,
|
||||
);
|
||||
const isScansPage = pathname.startsWith("/scans");
|
||||
const { apiDocsUrl } = useRuntimeConfig();
|
||||
const isCloudEnv = isCloud();
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
const navigationMode = useSidebar((state) => state.navigationMode);
|
||||
const setNavigationMode = useSidebar((state) => state.setNavigationMode);
|
||||
|
||||
const menuList = getMenuList({
|
||||
pathname,
|
||||
apiDocsUrl,
|
||||
});
|
||||
|
||||
const labelsToHide = MENU_HIDE_RULES.filter((rule) =>
|
||||
rule.condition(permissions),
|
||||
).map((rule) => rule.label);
|
||||
|
||||
const filteredMenuList = filterMenus(menuList, labelsToHide);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Launch Scan Button — mt-1 aligns its top with the main content panel
|
||||
(navbar 72px + py-4 = 88px), matching the fixed-height logo block. */}
|
||||
<div className="mt-1 flex shrink-0 justify-center px-2">
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
{isScansPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Launch Scan"
|
||||
className={cn(isOpen ? "h-14 w-full p-1" : "w-14")}
|
||||
variant="default"
|
||||
size="default"
|
||||
onClick={() => {
|
||||
openLaunchScanModal();
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
<LaunchScanButtonContent isOpen={isOpen} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className={cn(isOpen ? "h-14 w-full p-1" : "w-14")}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
<Link
|
||||
href={LAUNCH_SCAN_HREF}
|
||||
aria-label="Launch Scan"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<LaunchScanButtonContent isOpen={isOpen} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
{!isOpen && <TooltipContent side="right">Launch Scan</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<SidebarNavigationModeToggle
|
||||
isOpen={isOpen}
|
||||
value={navigationMode}
|
||||
onChange={setNavigationMode}
|
||||
chatEnabled={isCloudEnv}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isCloudEnv && navigationMode === SIDEBAR_NAVIGATION_MODE.CHAT ? (
|
||||
<LighthouseV2SidebarChat isOpen={isOpen} />
|
||||
) : (
|
||||
<ScrollArea className="h-full [&>div>div[style]]:block!">
|
||||
<nav className="mt-2 w-full lg:mt-6">
|
||||
<ul className="mx-2 flex flex-col items-start gap-1 pb-4">
|
||||
{filteredMenuList.map((group, groupIndex) => (
|
||||
<li key={groupIndex} className="w-full">
|
||||
{group.menus.map((menu, menuIndex) => (
|
||||
<div key={menuIndex} className="w-full">
|
||||
{menu.submenus && menu.submenus.length > 0 ? (
|
||||
<CollapsibleMenu
|
||||
icon={menu.icon}
|
||||
label={menu.label}
|
||||
submenus={menu.submenus}
|
||||
defaultOpen={menu.defaultOpen}
|
||||
isOpen={isOpen}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<MenuItem
|
||||
href={menu.href}
|
||||
label={menu.label}
|
||||
icon={menu.icon}
|
||||
active={menu.active}
|
||||
target={menu.target}
|
||||
tooltip={menu.tooltip}
|
||||
isOpen={isOpen}
|
||||
highlight={menu.highlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCloudEnv && (
|
||||
<div className="shrink-0 px-2 pt-4 pb-3">
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
aria-label="Explore Prowler Cloud"
|
||||
className={cn("w-full", isOpen ? "justify-center" : "px-0")}
|
||||
onClick={() => {
|
||||
openCloudUpgrade(
|
||||
CLOUD_UPGRADE_FEATURE.GENERAL,
|
||||
onSelect?.() ?? undefined,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Cloud aria-hidden="true" className="size-4" />
|
||||
{isOpen && <span>Explore Prowler Cloud</span>}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!isOpen && (
|
||||
<TooltipContent side="right">
|
||||
Explore Prowler Cloud
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-muted-foreground border-border-neutral-secondary flex shrink-0 items-center justify-center gap-2 border-t pt-4 pb-2 text-center text-xs">
|
||||
{isOpen ? (
|
||||
<>
|
||||
<span>{process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION}</span>
|
||||
{isCloudEnv && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<Link
|
||||
href="https://status.prowler.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<InfoIcon size={16} />
|
||||
<span className="text-muted-foreground font-normal opacity-80 transition-opacity hover:font-bold hover:opacity-100">
|
||||
Service Status
|
||||
</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
isCloudEnv && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="https://status.prowler.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<InfoIcon size={16} />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Service Status</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function LaunchScanButtonContent({ isOpen }: { isOpen: boolean }) {
|
||||
return (
|
||||
<span className={cn("flex items-center", isOpen && "gap-2.5")}>
|
||||
<ProwlerShort aria-hidden="true" className="size-5 text-current" />
|
||||
{isOpen && <span className="text-xl leading-8">Scan</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
ProwlerBrand: () => <span>Prowler</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/layout/sidebar/menu", () => ({
|
||||
Menu: ({ onSelect }: { onSelect?: () => void }) => (
|
||||
<button type="button" onClick={onSelect}>
|
||||
Alerts
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { SheetMenu } from "./sheet-menu";
|
||||
|
||||
describe("SheetMenu", () => {
|
||||
it("should close after selecting a menu action", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<SheetMenu />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
expect(screen.getByRole("dialog", { name: "Sidebar" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Alerts" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Sidebar" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { ProwlerBrand } from "@/components/icons";
|
||||
import { Menu } from "@/components/layout/sidebar/menu";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/shadcn/sheet";
|
||||
|
||||
export function SheetMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleSelect = () => {
|
||||
setOpen(false);
|
||||
return triggerRef.current;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger className="lg:hidden" asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
aria-label="Open menu"
|
||||
className="h-8"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="flex h-full flex-col px-3 sm:w-72" side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="sr-only">Sidebar</SheetTitle>
|
||||
<SheetDescription className="sr-only" />
|
||||
<Button
|
||||
className="flex items-center justify-center pt-1 pb-2"
|
||||
variant="link"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center"
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<ProwlerBrand />
|
||||
</Link>
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<Menu isOpen onSelect={handleSelect} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
|
||||
interface SidebarToggleProps {
|
||||
isOpen: boolean | undefined;
|
||||
setIsOpen?: () => void;
|
||||
}
|
||||
|
||||
export function SidebarToggle({ isOpen, setIsOpen }: SidebarToggleProps) {
|
||||
// Closed → chevron right (will open); open/undefined → chevron left (will collapse).
|
||||
const isClosed = isOpen === false;
|
||||
const Chevron = isClosed ? ChevronRight : ChevronLeft;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="bare"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsOpen?.()}
|
||||
aria-label={isClosed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<Chevron className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isClosed ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ProwlerBrand, ProwlerShort } from "@/components/icons";
|
||||
import { useSidebar } from "@/hooks/use-sidebar";
|
||||
import { useStore } from "@/hooks/use-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Menu } from "./menu";
|
||||
|
||||
export function Sidebar() {
|
||||
const sidebar = useStore(useSidebar, (x) => x);
|
||||
if (!sidebar) return null;
|
||||
const { isOpen, getOpenState, setIsHover, settings } = sidebar;
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-20 h-screen -translate-x-full transition-[width] duration-300 ease-in-out lg:translate-x-0",
|
||||
!getOpenState() ? "w-[90px]" : "w-[248px]",
|
||||
settings.disabled && "hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
className="no-scrollbar relative flex h-full flex-col overflow-x-hidden overflow-y-auto px-3 py-6"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"mb-6 flex w-full flex-col items-center justify-center px-3 transition-transform duration-300 ease-in-out",
|
||||
!getOpenState() ? "translate-x-1" : "translate-x-0",
|
||||
!isOpen && "gap-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx({
|
||||
hidden: isOpen,
|
||||
})}
|
||||
>
|
||||
<ProwlerShort />
|
||||
</div>
|
||||
<div
|
||||
className={clsx({
|
||||
hidden: !isOpen,
|
||||
"mt-0!": isOpen,
|
||||
})}
|
||||
>
|
||||
<ProwlerBrand />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Menu isOpen={getOpenState()} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { SUBMENU_KIND } from "@/types/components";
|
||||
|
||||
import { SubmenuItem } from "./submenu-item";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
const TestIcon = ({ size = 16 }: { size?: number }) => (
|
||||
<svg aria-hidden="true" height={size} width={size} />
|
||||
);
|
||||
|
||||
describe("SubmenuItem", () => {
|
||||
afterEach(() => {
|
||||
useCloudUpgradeStore.getState().closeCloudUpgrade();
|
||||
});
|
||||
|
||||
it("should open the Alerts upgrade modal from a Local Server menu item", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const returnFocusElement = document.createElement("button");
|
||||
const onSelect = vi.fn(() => returnFocusElement);
|
||||
render(
|
||||
<SubmenuItem
|
||||
kind={SUBMENU_KIND.CLOUD_UPGRADE}
|
||||
label="Alerts"
|
||||
icon={TestIcon}
|
||||
cloudUpgradeFeature={CLOUD_UPGRADE_FEATURE.ALERTS}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: /alerts/i });
|
||||
await user.click(button);
|
||||
|
||||
// Then
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(screen.getByText("Cloud")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /alerts/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
|
||||
CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
);
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(useCloudUpgradeStore.getState().returnFocusElement).toBe(
|
||||
returnFocusElement,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import {
|
||||
type MenuSelectionHandler,
|
||||
SUBMENU_KIND,
|
||||
type SubmenuProps,
|
||||
} from "@/types";
|
||||
|
||||
type SubmenuItemProps = SubmenuProps & {
|
||||
onSelect?: MenuSelectionHandler;
|
||||
};
|
||||
|
||||
export const SubmenuItem = (props: SubmenuItemProps) => {
|
||||
const pathname = usePathname();
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
|
||||
if (props.kind === SUBMENU_KIND.CLOUD_UPGRADE) {
|
||||
const { cloudUpgradeFeature, icon: Icon, label, onSelect } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="menu-inactive"
|
||||
className="mt-1 w-[calc(100%-12px)] justify-start px-2 py-1"
|
||||
onClick={() => {
|
||||
openCloudUpgrade(cloudUpgradeFeature, onSelect?.() ?? undefined);
|
||||
}}
|
||||
>
|
||||
<span className="mr-2">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{label}</span>
|
||||
<Badge variant="cloud" size="sm">
|
||||
Cloud
|
||||
</Badge>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
active,
|
||||
cloudOnly,
|
||||
disabled,
|
||||
highlight,
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
onSelect,
|
||||
target,
|
||||
} = props;
|
||||
const isActive = active !== undefined ? active : pathname === href;
|
||||
|
||||
// Special case: Mutelist with tooltip when disabled
|
||||
if (disabled && label === "Mutelist") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="pointer-events-none mt-1 w-[calc(100%-12px)] cursor-not-allowed justify-start px-2 py-1"
|
||||
disabled
|
||||
>
|
||||
<span className="mr-2">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<p className="min-w-0 truncate">{label}</p>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
The mutelist will be enabled after adding a provider
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
const tooltip = cloudOnly
|
||||
? "Available in Prowler Cloud"
|
||||
: `${label} is unavailable.`;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="group mt-1 inline-flex w-[calc(100%-12px)]"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Button
|
||||
variant="menu-inactive"
|
||||
className="text-text-neutral-tertiary w-full cursor-not-allowed justify-start px-2 py-1"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<span className="mr-2">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<p className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{label}</span>
|
||||
{highlight && (
|
||||
<Badge variant="new" size="sm">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={isActive ? "menu-active" : "menu-inactive"}
|
||||
className="mt-1 w-[calc(100%-12px)] justify-start px-2 py-1"
|
||||
asChild={!disabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
target={target}
|
||||
className="flex items-center"
|
||||
onClick={onSelect}
|
||||
>
|
||||
<span className="mr-2">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<p className="flex min-w-0 items-center">
|
||||
<span className="truncate">{label}</span>
|
||||
{highlight && (
|
||||
<Badge variant="new" size="sm" className="ml-2">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -25,12 +25,6 @@ const buttonVariants = cva(
|
||||
// Chrome-free: no border/background, only the icon shows. Hover/active shift
|
||||
// the icon color instead of painting a box. Pair with an `icon*` size.
|
||||
bare: "border-0 bg-transparent p-0 text-text-neutral-secondary hover:text-text-neutral-primary active:text-text-neutral-primary focus-visible:ring-border-neutral-secondary/50 disabled:bg-transparent",
|
||||
// Menu variant like secondary but more padding and the back is almost transparent
|
||||
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
|
||||
"menu-active":
|
||||
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
|
||||
"menu-inactive":
|
||||
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
|
||||
@@ -21,6 +21,7 @@ export * from "./headers";
|
||||
export * from "./info-field";
|
||||
export * from "./input/input";
|
||||
export * from "./label";
|
||||
export * from "./navigation-button";
|
||||
export * from "./navigation-progress";
|
||||
export * from "./progress";
|
||||
export * from "./search-input/search-input";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./navigation-button";
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { NavigationButton } from "./navigation-button";
|
||||
|
||||
describe("NavigationButton", () => {
|
||||
it("composes an active navigation link without rendering a nested button", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<NavigationButton asChild active>
|
||||
<a href="/findings">Findings</a>
|
||||
</NavigationButton>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const link = screen.getByRole("link", { name: "Findings" });
|
||||
expect(link).toHaveAttribute("data-slot", "navigation-button");
|
||||
expect(link).toHaveAttribute("data-active", "true");
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults native navigation controls to non-submitting buttons", () => {
|
||||
// Given / When
|
||||
render(<NavigationButton variant="toggle">Chat</NavigationButton>);
|
||||
|
||||
// Then
|
||||
const button = screen.getByRole("button", { name: "Chat" });
|
||||
expect(button).toHaveAttribute("type", "button");
|
||||
expect(button).toHaveAttribute("data-active", "false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navigationButtonVariants = cva(
|
||||
"focus-visible:ring-button-primary/50 flex min-w-0 items-center focus-visible:ring-2 focus-visible:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
item: "relative min-h-10 w-full justify-start gap-3 rounded-lg border px-3 py-2 text-left text-sm font-medium transition-all duration-200",
|
||||
subitem:
|
||||
"min-h-8 w-full justify-start gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
toggle:
|
||||
"h-8 flex-1 justify-center gap-1.5 rounded-lg border px-2 text-sm transition-all",
|
||||
},
|
||||
active: {
|
||||
true: "",
|
||||
false: "",
|
||||
},
|
||||
disabledState: {
|
||||
true: "pointer-events-none text-text-neutral-tertiary",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "item",
|
||||
active: true,
|
||||
disabledState: false,
|
||||
class:
|
||||
"border-border-sidebar-active bg-bg-sidebar-active text-text-neutral-primary shadow-sidebar-active",
|
||||
},
|
||||
{
|
||||
variant: "item",
|
||||
active: false,
|
||||
disabledState: false,
|
||||
class:
|
||||
"text-text-neutral-secondary hover:border-border-sidebar-hover hover:bg-bg-sidebar-hover hover:text-text-neutral-primary border-transparent",
|
||||
},
|
||||
{
|
||||
variant: "subitem",
|
||||
active: true,
|
||||
disabledState: false,
|
||||
class:
|
||||
"bg-bg-sidebar-subitem-active text-text-neutral-primary font-medium",
|
||||
},
|
||||
{
|
||||
variant: "subitem",
|
||||
active: false,
|
||||
disabledState: false,
|
||||
class:
|
||||
"text-text-neutral-secondary hover:bg-bg-sidebar-hover hover:text-text-neutral-primary",
|
||||
},
|
||||
{
|
||||
variant: "toggle",
|
||||
active: true,
|
||||
disabledState: false,
|
||||
class:
|
||||
"border-border-sidebar-active bg-bg-sidebar-active text-text-neutral-primary shadow-sidebar-active",
|
||||
},
|
||||
{
|
||||
variant: "toggle",
|
||||
active: false,
|
||||
disabledState: false,
|
||||
class:
|
||||
"text-text-neutral-secondary hover:bg-bg-sidebar-hover hover:text-text-neutral-primary border-transparent",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "item",
|
||||
active: false,
|
||||
disabledState: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface NavigationButtonProps
|
||||
extends ComponentProps<"button">,
|
||||
VariantProps<typeof navigationButtonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function NavigationButton({
|
||||
active = false,
|
||||
asChild = false,
|
||||
className,
|
||||
disabledState = false,
|
||||
type,
|
||||
variant,
|
||||
...props
|
||||
}: NavigationButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="navigation-button"
|
||||
data-active={active}
|
||||
data-disabled={disabledState}
|
||||
type={asChild ? undefined : (type ?? "button")}
|
||||
className={cn(
|
||||
navigationButtonVariants({
|
||||
active,
|
||||
className,
|
||||
disabledState,
|
||||
variant,
|
||||
}),
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { NavigationButton };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./sheet";
|
||||
|
||||
describe("SheetContent", () => {
|
||||
it("exposes the navigation drawer variant as a design-system contract", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<Sheet open>
|
||||
<SheetContent variant="navigation" showCloseButton={false}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>App navigation</SheetTitle>
|
||||
<SheetDescription>Primary destinations</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "App navigation" }),
|
||||
).toHaveAttribute("data-variant", "navigation");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import type { ComponentPropsWithRef, HTMLAttributes } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -15,19 +15,22 @@ const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
function SheetOverlay({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
@@ -42,42 +45,60 @@ const sheetVariants = cva(
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 rounded-l-xl data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||
},
|
||||
variant: {
|
||||
default: "",
|
||||
navigation:
|
||||
"bg-bg-neutral-primary w-[264px] max-w-[264px] gap-0 rounded-none p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
extends ComponentPropsWithRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-neutral-100 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
function SheetContent({
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
showCloseButton = true,
|
||||
side = "right",
|
||||
variant = "default",
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
data-variant={variant}
|
||||
className={cn(sheetVariants({ side, variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:bg-neutral-100 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
@@ -88,7 +109,7 @@ SheetHeader.displayName = "SheetHeader";
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2",
|
||||
@@ -99,31 +120,40 @@ const SheetFooter = ({
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
function SheetTitle({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold text-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
function SheetDescription({
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ComponentPropsWithRef<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm text-neutral-500 dark:text-neutral-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
|
||||
|
||||
import { SidebarNavigationModeSync } from "./navigation-mode-sync";
|
||||
|
||||
describe("SidebarNavigationModeSync", () => {
|
||||
beforeEach(() => {
|
||||
useSidebar.setState({
|
||||
navigationMode: SIDEBAR_NAVIGATION_MODE.CHAT,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores app navigation mode when the overview mounts", () => {
|
||||
// Given / When
|
||||
render(<SidebarNavigationModeSync mode={SIDEBAR_NAVIGATION_MODE.BROWSE} />);
|
||||
|
||||
// Then
|
||||
expect(useSidebar.getState().navigationMode).toBe(
|
||||
SIDEBAR_NAVIGATION_MODE.BROWSE,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { type SidebarNavigationMode, useSidebar } from "@/hooks/use-sidebar";
|
||||
|
||||
interface SidebarNavigationModeSyncProps {
|
||||
mode: SidebarNavigationMode;
|
||||
}
|
||||
|
||||
export function SidebarNavigationModeSync({
|
||||
mode,
|
||||
}: SidebarNavigationModeSyncProps) {
|
||||
const setNavigationMode = useSidebar((state) => state.setNavigationMode);
|
||||
|
||||
useMountEffect(() => {
|
||||
setNavigationMode(mode);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Home } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
SIDEBAR_NAVIGATION_MODE,
|
||||
type SidebarNavigationMode,
|
||||
} from "@/hooks/use-sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import type { MenuSelectionHandler } from "@/types/components";
|
||||
|
||||
export function SidebarNavigationModeToggle({
|
||||
isOpen,
|
||||
value,
|
||||
onChange,
|
||||
chatEnabled = true,
|
||||
onSelect,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
value: SidebarNavigationMode;
|
||||
onChange: (value: SidebarNavigationMode) => void;
|
||||
chatEnabled?: boolean;
|
||||
onSelect?: MenuSelectionHandler;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
);
|
||||
const modes = [
|
||||
{
|
||||
value: SIDEBAR_NAVIGATION_MODE.BROWSE,
|
||||
label: "Home",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
value: SIDEBAR_NAVIGATION_MODE.CHAT,
|
||||
label: "Chat",
|
||||
icon: LighthouseIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const handleModeChange = (mode: SidebarNavigationMode, disabled: boolean) => {
|
||||
if (disabled) {
|
||||
openCloudUpgrade(
|
||||
CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI,
|
||||
onSelect?.() ?? undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onChange(mode);
|
||||
onSelect?.();
|
||||
if (mode === SIDEBAR_NAVIGATION_MODE.CHAT) {
|
||||
router.push("/lighthouse");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mt-4 shrink-0 px-2", !isOpen && "flex justify-center")}>
|
||||
<div
|
||||
className={cn(
|
||||
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 flex gap-1 rounded-lg border p-1",
|
||||
isOpen ? "w-full" : "flex-col",
|
||||
)}
|
||||
>
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const active = value === mode.value;
|
||||
const disabled =
|
||||
mode.value === SIDEBAR_NAVIGATION_MODE.CHAT && !chatEnabled;
|
||||
const button = (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
aria-label={mode.label}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-[6px] border text-sm transition-all duration-200 ease-out",
|
||||
isOpen
|
||||
? disabled
|
||||
? "min-w-0 gap-1 px-1.5"
|
||||
: "min-w-0 gap-2 px-2"
|
||||
: "w-8 px-2",
|
||||
active
|
||||
? "border-border-input-primary bg-bg-neutral-primary text-text-neutral-primary shadow-md"
|
||||
: "text-text-neutral-secondary hover:text-text-neutral-primary border-transparent",
|
||||
// Local Server gives the Chat upsell enough room to keep both
|
||||
// the feature name and its Cloud badge visible.
|
||||
isOpen &&
|
||||
(!chatEnabled
|
||||
? mode.value === SIDEBAR_NAVIGATION_MODE.CHAT
|
||||
? "flex-[3]"
|
||||
: "flex-[2]"
|
||||
: active
|
||||
? "flex-[11]"
|
||||
: "flex-[9]"),
|
||||
disabled && "text-text-neutral-secondary",
|
||||
)}
|
||||
onClick={() => handleModeChange(mode.value, disabled)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||
{isOpen && <span className="shrink-0">{mode.label}</span>}
|
||||
{isOpen && disabled && (
|
||||
<Badge variant="cloud" size="sm">
|
||||
Cloud
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? "Available in Prowler Cloud"
|
||||
: !isOpen
|
||||
? mode.label
|
||||
: null;
|
||||
|
||||
if (!tooltipContent) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={mode.value} delayDuration={100}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="right">{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ export * from "./dialog/dialog";
|
||||
export * from "./dropdown/Dropdown";
|
||||
export * from "./select";
|
||||
export * from "@/components/layout/main-layout";
|
||||
export * from "@/components/layout/sidebar";
|
||||
export * from "@/components/shadcn/accordion";
|
||||
export * from "@/components/shadcn/action-card";
|
||||
export * from "@/components/shadcn/alert-dialog";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/layout/sidebar";
|
||||
@@ -5,6 +5,5 @@ export * from "./use-local-storage";
|
||||
export * from "./use-mount-effect";
|
||||
export * from "./use-related-filters";
|
||||
export * from "./use-scroll-hint";
|
||||
export * from "./use-sidebar";
|
||||
export * from "./use-store";
|
||||
export * from "./use-url-filters";
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export const SIDEBAR_NAVIGATION_MODE = {
|
||||
BROWSE: "browse",
|
||||
CHAT: "chat",
|
||||
} as const;
|
||||
|
||||
export type SidebarNavigationMode =
|
||||
(typeof SIDEBAR_NAVIGATION_MODE)[keyof typeof SIDEBAR_NAVIGATION_MODE];
|
||||
|
||||
type SidebarSettings = { disabled: boolean; isHoverOpen: boolean };
|
||||
type SidebarStore = {
|
||||
isOpen: boolean;
|
||||
isHover: boolean;
|
||||
navigationMode: SidebarNavigationMode;
|
||||
settings: SidebarSettings;
|
||||
toggleOpen: () => void;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
setIsHover: (isHover: boolean) => void;
|
||||
setNavigationMode: (navigationMode: SidebarNavigationMode) => void;
|
||||
getOpenState: () => boolean;
|
||||
};
|
||||
|
||||
export const useSidebar = create(
|
||||
persist<SidebarStore>(
|
||||
(set, get) => ({
|
||||
isOpen: true,
|
||||
isHover: false,
|
||||
navigationMode: SIDEBAR_NAVIGATION_MODE.BROWSE,
|
||||
settings: { disabled: false, isHoverOpen: false },
|
||||
toggleOpen: () => {
|
||||
set({ isOpen: !get().isOpen });
|
||||
},
|
||||
setIsOpen: (isOpen: boolean) => {
|
||||
set({ isOpen });
|
||||
},
|
||||
setIsHover: (isHover: boolean) => {
|
||||
set({ isHover });
|
||||
},
|
||||
setNavigationMode: (navigationMode: SidebarNavigationMode) => {
|
||||
set({ navigationMode });
|
||||
},
|
||||
getOpenState: () => {
|
||||
const state = get();
|
||||
return state.isOpen || (state.settings.isHoverOpen && state.isHover);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "sidebar",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -4,7 +4,6 @@ export * from "./findings-filters";
|
||||
export * from "./findings-sort";
|
||||
export * from "./helper";
|
||||
export * from "./helper-filters";
|
||||
export * from "./menu-list";
|
||||
export * from "./mute-rules";
|
||||
export * from "./permissions";
|
||||
export * from "./provider-helpers";
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import { SUBMENU_KIND } from "@/types/components";
|
||||
|
||||
import { getMenuList } from "./menu-list";
|
||||
|
||||
const findMenu = (label: string) =>
|
||||
getMenuList({ pathname: "/alerts" })
|
||||
.flatMap((group) => group.menus)
|
||||
.find((menu) => menu.label === label);
|
||||
|
||||
const findSubmenu = (label: string) =>
|
||||
getMenuList({ pathname: "/alerts" })
|
||||
.flatMap((group) => group.menus)
|
||||
.flatMap((menu) => menu.submenus ?? [])
|
||||
.find((submenu) => submenu.label === label);
|
||||
|
||||
const findApiReference = (options: Parameters<typeof getMenuList>[0]) =>
|
||||
getMenuList(options)
|
||||
.flatMap((group) => group.menus)
|
||||
.flatMap((menu) => menu.submenus ?? [])
|
||||
.find((submenu) => submenu.label === "API reference");
|
||||
|
||||
const getTopLevelLabels = () =>
|
||||
getMenuList({ pathname: "/", apiDocsUrl: null }).flatMap((group) =>
|
||||
group.menus.map((menu) => menu.label),
|
||||
);
|
||||
|
||||
const getConfigurationLabels = () =>
|
||||
getMenuList({ pathname: "/lighthouse/settings", apiDocsUrl: null })
|
||||
.flatMap((group) => group.menus)
|
||||
.find((menu) => menu.label === "Configuration")
|
||||
?.submenus?.map((submenu) => submenu.label) ?? [];
|
||||
|
||||
const getConfigurationSubmenu = (label: string) =>
|
||||
getMenuList({ pathname: "/lighthouse/settings", apiDocsUrl: null })
|
||||
.flatMap((group) => group.menus)
|
||||
.flatMap((menu) => menu.submenus ?? [])
|
||||
.find((submenu) => submenu.label === label);
|
||||
|
||||
describe("getMenuList", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
|
||||
});
|
||||
|
||||
describe("API reference link", () => {
|
||||
it("should use the apiDocsUrl provided by the caller in OSS", () => {
|
||||
// Given / When — the caller resolves the runtime value (hydration-safe)
|
||||
const apiRef = findApiReference({
|
||||
pathname: "/",
|
||||
apiDocsUrl: "https://self-hosted.example/api/v1/docs",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(apiRef?.href).toBe("https://self-hosted.example/api/v1/docs");
|
||||
});
|
||||
|
||||
it("should default to an empty href when no apiDocsUrl is provided", () => {
|
||||
// Given / When — no island read here, so SSR and client agree
|
||||
const apiRef = findApiReference({ pathname: "/" });
|
||||
|
||||
// Then
|
||||
expect(apiRef?.href).toBe("");
|
||||
});
|
||||
|
||||
it("should use the Cloud docs URL and ignore apiDocsUrl when Cloud is enabled", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const apiRef = findApiReference({
|
||||
pathname: "/",
|
||||
apiDocsUrl: "https://ignored.example/docs",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(apiRef?.href).toBe("https://api.prowler.com/api/v1/docs");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show Alerts as a contextual Cloud upgrade in Local Server", () => {
|
||||
// Given / When
|
||||
const alerts = findSubmenu("Alerts");
|
||||
|
||||
// Then
|
||||
expect(alerts).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: SUBMENU_KIND.CLOUD_UPGRADE,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
}),
|
||||
);
|
||||
expect(alerts).not.toHaveProperty("href");
|
||||
});
|
||||
|
||||
it("should show Alerts as new under Configuration when Cloud is enabled", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const alerts = findSubmenu("Alerts");
|
||||
|
||||
// Then
|
||||
expect(alerts).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/alerts",
|
||||
active: true,
|
||||
highlight: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should show Scan as a contextual Cloud upgrade in Local Server", () => {
|
||||
// Given / When
|
||||
const scanConfig = findSubmenu("Scan");
|
||||
|
||||
// Then
|
||||
expect(scanConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: SUBMENU_KIND.CLOUD_UPGRADE,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
|
||||
}),
|
||||
);
|
||||
expect(scanConfig).not.toHaveProperty("href");
|
||||
});
|
||||
|
||||
it("should expose CLI Import as a contextual Cloud upgrade in Local Server", () => {
|
||||
// Given / When
|
||||
const cliImport = findSubmenu("CLI Import");
|
||||
|
||||
// Then
|
||||
expect(cliImport).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: SUBMENU_KIND.CLOUD_UPGRADE,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
|
||||
}),
|
||||
);
|
||||
expect(cliImport).not.toHaveProperty("href");
|
||||
});
|
||||
|
||||
it("should omit CLI Import from the Cloud menu", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const cliImport = findSubmenu("CLI Import");
|
||||
|
||||
// Then
|
||||
expect(cliImport).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should show Scan as new under Configuration when Cloud is enabled", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const menus = getMenuList({ pathname: "/scans/config" }).flatMap(
|
||||
(group) => group.menus,
|
||||
);
|
||||
const scanConfig = menus
|
||||
.flatMap((menu) => menu.submenus ?? [])
|
||||
.find((submenu) => submenu.label === "Scan");
|
||||
const scans = menus.find((menu) => menu.label === "Scans");
|
||||
|
||||
// Then
|
||||
expect(scanConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
href: "/scans/config",
|
||||
active: true,
|
||||
highlight: true,
|
||||
}),
|
||||
);
|
||||
// The top-level Scans item uses an exact-match active rule, so it must stay
|
||||
// inactive on the `/scans/config` sub-route.
|
||||
expect(scans).toEqual(expect.objectContaining({ active: false }));
|
||||
});
|
||||
|
||||
it("should remove the new highlight from Attack Paths", () => {
|
||||
// Given / When
|
||||
const attackPaths = findMenu("Attack Paths");
|
||||
|
||||
// Then
|
||||
expect(attackPaths).toEqual(
|
||||
expect.not.objectContaining({ highlight: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep Lighthouse as a browse item in OSS", () => {
|
||||
// Given / When
|
||||
const labels = getTopLevelLabels();
|
||||
|
||||
// Then
|
||||
expect(labels).toContain("Lighthouse AI");
|
||||
});
|
||||
|
||||
it("should move Lighthouse out of the Cloud browse menu but keep its configuration entry", () => {
|
||||
// Given
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
|
||||
|
||||
// When
|
||||
const labels = getTopLevelLabels();
|
||||
const configLabels = getConfigurationLabels();
|
||||
const lighthouseSettings = getConfigurationSubmenu("Lighthouse AI");
|
||||
|
||||
// Then
|
||||
expect(labels).not.toContain("Lighthouse AI");
|
||||
expect(configLabels).toContain("Lighthouse AI");
|
||||
expect(lighthouseSettings?.href).toBe("/lighthouse/settings");
|
||||
});
|
||||
});
|
||||
@@ -1,294 +0,0 @@
|
||||
import {
|
||||
BellRing,
|
||||
CloudCog,
|
||||
Cog,
|
||||
GitBranch,
|
||||
Mail,
|
||||
MessageCircleQuestion,
|
||||
Puzzle,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
SquareChartGantt,
|
||||
Tag,
|
||||
Timer,
|
||||
Upload,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
VolumeX,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ProwlerShort } from "@/components/icons";
|
||||
import {
|
||||
APIdocIcon,
|
||||
DocIcon,
|
||||
GithubIcon,
|
||||
LighthouseIcon,
|
||||
SupportIcon,
|
||||
} from "@/components/icons/Icons";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
type CloudUpgradeFeature,
|
||||
type GroupProps,
|
||||
type IconComponent,
|
||||
SUBMENU_KIND,
|
||||
type SubmenuProps,
|
||||
} from "@/types";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
|
||||
interface MenuListOptions {
|
||||
pathname: string;
|
||||
// Passed in (not read here) so the island isn't read during SSR — that would
|
||||
// cause a hydration mismatch. See useRuntimeConfig.
|
||||
apiDocsUrl?: string | null;
|
||||
}
|
||||
|
||||
interface CloudFeatureSubmenuOptions {
|
||||
isCloudEnvironment: boolean;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: IconComponent;
|
||||
active: boolean;
|
||||
feature: CloudUpgradeFeature;
|
||||
}
|
||||
|
||||
const getCloudFeatureSubmenu = ({
|
||||
isCloudEnvironment,
|
||||
href,
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
feature,
|
||||
}: CloudFeatureSubmenuOptions): SubmenuProps => {
|
||||
if (!isCloudEnvironment) {
|
||||
return {
|
||||
kind: SUBMENU_KIND.CLOUD_UPGRADE,
|
||||
label,
|
||||
icon,
|
||||
cloudUpgradeFeature: feature,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: SUBMENU_KIND.LINK,
|
||||
href,
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
highlight: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getMenuList = ({
|
||||
pathname,
|
||||
apiDocsUrl = null,
|
||||
}: MenuListOptions): GroupProps[] => {
|
||||
const isCloudEnv = isCloud();
|
||||
|
||||
return [
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/",
|
||||
label: "Overview",
|
||||
icon: SquareChartGantt,
|
||||
active: pathname === "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/compliance",
|
||||
label: "Compliance",
|
||||
icon: ShieldCheck,
|
||||
active: pathname === "/compliance",
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCloudEnv
|
||||
? []
|
||||
: [
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/lighthouse",
|
||||
label: "Lighthouse AI",
|
||||
icon: LighthouseIcon,
|
||||
active: pathname === "/lighthouse",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/attack-paths",
|
||||
label: "Attack Paths",
|
||||
icon: GitBranch,
|
||||
active: pathname.startsWith("/attack-paths"),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/findings?filter[muted]=false&filter[status__in]=FAIL",
|
||||
label: "Findings",
|
||||
icon: Tag,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/scans",
|
||||
label: "Scans",
|
||||
icon: Timer,
|
||||
// Exact match so it isn't also marked active on the `/scans/config`
|
||||
// sub-route (mirrors the top-level Lighthouse entry).
|
||||
active: pathname === "/scans",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "/resources",
|
||||
label: "Resources",
|
||||
icon: Warehouse,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "",
|
||||
label: "Configuration",
|
||||
icon: Settings,
|
||||
submenus: [
|
||||
{ href: "/providers", label: "Providers", icon: CloudCog },
|
||||
getCloudFeatureSubmenu({
|
||||
isCloudEnvironment: isCloudEnv,
|
||||
href: "/alerts",
|
||||
label: "Alerts",
|
||||
icon: BellRing,
|
||||
active: pathname.startsWith("/alerts"),
|
||||
feature: CLOUD_UPGRADE_FEATURE.ALERTS,
|
||||
}),
|
||||
{
|
||||
href: "/mutelist",
|
||||
label: "Mutelist",
|
||||
icon: VolumeX,
|
||||
active: pathname === "/mutelist",
|
||||
},
|
||||
getCloudFeatureSubmenu({
|
||||
isCloudEnvironment: isCloudEnv,
|
||||
href: "/scans/config",
|
||||
label: "Scan",
|
||||
icon: SlidersHorizontal,
|
||||
active: pathname.startsWith("/scans/config"),
|
||||
feature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
|
||||
}),
|
||||
...(!isCloudEnv
|
||||
? [
|
||||
{
|
||||
kind: SUBMENU_KIND.CLOUD_UPGRADE,
|
||||
label: "CLI Import",
|
||||
icon: Upload,
|
||||
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ href: "/integrations", label: "Integrations", icon: Puzzle },
|
||||
{ href: "/lighthouse/settings", label: "Lighthouse AI", icon: Cog },
|
||||
],
|
||||
defaultOpen: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "",
|
||||
label: "Organization",
|
||||
icon: Users,
|
||||
submenus: [
|
||||
{ href: "/users", label: "Users", icon: User },
|
||||
{ href: "/invitations", label: "Invitations", icon: Mail },
|
||||
{ href: "/roles", label: "Roles", icon: UserCog },
|
||||
],
|
||||
defaultOpen: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "",
|
||||
label: "Support & Help",
|
||||
icon: SupportIcon,
|
||||
submenus: [
|
||||
{
|
||||
href: "https://docs.prowler.com/",
|
||||
target: "_blank",
|
||||
label: "Documentation",
|
||||
icon: DocIcon,
|
||||
},
|
||||
{
|
||||
href: isCloudEnv
|
||||
? "https://api.prowler.com/api/v1/docs"
|
||||
: (apiDocsUrl ?? ""),
|
||||
target: "_blank",
|
||||
label: "API reference",
|
||||
icon: APIdocIcon,
|
||||
},
|
||||
...(isCloudEnv
|
||||
? [
|
||||
{
|
||||
href: "https://customer.support.prowler.com/servicedesk/customer/portal/9/create/102",
|
||||
target: "_blank",
|
||||
label: "Support Desk",
|
||||
icon: MessageCircleQuestion,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
href: "https://github.com/prowler-cloud/prowler/issues",
|
||||
target: "_blank",
|
||||
label: "Community Support",
|
||||
icon: GithubIcon,
|
||||
},
|
||||
]),
|
||||
],
|
||||
defaultOpen: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupLabel: "",
|
||||
menus: [
|
||||
{
|
||||
href: "https://hub.prowler.com/",
|
||||
label: "Prowler Hub",
|
||||
icon: ProwlerShort,
|
||||
target: "_blank",
|
||||
tooltip: "Looking for all available checks? learn more.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
+3
-3
@@ -23,9 +23,9 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:browser:watch": "vitest --project browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config",
|
||||
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --debug",
|
||||
"test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --headed",
|
||||
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation",
|
||||
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --debug",
|
||||
"test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --project=navigation --headed",
|
||||
"test:e2e:install": "playwright install",
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --ui",
|
||||
|
||||
@@ -145,6 +145,16 @@ export default defineConfig({
|
||||
testMatch: "invitations.spec.ts",
|
||||
dependencies: ["admin.auth.setup"],
|
||||
},
|
||||
// This project validates the responsive application navigation shell
|
||||
{
|
||||
name: "navigation",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
testMatch: /navigation\/.*\.spec\.ts/,
|
||||
dependencies: ["admin.auth.setup"],
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
|
||||
@@ -93,6 +93,25 @@
|
||||
--shadow-progress-glow:
|
||||
0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
|
||||
|
||||
/* App sidebar */
|
||||
--bg-sidebar-active: rgba(255, 255, 255, 0.78);
|
||||
--bg-sidebar-hover: rgba(255, 255, 255, 0.55);
|
||||
--bg-sidebar-subitem-active: rgba(16, 185, 129, 0.1);
|
||||
--bg-sidebar-toggle: rgba(255, 255, 255, 0.4);
|
||||
--border-sidebar-active: rgba(15, 23, 42, 0.09);
|
||||
--border-sidebar-hover: rgba(15, 23, 42, 0.06);
|
||||
--border-sidebar-guide: rgba(15, 23, 42, 0.12);
|
||||
--border-sidebar-toggle: rgba(15, 23, 42, 0.08);
|
||||
--sidebar-active-bar: var(--color-emerald-400);
|
||||
--sidebar-active-icon: var(--color-emerald-600);
|
||||
--shadow-sidebar-active: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--gradient-sidebar-halo: radial-gradient(
|
||||
circle,
|
||||
rgba(46, 229, 155, 0.14) 0%,
|
||||
rgba(98, 223, 240, 0.06) 42%,
|
||||
transparent 72%
|
||||
);
|
||||
|
||||
/* Lighthouse AI */
|
||||
--gradient-lighthouse: linear-gradient(96deg, #2ee59b 3.55%, #62dff0 98.85%);
|
||||
|
||||
@@ -182,6 +201,25 @@
|
||||
/* Progress Bar */
|
||||
--shadow-progress-glow:
|
||||
0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
|
||||
|
||||
/* App sidebar */
|
||||
--bg-sidebar-active: rgba(255, 255, 255, 0.06);
|
||||
--bg-sidebar-hover: rgba(255, 255, 255, 0.045);
|
||||
--bg-sidebar-subitem-active: rgba(46, 229, 155, 0.09);
|
||||
--bg-sidebar-toggle: rgba(255, 255, 255, 0.025);
|
||||
--border-sidebar-active: rgba(255, 255, 255, 0.11);
|
||||
--border-sidebar-hover: rgba(255, 255, 255, 0.07);
|
||||
--border-sidebar-guide: rgba(255, 255, 255, 0.1);
|
||||
--border-sidebar-toggle: rgba(255, 255, 255, 0.08);
|
||||
--sidebar-active-bar: var(--color-emerald-300);
|
||||
--sidebar-active-icon: var(--color-emerald-300);
|
||||
--shadow-sidebar-active: 0 10px 28px rgba(0, 0, 0, 0.22);
|
||||
--gradient-sidebar-halo: radial-gradient(
|
||||
circle,
|
||||
rgba(46, 229, 155, 0.16) 0%,
|
||||
rgba(98, 223, 240, 0.07) 40%,
|
||||
transparent 72%
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== TAILWIND THEME MAPPINGS ===== */
|
||||
@@ -254,8 +292,21 @@
|
||||
--color-bg-feature-new: var(--bg-feature-new);
|
||||
--color-text-feature-new: var(--text-feature-new);
|
||||
|
||||
/* App sidebar */
|
||||
--color-bg-sidebar-active: var(--bg-sidebar-active);
|
||||
--color-bg-sidebar-hover: var(--bg-sidebar-hover);
|
||||
--color-bg-sidebar-subitem-active: var(--bg-sidebar-subitem-active);
|
||||
--color-bg-sidebar-toggle: var(--bg-sidebar-toggle);
|
||||
--color-border-sidebar-active: var(--border-sidebar-active);
|
||||
--color-border-sidebar-hover: var(--border-sidebar-hover);
|
||||
--color-border-sidebar-guide: var(--border-sidebar-guide);
|
||||
--color-border-sidebar-toggle: var(--border-sidebar-toggle);
|
||||
--color-sidebar-active-bar: var(--sidebar-active-bar);
|
||||
--color-sidebar-active-icon: var(--sidebar-active-icon);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-progress-glow: var(--shadow-progress-glow);
|
||||
--shadow-sidebar-active: var(--shadow-sidebar-active);
|
||||
|
||||
/* Background images */
|
||||
--background-image-feature-cloud: var(--gradient-feature-cloud);
|
||||
@@ -320,6 +371,17 @@
|
||||
|
||||
/* ===== COMPONENT LAYER ===== */
|
||||
@layer components {
|
||||
.app-sidebar-halo {
|
||||
position: absolute;
|
||||
top: -8rem;
|
||||
left: -9rem;
|
||||
width: 26rem;
|
||||
height: 26rem;
|
||||
pointer-events: none;
|
||||
background: var(--gradient-sidebar-halo);
|
||||
filter: blur(28px);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { BasePage } from "../base-page";
|
||||
|
||||
export class NavigationPage extends BasePage {
|
||||
readonly appSidebar: Locator;
|
||||
readonly closeMenuButton: Locator;
|
||||
readonly openMenuButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.appSidebar = page.getByRole("dialog", { name: "App sidebar" });
|
||||
this.closeMenuButton = page.getByRole("button", { name: "Close menu" });
|
||||
this.openMenuButton = page.getByRole("button", { name: "Open menu" });
|
||||
}
|
||||
|
||||
async goto(): Promise<void> {
|
||||
await super.goto("/");
|
||||
}
|
||||
|
||||
async verifyPageLoaded(): Promise<void> {
|
||||
await expect(this.openMenuButton).toBeVisible();
|
||||
}
|
||||
|
||||
async openMobileSidebar(): Promise<void> {
|
||||
await this.openMenuButton.click();
|
||||
await expect(this.appSidebar).toBeVisible();
|
||||
await expect(this.openMenuButton).toBeHidden();
|
||||
await this.appSidebar.evaluate(async (element) => {
|
||||
await Promise.all(
|
||||
element
|
||||
.getAnimations()
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async verifyMobileSidebarFitsViewport(): Promise<void> {
|
||||
const viewport = this.page.viewportSize();
|
||||
const sidebarBox = await this.appSidebar.boundingBox();
|
||||
const closeButtonBox = await this.closeMenuButton.boundingBox();
|
||||
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(sidebarBox).not.toBeNull();
|
||||
expect(closeButtonBox).not.toBeNull();
|
||||
|
||||
if (!viewport || !sidebarBox || !closeButtonBox) return;
|
||||
|
||||
for (const box of [sidebarBox, closeButtonBox]) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
|
||||
}
|
||||
|
||||
const bodyWidth = await this.page.locator("body").evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}));
|
||||
expect(bodyWidth.scrollWidth).toBeLessThanOrEqual(bodyWidth.clientWidth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
### E2E Tests: App Navigation
|
||||
|
||||
**Suite ID:** `NAV-E2E`
|
||||
**Feature:** Responsive application sidebar.
|
||||
|
||||
---
|
||||
|
||||
## Test Case: `NAV-E2E-001` - Mobile sidebar fits viewport
|
||||
|
||||
**Priority:** `high`
|
||||
**Tags:** @e2e, @navigation
|
||||
|
||||
**Preconditions:**
|
||||
|
||||
- Admin user authentication state exists
|
||||
- Chromium mobile viewport is 390 x 844 CSS pixels
|
||||
|
||||
### Flow Steps
|
||||
|
||||
1. Navigate to Overview
|
||||
2. Open mobile application menu
|
||||
3. Wait for drawer animation to finish
|
||||
4. Measure drawer, close control, viewport, and document width
|
||||
|
||||
### Expected Result
|
||||
|
||||
- Drawer stays inside viewport
|
||||
- Close control stays visible inside viewport
|
||||
- Open-menu control is hidden while drawer is open
|
||||
- Page has no horizontal overflow
|
||||
|
||||
### Key Verification Points
|
||||
|
||||
- Drawer uses accessible name "App sidebar"
|
||||
- Drawer and close control edges do not exceed viewport edges
|
||||
- Body scroll width does not exceed client width
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
import { NavigationPage } from "./navigation-page";
|
||||
|
||||
test.describe("App navigation", () => {
|
||||
test.use({ storageState: "playwright/.auth/admin_user.json" });
|
||||
|
||||
test(
|
||||
"keeps the mobile sidebar and close control inside the viewport",
|
||||
{
|
||||
tag: ["@e2e", "@navigation", "@high", "@NAV-E2E-001"],
|
||||
},
|
||||
async ({ page }) => {
|
||||
const navigationPage = new NavigationPage(page);
|
||||
|
||||
await navigationPage.goto();
|
||||
await navigationPage.verifyPageLoaded();
|
||||
await navigationPage.openMobileSidebar();
|
||||
await navigationPage.verifyMobileSidebarFitsViewport();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { SVGProps } from "react";
|
||||
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
|
||||
import type { CloudUpgradeFeature } from "./cloud-upgrade";
|
||||
import type { FindingTriageSummary } from "./findings-triage";
|
||||
|
||||
export type IconSvgProps = SVGProps<SVGSVGElement> & {
|
||||
@@ -17,67 +16,6 @@ export type IconProps = {
|
||||
|
||||
export type IconComponent = LucideIcon | React.FC<IconSvgProps>;
|
||||
|
||||
export const SUBMENU_KIND = {
|
||||
LINK: "link",
|
||||
CLOUD_UPGRADE: "cloud_upgrade",
|
||||
} as const;
|
||||
|
||||
interface SubmenuBaseProps {
|
||||
label: string;
|
||||
icon: IconComponent;
|
||||
}
|
||||
|
||||
export interface SubmenuLinkProps extends SubmenuBaseProps {
|
||||
kind?: typeof SUBMENU_KIND.LINK;
|
||||
href: string;
|
||||
target?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
cloudOnly?: boolean;
|
||||
cloudUpgradeFeature?: never;
|
||||
}
|
||||
|
||||
export interface SubmenuCloudUpgradeProps extends SubmenuBaseProps {
|
||||
kind: typeof SUBMENU_KIND.CLOUD_UPGRADE;
|
||||
cloudUpgradeFeature: CloudUpgradeFeature;
|
||||
href?: never;
|
||||
target?: never;
|
||||
active?: never;
|
||||
disabled?: never;
|
||||
highlight?: never;
|
||||
cloudOnly?: never;
|
||||
}
|
||||
|
||||
export type SubmenuProps = SubmenuLinkProps | SubmenuCloudUpgradeProps;
|
||||
|
||||
export type MenuSelectionHandler = () => HTMLElement | null;
|
||||
|
||||
export type MenuProps = {
|
||||
href: string;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
icon: IconComponent;
|
||||
submenus?: SubmenuProps[];
|
||||
defaultOpen?: boolean;
|
||||
target?: string;
|
||||
tooltip?: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export type GroupProps = {
|
||||
groupLabel: string;
|
||||
menus: MenuProps[];
|
||||
};
|
||||
|
||||
export interface CollapseMenuButtonProps {
|
||||
icon: IconComponent;
|
||||
label: string;
|
||||
submenus: SubmenuProps[];
|
||||
defaultOpen: boolean;
|
||||
isOpen: boolean | undefined;
|
||||
}
|
||||
|
||||
export const NEXT_UI_VARIANTS = {
|
||||
SOLID: "solid",
|
||||
FADED: "faded",
|
||||
|
||||
Reference in New Issue
Block a user