mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-29 06:44:21 +00:00
feat(ui): migrate from HeroUI to shadcn/ui (#11532)
This commit is contained in:
1 parent
c665005790
commit
52875b5c7c
519 files changed
+4798
-6997
No files matched your search
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Accordion as NextUIAccordion, AccordionItem } from "@heroui/accordion";
|
||||
import type { Selection } from "@react-types/shared";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccordionItemProps {
|
||||
key: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
content: ReactNode;
|
||||
items?: AccordionItemProps[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AccordionProps {
|
||||
items: AccordionItemProps[];
|
||||
variant?: "light" | "shadow" | "bordered" | "splitted";
|
||||
className?: string;
|
||||
defaultExpandedKeys?: string[];
|
||||
selectedKeys?: string[];
|
||||
selectionMode?: "single" | "multiple";
|
||||
isCompact?: boolean;
|
||||
showDivider?: boolean;
|
||||
onItemExpand?: (key: string) => void;
|
||||
onSelectionChange?: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
const AccordionContent = ({
|
||||
content,
|
||||
items,
|
||||
selectedKeys,
|
||||
onSelectionChange,
|
||||
}: {
|
||||
content: ReactNode;
|
||||
items?: AccordionItemProps[];
|
||||
selectedKeys?: string[];
|
||||
onSelectionChange?: (keys: string[]) => void;
|
||||
}) => {
|
||||
// Normalize possible array content to automatically assign stable keys
|
||||
const normalizedContent = Array.isArray(content)
|
||||
? React.Children.toArray(content)
|
||||
: content;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{normalizedContent}
|
||||
{items && items.length > 0 && (
|
||||
<div className="mt-4 ml-2 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
|
||||
<Accordion
|
||||
items={items}
|
||||
variant="light"
|
||||
isCompact
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Accordion = ({
|
||||
items,
|
||||
variant = "light",
|
||||
className,
|
||||
defaultExpandedKeys = [],
|
||||
selectedKeys,
|
||||
selectionMode = "single",
|
||||
isCompact = false,
|
||||
showDivider = true,
|
||||
onItemExpand,
|
||||
onSelectionChange,
|
||||
}: AccordionProps) => {
|
||||
// Determine if component is in controlled or uncontrolled mode
|
||||
const isControlled = selectedKeys !== undefined;
|
||||
|
||||
const [internalExpandedKeys, setInternalExpandedKeys] = useState<Selection>(
|
||||
new Set(defaultExpandedKeys),
|
||||
);
|
||||
|
||||
// Use selectedKeys if controlled, otherwise use internal state
|
||||
const expandedKeys = useMemo(
|
||||
() => (isControlled ? new Set(selectedKeys) : internalExpandedKeys),
|
||||
[isControlled, selectedKeys, internalExpandedKeys],
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(keys: Selection) => {
|
||||
const keysArray = Array.from(keys as Set<string>);
|
||||
|
||||
// If controlled mode, call parent callback
|
||||
if (isControlled && onSelectionChange) {
|
||||
onSelectionChange(keysArray);
|
||||
} else {
|
||||
// If uncontrolled, update internal state
|
||||
setInternalExpandedKeys(keys);
|
||||
}
|
||||
|
||||
// Handle onItemExpand for backward compatibility
|
||||
if (onItemExpand && keys !== expandedKeys) {
|
||||
const currentKeys = Array.from(expandedKeys as Set<string>);
|
||||
const newKeys = keysArray;
|
||||
|
||||
const newlyExpandedKeys = newKeys.filter(
|
||||
(key) => !currentKeys.includes(key),
|
||||
);
|
||||
|
||||
newlyExpandedKeys.forEach((key) => {
|
||||
onItemExpand(key);
|
||||
});
|
||||
}
|
||||
},
|
||||
[expandedKeys, onItemExpand, isControlled, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<NextUIAccordion
|
||||
className={cn(
|
||||
"bg-bg-neutral-primary border-border-neutral-secondary w-full rounded-lg border",
|
||||
className,
|
||||
)}
|
||||
variant={variant}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={expandedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
isCompact={isCompact}
|
||||
showDivider={showDivider}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<AccordionItem
|
||||
key={item.key}
|
||||
data-accordion-key={item.key}
|
||||
aria-label={
|
||||
typeof item.title === "string" ? item.title : `Item ${item.key}`
|
||||
}
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
isDisabled={item.isDisabled}
|
||||
indicator={<ChevronDown className="text-gray-500" />}
|
||||
classNames={{
|
||||
base: index === 0 || index === 1 ? "my-2" : "my-2",
|
||||
title: "text-sm",
|
||||
subtitle: "text-xs text-gray-500",
|
||||
trigger:
|
||||
"py-2 px-2 rounded-lg data-[hover=true]:bg-bg-neutral-tertiary data-[open=true]:bg-bg-neutral-tertiary w-full flex items-center transition-colors",
|
||||
content: "px-0 py-1",
|
||||
}}
|
||||
>
|
||||
<AccordionContent
|
||||
key={`${item.key}-content`}
|
||||
content={item.content}
|
||||
items={item.items}
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</NextUIAccordion>
|
||||
);
|
||||
};
|
||||
|
||||
Accordion.displayName = "Accordion";
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/accordion";
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { CardProps } from "@heroui/card";
|
||||
import { Card, CardBody } from "@heroui/card";
|
||||
import { Icon } from "@iconify/react";
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib";
|
||||
|
||||
export type ActionCardProps = CardProps & {
|
||||
icon: string;
|
||||
title: string;
|
||||
color?: "success" | "secondary" | "warning" | "fail";
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const ActionCard = React.forwardRef<HTMLDivElement, ActionCardProps>(
|
||||
({ color, title, icon, description, children, className, ...props }, ref) => {
|
||||
const colors = React.useMemo(() => {
|
||||
switch (color) {
|
||||
case "success":
|
||||
return {
|
||||
card: "border-system-success-medium",
|
||||
iconWrapper: "bg-system-success-lighter border-system-success",
|
||||
icon: "text-system-success",
|
||||
};
|
||||
case "secondary":
|
||||
return {
|
||||
card: "border-secondary-100",
|
||||
iconWrapper: "bg-secondary-50 border-secondary-100",
|
||||
icon: "text-secondary",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
card: "border-warning-500",
|
||||
iconWrapper: "bg-warning-50 border-warning-100",
|
||||
icon: "text-warning-600",
|
||||
};
|
||||
case "fail":
|
||||
return {
|
||||
card: "border-danger-300",
|
||||
iconWrapper: "bg-danger-50 border-danger-100",
|
||||
icon: "text-text-error",
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
card: "border-default-200",
|
||||
iconWrapper: "bg-default-50 border-default-100",
|
||||
icon: "text-default-500",
|
||||
};
|
||||
}
|
||||
}, [color]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
isPressable
|
||||
className={cn("border-small", colors?.card, className)}
|
||||
shadow="sm"
|
||||
{...props}
|
||||
>
|
||||
<CardBody className="flex h-full flex-row items-center gap-2 p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"item-center rounded-medium flex border p-1",
|
||||
colors?.iconWrapper,
|
||||
)}
|
||||
>
|
||||
<Icon className={colors?.icon} icon={icon} width={24} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-md">{title}</p>
|
||||
<p className="text-default-400 text-sm">
|
||||
{description || children}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ActionCard.displayName = "ActionCard";
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/action-card";
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@heroui/theme";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import * as React from "react";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.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}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
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 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-neutral-200 bg-white p-6 shadow-lg duration-200 sm:rounded-lg dark:border-neutral-800 dark:bg-neutral-950",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-neutral-500 dark:text-neutral-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(className)} {...props} />
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn("mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/alert-dialog";
|
||||
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
|
||||
destructive:
|
||||
"bg-danger-50 border-red-500/50 text-red-700 dark:border-red-500 dark:border-red-900/50 dark:text-red-700 dark:dark:border-red-900",
|
||||
"bg-bg-fail-secondary border-red-500/50 text-red-700 dark:border-red-500 dark:border-red-900/50 dark:text-red-700 dark:dark:border-red-900",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarFallback, AvatarImage };
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/avatar";
|
||||
@@ -1,72 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { BreadcrumbNavigation } from "./breadcrumb-navigation";
|
||||
|
||||
const navigationMock = vi.hoisted(() => ({
|
||||
pathname: "/findings",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigationMock.pathname,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@iconify/react", () => ({
|
||||
Icon: ({ icon }: { icon: string }) => <span aria-label={icon} />,
|
||||
}));
|
||||
|
||||
vi.mock("@heroui/breadcrumbs", () => ({
|
||||
Breadcrumbs: ({ children }: { children: React.ReactNode }) => (
|
||||
<nav aria-label="Breadcrumb">{children}</nav>
|
||||
),
|
||||
BreadcrumbItem: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("BreadcrumbNavigation", () => {
|
||||
afterEach(() => {
|
||||
navigationMock.pathname = "/findings";
|
||||
});
|
||||
|
||||
it("renders the title action next to the current breadcrumb title", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title="Findings"
|
||||
titleAction={<button type="button">Start product tour</button>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Findings" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Start product tour" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render icons for secondary breadcrumb items", () => {
|
||||
// Given
|
||||
navigationMock.pathname = "/scans/config";
|
||||
|
||||
// When
|
||||
render(
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title="Configuration"
|
||||
icon="lucide:sliders"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("lucide:timer")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("lucide:sliders")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Configuration" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BreadcrumbItem, Breadcrumbs } from "@heroui/breadcrumbs";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { LighthouseIcon } from "@/components/icons/Icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CustomBreadcrumbItem {
|
||||
name: string;
|
||||
path?: string;
|
||||
icon?: string | ReactNode;
|
||||
isLast?: boolean;
|
||||
isClickable?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface BreadcrumbNavigationProps {
|
||||
mode?: "auto" | "custom" | "hybrid";
|
||||
title?: string;
|
||||
icon?: string | ReactNode;
|
||||
titleAction?: ReactNode;
|
||||
customItems?: CustomBreadcrumbItem[];
|
||||
className?: string;
|
||||
paramToPreserve?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
export function BreadcrumbNavigation({
|
||||
mode = "auto",
|
||||
title,
|
||||
icon,
|
||||
titleAction,
|
||||
customItems = [],
|
||||
className = "",
|
||||
paramToPreserve = "scanId",
|
||||
showTitle = true,
|
||||
}: BreadcrumbNavigationProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => {
|
||||
const pathIconMapping: Record<string, string | ReactNode> = {
|
||||
"/integrations": "lucide:puzzle",
|
||||
"/alerts": "lucide:bell-ring",
|
||||
"/providers": "lucide:cloud",
|
||||
"/users": "lucide:users",
|
||||
"/compliance": "lucide:shield-check",
|
||||
"/findings": "lucide:search",
|
||||
"/scans": "lucide:timer",
|
||||
"/roles": "lucide:key",
|
||||
"/resources": "lucide:database",
|
||||
"/lighthouse": <LighthouseIcon />,
|
||||
"/manage-groups": "lucide:users-2",
|
||||
"/services": "lucide:server",
|
||||
"/workloads": "lucide:layers",
|
||||
"/attack-paths": "lucide:git-branch",
|
||||
};
|
||||
|
||||
const pathSegments = pathname
|
||||
.split("/")
|
||||
.filter((segment) => segment !== "");
|
||||
|
||||
if (pathSegments.length === 0) {
|
||||
return [{ name: "Home", path: "/", isLast: true }];
|
||||
}
|
||||
|
||||
const breadcrumbs: CustomBreadcrumbItem[] = [];
|
||||
let currentPath = "";
|
||||
|
||||
pathSegments.forEach((segment, index) => {
|
||||
currentPath += `/${segment}`;
|
||||
const isLast = index === pathSegments.length - 1;
|
||||
let displayName = segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
|
||||
if (segment.includes("-")) {
|
||||
displayName = segment
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
if (segment === "lighthouse") {
|
||||
displayName = "Lighthouse AI";
|
||||
}
|
||||
|
||||
const segmentIcon = !isLast ? pathIconMapping[currentPath] : undefined;
|
||||
|
||||
breadcrumbs.push({
|
||||
name: displayName,
|
||||
path: currentPath,
|
||||
icon: segmentIcon,
|
||||
isLast,
|
||||
isClickable: !isLast,
|
||||
});
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
};
|
||||
|
||||
const buildNavigationUrl = (path: string) => {
|
||||
const paramValue = searchParams.get(paramToPreserve);
|
||||
if (path === "/compliance" && paramValue) {
|
||||
return `/compliance?${paramToPreserve}=${paramValue}`;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const renderTitleWithIcon = (
|
||||
titleText: string,
|
||||
isLink: boolean = false,
|
||||
showIcon: boolean = true,
|
||||
) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{showIcon && typeof icon === "string" ? (
|
||||
<Icon
|
||||
className="text-text-neutral-primary"
|
||||
height={24}
|
||||
icon={icon}
|
||||
width={24}
|
||||
/>
|
||||
) : showIcon && icon ? (
|
||||
<div className="flex h-8 w-8 items-center justify-center *:h-full *:w-full">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<h1
|
||||
className={`text-text-neutral-primary max-w-[200px] truncate text-sm font-bold sm:max-w-none ${isLink ? "hover:text-primary transition-colors" : ""}`}
|
||||
>
|
||||
{titleText}
|
||||
</h1>
|
||||
{titleAction}
|
||||
</div>
|
||||
);
|
||||
|
||||
let breadcrumbItems: CustomBreadcrumbItem[] = [];
|
||||
|
||||
switch (mode) {
|
||||
case "auto":
|
||||
breadcrumbItems = generateAutoBreadcrumbs();
|
||||
break;
|
||||
case "custom":
|
||||
breadcrumbItems = customItems;
|
||||
break;
|
||||
case "hybrid":
|
||||
breadcrumbItems = [...generateAutoBreadcrumbs(), ...customItems];
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className, "w-fit md:w-full")}>
|
||||
<Breadcrumbs separator="/">
|
||||
{breadcrumbItems.map((breadcrumb, index) => (
|
||||
<BreadcrumbItem key={breadcrumb.path || index}>
|
||||
{breadcrumb.isLast && showTitle && title ? (
|
||||
renderTitleWithIcon(title, false, index === 0)
|
||||
) : breadcrumb.isClickable && breadcrumb.path ? (
|
||||
<Link
|
||||
href={buildNavigationUrl(breadcrumb.path)}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-primary"
|
||||
height={24}
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<span className="text-text-neutral-primary hover:text-primary max-w-[150px] truncate text-sm font-bold transition-colors sm:max-w-none">
|
||||
{breadcrumb.name}
|
||||
</span>
|
||||
</Link>
|
||||
) : breadcrumb.isClickable && breadcrumb.onClick ? (
|
||||
<button
|
||||
onClick={breadcrumb.onClick}
|
||||
className="text-text-neutral-primary hover:text-text-neutral-primary-hover flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-text-neutral-primary"
|
||||
height={24}
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<span className="max-w-[150px] truncate sm:max-w-none">
|
||||
{breadcrumb.name}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{index === 0 &&
|
||||
breadcrumb.icon &&
|
||||
typeof breadcrumb.icon === "string" ? (
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className="text-default-500"
|
||||
height={24}
|
||||
icon={breadcrumb.icon}
|
||||
width={24}
|
||||
/>
|
||||
) : index === 0 && breadcrumb.icon ? (
|
||||
<div className="flex h-6 w-6 items-center justify-center *:h-full *:w-full">
|
||||
{breadcrumb.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<span className="max-w-[150px] truncate text-sm font-medium text-gray-900 sm:max-w-none dark:text-gray-100">
|
||||
{breadcrumb.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./breadcrumb-navigation";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/breadcrumbs";
|
||||
@@ -10,16 +10,16 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground font-semibold shadow hover:bg-primary/90",
|
||||
"bg-button-primary text-black font-semibold shadow hover:bg-button-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-bg-neutral-primary shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"border-2 border-slate-950 text-neutral-primary dark:border-white dark:text-neutral-primary font-bold px-[14px]",
|
||||
ghost:
|
||||
"border-2 border-transparent text-neutral-secondary dark:text-neutral-secondary hover:border-slate-950 dark:hover:border-white hover:font-bold px-[14px]",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
link: "text-button-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-slate-200/50 bg-white px-2.5 py-1.5 text-xs shadow-xl dark:border-slate-800/50 dark:bg-slate-950",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-slate-500 dark:[&>svg]:text-slate-400",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium text-slate-950 tabular-nums dark:text-slate-50">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-slate-500 dark:[&>svg]:text-slate-400",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/chart";
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeSnippetProps {
|
||||
value: string;
|
||||
className?: string;
|
||||
/** Hide the code text and show only the copy button */
|
||||
hideCode?: boolean;
|
||||
/** Hide the copy button */
|
||||
hideCopyButton?: boolean;
|
||||
/** Icon to display before the text */
|
||||
icon?: ReactNode;
|
||||
/** Function to format the displayed text (value is still copied as-is) */
|
||||
formatter?: (value: string) => string;
|
||||
/** Enable multiline display (disables truncation, enables word wrap) */
|
||||
multiline?: boolean;
|
||||
/** Remove background and border */
|
||||
transparent?: boolean;
|
||||
/** Custom aria-label for the copy button */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export const CodeSnippet = ({
|
||||
value,
|
||||
className,
|
||||
hideCode = false,
|
||||
hideCopyButton = false,
|
||||
icon,
|
||||
formatter,
|
||||
transparent = false,
|
||||
multiline = false,
|
||||
ariaLabel = "Copy to clipboard",
|
||||
}: CodeSnippetProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const displayValue = formatter ? formatter(value) : value;
|
||||
|
||||
const CopyButton = () => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer transition-colors"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// When hideCode is true, render only the copy button without container styling
|
||||
if (hideCode) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"hover:bg-bg-neutral-tertiary text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer rounded-md p-1 transition-colors",
|
||||
className,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit min-w-0 items-center gap-1.5 text-xs",
|
||||
transparent
|
||||
? "text-text-neutral-tertiary border-0 bg-transparent px-0 py-0"
|
||||
: "text-text-neutral-primary bg-bg-neutral-tertiary border-border-neutral-tertiary border-2 px-2 py-0.5",
|
||||
multiline
|
||||
? "h-auto rounded-lg"
|
||||
: transparent
|
||||
? "h-auto"
|
||||
: "h-6 rounded-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span className="text-text-neutral-secondary shrink-0">{icon}</span>
|
||||
)}
|
||||
{multiline ? (
|
||||
<span className="min-w-0 flex-1 break-all whitespace-pre-wrap">
|
||||
{displayValue}
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 flex-1 truncate">{displayValue}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{value}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!hideCopyButton && <CopyButton />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/code-snippet";
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Navbar, type OnboardingActionConfig } from "../nav-bar/navbar";
|
||||
|
||||
interface ContentLayoutProps {
|
||||
title: string;
|
||||
icon?: string | ReactNode;
|
||||
onboardingAction?: OnboardingActionConfig;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ContentLayout({
|
||||
title,
|
||||
icon,
|
||||
onboardingAction,
|
||||
children,
|
||||
}: ContentLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<Navbar title={title} icon={icon} onboardingAction={onboardingAction} />
|
||||
<div className="py-4 pr-6">{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/content-layout";
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Skeleton } from "@heroui/skeleton";
|
||||
|
||||
export const SkeletonContentLayout = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Theme Switch Skeleton */}
|
||||
<Skeleton className="dark:bg-prowler-blue-800 h-8 w-8 rounded-full">
|
||||
<div className="bg-default-200 h-8 w-8"></div>
|
||||
</Skeleton>
|
||||
|
||||
{/* User Avatar Skeleton */}
|
||||
<Skeleton className="dark:bg-prowler-blue-800 h-10 w-10 rounded-full">
|
||||
<div className="bg-default-200 h-10 w-10"></div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
interface CustomBannerProps {
|
||||
title: string;
|
||||
message: string;
|
||||
buttonLabel?: string;
|
||||
buttonLink?: string;
|
||||
}
|
||||
|
||||
export const CustomBanner = ({
|
||||
title,
|
||||
message,
|
||||
buttonLabel = "Go Home",
|
||||
buttonLink = "/",
|
||||
}: CustomBannerProps) => {
|
||||
return (
|
||||
<Card variant="inner">
|
||||
<CardContent className="flex items-center justify-start">
|
||||
<div className="flex w-full flex-col items-start gap-6 md:flex-row md:items-center md:justify-between md:gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
<InfoIcon className="text-bg-data-info h-6 w-6" />
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full md:w-auto md:shrink-0">
|
||||
<Button
|
||||
asChild
|
||||
className="w-full justify-center md:w-fit"
|
||||
size="default"
|
||||
>
|
||||
<Link href={buttonLink}>{buttonLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@heroui/input";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useState } from "react";
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import { FormControl, FormField } from "@/components/ui/form";
|
||||
|
||||
interface CustomInputProps<T extends FieldValues> {
|
||||
control: Control<T>;
|
||||
name: FieldPath<T>;
|
||||
label?: string;
|
||||
labelPlacement?: "inside" | "outside";
|
||||
variant?: "flat" | "bordered" | "underlined" | "faded";
|
||||
size?: "sm" | "md" | "lg";
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
password?: boolean;
|
||||
confirmPassword?: boolean;
|
||||
defaultValue?: string;
|
||||
isReadOnly?: boolean;
|
||||
isRequired?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const CustomInput = <T extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
type = "text",
|
||||
label = name,
|
||||
labelPlacement = "inside",
|
||||
placeholder,
|
||||
variant = "bordered",
|
||||
size = "md",
|
||||
confirmPassword = false,
|
||||
password = false,
|
||||
defaultValue,
|
||||
isReadOnly = false,
|
||||
isRequired = true,
|
||||
isDisabled = false,
|
||||
}: CustomInputProps<T>) => {
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
|
||||
useState(false);
|
||||
|
||||
const inputLabel = confirmPassword
|
||||
? "Confirm Password"
|
||||
: password
|
||||
? "Password"
|
||||
: label;
|
||||
|
||||
const inputPlaceholder = confirmPassword
|
||||
? "Confirm Password"
|
||||
: password
|
||||
? "Password"
|
||||
: placeholder;
|
||||
|
||||
const inputType =
|
||||
password || confirmPassword
|
||||
? isPasswordVisible || isConfirmPasswordVisible
|
||||
? "text"
|
||||
: "password"
|
||||
: type;
|
||||
const inputIsRequired = password || confirmPassword ? true : isRequired;
|
||||
|
||||
const toggleVisibility = () => {
|
||||
if (password) {
|
||||
setIsPasswordVisible(!isPasswordVisible);
|
||||
} else if (confirmPassword) {
|
||||
setIsConfirmPasswordVisible(!isConfirmPasswordVisible);
|
||||
}
|
||||
};
|
||||
|
||||
const endContent = (password || confirmPassword) && (
|
||||
<button type="button" onClick={toggleVisibility}>
|
||||
<Icon
|
||||
className="text-default-400 pointer-events-none text-2xl"
|
||||
icon={
|
||||
(password && isPasswordVisible) ||
|
||||
(confirmPassword && isConfirmPasswordVisible)
|
||||
? "solar:eye-closed-linear"
|
||||
: "solar:eye-bold"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Input
|
||||
id={name}
|
||||
classNames={{
|
||||
label:
|
||||
"tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
|
||||
input: "text-text-neutral-secondary text-small",
|
||||
}}
|
||||
isRequired={inputIsRequired}
|
||||
label={inputLabel}
|
||||
labelPlacement={labelPlacement}
|
||||
placeholder={inputPlaceholder}
|
||||
type={inputType}
|
||||
variant={variant}
|
||||
size={size}
|
||||
defaultValue={defaultValue}
|
||||
endContent={endContent}
|
||||
isDisabled={isDisabled}
|
||||
isReadOnly={isReadOnly}
|
||||
isInvalid={!!fieldState.error}
|
||||
errorMessage={fieldState.error?.message}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("custom link", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "custom-link.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("renders external or placeholder hrefs as plain anchors instead of next/link", () => {
|
||||
expect(source).toContain("isExternalHref");
|
||||
expect(source).toContain("hasDynamicHrefPlaceholder");
|
||||
expect(source).toContain("<a");
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { type AnchorHTMLAttributes, forwardRef, type ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib";
|
||||
|
||||
interface CustomLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
target?: "_self" | "_blank" | string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
scroll?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
function isExternalHref(href: string) {
|
||||
return /^https?:\/\//.test(href) || href.startsWith("mailto:");
|
||||
}
|
||||
|
||||
function hasDynamicHrefPlaceholder(href: string) {
|
||||
return href.includes("[") && href.includes("]");
|
||||
}
|
||||
|
||||
export const CustomLink = forwardRef<HTMLAnchorElement, CustomLinkProps>(
|
||||
(
|
||||
{
|
||||
href,
|
||||
target = "_blank",
|
||||
ariaLabel,
|
||||
className,
|
||||
children,
|
||||
scroll = true,
|
||||
size = "xs",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const linkClassName = cn(
|
||||
`text-${size} text-button-tertiary p-0`,
|
||||
className,
|
||||
);
|
||||
const shouldUseAnchor =
|
||||
isExternalHref(href) || hasDynamicHrefPlaceholder(href);
|
||||
|
||||
if (shouldUseAnchor) {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
href={href}
|
||||
aria-label={ariaLabel}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className={linkClassName}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={href}
|
||||
scroll={scroll}
|
||||
aria-label={ariaLabel}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className={linkClassName}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CustomLink.displayName = "CustomLink";
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
|
||||
interface ModalButtonsProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
submitText?: string;
|
||||
submitColor?: "action" | "danger";
|
||||
submitIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export const ModalButtons = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
submitText = "Save",
|
||||
submitColor = "action",
|
||||
submitIcon,
|
||||
}: ModalButtonsProps) => {
|
||||
const submitVariant = submitColor === "danger" ? "destructive" : "default";
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant={submitVariant}
|
||||
onClick={onSubmit}
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
submitIcon && submitIcon
|
||||
)}
|
||||
{isLoading ? "Loading" : submitText}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useRadio } from "@heroui/radio";
|
||||
import { cn } from "@heroui/theme";
|
||||
import { VisuallyHidden } from "@react-aria/visually-hidden";
|
||||
import React from "react";
|
||||
|
||||
interface CustomRadioProps {
|
||||
description?: string;
|
||||
value?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomRadio: React.FC<CustomRadioProps> = (props) => {
|
||||
const {
|
||||
Component,
|
||||
children,
|
||||
// description,
|
||||
getBaseProps,
|
||||
getWrapperProps,
|
||||
getInputProps,
|
||||
getLabelProps,
|
||||
getLabelWrapperProps,
|
||||
getControlProps,
|
||||
} = useRadio({ ...props, value: props.value || "" });
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...getBaseProps()}
|
||||
className={cn(
|
||||
"group tap-highlight-transparent inline-flex flex-row-reverse items-center justify-between hover:opacity-70 active:opacity-50",
|
||||
"border-default max-w-full cursor-pointer gap-4 rounded-lg border-2 p-4",
|
||||
"hover:border-button-primary data-[selected=true]:border-button-primary w-full",
|
||||
)}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<input {...getInputProps()} />
|
||||
</VisuallyHidden>
|
||||
<span {...getWrapperProps()}>
|
||||
<span {...getControlProps()} />
|
||||
</span>
|
||||
<div {...getLabelWrapperProps()}>
|
||||
{children && <span {...getLabelProps()}>{children}</span>}
|
||||
{/* {description && (
|
||||
<span className="text-small text-foreground opacity-70">
|
||||
{description}
|
||||
</span>
|
||||
)} */}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@heroui/input";
|
||||
import React from "react";
|
||||
|
||||
interface CustomServerInputProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
labelPlacement?: "inside" | "outside";
|
||||
variant?: "flat" | "bordered" | "underlined" | "faded";
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
isRequired?: boolean;
|
||||
isInvalid?: boolean;
|
||||
errorMessage?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom input component that is used to display a server input without useForm hook.
|
||||
*/
|
||||
export const CustomServerInput = ({
|
||||
name,
|
||||
type = "text",
|
||||
label,
|
||||
labelPlacement = "outside",
|
||||
placeholder,
|
||||
variant = "bordered",
|
||||
isRequired = false,
|
||||
isInvalid = false,
|
||||
errorMessage,
|
||||
value,
|
||||
onChange,
|
||||
}: CustomServerInputProps) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
label={label}
|
||||
labelPlacement={labelPlacement}
|
||||
placeholder={placeholder}
|
||||
variant={variant}
|
||||
isRequired={isRequired}
|
||||
isInvalid={isInvalid}
|
||||
errorMessage={errorMessage}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
classNames={{
|
||||
label: "tracking-tight font-light !text-default-500 text-xs z-0!",
|
||||
input: "text-default-500 text-small",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
|
||||
interface TableLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => {
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<span className="text-text-neutral-tertiary inline-flex h-9 cursor-not-allowed items-center justify-center px-3 text-xs font-medium opacity-60">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button asChild variant="link" size="sm" className="text-xs">
|
||||
<Link href={href} prefetch={false}>
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
TableLink.displayName = "TableLink";
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Textarea } from "@heroui/input";
|
||||
import React from "react";
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
|
||||
|
||||
interface CustomTextareaProps<T extends FieldValues> {
|
||||
control: Control<T>;
|
||||
name: FieldPath<T>;
|
||||
label?: string;
|
||||
labelPlacement?: "inside" | "outside" | "outside-left";
|
||||
variant?: "flat" | "bordered" | "underlined" | "faded";
|
||||
size?: "sm" | "md" | "lg";
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
isRequired?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
fullWidth?: boolean;
|
||||
disableAutosize?: boolean;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomTextarea = <T extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label = name,
|
||||
labelPlacement = "inside",
|
||||
placeholder,
|
||||
variant = "flat",
|
||||
size = "md",
|
||||
defaultValue,
|
||||
isRequired = false,
|
||||
minRows = 3,
|
||||
maxRows = 8,
|
||||
fullWidth = true,
|
||||
disableAutosize = false,
|
||||
description,
|
||||
}: CustomTextareaProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id={name}
|
||||
label={label}
|
||||
labelPlacement={labelPlacement}
|
||||
placeholder={placeholder}
|
||||
variant={variant}
|
||||
size={size}
|
||||
isRequired={isRequired}
|
||||
defaultValue={defaultValue}
|
||||
minRows={minRows}
|
||||
maxRows={maxRows}
|
||||
fullWidth={fullWidth}
|
||||
disableAutosize={disableAutosize}
|
||||
description={description}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-text-error max-w-full text-xs" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,3 @@
|
||||
export * from "./custom-banner";
|
||||
export * from "./custom-input";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-modal-buttons";
|
||||
export * from "./custom-radio";
|
||||
export * from "./custom-server-input";
|
||||
export * from "./custom-table-link";
|
||||
export * from "./custom-textarea";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/custom";
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
|
||||
interface DownloadIconButtonProps {
|
||||
paramId: string;
|
||||
onDownload: (paramId: string) => void;
|
||||
ariaLabel?: string;
|
||||
isDisabled?: boolean;
|
||||
textTooltip?: string;
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
export const DownloadIconButton = ({
|
||||
paramId,
|
||||
onDownload,
|
||||
ariaLabel = "Download report",
|
||||
isDisabled,
|
||||
textTooltip = "Download report",
|
||||
isDownloading = false,
|
||||
}: DownloadIconButtonProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Tooltip content={textTooltip} className="text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={isDisabled || isDownloading}
|
||||
onClick={() => onDownload(paramId)}
|
||||
aria-label={ariaLabel}
|
||||
className="p-0 disabled:opacity-30"
|
||||
>
|
||||
<DownloadIcon
|
||||
className={isDownloading ? "animate-download-icon" : ""}
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/download-icon-button";
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hover:text-accent-foreground focus:bg-accent data-[state=open]:bg-accent text-default-600 hover:bg-default-100 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:font-bold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-popover-foreground border-border-neutral-secondary bg-bg-neutral-secondary data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"text-popover-foreground border-border-neutral-secondary bg-bg-neutral-secondary z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground text-default-600 hover:bg-default-100 relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm subpixel-antialiased transition-colors outline-none select-none hover:[font-variation-settings:'wght'_600] data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground text-default-600 hover:bg-default-100 relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none hover:font-bold data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-default-200 dark:bg-default-700 -mx-1 my-1 h-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/dropdown-menu";
|
||||
@@ -1,13 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DateWithTime } from "./date-with-time";
|
||||
|
||||
describe("DateWithTime", () => {
|
||||
it("uses a single dash placeholder when date is empty", () => {
|
||||
render(<DateWithTime dateTime="" />);
|
||||
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
expect(screen.queryByText("--")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { formatLocalTimeWithZone } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateWithTimeProps {
|
||||
dateTime: string | null; // e.g., "2024-07-17T09:55:14.191475Z"
|
||||
showTime?: boolean;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export const DateWithTime = ({
|
||||
dateTime,
|
||||
showTime = true,
|
||||
inline = false,
|
||||
}: DateWithTimeProps) => {
|
||||
if (!dateTime) return <span>-</span>;
|
||||
|
||||
try {
|
||||
const date = parseISO(dateTime);
|
||||
|
||||
// Validate if the date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const formattedDate = format(date, "MMM dd, yyyy");
|
||||
const timeWithZone = formatLocalTimeWithZone(dateTime);
|
||||
|
||||
const fullText =
|
||||
showTime && timeWithZone
|
||||
? `${formattedDate} ${timeWithZone}`
|
||||
: formattedDate;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"gap-1",
|
||||
inline
|
||||
? "inline-flex flex-row items-center overflow-hidden"
|
||||
: "flex flex-col",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-text-neutral-primary text-sm whitespace-nowrap",
|
||||
inline && "truncate",
|
||||
)}
|
||||
>
|
||||
{formattedDate}
|
||||
</span>
|
||||
{showTime && timeWithZone && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary text-xs font-medium whitespace-nowrap",
|
||||
inline && "truncate",
|
||||
)}
|
||||
>
|
||||
{timeWithZone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden">{content}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{fullText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch {
|
||||
return <span>-</span>;
|
||||
}
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import type { ProviderType } from "@/types";
|
||||
|
||||
import { getProviderLogo } from "./get-provider-logo";
|
||||
|
||||
interface EntityInfoProps {
|
||||
cloudProvider?: ProviderType;
|
||||
icon?: ReactNode;
|
||||
/** Small icon rendered inline before the entity alias text */
|
||||
nameIcon?: ReactNode;
|
||||
/** Inline element rendered after the entity alias (e.g. action link). */
|
||||
nameAction?: ReactNode;
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
badge?: string;
|
||||
/** Label before the ID value. Defaults to "UID" */
|
||||
idLabel?: string;
|
||||
showCopyAction?: boolean;
|
||||
/** Inline element rendered after the entity ID (e.g. action link). */
|
||||
idAction?: ReactNode;
|
||||
/** @deprecated No longer used — layout handles overflow naturally */
|
||||
maxWidth?: string;
|
||||
/** @deprecated No longer used */
|
||||
showConnectionStatus?: boolean;
|
||||
/** @deprecated No longer used */
|
||||
snippetWidth?: string;
|
||||
}
|
||||
|
||||
export const EntityInfo = ({
|
||||
cloudProvider,
|
||||
icon,
|
||||
nameIcon,
|
||||
nameAction,
|
||||
entityAlias,
|
||||
entityId,
|
||||
badge,
|
||||
idLabel = "UID",
|
||||
showCopyAction = true,
|
||||
idAction,
|
||||
}: EntityInfoProps) => {
|
||||
const canCopy = Boolean(entityId && showCopyAction);
|
||||
const renderedIcon =
|
||||
icon ?? (cloudProvider ? getProviderLogo(cloudProvider) : null);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center text-sm">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{renderedIcon && <div className="shrink-0">{renderedIcon}</div>}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{nameIcon && (
|
||||
<span className="text-text-neutral-tertiary shrink-0">
|
||||
{nameIcon}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate font-medium">
|
||||
{entityAlias || entityId || "-"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{entityAlias || entityId || "No alias"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{badge && (
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
({badge})
|
||||
</span>
|
||||
)}
|
||||
{nameAction && <span className="shrink-0">{nameAction}</span>}
|
||||
</div>
|
||||
{entityId && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-text-neutral-tertiary shrink-0 text-xs font-medium">
|
||||
{idLabel}:
|
||||
</span>
|
||||
<CodeSnippet
|
||||
value={entityId}
|
||||
className="max-w-[160px]"
|
||||
hideCopyButton={!canCopy}
|
||||
/>
|
||||
{idAction && <span className="shrink-0">{idAction}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const getProviderLogo = (provider: ProviderType) => {
|
||||
switch (provider) {
|
||||
case "aws":
|
||||
return <AWSProviderBadge width={35} height={35} />;
|
||||
case "azure":
|
||||
return <AzureProviderBadge width={35} height={35} />;
|
||||
case "gcp":
|
||||
return <GCPProviderBadge width={35} height={35} />;
|
||||
case "kubernetes":
|
||||
return <KS8ProviderBadge width={35} height={35} />;
|
||||
case "m365":
|
||||
return <M365ProviderBadge width={35} height={35} />;
|
||||
case "github":
|
||||
return <GitHubProviderBadge width={35} height={35} />;
|
||||
case "googleworkspace":
|
||||
return <GoogleWorkspaceProviderBadge width={35} height={35} />;
|
||||
case "iac":
|
||||
return <IacProviderBadge width={35} height={35} />;
|
||||
case "image":
|
||||
return <ImageProviderBadge width={35} height={35} />;
|
||||
case "oraclecloud":
|
||||
return <OracleCloudProviderBadge width={35} height={35} />;
|
||||
case "mongodbatlas":
|
||||
return <MongoDBAtlasProviderBadge width={35} height={35} />;
|
||||
case "alibabacloud":
|
||||
return <AlibabaCloudProviderBadge width={35} height={35} />;
|
||||
case "cloudflare":
|
||||
return <CloudflareProviderBadge width={35} height={35} />;
|
||||
case "openstack":
|
||||
return <OpenStackProviderBadge width={35} height={35} />;
|
||||
case "vercel":
|
||||
return <VercelProviderBadge width={35} height={35} />;
|
||||
case "okta":
|
||||
return <OktaProviderBadge width={35} height={35} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getProviderName = (provider: ProviderType): string => {
|
||||
switch (provider) {
|
||||
case "aws":
|
||||
return "Amazon Web Services";
|
||||
case "azure":
|
||||
return "Microsoft Azure";
|
||||
case "gcp":
|
||||
return "Google Cloud Platform";
|
||||
case "kubernetes":
|
||||
return "Kubernetes";
|
||||
case "m365":
|
||||
return "Microsoft 365";
|
||||
case "github":
|
||||
return "GitHub";
|
||||
case "googleworkspace":
|
||||
return "Google Workspace";
|
||||
case "iac":
|
||||
return "Infrastructure as Code";
|
||||
case "image":
|
||||
return "Container Registry";
|
||||
case "oraclecloud":
|
||||
return "Oracle Cloud Infrastructure";
|
||||
case "mongodbatlas":
|
||||
return "MongoDB Atlas";
|
||||
case "alibabacloud":
|
||||
return "Alibaba Cloud";
|
||||
case "cloudflare":
|
||||
return "Cloudflare";
|
||||
case "openstack":
|
||||
return "OpenStack";
|
||||
case "vercel":
|
||||
return "Vercel";
|
||||
case "okta":
|
||||
return "Okta";
|
||||
default:
|
||||
return "Unknown Provider";
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./date-with-time";
|
||||
export * from "./entity-info";
|
||||
export * from "./get-provider-logo";
|
||||
export * from "./scan-status";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/entities";
|
||||
@@ -1,28 +0,0 @@
|
||||
import { formatDuration, intervalToDuration, parseISO } from "date-fns";
|
||||
import React from "react";
|
||||
|
||||
interface ScanStatusProps {
|
||||
status: string;
|
||||
createdAt: string; // ISO string
|
||||
}
|
||||
|
||||
export const ScanStatus: React.FC<ScanStatusProps> = ({
|
||||
status,
|
||||
createdAt,
|
||||
}) => {
|
||||
const duration = intervalToDuration({
|
||||
start: parseISO(createdAt),
|
||||
end: new Date(),
|
||||
});
|
||||
|
||||
const formattedDuration = formatDuration(duration, { delimiter: ", " });
|
||||
|
||||
return (
|
||||
<div className="max-w-fit">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-md font-semibold">{status}</span>
|
||||
<span className="text-sm text-gray-500">{formattedDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExpandableSectionProps {
|
||||
isExpanded: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated expandable section using CSS grid for smooth height transitions.
|
||||
* Animates from height 0 to auto content height.
|
||||
*/
|
||||
export function ExpandableSection({
|
||||
isExpanded,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: ExpandableSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-300 ease-in-out",
|
||||
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={cn("pt-4", contentClassName, !isExpanded && "invisible")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/expandable-section";
|
||||
@@ -1,66 +0,0 @@
|
||||
import { AlertIcon } from "@/components/icons/Icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FeedbackType = "error" | "warning" | "info" | "success";
|
||||
|
||||
interface FeedbackBannerProps {
|
||||
type?: FeedbackType;
|
||||
title: string;
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const typeStyles: Record<
|
||||
FeedbackType,
|
||||
{ border: string; bg: string; text: string }
|
||||
> = {
|
||||
error: {
|
||||
border: "border-danger",
|
||||
bg: "bg-system-error-light/30 dark:bg-system-error-light/80",
|
||||
text: "text-text-error",
|
||||
},
|
||||
warning: {
|
||||
border: "border-warning",
|
||||
bg: "bg-yellow-100 dark:bg-yellow-200",
|
||||
text: "text-yellow-800",
|
||||
},
|
||||
info: {
|
||||
border: "border-blue-400",
|
||||
bg: "bg-blue-50 dark:bg-blue-100",
|
||||
text: "text-blue-800",
|
||||
},
|
||||
success: {
|
||||
border: "border-green-500",
|
||||
bg: "bg-green-50 dark:bg-green-100",
|
||||
text: "text-green-800",
|
||||
},
|
||||
};
|
||||
|
||||
export const FeedbackBanner: React.FC<FeedbackBannerProps> = ({
|
||||
type = "info",
|
||||
title,
|
||||
message,
|
||||
className,
|
||||
}) => {
|
||||
const styles = typeStyles[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border-l-4 p-4 shadow-sm",
|
||||
styles.border,
|
||||
styles.bg,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn("mt-1", styles.text)}>
|
||||
<AlertIcon size={20} />
|
||||
</span>
|
||||
<p className={cn("text-sm", styles.text)}>
|
||||
<strong>{title}</strong> {message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/feedback-banner";
|
||||
@@ -1,65 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "./Form";
|
||||
|
||||
interface TestValues {
|
||||
providerUid: string;
|
||||
}
|
||||
|
||||
function TestFormWithError() {
|
||||
const form = useForm<TestValues>({
|
||||
defaultValues: {
|
||||
providerUid: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.setError("providerUid", {
|
||||
type: "manual",
|
||||
message: "Provider ID is required",
|
||||
});
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerUid"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider UID</FormLabel>
|
||||
<FormControl>
|
||||
<input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Form", () => {
|
||||
it("should use the existing error text token for labels and messages", async () => {
|
||||
// Given
|
||||
render(<TestFormWithError />);
|
||||
|
||||
// When
|
||||
const label = await screen.findByText("Provider UID");
|
||||
const message = await screen.findByText("Provider ID is required");
|
||||
|
||||
// Then
|
||||
expect(label).toHaveClass("text-text-error-primary");
|
||||
expect(message).toHaveClass("text-text-error-primary");
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Label } from "./Label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
error && "text-text-error-primary max-w-full text-xs",
|
||||
className,
|
||||
)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-text-neutral-tertiary max-w-full text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn(
|
||||
"text-text-error-primary max-w-full text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { SaveIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
|
||||
interface FormCancelButtonProps {
|
||||
setIsOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
onCancel?: () => void;
|
||||
children?: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface FormSubmitButtonProps {
|
||||
children?: React.ReactNode;
|
||||
loadingText?: string;
|
||||
isDisabled?: boolean;
|
||||
rightIcon?: React.ReactNode;
|
||||
color?: SubmitColorsType;
|
||||
}
|
||||
|
||||
interface FormButtonsProps {
|
||||
setIsOpen?: Dispatch<SetStateAction<boolean>>;
|
||||
onCancel?: () => void;
|
||||
submitText?: string;
|
||||
cancelText?: string;
|
||||
loadingText?: string;
|
||||
isDisabled?: boolean;
|
||||
rightIcon?: React.ReactNode;
|
||||
leftIcon?: React.ReactNode;
|
||||
submitColor?: SubmitColorsType;
|
||||
}
|
||||
|
||||
export const SubmitColors = {
|
||||
action: "action",
|
||||
danger: "danger",
|
||||
} as const;
|
||||
|
||||
export type SubmitColorsType = (typeof SubmitColors)[keyof typeof SubmitColors];
|
||||
|
||||
const FormCancelButton = ({
|
||||
setIsOpen,
|
||||
onCancel,
|
||||
children = "Cancel",
|
||||
leftIcon,
|
||||
}: FormCancelButtonProps) => {
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else if (setIsOpen) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={handleCancel}
|
||||
className="w-40"
|
||||
>
|
||||
{leftIcon}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const FormSubmitButton = ({
|
||||
children = "Save",
|
||||
loadingText = "Loading",
|
||||
isDisabled = false,
|
||||
color = "action",
|
||||
rightIcon,
|
||||
}: FormSubmitButtonProps) => {
|
||||
const { pending } = useFormStatus();
|
||||
const submitVariant = color === "danger" ? "destructive" : "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
variant={submitVariant}
|
||||
size="lg"
|
||||
disabled={isDisabled || pending}
|
||||
className="w-40"
|
||||
>
|
||||
{pending ? <Loader2 className="animate-spin" /> : rightIcon}
|
||||
{pending ? loadingText : children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormButtons = ({
|
||||
setIsOpen,
|
||||
submitColor,
|
||||
onCancel,
|
||||
submitText = "Save",
|
||||
cancelText = "Cancel",
|
||||
loadingText = "Loading",
|
||||
isDisabled = false,
|
||||
rightIcon = <SaveIcon size={24} />,
|
||||
leftIcon,
|
||||
}: FormButtonsProps) => {
|
||||
return (
|
||||
<div className="flex w-full justify-between gap-4">
|
||||
<FormCancelButton
|
||||
setIsOpen={setIsOpen}
|
||||
onCancel={onCancel}
|
||||
leftIcon={leftIcon}
|
||||
>
|
||||
{cancelText}
|
||||
</FormCancelButton>
|
||||
|
||||
<FormSubmitButton
|
||||
loadingText={loadingText}
|
||||
isDisabled={isDisabled}
|
||||
rightIcon={rightIcon}
|
||||
color={submitColor}
|
||||
>
|
||||
{submitText}
|
||||
</FormSubmitButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./Form";
|
||||
export * from "./form-buttons";
|
||||
export * from "./Label";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/form";
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/headers";
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
|
||||
interface NavigationHeaderProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export const NavigationHeader = ({
|
||||
title,
|
||||
icon,
|
||||
href,
|
||||
}: NavigationHeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<header className="flex items-center gap-3 border-b border-gray-200 px-6 py-4 dark:border-gray-800">
|
||||
<Button
|
||||
className="border-gray-200 bg-transparent p-0"
|
||||
aria-label="Navigation button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
asChild
|
||||
>
|
||||
<Link href={href || ""}>
|
||||
<Icon icon={icon} className="text-gray-600 dark:text-gray-400" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Divider orientation="vertical" className="h-6" />
|
||||
<h1 className="text-default-700 text-xl font-light">{title}</h1>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+15
-13
@@ -1,18 +1,20 @@
|
||||
export * from "./accordion/Accordion";
|
||||
export * from "./action-card/ActionCard";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "./alert/Alert";
|
||||
export * from "./alert-dialog/AlertDialog";
|
||||
export * from "./breadcrumbs";
|
||||
export * from "./collapsible/collapsible";
|
||||
export * from "./content-layout/content-layout";
|
||||
export * from "./dialog/dialog";
|
||||
export * from "./download-icon-button/download-icon-button";
|
||||
export * from "./dropdown/Dropdown";
|
||||
export * from "./feedback-banner/feedback-banner";
|
||||
export * from "./headers/navigation-header";
|
||||
export * from "./label/Label";
|
||||
export * from "./main-layout/main-layout";
|
||||
export * from "./navigation-progress";
|
||||
export * from "./select";
|
||||
export * from "./sidebar";
|
||||
export * from "./toast";
|
||||
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";
|
||||
export * from "@/components/shadcn/breadcrumbs";
|
||||
export * from "@/components/shadcn/content-layout";
|
||||
export * from "@/components/shadcn/download-icon-button";
|
||||
export * from "@/components/shadcn/feedback-banner";
|
||||
export * from "@/components/shadcn/headers";
|
||||
export * from "@/components/shadcn/label";
|
||||
export * from "@/components/shadcn/navigation-progress";
|
||||
export * from "@/components/shadcn/toast";
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/label";
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/layout/main-layout";
|
||||
@@ -1,37 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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" />,
|
||||
}));
|
||||
|
||||
describe("MainLayout", () => {
|
||||
it("renders subdued background glows for side-nav contrast", () => {
|
||||
render(
|
||||
<MainLayout>
|
||||
<div>Page content</div>
|
||||
</MainLayout>,
|
||||
);
|
||||
|
||||
const topGlow =
|
||||
screen.getByTestId("sidebar").previousElementSibling
|
||||
?.previousElementSibling;
|
||||
const bottomGlow = screen.getByTestId("sidebar").previousElementSibling;
|
||||
|
||||
expect(topGlow).toHaveClass("h-[120%]", "w-[160%]", "opacity-[7%]");
|
||||
expect(bottomGlow).toHaveClass("h-[50%]", "w-[50%]", "opacity-[7%]");
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
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 />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/layout/nav-bar";
|
||||
@@ -1,255 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { localStorageAdapter } from "@/lib/tours/store/local-storage-adapter";
|
||||
import { usePageReadyStore } from "@/store/page-ready";
|
||||
|
||||
import { NavbarClient } from "./navbar-client";
|
||||
|
||||
const navigationMocks = vi.hoisted(() => ({
|
||||
pathname: "/findings",
|
||||
push: vi.fn(),
|
||||
searchParams: new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const requestReplayMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navigationMocks.pathname,
|
||||
useRouter: () => ({ push: navigationMocks.push }),
|
||||
useSearchParams: () => navigationMocks.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/store/onboarding-replay", () => {
|
||||
const state = { requestReplay: requestReplayMock };
|
||||
const hook = (selector: (s: typeof state) => unknown) => selector(state);
|
||||
hook.getState = () => state;
|
||||
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>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
BreadcrumbNavigation: ({
|
||||
title,
|
||||
titleAction,
|
||||
}: {
|
||||
title: string;
|
||||
titleAction?: ReactNode;
|
||||
}) => (
|
||||
<nav aria-label="Breadcrumb">
|
||||
<h1>{title}</h1>
|
||||
{titleAction}
|
||||
</nav>
|
||||
),
|
||||
}));
|
||||
|
||||
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("../user-nav/user-nav", () => ({
|
||||
UserNav: () => <button type="button">User menu</button>,
|
||||
}));
|
||||
|
||||
describe("NavbarClient", () => {
|
||||
beforeEach(() => {
|
||||
navigationMocks.pathname = "/findings";
|
||||
navigationMocks.push.mockClear();
|
||||
requestReplayMock.mockClear();
|
||||
navigationMocks.searchParams = new URLSearchParams();
|
||||
window.localStorage.clear();
|
||||
// Replay icon is Cloud-only.
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
// Default: the current route's content has loaded, so the icon is enabled.
|
||||
usePageReadyStore.setState({ readyPath: "/findings" });
|
||||
});
|
||||
|
||||
it("renders an accessible contextual onboarding button in the breadcrumb", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.hover(
|
||||
screen.getByRole("button", {
|
||||
name: /start product tour: explore your findings/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Findings" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByText("See how it works")).not.toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /start product tour: explore your findings/i,
|
||||
}),
|
||||
).toHaveClass("size-7");
|
||||
});
|
||||
|
||||
it("pulses the onboarding icon while the tour has not been seen", () => {
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const icon = screen
|
||||
.getByRole("button", {
|
||||
name: /start product tour: explore your findings/i,
|
||||
})
|
||||
.querySelector("svg");
|
||||
expect(icon).toHaveClass("animate-pulse");
|
||||
});
|
||||
|
||||
it("keeps the onboarding button but stops pulsing once the tour is closed", () => {
|
||||
const flow = getFlowById("explore-findings");
|
||||
localStorageAdapter.set(flow!.tour, {
|
||||
tourId: flow!.tour.id,
|
||||
version: flow!.tour.version,
|
||||
state: "dismissed",
|
||||
completedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", {
|
||||
name: /start product tour: explore your findings/i,
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.querySelector("svg")).not.toHaveClass("animate-pulse");
|
||||
});
|
||||
|
||||
it("starts the replay in-memory (no navigation) when already on the flow's route", async () => {
|
||||
// Given the user is already on the flow's page
|
||||
navigationMocks.pathname = "/compliance";
|
||||
navigationMocks.searchParams = new URLSearchParams("scanId=scan-1&foo=bar");
|
||||
usePageReadyStore.setState({ readyPath: "/compliance" });
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Compliance"
|
||||
onboardingAction={{ flowId: "view-compliance" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /start product tour: check compliance/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then the replay is requested via the in-memory store — no router.push, so no
|
||||
// `?onboarding=` URL param and no Next.js RSC refetch of the page.
|
||||
expect(requestReplayMock).toHaveBeenCalledWith("view-compliance");
|
||||
expect(navigationMocks.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the fallback flow without leaking current-route params", async () => {
|
||||
// Given
|
||||
navigationMocks.pathname = "/findings";
|
||||
navigationMocks.searchParams = new URLSearchParams(
|
||||
"filter%5Bseverity__in%5D=critical",
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{
|
||||
flowId: "explore-findings",
|
||||
fallbackFlowId: "view-first-scan",
|
||||
useFallback: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /start product tour: run your first scan/i,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then: the flow's own route (incl. its `?tab=active`) is preserved and the
|
||||
// onboarding param is appended with `&` — the current /findings filter is dropped.
|
||||
expect(navigationMocks.push).toHaveBeenCalledWith(
|
||||
"/scans?tab=active&onboarding=view-first-scan",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the replay icon until the route's content has loaded", () => {
|
||||
// Given the page has not signalled ready for the current route
|
||||
usePageReadyStore.setState({ readyPath: null });
|
||||
|
||||
// When
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then the icon is not rendered at all
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: /start product tour: explore your findings/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the replay icon entirely in self-hosted (OSS) deployments", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "explore-findings" }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /start product tour/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a contextual onboarding button for unknown flows", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<NavbarClient
|
||||
title="Findings"
|
||||
onboardingAction={{ flowId: "unknown-flow" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /start product tour/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, Info } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { ThemeSwitch } from "@/components/ThemeSwitch";
|
||||
import { BreadcrumbNavigation } from "@/components/ui";
|
||||
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";
|
||||
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 {
|
||||
flowId: string;
|
||||
fallbackFlowId?: string;
|
||||
useFallback?: boolean;
|
||||
}
|
||||
|
||||
interface NavbarClientProps {
|
||||
title: string;
|
||||
icon?: string | ReactNode;
|
||||
onboardingAction?: OnboardingActionConfig;
|
||||
feedsSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function NavbarClient({
|
||||
title,
|
||||
icon,
|
||||
onboardingAction,
|
||||
feedsSlot,
|
||||
}: NavbarClientProps) {
|
||||
const { isOpen, toggleOpen } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const requestReplay = useOnboardingReplayStore(
|
||||
(state) => state.requestReplay,
|
||||
);
|
||||
const targetFlowId =
|
||||
onboardingAction?.useFallback && onboardingAction.fallbackFlowId
|
||||
? onboardingAction.fallbackFlowId
|
||||
: onboardingAction?.flowId;
|
||||
// Cloud-only: no flow → no replay icon in OSS.
|
||||
const flow =
|
||||
isCloud() && targetFlowId ? getFlowById(targetFlowId) : undefined;
|
||||
// Pulse only until the tour is seen; any close (completed/skipped/dismissed)
|
||||
// calms it. The replay button itself stays.
|
||||
const seen = useTourCompletion(flow?.tour ?? null) !== null;
|
||||
|
||||
// Keep the replay icon disabled until this route's content has finished loading,
|
||||
// so a tour never starts before its anchors are in the DOM.
|
||||
const readyPath = usePageReadyStore((state) => state.readyPath);
|
||||
const pageReady = readyPath === pathname;
|
||||
|
||||
const replayFlow = () => {
|
||||
if (!flow) return;
|
||||
|
||||
// Already on the flow's page: start the replay via an in-memory signal so the
|
||||
// URL never changes and Next.js never refetches the (often heavy) page.
|
||||
if (flow.route === pathname) {
|
||||
requestReplay(flow.id);
|
||||
return;
|
||||
}
|
||||
// Different page: a real navigation is needed; the `onboarding=` param survives
|
||||
// it and starts the tour on the destination route. Routes may already carry a
|
||||
// query string (e.g. `/scans?tab=active`), so pick the right separator.
|
||||
const separator = flow.route.includes("?") ? "&" : "?";
|
||||
router.push(`${flow.route}${separator}onboarding=${flow.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
{/* Suspense contains the useSearchParams() CSR bailout in BreadcrumbNavigation
|
||||
so statically prerendered pages don't fail the build. */}
|
||||
<Suspense fallback={null}>
|
||||
<BreadcrumbNavigation
|
||||
mode="auto"
|
||||
title={title}
|
||||
icon={icon}
|
||||
titleAction={
|
||||
// Hidden until the route's content has loaded, so the tour never starts
|
||||
// before its anchors exist (and we avoid a disabled-then-enabled flash).
|
||||
flow && pageReady ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Start product tour: ${flow.title}`}
|
||||
onClick={replayFlow}
|
||||
>
|
||||
<Info
|
||||
className={cn(
|
||||
"text-bg-data-info size-4",
|
||||
!seen && "animate-pulse",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>See how it works</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
paramToPreserve="scanId"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-3">
|
||||
<ThemeSwitch />
|
||||
{feedsSlot}
|
||||
<UserNav />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedsLoadingFallback() {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-border-input-primary-fill relative h-8 w-8 rounded-full bg-transparent p-2"
|
||||
disabled
|
||||
>
|
||||
<BellRing size={18} className="animate-pulse text-slate-400" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ReactNode, Suspense } from "react";
|
||||
|
||||
import { FeedsServer } from "@/components/feeds";
|
||||
|
||||
import {
|
||||
FeedsLoadingFallback,
|
||||
NavbarClient,
|
||||
type OnboardingActionConfig,
|
||||
} from "./navbar-client";
|
||||
|
||||
export type { OnboardingActionConfig };
|
||||
|
||||
interface NavbarProps {
|
||||
title: string;
|
||||
icon?: string | ReactNode;
|
||||
onboardingAction?: OnboardingActionConfig;
|
||||
}
|
||||
|
||||
export function Navbar({ title, icon, onboardingAction }: NavbarProps) {
|
||||
return (
|
||||
<NavbarClient
|
||||
title={title}
|
||||
icon={icon}
|
||||
onboardingAction={onboardingAction}
|
||||
feedsSlot={
|
||||
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
|
||||
<FeedsServer limit={15} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
export { NavigationProgress } from "./navigation-progress";
|
||||
export {
|
||||
cancelProgress,
|
||||
completeProgress,
|
||||
startProgress,
|
||||
useNavigationProgress,
|
||||
} from "./use-navigation-progress";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/navigation-progress";
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib";
|
||||
|
||||
import { useNavigationProgress } from "./use-navigation-progress";
|
||||
|
||||
const HIDE_DELAY_MS = 200;
|
||||
|
||||
export function NavigationProgress() {
|
||||
const { isLoading, progress } = useNavigationProgress();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return setVisible(true);
|
||||
|
||||
const timeout = setTimeout(() => setVisible(false), HIDE_DELAY_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isLoading]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 z-[99999] h-[3px] w-full"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Page loading progress"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-button-primary h-full transition-all duration-200 ease-out",
|
||||
isLoading && "shadow-progress-glow",
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
interface ProgressState {
|
||||
isLoading: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let state: ProgressState = { isLoading: false, progress: 0 };
|
||||
const listeners = new Set<() => void>();
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Cached server snapshot to avoid infinite loop with useSyncExternalStore
|
||||
const SERVER_SNAPSHOT: ProgressState = { isLoading: false, progress: 0 };
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function setState(newState: ProgressState) {
|
||||
state = newState;
|
||||
notify();
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the progress bar animation.
|
||||
* Progress increases quickly at first, then slows down as it approaches 90%.
|
||||
*/
|
||||
export function startProgress() {
|
||||
clearTimers();
|
||||
setState({ isLoading: true, progress: 0 });
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
if (state.progress < 90) {
|
||||
const increment = (90 - state.progress) * 0.1;
|
||||
setState({
|
||||
...state,
|
||||
progress: Math.min(90, state.progress + increment),
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the progress bar animation.
|
||||
* Jumps to 100% and then hides after a brief delay.
|
||||
*/
|
||||
export function completeProgress() {
|
||||
clearTimers();
|
||||
setState({ isLoading: false, progress: 100 });
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
setState({ isLoading: false, progress: 0 });
|
||||
timeoutId = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the progress bar immediately without animation.
|
||||
*/
|
||||
export function cancelProgress() {
|
||||
clearTimers();
|
||||
setState({ isLoading: false, progress: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access progress bar state.
|
||||
* Automatically completes progress when URL changes.
|
||||
*/
|
||||
export function useNavigationProgress() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentState = useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
() => state,
|
||||
() => SERVER_SNAPSHOT,
|
||||
);
|
||||
|
||||
// Complete progress when URL changes (only if currently loading)
|
||||
useEffect(() => {
|
||||
if (state.isLoading) {
|
||||
completeProgress();
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return currentState;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/scroll-area";
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-px",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./sheet";
|
||||
export * from "./trigger-sheet";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/sheet";
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
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}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 border border-border-neutral-secondary bg-bg-neutral-secondary p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 rounded-b-xl data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 rounded-t-xl data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 rounded-r-xl data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
||||
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",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
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>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
));
|
||||
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}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "./sheet";
|
||||
|
||||
interface TriggerSheetProps {
|
||||
triggerComponent: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TriggerSheet({
|
||||
triggerComponent,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: TriggerSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
|
||||
<SheetTrigger className="flex items-center gap-2">
|
||||
{triggerComponent}
|
||||
</SheetTrigger>
|
||||
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="sr-only">{title}</SheetTitle>
|
||||
<SheetDescription className="sr-only">{description}</SheetDescription>
|
||||
</SheetHeader>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible/collapsible";
|
||||
import { SubmenuItem } from "@/components/ui/sidebar/submenu-item";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconComponent, SubmenuProps } from "@/types";
|
||||
|
||||
interface CollapsibleMenuProps {
|
||||
icon: IconComponent;
|
||||
label: string;
|
||||
submenus: SubmenuProps[];
|
||||
defaultOpen?: boolean;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const CollapsibleMenu = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
submenus,
|
||||
defaultOpen = false,
|
||||
isOpen: isSidebarOpen,
|
||||
}: CollapsibleMenuProps) => {
|
||||
const pathname = usePathname();
|
||||
const isSubmenuActive = submenus.some((submenu) =>
|
||||
submenu.active === undefined ? submenu.href === pathname : submenu.active,
|
||||
);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
isSubmenuActive || defaultOpen,
|
||||
);
|
||||
|
||||
// Collapse the menu when sidebar is closed
|
||||
useEffect(() => {
|
||||
if (!isSidebarOpen) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isCollapsed}
|
||||
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} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,3 @@
|
||||
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";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/layout/sidebar";
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconComponent } from "@/types";
|
||||
|
||||
interface MenuItemProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: IconComponent;
|
||||
active?: boolean;
|
||||
target?: string;
|
||||
tooltip?: string;
|
||||
isOpen: boolean;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export const MenuItem = ({
|
||||
href,
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
target,
|
||||
tooltip,
|
||||
isOpen,
|
||||
highlight,
|
||||
}: 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}>
|
||||
<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 && (
|
||||
<MenuFeatureBadge
|
||||
label="New"
|
||||
variant="new"
|
||||
size="sm"
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent side="right">{tooltip || label}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -1,256 +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,
|
||||
pathnameValue,
|
||||
pushMock,
|
||||
navigationModeValue,
|
||||
setNavigationModeMock,
|
||||
} = vi.hoisted(() => ({
|
||||
openLaunchScanModalMock: 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 }),
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
beforeAll(async () => {
|
||||
MenuComponent = (await import("./menu")).Menu;
|
||||
SidebarNavigationModeToggleComponent = (
|
||||
await import("@/components/sidebar/navigation-mode-toggle")
|
||||
).SidebarNavigationModeToggle;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
navigationModeValue.current = "browse";
|
||||
});
|
||||
|
||||
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("shows the mode toggle with Chat disabled outside cloud", () => {
|
||||
pathnameValue.current = "/findings";
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(<MenuComponent isOpen />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toHaveAttribute(
|
||||
"aria-disabled",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("blocks Chat 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();
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Divider } from "@heroui/divider";
|
||||
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 { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { SidebarNavigationModeToggle } from "@/components/sidebar/navigation-mode-toggle";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area/scroll-area";
|
||||
import { CollapsibleMenu } from "@/components/ui/sidebar/collapsible-menu";
|
||||
import { MenuItem } from "@/components/ui/sidebar/menu-item";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { useScansStore } from "@/store";
|
||||
import { GroupProps } from "@/types";
|
||||
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);
|
||||
};
|
||||
|
||||
export const Menu = ({ isOpen }: { isOpen: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
const openLaunchScanModal = useScansStore(
|
||||
(state) => state.openLaunchScanModal,
|
||||
);
|
||||
const isScansPage = pathname.startsWith("/scans");
|
||||
const { apiDocsUrl } = useRuntimeConfig();
|
||||
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
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}
|
||||
>
|
||||
<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">
|
||||
<LaunchScanButtonContent isOpen={isOpen} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
{!isOpen && <TooltipContent side="right">Launch Scan</TooltipContent>}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<SidebarNavigationModeToggle
|
||||
isOpen={isOpen}
|
||||
value={navigationMode}
|
||||
onChange={setNavigationMode}
|
||||
chatEnabled={isCloudEnv}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
) : (
|
||||
<MenuItem
|
||||
href={menu.href}
|
||||
label={menu.label}
|
||||
icon={menu.icon}
|
||||
active={menu.active}
|
||||
target={menu.target}
|
||||
tooltip={menu.tooltip}
|
||||
isOpen={isOpen}
|
||||
highlight={menu.highlight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</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>
|
||||
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
|
||||
<>
|
||||
<Divider orientation="vertical" />
|
||||
<Link
|
||||
href="https://status.prowler.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<InfoIcon size={16} />
|
||||
<span className="text-muted-foreground font-normal opacity-80 transition-opacity hover:font-bold hover:opacity-100">
|
||||
Service Status
|
||||
</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="https://status.prowler.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<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,43 +0,0 @@
|
||||
import { MenuIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ProwlerExtended } from "@/components/icons";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Menu } from "@/components/ui/sidebar/menu";
|
||||
|
||||
import { Button } from "../button/button";
|
||||
|
||||
export function SheetMenu() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger className="lg:hidden" asChild>
|
||||
<Button 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 gap-2">
|
||||
<ProwlerExtended />
|
||||
</Link>
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<Menu isOpen />
|
||||
</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,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ProwlerShort } from "@/components/icons";
|
||||
import { ProwlerExtended } from "@/components/icons";
|
||||
import { useSidebar } from "@/hooks/use-sidebar";
|
||||
import { useStore } from "@/hooks/use-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "../button/button";
|
||||
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"
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"mb-1 transition-transform duration-300 ease-in-out",
|
||||
!getOpenState() ? "translate-x-1" : "translate-x-0",
|
||||
)}
|
||||
variant="link"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className={clsx(
|
||||
"mb-6 flex w-full flex-col items-center justify-center px-3",
|
||||
{
|
||||
"gap-0": !isOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx({
|
||||
hidden: isOpen,
|
||||
})}
|
||||
>
|
||||
<ProwlerShort />
|
||||
</div>
|
||||
<div
|
||||
className={clsx({
|
||||
hidden: !isOpen,
|
||||
"mt-0!": isOpen,
|
||||
})}
|
||||
>
|
||||
<ProwlerExtended />
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Menu isOpen={getOpenState()} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
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", () => {
|
||||
it("should show the cloud-only tooltip for disabled cloud menu items", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SubmenuItem
|
||||
href="/alerts"
|
||||
label="Alerts"
|
||||
icon={TestIcon}
|
||||
disabled
|
||||
highlight
|
||||
cloudOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: /alerts/i });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveClass(
|
||||
"cursor-not-allowed",
|
||||
"text-text-neutral-tertiary",
|
||||
);
|
||||
await user.hover(button.parentElement as HTMLElement);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]");
|
||||
expect(screen.queryByText("Cloud")).not.toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findAllByText("Available in Prowler Cloud"),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should render disabled Scan config menu items like disabled Alerts", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SubmenuItem
|
||||
href="/scans/config"
|
||||
label="Scan"
|
||||
icon={TestIcon}
|
||||
disabled
|
||||
highlight
|
||||
cloudOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: /scan/i });
|
||||
await user.hover(button.parentElement as HTMLElement);
|
||||
|
||||
// Then
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveClass(
|
||||
"cursor-not-allowed",
|
||||
"text-text-neutral-tertiary",
|
||||
);
|
||||
expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]");
|
||||
expect(
|
||||
await screen.findAllByText("Available in Prowler Cloud"),
|
||||
).not.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { type MouseEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge";
|
||||
import { IconComponent } from "@/types";
|
||||
|
||||
interface SubmenuItemProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: IconComponent;
|
||||
active?: boolean;
|
||||
target?: string;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
cloudOnly?: boolean;
|
||||
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export const SubmenuItem = ({
|
||||
href,
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
target,
|
||||
disabled,
|
||||
highlight,
|
||||
cloudOnly,
|
||||
onClick,
|
||||
}: SubmenuItemProps) => {
|
||||
const pathname = usePathname();
|
||||
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 && (
|
||||
<MenuFeatureBadge label="New" variant="new" size="sm" />
|
||||
)}
|
||||
</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={onClick}
|
||||
>
|
||||
<span className="mr-2">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<p className="flex min-w-0 items-center">
|
||||
<span className="truncate">{label}</span>
|
||||
{highlight && (
|
||||
<MenuFeatureBadge
|
||||
label="New"
|
||||
variant="new"
|
||||
size="sm"
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -39,7 +39,7 @@ export function Skeleton({
|
||||
: undefined,
|
||||
}}
|
||||
className={cn(
|
||||
"dark:bg-prowler-blue-800 animate-pulse bg-gray-200",
|
||||
"bg-border-neutral-tertiary animate-pulse",
|
||||
variantClasses[variant],
|
||||
!animate && "animate-none",
|
||||
className,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Cell, flexRender, Row } from "@tanstack/react-table";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableAnimatedRowProps<TData> {
|
||||
row: Row<TData>;
|
||||
/** Pass explicitly to break React Compiler memoization when selection changes */
|
||||
isSelected?: boolean;
|
||||
/** Pass explicitly to break React Compiler memoization for partial selection */
|
||||
isSomeSelected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableAnimatedRow renders a table row with smooth expand/collapse animations.
|
||||
*
|
||||
* The trick: You cannot animate <tr> height directly (tables ignore it).
|
||||
* Instead, we wrap each cell's content in a motion.div and animate THAT.
|
||||
*
|
||||
* How it works:
|
||||
* 1. The <tr> itself is not animated
|
||||
* 2. Each <td> contains a motion.div wrapper
|
||||
* 3. The wrapper animates height from 0 to "auto"
|
||||
* 4. overflow-hidden clips content during animation
|
||||
* 5. Padding is on the inner content, not the td
|
||||
*/
|
||||
export function DataTableAnimatedRow<TData>({
|
||||
row,
|
||||
isSelected,
|
||||
isSomeSelected,
|
||||
}: DataTableAnimatedRowProps<TData>) {
|
||||
"use no memo";
|
||||
void isSomeSelected;
|
||||
|
||||
return (
|
||||
<motion.tr
|
||||
initial="collapsed"
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
open: { opacity: 1 },
|
||||
collapsed: { opacity: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
"[&>td:first-child]:rounded-l-full [&>td:last-child]:rounded-r-full",
|
||||
"hover:bg-bg-neutral-tertiary",
|
||||
"data-[state=selected]:bg-bg-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, index, cells) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === cells.length - 1;
|
||||
const cellDef = cell.column.columnDef.cell;
|
||||
const cellContext = cell.getContext();
|
||||
const cellContent =
|
||||
typeof cellDef === "function"
|
||||
? cellDef(cellContext)
|
||||
: flexRender(cellDef, cellContext);
|
||||
|
||||
return (
|
||||
<td key={cell.id} className="overflow-hidden p-0">
|
||||
<motion.div
|
||||
initial="collapsed"
|
||||
animate="open"
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
open: {
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
transition: {
|
||||
height: { duration: 0.2, ease: "easeOut" },
|
||||
opacity: { duration: 0.15, delay: 0.05 },
|
||||
},
|
||||
},
|
||||
collapsed: {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
height: { duration: 0.2, ease: "easeIn" },
|
||||
opacity: { duration: 0.1 },
|
||||
},
|
||||
},
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"px-1.5 py-2",
|
||||
isFirst && "pl-3",
|
||||
isLast && "pr-3",
|
||||
)}
|
||||
>
|
||||
{cellContent}
|
||||
</div>
|
||||
</motion.div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</motion.tr>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Column } from "@tanstack/react-table";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronsLeftRightIcon,
|
||||
} from "@/components/icons";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
param?: string;
|
||||
}
|
||||
|
||||
export const DataTableColumnHeader = <TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
param,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getToggleSortingHandler = () => {
|
||||
const currentParams = new URLSearchParams(searchParams.toString());
|
||||
const currentSortParam = currentParams.get("sort");
|
||||
let newSortParam = "";
|
||||
|
||||
if (currentSortParam === `${param}`) {
|
||||
// If already sorting ascending, switch to descending
|
||||
newSortParam = `-${param}`;
|
||||
} else if (currentSortParam === `-${param}`) {
|
||||
// If already sorting descending, remove sorting
|
||||
newSortParam = "";
|
||||
} else {
|
||||
// Sort ascending for the first time or switch to a different column
|
||||
newSortParam = `${param}`;
|
||||
}
|
||||
|
||||
// Update or remove the sort parameter
|
||||
if (newSortParam) {
|
||||
currentParams.set("sort", newSortParam);
|
||||
} else {
|
||||
currentParams.delete("sort");
|
||||
}
|
||||
|
||||
// Construct the new URL with all parameters
|
||||
const newUrl = `${pathname}?${currentParams.toString()}`;
|
||||
|
||||
router.push(newUrl, {
|
||||
scroll: false,
|
||||
});
|
||||
};
|
||||
|
||||
const renderSortIcon = () => {
|
||||
const currentSortParam = searchParams.get("sort");
|
||||
if (
|
||||
!currentSortParam ||
|
||||
currentSortParam === "" ||
|
||||
(currentSortParam !== param && currentSortParam !== `-${param}`)
|
||||
) {
|
||||
return <ChevronsLeftRightIcon size={14} className="ml-1 rotate-90" />;
|
||||
}
|
||||
return currentSortParam === `-${param}` ? (
|
||||
<ArrowDownIcon size={12} className="ml-1" />
|
||||
) : (
|
||||
<ArrowUpIcon size={12} className="ml-1" />
|
||||
);
|
||||
};
|
||||
|
||||
const baseClassName =
|
||||
"text-text-neutral-primary flex h-8 items-center text-left align-middle text-sm font-semibold whitespace-nowrap outline-none -ml-px";
|
||||
|
||||
if (!column.getCanSort()) {
|
||||
return (
|
||||
<div className={baseClassName}>
|
||||
<span className="block break-normal whitespace-nowrap">{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseClassName} hover:text-text-neutral-tertiary cursor-pointer`}
|
||||
onClick={getToggleSortingHandler}
|
||||
>
|
||||
<span className="block break-normal whitespace-nowrap">{title}</span>
|
||||
{renderSortIcon()}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableExpandAllToggleProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableExpandAllToggle provides a button in the table header to expand
|
||||
* or collapse all rows at once.
|
||||
*
|
||||
* Features:
|
||||
* - Shows Maximize2 icon when rows are collapsed (click to expand all)
|
||||
* - Shows Minimize2 icon when rows are expanded (click to collapse all)
|
||||
* - Accessible with proper aria-label
|
||||
* - Only renders when the table has expandable rows
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In column definition header:
|
||||
* {
|
||||
* id: "name",
|
||||
* header: ({ table }) => (
|
||||
* <div className="flex items-center gap-2">
|
||||
* <DataTableExpandAllToggle table={table} />
|
||||
* <span>Name</span>
|
||||
* </div>
|
||||
* ),
|
||||
* cell: ({ row }) => (
|
||||
* <DataTableExpandableCell row={row}>
|
||||
* <span>{row.original.name}</span>
|
||||
* </DataTableExpandableCell>
|
||||
* ),
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function DataTableExpandAllToggle<TData>({
|
||||
table,
|
||||
}: DataTableExpandAllToggleProps<TData>) {
|
||||
const isAllExpanded = table.getIsAllRowsExpanded();
|
||||
const canExpand = table.getCanSomeRowsExpand();
|
||||
|
||||
if (!canExpand) {
|
||||
return <div className="w-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => table.toggleAllRowsExpanded(!isAllExpanded)}
|
||||
className={cn(
|
||||
"rounded transition-colors",
|
||||
"hover:bg-prowler-white/10",
|
||||
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
aria-label={isAllExpanded ? "Collapse all rows" : "Expand all rows"}
|
||||
aria-expanded={isAllExpanded}
|
||||
>
|
||||
{isAllExpanded ? (
|
||||
<Minimize2Icon className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableExpandToggleProps<TData> {
|
||||
row: Row<TData>;
|
||||
/**
|
||||
* Explicit expanded state to ensure React Compiler re-renders when state changes.
|
||||
* TanStack Table Row instances keep stable references — getter methods like
|
||||
* `row.getIsExpanded()` read mutable state that React Compiler cannot track.
|
||||
* Pass this prop from the parent to break memoization.
|
||||
*/
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableExpandToggle provides a clickable chevron button for expanding/collapsing
|
||||
* table rows that have sub-rows.
|
||||
*
|
||||
* Features:
|
||||
* - Only shows for rows that can expand (have sub-rows)
|
||||
* - Provides consistent spacing for rows without sub-rows
|
||||
* - Animates chevron rotation on expand/collapse
|
||||
* - Accessible with proper aria-label
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In column definition:
|
||||
* {
|
||||
* id: "expand",
|
||||
* cell: ({ row }) => (
|
||||
* <DataTableExpandToggle row={row} isExpanded={row.getIsExpanded()} />
|
||||
* ),
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function DataTableExpandToggle<TData>({
|
||||
row,
|
||||
isExpanded: isExpandedProp,
|
||||
}: DataTableExpandToggleProps<TData>) {
|
||||
const isExpanded = isExpandedProp ?? row.getIsExpanded();
|
||||
|
||||
if (!row.getCanExpand()) {
|
||||
return <div className="w-4 shrink-0" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={row.getToggleExpandedHandler()}
|
||||
className={cn(
|
||||
"shrink-0 rounded transition-colors",
|
||||
"hover:bg-prowler-white/10",
|
||||
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
aria-label={isExpanded ? "Collapse row" : "Expand row"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary h-4 w-4 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { CornerDownRightIcon } from "lucide-react";
|
||||
|
||||
import { DataTableExpandToggle } from "./data-table-expand-toggle";
|
||||
|
||||
/**
|
||||
* Indentation per nesting level in rem units.
|
||||
* Matches the parent's icon (w-4 = 1rem) + gap-2 (0.5rem) = 1.5rem,
|
||||
* so the child's first icon aligns horizontally with the parent's checkbox.
|
||||
*/
|
||||
const INDENT_PER_LEVEL_REM = 1.5;
|
||||
|
||||
interface DataTableExpandableCellProps<TData> {
|
||||
row: Row<TData>;
|
||||
children: React.ReactNode;
|
||||
/** Whether to show the expand/collapse toggle (default: true) */
|
||||
showToggle?: boolean;
|
||||
/** Explicit expanded state — pass to break React Compiler memoization */
|
||||
isExpanded?: boolean;
|
||||
/** Hide the CornerDownRight icon even for child rows (e.g. OUs that can expand) */
|
||||
hideChildIcon?: boolean;
|
||||
/** Optional slot rendered after expand arrows and before children (e.g. checkbox) */
|
||||
checkboxSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableExpandableCell is a wrapper component for table cells that need
|
||||
* to display content with proper indentation for nested rows.
|
||||
*
|
||||
* Features:
|
||||
* - Automatically indents content based on row depth
|
||||
* - Shows CornerDownRight icon for child rows (depth > 0)
|
||||
* - Optionally includes the expand/collapse toggle for parent rows
|
||||
* - Maintains proper alignment for all nesting levels
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In column definition:
|
||||
* {
|
||||
* accessorKey: "name",
|
||||
* header: "Name",
|
||||
* cell: ({ row }) => (
|
||||
* <DataTableExpandableCell row={row}>
|
||||
* <span>{row.original.name}</span>
|
||||
* </DataTableExpandableCell>
|
||||
* ),
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function DataTableExpandableCell<TData>({
|
||||
row,
|
||||
children,
|
||||
showToggle = true,
|
||||
isExpanded,
|
||||
hideChildIcon = false,
|
||||
checkboxSlot,
|
||||
}: DataTableExpandableCellProps<TData>) {
|
||||
const isChildRow = row.depth > 0;
|
||||
const canExpand = row.getCanExpand();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 overflow-hidden"
|
||||
style={{ paddingLeft: `${row.depth * INDENT_PER_LEVEL_REM}rem` }}
|
||||
>
|
||||
{showToggle && (
|
||||
<>
|
||||
{isChildRow && !hideChildIcon && (
|
||||
<CornerDownRightIcon className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{canExpand ? (
|
||||
<DataTableExpandToggle row={row} isExpanded={isExpanded} />
|
||||
) : !isChildRow ? (
|
||||
// shrink-0: keep this fixed-width spacer from collapsing under wide content, which would drag the checkbox left
|
||||
<div className="w-4 shrink-0" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{checkboxSlot && (
|
||||
<div className="mr-2 flex shrink-0 items-center">{checkboxSlot}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilterOption } from "@/types/filters";
|
||||
|
||||
// ── next/navigation mock ────────────────────────────────────────────────────
|
||||
const mockPush = vi.fn();
|
||||
const mockUpdateFilter = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// ── useUrlFilters mock — tracks whether updateFilter is called ───────────────
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({ updateFilter: mockUpdateFilter }),
|
||||
}));
|
||||
|
||||
// ── context (optional dependency used by useUrlFilters) ────────────────────
|
||||
vi.mock("@/contexts", () => ({
|
||||
useFilterTransitionOptional: () => null,
|
||||
}));
|
||||
|
||||
// ── MultiSelect mock — renders a simple <select> backed by onValuesChange ──
|
||||
// This lets us trigger filter changes without needing the full Popover UI.
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({
|
||||
children,
|
||||
values,
|
||||
onValuesChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
values?: string[];
|
||||
onValuesChange?: (values: string[]) => void;
|
||||
}) => (
|
||||
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
|
||||
{children}
|
||||
{/* expose a select to drive value changes in tests */}
|
||||
<select
|
||||
data-testid="multiselect-trigger"
|
||||
multiple
|
||||
defaultValue={values ?? []}
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.selectedOptions).map(
|
||||
(o) => o.value,
|
||||
);
|
||||
onValuesChange?.(selected);
|
||||
}}
|
||||
>
|
||||
<option value="critical">critical</option>
|
||||
<option value="high">high</option>
|
||||
<option value="FAIL">FAIL</option>
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
),
|
||||
MultiSelectContent: ({
|
||||
children,
|
||||
width,
|
||||
search,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
search?: boolean | { placeholder?: string; emptyMessage?: string };
|
||||
}) => (
|
||||
<div
|
||||
data-testid="multiselect-content"
|
||||
data-width={width ?? "default"}
|
||||
data-search-placeholder={
|
||||
typeof search === "object" ? search.placeholder : String(search)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
MultiSelectSeparator: () => <hr />,
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
// ── ClearFiltersButton stub ─────────────────────────────────────────────────
|
||||
vi.mock("@/components/filters/clear-filters-button", () => ({
|
||||
ClearFiltersButton: () => <button type="button">Clear</button>,
|
||||
}));
|
||||
|
||||
// ── Other component stubs ───────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"@/components/compliance/compliance-header/compliance-scan-info",
|
||||
() => ({
|
||||
ComplianceScanInfo: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/helper-filters", () => ({
|
||||
isScanEntity: () => false,
|
||||
isConnectionStatus: () => false,
|
||||
}));
|
||||
|
||||
import { DataTableFilterCustom } from "./data-table-filter-custom";
|
||||
|
||||
// ── Future E2E coverage ────────────────────────────────────────────────────
|
||||
// TODO (E2E): Integration tests for DataTableFilterCustom in batch mode:
|
||||
// - In batch mode, selecting filters does NOT navigate the browser immediately
|
||||
// - Multiple filter selections accumulate in pending state
|
||||
// - Pressing Apply sends a single router.push with all staged filters
|
||||
// - Pressing Discard reverts staged selections to match the current URL
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const severityFilter: FilterOption = {
|
||||
key: "filter[severity__in]",
|
||||
labelCheckboxGroup: "Severity",
|
||||
values: ["critical", "high"],
|
||||
};
|
||||
|
||||
const scanFilter: FilterOption = {
|
||||
key: "filter[scan__in]",
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: ["scan-1"],
|
||||
width: "wide",
|
||||
};
|
||||
|
||||
describe("DataTableFilterCustom — batch vs instant mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Default / instant mode ───────────────────────────────────────────────
|
||||
|
||||
describe("instant mode (default)", () => {
|
||||
it("should call updateFilter (URL update) when a selection changes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// When — simulate a value change on the mock select
|
||||
const select = screen.getByTestId("multiselect-trigger");
|
||||
await user.selectOptions(select, ["critical"]);
|
||||
|
||||
// Then — instant mode pushes to URL via updateFilter
|
||||
expect(mockUpdateFilter).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateFilter).toHaveBeenCalledWith(
|
||||
"filter[severity__in]",
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT call onBatchChange in instant mode", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onBatchChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
onBatchChange={onBatchChange}
|
||||
// no mode prop → defaults to "instant"
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const select = screen.getByTestId("multiselect-trigger");
|
||||
await user.selectOptions(select, ["critical"]);
|
||||
|
||||
// Then
|
||||
expect(onBatchChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render without mode prop (backward compatibility)", () => {
|
||||
// Given / When
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// Then — renders without crashing
|
||||
expect(screen.getByText("All Severity")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Batch mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("batch mode", () => {
|
||||
it("should call onBatchChange instead of updateFilter when selection changes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onBatchChange = vi.fn();
|
||||
const getFilterValue = vi.fn().mockReturnValue([]);
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
mode="batch"
|
||||
onBatchChange={onBatchChange}
|
||||
getFilterValue={getFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const select = screen.getByTestId("multiselect-trigger");
|
||||
await user.selectOptions(select, ["critical"]);
|
||||
|
||||
// Then — batch mode notifies caller instead of URL
|
||||
expect(onBatchChange).toHaveBeenCalledTimes(1);
|
||||
expect(onBatchChange).toHaveBeenCalledWith(
|
||||
"filter[severity__in]",
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(mockUpdateFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should read selected values from getFilterValue in batch mode", () => {
|
||||
// Given — batch mode with pre-seeded pending state
|
||||
const onBatchChange = vi.fn();
|
||||
const getFilterValue = vi
|
||||
.fn()
|
||||
.mockImplementation((key: string) =>
|
||||
key === "filter[severity__in]" ? ["critical"] : [],
|
||||
);
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
mode="batch"
|
||||
onBatchChange={onBatchChange}
|
||||
getFilterValue={getFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — the mock multiselect receives the pending values
|
||||
const multiselect = screen.getByTestId("multiselect");
|
||||
expect(multiselect).toHaveAttribute(
|
||||
"data-values",
|
||||
JSON.stringify(["critical"]),
|
||||
);
|
||||
|
||||
// getFilterValue must have been called for the filter key
|
||||
expect(getFilterValue).toHaveBeenCalledWith("filter[severity__in]");
|
||||
});
|
||||
|
||||
it("should pass empty array to MultiSelect when getFilterValue returns empty", () => {
|
||||
// Given
|
||||
const getFilterValue = vi.fn().mockReturnValue([]);
|
||||
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
mode="batch"
|
||||
onBatchChange={vi.fn()}
|
||||
getFilterValue={getFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then — multiselect gets empty values
|
||||
const multiselect = screen.getByTestId("multiselect");
|
||||
expect(multiselect).toHaveAttribute("data-values", JSON.stringify([]));
|
||||
});
|
||||
});
|
||||
|
||||
// ── hideClearButton ──────────────────────────────────────────────────────
|
||||
|
||||
describe("hideClearButton prop", () => {
|
||||
it("should hide the ClearFiltersButton when hideClearButton is true", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<DataTableFilterCustom
|
||||
filters={[severityFilter]}
|
||||
hideClearButton={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Clear" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the ClearFiltersButton by default", () => {
|
||||
// Given / When
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropdown width", () => {
|
||||
it("should propagate the filter width to the dropdown content", () => {
|
||||
render(<DataTableFilterCustom filters={[scanFilter]} />);
|
||||
|
||||
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
|
||||
"data-width",
|
||||
"wide",
|
||||
);
|
||||
});
|
||||
|
||||
it("should enable searchable filter dropdowns by default", () => {
|
||||
render(<DataTableFilterCustom filters={[severityFilter]} />);
|
||||
|
||||
expect(screen.getByTestId("multiselect-content")).toHaveAttribute(
|
||||
"data-search-placeholder",
|
||||
"Search severity...",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectSelectAll,
|
||||
MultiSelectSeparator,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { EntityInfo } from "@/components/ui/entities/entity-info";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import {
|
||||
getScanEntityLabel,
|
||||
isConnectionStatus,
|
||||
isScanEntity,
|
||||
} from "@/lib/helper-filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FilterEntity,
|
||||
FilterOption,
|
||||
ProviderEntity,
|
||||
ScanEntity,
|
||||
} from "@/types";
|
||||
import { DATA_TABLE_FILTER_MODE, DataTableFilterMode } from "@/types/filters";
|
||||
import { ProviderConnectionStatus } from "@/types/providers";
|
||||
|
||||
function isNonEmptyString(value: string | null | undefined): value is string {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
export interface DataTableFilterCustomProps {
|
||||
filters: FilterOption[];
|
||||
/** Optional element to render at the start of the filters grid */
|
||||
prependElement?: React.ReactNode;
|
||||
/** Optional className override for the filters grid layout */
|
||||
gridClassName?: string;
|
||||
/** Hide the clear filters button and active badges (useful when parent manages this) */
|
||||
hideClearButton?: boolean;
|
||||
/**
|
||||
* Controls when filter selections are pushed to the URL.
|
||||
* - "instant" (default): each selection immediately updates the URL (legacy behavior, backward-compatible).
|
||||
* - "batch": selections accumulate in pending state; caller manages when to push URL.
|
||||
*/
|
||||
mode?: DataTableFilterMode;
|
||||
/**
|
||||
* Called in "batch" mode when a filter value changes.
|
||||
* The key is the raw filter key (e.g. "filter[severity__in]" or "severity__in").
|
||||
* Only invoked when mode === "batch".
|
||||
*/
|
||||
onBatchChange?: (filterKey: string, values: string[]) => void;
|
||||
/**
|
||||
* Returns the current selected values for a filter in "batch" mode.
|
||||
* Replaces reading from URL searchParams when mode === "batch".
|
||||
* Only used when mode === "batch".
|
||||
*/
|
||||
getFilterValue?: (filterKey: string) => string[];
|
||||
}
|
||||
|
||||
export const DataTableFilterCustom = ({
|
||||
filters,
|
||||
prependElement,
|
||||
gridClassName,
|
||||
hideClearButton = false,
|
||||
mode = DATA_TABLE_FILTER_MODE.INSTANT,
|
||||
onBatchChange,
|
||||
getFilterValue,
|
||||
}: DataTableFilterCustomProps) => {
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const searchParams = useSearchParams();
|
||||
const [openFilterKey, setOpenFilterKey] = useState<string | null>(null);
|
||||
|
||||
const buildSearchConfig = (filter: FilterOption) => {
|
||||
const label = filter.labelCheckboxGroup.toLowerCase();
|
||||
return {
|
||||
placeholder: `Search ${label}...`,
|
||||
emptyMessage: `No ${label} found.`,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to get entity from valueLabelMapping
|
||||
const getEntityForValue = (
|
||||
filter: FilterOption,
|
||||
value: string,
|
||||
): FilterEntity | undefined => {
|
||||
if (!filter.valueLabelMapping) return undefined;
|
||||
const entry = filter.valueLabelMapping.find((mapping) => mapping[value]);
|
||||
return entry ? entry[value] : undefined;
|
||||
};
|
||||
|
||||
// Helper function to get badge label from entity
|
||||
const getBadgeLabel = (
|
||||
entity: FilterEntity | undefined,
|
||||
value: string,
|
||||
): string => {
|
||||
if (!entity) return value;
|
||||
|
||||
if (isScanEntity(entity as ScanEntity)) {
|
||||
// Match the summary-strip chip: "Scan: {provider} - {name}". Without the
|
||||
// "Scan:" prefix, the trigger badge would just say "AWS Prod - Nightly",
|
||||
// which reads as a generic account tag and hides that it's a scan filter.
|
||||
const label = getScanEntityLabel(entity as ScanEntity);
|
||||
return label ? `Scan: ${label}` : value;
|
||||
}
|
||||
if (isConnectionStatus(entity)) {
|
||||
const connectionStatus = entity as ProviderConnectionStatus;
|
||||
return connectionStatus.label;
|
||||
}
|
||||
// Provider entity
|
||||
const providerEntity = entity as ProviderEntity;
|
||||
return providerEntity.alias || providerEntity.uid || value;
|
||||
};
|
||||
|
||||
// Render custom content for entity (scan, provider, or connection status)
|
||||
const renderEntityContent = (entity: FilterEntity) => {
|
||||
if (isScanEntity(entity as ScanEntity)) {
|
||||
return <ComplianceScanInfo scan={entity as ScanEntity} />;
|
||||
}
|
||||
if (isConnectionStatus(entity)) {
|
||||
const connectionStatus = entity as ProviderConnectionStatus;
|
||||
return <span>{connectionStatus.label}</span>;
|
||||
}
|
||||
// Provider entity
|
||||
const providerEntity = entity as ProviderEntity;
|
||||
return (
|
||||
<EntityInfo
|
||||
cloudProvider={providerEntity.provider}
|
||||
entityAlias={providerEntity.alias ?? undefined}
|
||||
entityId={providerEntity.uid}
|
||||
showCopyAction={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getSearchKeywords = (
|
||||
entity: FilterEntity | undefined,
|
||||
value: string,
|
||||
displayLabel: string,
|
||||
): string[] => {
|
||||
if (!entity) {
|
||||
return [displayLabel, value];
|
||||
}
|
||||
|
||||
if (isScanEntity(entity as ScanEntity)) {
|
||||
const label = getScanEntityLabel(entity as ScanEntity);
|
||||
return [displayLabel, value, label].filter(isNonEmptyString);
|
||||
}
|
||||
|
||||
if (isConnectionStatus(entity)) {
|
||||
return [displayLabel, value, (entity as ProviderConnectionStatus).label];
|
||||
}
|
||||
|
||||
const providerEntity = entity as ProviderEntity;
|
||||
return [
|
||||
displayLabel,
|
||||
value,
|
||||
providerEntity.alias,
|
||||
providerEntity.uid,
|
||||
providerEntity.provider,
|
||||
].filter(isNonEmptyString);
|
||||
};
|
||||
|
||||
// Sort filters by index property, with fallback to original order for filters without index
|
||||
const sortedFilters = () => {
|
||||
return [...filters].sort((a, b) => {
|
||||
// If both have index, sort by index
|
||||
if (a.index !== undefined && b.index !== undefined) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
// If only one has index, prioritize the one with index
|
||||
if (a.index !== undefined) return -1;
|
||||
if (b.index !== undefined) return 1;
|
||||
// If neither has index, maintain original order
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const pushDropdownFilter = (filter: FilterOption, values: string[]) => {
|
||||
if (mode === DATA_TABLE_FILTER_MODE.BATCH && onBatchChange) {
|
||||
// In batch mode, notify the caller instead of updating the URL
|
||||
onBatchChange(filter.key, values);
|
||||
return;
|
||||
}
|
||||
|
||||
// Instant mode (default): push to URL immediately
|
||||
// If this filter defaults to "all selected" and the user selected all items,
|
||||
// clear the URL param to represent "no specific filter" (i.e., all).
|
||||
const allSelected =
|
||||
filter.values.length > 0 && values.length === filter.values.length;
|
||||
|
||||
if (filter.defaultToSelectAll && allSelected) {
|
||||
updateFilter(filter.key, null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateFilter(filter.key, values.length > 0 ? values : null);
|
||||
};
|
||||
|
||||
const getSelectedValues = (filter: FilterOption): string[] => {
|
||||
if (mode === DATA_TABLE_FILTER_MODE.BATCH && getFilterValue) {
|
||||
// In batch mode, read from pending state provided by the caller
|
||||
return getFilterValue(filter.key);
|
||||
}
|
||||
|
||||
// Instant mode (default): read from URL searchParams
|
||||
const filterKey = filter.key.startsWith("filter[")
|
||||
? filter.key
|
||||
: `filter[${filter.key}]`;
|
||||
const paramValue = searchParams.get(filterKey);
|
||||
|
||||
// If defaultToSelectAll is true and no filter param exists,
|
||||
// treat it as "all selected" by returning all values
|
||||
if (!paramValue && filter.defaultToSelectAll) {
|
||||
return filter.values;
|
||||
}
|
||||
|
||||
return paramValue ? paramValue.split(",") : [];
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5",
|
||||
gridClassName,
|
||||
)}
|
||||
>
|
||||
{prependElement}
|
||||
{sortedFilters().map((filter) => {
|
||||
const selectedValues = getSelectedValues(filter);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
key={filter.key}
|
||||
open={openFilterKey === filter.key}
|
||||
onOpenChange={(open) => setOpenFilterKey(open ? filter.key : null)}
|
||||
values={selectedValues}
|
||||
onValuesChange={(values) => pushDropdownFilter(filter, values)}
|
||||
>
|
||||
<MultiSelectTrigger size="default">
|
||||
<MultiSelectValue
|
||||
placeholder={`All ${filter.labelCheckboxGroup}`}
|
||||
/>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={buildSearchConfig(filter)}
|
||||
width={filter.width ?? "default"}
|
||||
>
|
||||
<MultiSelectSelectAll>Select All</MultiSelectSelectAll>
|
||||
<MultiSelectSeparator />
|
||||
{filter.values.map((value) => {
|
||||
const entity = getEntityForValue(filter, value);
|
||||
const displayLabel = filter.labelFormatter
|
||||
? filter.labelFormatter(value)
|
||||
: value;
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={value}
|
||||
value={value}
|
||||
badgeLabel={getBadgeLabel(entity, displayLabel)}
|
||||
keywords={getSearchKeywords(entity, value, displayLabel)}
|
||||
>
|
||||
{entity ? renderEntityContent(entity) : displayLabel}
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
);
|
||||
})}
|
||||
{!hideClearButton && (
|
||||
<div className="flex items-center justify-start">
|
||||
<ClearFiltersButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MetaDataProps } from "@/types";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/providers",
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
getPaginationInfo: () => ({
|
||||
currentPage: 2,
|
||||
totalPages: 4,
|
||||
totalEntries: 40,
|
||||
itemsPerPageOptions: [10, 20, 50],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
SelectValue: () => <span>10</span>,
|
||||
}));
|
||||
|
||||
import { DataTablePagination } from "./data-table-pagination";
|
||||
|
||||
const metadata: MetaDataProps = {
|
||||
pagination: {
|
||||
page: 2,
|
||||
pages: 4,
|
||||
count: 40,
|
||||
itemsPerPage: [10, 20, 50],
|
||||
},
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
describe("DataTablePagination", () => {
|
||||
it("keeps navigation arrows visible on hover in light theme", () => {
|
||||
render(<DataTablePagination metadata={metadata} />);
|
||||
|
||||
expect(screen.getByLabelText("Go to first page")).toHaveClass(
|
||||
"hover:text-text-neutral-primary",
|
||||
);
|
||||
expect(screen.getByLabelText("Go to first page")).toHaveClass(
|
||||
"hover:bg-bg-neutral-tertiary",
|
||||
);
|
||||
expect(screen.getByLabelText("Go to next page")).toHaveClass(
|
||||
"hover:text-text-neutral-primary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,344 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { getPaginationInfo } from "@/lib";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MetaDataProps } from "@/types";
|
||||
|
||||
interface DataTablePaginationProps {
|
||||
metadata?: MetaDataProps;
|
||||
disableScroll?: boolean;
|
||||
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
|
||||
paramPrefix?: string;
|
||||
|
||||
/*
|
||||
* Controlled mode: Use these props to manage pagination via React state
|
||||
* instead of URL params. Useful for tables in drawers/modals to avoid
|
||||
* triggering page re-renders when paginating.
|
||||
*/
|
||||
controlledPage?: number;
|
||||
controlledPageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}
|
||||
|
||||
const NAV_BUTTON_STYLES = {
|
||||
base: "flex items-center justify-center rounded-full p-3 transition-colors",
|
||||
enabled:
|
||||
"text-text-neutral-secondary hover:bg-bg-neutral-tertiary hover:text-text-neutral-primary",
|
||||
disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none",
|
||||
} as const;
|
||||
|
||||
export function DataTablePagination({
|
||||
metadata,
|
||||
disableScroll = false,
|
||||
paramPrefix = "",
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: DataTablePaginationProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Determine if we're in controlled mode
|
||||
const isControlled = controlledPage !== undefined && onPageChange;
|
||||
|
||||
// Determine param names based on prefix
|
||||
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||
const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize";
|
||||
|
||||
const initialPageSize = isControlled
|
||||
? String(controlledPageSize ?? 10)
|
||||
: (searchParams.get(pageSizeParam) ?? "10");
|
||||
|
||||
const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize);
|
||||
|
||||
if (!metadata) return null;
|
||||
|
||||
const {
|
||||
currentPage: metaCurrentPage,
|
||||
totalPages,
|
||||
totalEntries,
|
||||
itemsPerPageOptions,
|
||||
} = getPaginationInfo(metadata);
|
||||
|
||||
// For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata
|
||||
const currentPage = isControlled
|
||||
? controlledPage
|
||||
: paramPrefix
|
||||
? parseInt(searchParams.get(pageParam) || "1", 10)
|
||||
: metaCurrentPage;
|
||||
|
||||
const createPageUrl = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve all important parameters
|
||||
const scanId = searchParams.get("scanId");
|
||||
const id = searchParams.get("id");
|
||||
const version = searchParams.get("version");
|
||||
|
||||
if (+pageNumber > totalPages) {
|
||||
return `${pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
params.set(pageParam, pageNumber.toString());
|
||||
|
||||
// Ensure that scanId, id and version are preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
if (id) params.set("id", id);
|
||||
if (version) params.set("version", version);
|
||||
|
||||
return `${pathname}?${params.toString()}`;
|
||||
};
|
||||
|
||||
// Handle page navigation for controlled mode
|
||||
const handlePageChange = (pageNumber: number) => {
|
||||
if (isControlled) {
|
||||
onPageChange(pageNumber);
|
||||
} else {
|
||||
const url = createPageUrl(pageNumber);
|
||||
if (disableScroll) {
|
||||
router.push(url, { scroll: false });
|
||||
} else {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end gap-6 py-1.5">
|
||||
{totalEntries > 10 && (
|
||||
<>
|
||||
{/* Rows per page selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-neutral-secondary text-xs font-medium whitespace-nowrap">
|
||||
Rows per page
|
||||
</span>
|
||||
<Select
|
||||
value={selectedPageSize}
|
||||
onValueChange={(value) => {
|
||||
setSelectedPageSize(value);
|
||||
|
||||
if (isControlled) {
|
||||
onPageSizeChange?.(parseInt(value, 10));
|
||||
onPageChange(1); // Reset to first page
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Preserve all important parameters
|
||||
const scanId = searchParams.get("scanId");
|
||||
const id = searchParams.get("id");
|
||||
const version = searchParams.get("version");
|
||||
|
||||
params.set(pageSizeParam, value);
|
||||
params.set(pageParam, "1");
|
||||
|
||||
// Ensure that scanId, id and version are preserved
|
||||
if (scanId) params.set("scanId", scanId);
|
||||
if (id) params.set("id", id);
|
||||
if (version) params.set("version", version);
|
||||
|
||||
// This pushes the URL without reloading the page
|
||||
if (disableScroll) {
|
||||
const url = `${pathname}?${params.toString()}`;
|
||||
router.push(url, { scroll: false });
|
||||
} else {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
iconSize="sm"
|
||||
className="bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{itemsPerPageOptions.map((pageSize) => (
|
||||
<SelectItem
|
||||
key={pageSize}
|
||||
value={`${pageSize}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Page info and navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-neutral-secondary hidden text-xs font-medium sm:inline">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isControlled ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isFirstPage}
|
||||
onClick={() => handlePageChange(1)}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isFirstPage}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isLastPage}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
disabled={isLastPage}
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
aria-label="Go to first page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronFirst className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to previous page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isFirstPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isFirstPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage - 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isFirstPage}
|
||||
onClick={(e) => isFirstPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLeft className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to next page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(currentPage + 1)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronRight className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
aria-label="Go to last page"
|
||||
className={cn(
|
||||
NAV_BUTTON_STYLES.base,
|
||||
isLastPage
|
||||
? NAV_BUTTON_STYLES.disabled
|
||||
: NAV_BUTTON_STYLES.enabled,
|
||||
)}
|
||||
href={
|
||||
isLastPage
|
||||
? pathname + "?" + searchParams.toString()
|
||||
: createPageUrl(totalPages)
|
||||
}
|
||||
scroll={!disableScroll}
|
||||
aria-disabled={isLastPage}
|
||||
onClick={(e) => isLastPage && e.preventDefault()}
|
||||
>
|
||||
<ChevronLast className="size-6" aria-hidden="true" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderCircleIcon, SearchIcon, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 500;
|
||||
|
||||
interface DataTableSearchProps {
|
||||
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */
|
||||
paramPrefix?: string;
|
||||
|
||||
/*
|
||||
* Controlled mode: Use these props to manage search via React state
|
||||
* instead of URL params. Useful for tables in drawers/modals to avoid
|
||||
* triggering page re-renders when searching.
|
||||
*/
|
||||
controlledValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
/**
|
||||
* Called when the user commits a search (pressing Enter).
|
||||
* When provided, the search is only "committed" on Enter, while
|
||||
* onSearchChange still fires on every keystroke for responsive display.
|
||||
* Use this to avoid remounting child components on every keystroke.
|
||||
*/
|
||||
onSearchCommit?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group title) */
|
||||
badge?: { label: string; onDismiss: () => void };
|
||||
}
|
||||
|
||||
export const DataTableSearch = ({
|
||||
paramPrefix = "",
|
||||
controlledValue,
|
||||
onSearchChange,
|
||||
onSearchCommit,
|
||||
placeholder = "Search...",
|
||||
badge,
|
||||
}: DataTableSearchProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [internalValue, setInternalValue] = useState("");
|
||||
// In controlled mode, track display value separately for immediate feedback
|
||||
const [displayValue, setDisplayValue] = useState(controlledValue ?? "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const id = useId();
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use controlled value if provided, otherwise internal state
|
||||
const isControlled = controlledValue !== undefined && onSearchChange;
|
||||
// For display: use displayValue in controlled mode (for responsive typing), internalValue otherwise
|
||||
const value = isControlled ? displayValue : internalValue;
|
||||
|
||||
// Force expanded when badge is present
|
||||
const hasBadge = !!badge;
|
||||
|
||||
// Sync displayValue when controlledValue changes externally (e.g., clear filters)
|
||||
useEffect(() => {
|
||||
if (isControlled) {
|
||||
setDisplayValue(controlledValue);
|
||||
}
|
||||
}, [controlledValue, isControlled]);
|
||||
|
||||
// Determine param names based on prefix
|
||||
const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]";
|
||||
const pageParam = paramPrefix ? `${paramPrefix}Page` : "page";
|
||||
|
||||
// Sync with URL on mount (only for uncontrolled mode)
|
||||
useEffect(() => {
|
||||
if (isControlled) return;
|
||||
const searchFromUrl = searchParams.get(searchParam) || "";
|
||||
setInternalValue(searchFromUrl);
|
||||
}, [searchParams, searchParam, isControlled]);
|
||||
|
||||
// Handle input change with debounce
|
||||
const handleChange = (newValue: string) => {
|
||||
if (isControlled) {
|
||||
setDisplayValue(newValue);
|
||||
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (onSearchCommit) {
|
||||
// Enter-to-commit mode: sync parent immediately (no debounce, no loading).
|
||||
// The actual search commit happens on Enter via onSearchCommit.
|
||||
onSearchChange(newValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard controlled mode: debounce the callback
|
||||
setIsLoading(true);
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
onSearchChange(newValue);
|
||||
setIsLoading(false);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uncontrolled mode: only update display value on keystroke.
|
||||
// The actual URL update happens on Enter (see onKeyDown handler).
|
||||
setInternalValue(newValue);
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center",
|
||||
hasBadge ? "w-[28rem]" : "w-64",
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-tertiary hover:bg-bg-neutral-secondary flex items-center gap-1.5 rounded-md border transition-colors",
|
||||
isFocused && "border-border-input-primary-pressed",
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center pl-3">
|
||||
<SearchIcon className="text-text-neutral-tertiary size-4" />
|
||||
</div>
|
||||
|
||||
{hasBadge && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="max-w-[200px] shrink-0 cursor-default gap-1 truncate"
|
||||
>
|
||||
<span className="truncate">{badge.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss filter"
|
||||
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
badge.onDismiss();
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{badge.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="search"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
// Cancel any pending debounce — Enter commits immediately
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
// Controlled mode with explicit commit callback
|
||||
if (isControlled && onSearchCommit) {
|
||||
onSearchCommit(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Uncontrolled mode: immediate URL update (shortcut for debounce)
|
||||
if (!isControlled) {
|
||||
if (paramPrefix) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(searchParam, value);
|
||||
} else {
|
||||
params.delete(searchParam);
|
||||
}
|
||||
params.set(pageParam, "1");
|
||||
router.push(`${pathname}?${params.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
} else {
|
||||
updateFilter("search", value || null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className="h-9 min-w-0 flex-1 border-0 bg-transparent pr-9 shadow-none hover:bg-transparent focus:border-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex shrink-0 items-center pr-3">
|
||||
<LoaderCircleIcon className="text-text-neutral-tertiary size-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MetaDataProps } from "@/types";
|
||||
|
||||
import { DataTable } from "./data-table";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/findings",
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts", () => ({
|
||||
useFilterTransitionOptional: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
cn: (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(" "),
|
||||
getPaginationInfo: (metadata: MetaDataProps) => ({
|
||||
currentPage: metadata.pagination.page,
|
||||
totalPages: metadata.pagination.pages,
|
||||
totalEntries: metadata.pagination.count,
|
||||
itemsPerPageOptions: metadata.pagination.itemsPerPage ?? [10, 20, 50],
|
||||
}),
|
||||
}));
|
||||
|
||||
interface TestRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TestRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
];
|
||||
|
||||
const columnsWithActions: ColumnDef<TestRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: () => <button type="button">Actions</button>,
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
const metadata: MetaDataProps = {
|
||||
pagination: {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
count: 7,
|
||||
},
|
||||
version: "v1",
|
||||
};
|
||||
|
||||
describe("DataTable", () => {
|
||||
describe("when toolbar right content is provided", () => {
|
||||
it("should render it before the total entries count", () => {
|
||||
// Given
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={[{ name: "Finding A" }]}
|
||||
metadata={metadata}
|
||||
toolbarRightContent={<span>Include muted findings</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const toolbarContent = screen.getByText("Include muted findings");
|
||||
const totalEntries = screen.getByText("7 Total Entries");
|
||||
const tableContainerText =
|
||||
toolbarContent.parentElement?.textContent ?? "";
|
||||
|
||||
// Then
|
||||
expect(toolbarContent).toBeInTheDocument();
|
||||
expect(totalEntries).toBeInTheDocument();
|
||||
expect(tableContainerText.indexOf("Include muted findings")).toBeLessThan(
|
||||
tableContainerText.indexOf("7 Total Entries"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should stack the right content below search on narrow screens", () => {
|
||||
// Given
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={[{ name: "Finding A" }]}
|
||||
metadata={metadata}
|
||||
showSearch
|
||||
toolbarRightContent={<span>Include muted findings</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const toolbar = screen.getByTestId("data-table-toolbar");
|
||||
const rightContent = screen.getByTestId("data-table-toolbar-right");
|
||||
|
||||
// Then
|
||||
expect(toolbar).toHaveClass("flex-col");
|
||||
expect(toolbar).toHaveClass("md:flex-row");
|
||||
expect(rightContent).toHaveClass("w-full");
|
||||
expect(rightContent).toHaveClass("md:w-auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when an actions column is present", () => {
|
||||
it("should keep the actions header and cells sticky on the right edge", () => {
|
||||
// Given
|
||||
render(
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={[{ name: "Finding A" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const columnHeaders = screen.getAllByRole("columnheader");
|
||||
const nameHeader = columnHeaders[0];
|
||||
const actionsHeader = columnHeaders.at(-1);
|
||||
expect(actionsHeader).toBeDefined();
|
||||
const actionsHeaderElement = actionsHeader as HTMLElement;
|
||||
const nameCell = screen.getByText("Finding A").closest("td");
|
||||
const actionsCell = screen
|
||||
.getByRole("button", {
|
||||
name: "Actions",
|
||||
})
|
||||
.closest("td");
|
||||
|
||||
// Then
|
||||
expect(nameHeader).not.toHaveClass("sticky");
|
||||
expect(nameCell).not.toHaveClass("sticky");
|
||||
expect(nameHeader).toHaveClass("pr-6");
|
||||
expect(nameCell).toHaveClass("pr-6");
|
||||
expect(actionsHeaderElement).not.toHaveClass("sticky");
|
||||
expect(actionsHeaderElement).not.toHaveClass("pr-6");
|
||||
expect(actionsHeaderElement).not.toHaveClass("right-0");
|
||||
expect(actionsHeaderElement).not.toHaveClass("z-20");
|
||||
expect(actionsHeaderElement).toHaveClass("bg-bg-neutral-tertiary");
|
||||
expect(actionsHeaderElement).toHaveClass("border-y");
|
||||
expect(actionsHeaderElement).toHaveClass("last:border-r");
|
||||
expect(actionsHeaderElement).toHaveClass("last:rounded-r-full");
|
||||
expect(actionsHeaderElement).not.toHaveClass("bg-transparent");
|
||||
expect(actionsHeaderElement).not.toHaveClass("border-y-0");
|
||||
expect(actionsHeaderElement).not.toHaveClass("border-l-0");
|
||||
expect(actionsHeaderElement).not.toHaveClass("border-r-0");
|
||||
expect(actionsHeaderElement).not.toHaveClass("last:border-r-0");
|
||||
expect(actionsHeaderElement).not.toHaveClass("backdrop-blur-none");
|
||||
expect(actionsHeaderElement).not.toHaveClass("last:rounded-r-none");
|
||||
expect(actionsHeaderElement).not.toHaveClass("before:bg-gradient-to-r");
|
||||
expect(actionsHeaderElement).not.toHaveClass("before:content-['']");
|
||||
expect(actionsHeaderElement).not.toHaveClass("after:content-['']");
|
||||
expect(actionsHeaderElement).not.toHaveClass("after:rounded-r-full");
|
||||
expect(actionsHeaderElement.querySelector("div")).not.toBeInTheDocument();
|
||||
expect(actionsCell).toHaveClass("sticky");
|
||||
expect(actionsCell).not.toHaveClass("pr-6");
|
||||
expect(actionsCell).toHaveClass("right-0");
|
||||
expect(actionsCell).toHaveClass("z-20");
|
||||
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
|
||||
expect(actionsCell).toHaveClass("last:rounded-r-none!");
|
||||
expect(actionsCell).not.toHaveClass("border-l");
|
||||
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
|
||||
expect(actionsCell).toHaveClass("before:from-transparent");
|
||||
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,397 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
ExpandedState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
OnChangeFn,
|
||||
Row,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DataTableAnimatedRow } from "@/components/ui/table/data-table-animated-row";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
import { DataTableSearch } from "@/components/ui/table/data-table-search";
|
||||
import { useFilterTransitionOptional } from "@/contexts";
|
||||
import { cn } from "@/lib";
|
||||
import { FilterOption, MetaDataProps } from "@/types";
|
||||
|
||||
type DataTableRowAttributes = {
|
||||
[key: `data-${string}`]: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default column size used by TanStack Table when no explicit size is set.
|
||||
* We skip applying inline width styles for columns with this default value
|
||||
* to allow them to flex naturally within the table layout.
|
||||
*/
|
||||
const DEFAULT_COLUMN_SIZE = 150;
|
||||
const ACTIONS_COLUMN_ID = "actions";
|
||||
const TABLE_COLUMN_GAP_CLASS = "pr-6";
|
||||
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
|
||||
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
|
||||
|
||||
const getTableColumnClassName = (
|
||||
columnId: string,
|
||||
variant: "header" | "cell",
|
||||
) => {
|
||||
const isActionsColumn = columnId === ACTIONS_COLUMN_ID;
|
||||
|
||||
return cn(
|
||||
!isActionsColumn && TABLE_COLUMN_GAP_CLASS,
|
||||
isActionsColumn && variant === "cell" && STICKY_ACTION_CELL_CLASS,
|
||||
);
|
||||
};
|
||||
|
||||
interface DataTableProviderProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
metadata?: MetaDataProps;
|
||||
customFilters?: FilterOption[];
|
||||
disableScroll?: boolean;
|
||||
enableRowSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
/** Function to determine if a row can be selected */
|
||||
getRowCanSelect?: (row: Row<TData>) => boolean;
|
||||
/** Show search bar in the table toolbar */
|
||||
showSearch?: boolean;
|
||||
/** Function to extract sub-rows from a row for hierarchical data */
|
||||
getSubRows?: (row: TData) => TData[] | undefined;
|
||||
/** Controlled expanded state */
|
||||
expanded?: ExpandedState;
|
||||
/** Callback when expanded state changes */
|
||||
onExpandedChange?: OnChangeFn<ExpandedState>;
|
||||
/** Auto-select children when parent selected (default: true) */
|
||||
enableSubRowSelection?: boolean;
|
||||
/** Expand all rows by default, or provide specific expanded state */
|
||||
defaultExpanded?: boolean | ExpandedState;
|
||||
/** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */
|
||||
paramPrefix?: string;
|
||||
|
||||
/*
|
||||
* Controlled Mode Props
|
||||
* ---------------------
|
||||
* By default, DataTable uses URL params for pagination/search (via paramPrefix).
|
||||
* This causes Next.js page re-renders on every interaction.
|
||||
*
|
||||
* For tables inside drawers/modals, use controlled mode instead:
|
||||
* - Pass controlledPage, controlledPageSize, controlledSearch as state values
|
||||
* - Pass onPageChange, onPageSizeChange, onSearchChange as state setters
|
||||
* - This keeps state local, avoiding URL changes and unnecessary page re-renders
|
||||
*
|
||||
* Example:
|
||||
* const [page, setPage] = useState(1);
|
||||
* const [search, setSearch] = useState("");
|
||||
* <DataTable
|
||||
* controlledPage={page}
|
||||
* onPageChange={setPage}
|
||||
* controlledSearch={search}
|
||||
* onSearchChange={setSearch}
|
||||
* isLoading={isLoading}
|
||||
* />
|
||||
*/
|
||||
controlledSearch?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
/**
|
||||
* Called when the user commits a search by pressing Enter.
|
||||
* Use this alongside onSearchChange to implement "search on Enter" behavior.
|
||||
*/
|
||||
onSearchCommit?: (value: string) => void;
|
||||
controlledPage?: number;
|
||||
controlledPageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
/** Show loading state with opacity overlay (for controlled mode) */
|
||||
isLoading?: boolean;
|
||||
/** Custom placeholder text for the search input */
|
||||
searchPlaceholder?: string;
|
||||
/** Render additional content after each row (e.g., inline expansion) */
|
||||
renderAfterRow?: (row: Row<TData>) => ReactNode;
|
||||
/** Badge shown inside the search input (e.g., active drill-down group) */
|
||||
searchBadge?: { label: string; onDismiss: () => void };
|
||||
/** Optional click handler for top-level rows. */
|
||||
onRowClick?: (row: Row<TData>) => void;
|
||||
/** Optional data attributes applied to each top-level row. */
|
||||
getRowAttributes?: (row: Row<TData>) => DataTableRowAttributes;
|
||||
/** Optional header rendered inside the table container, above the toolbar. */
|
||||
header?: ReactNode;
|
||||
/** Optional content rendered in the toolbar before the total entries count. */
|
||||
toolbarRightContent?: ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
metadata,
|
||||
disableScroll = false,
|
||||
enableRowSelection = false,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
getRowCanSelect,
|
||||
showSearch = false,
|
||||
getSubRows,
|
||||
expanded: controlledExpanded,
|
||||
onExpandedChange,
|
||||
enableSubRowSelection = true,
|
||||
defaultExpanded,
|
||||
paramPrefix = "",
|
||||
controlledSearch,
|
||||
onSearchChange,
|
||||
onSearchCommit,
|
||||
controlledPage,
|
||||
controlledPageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
isLoading = false,
|
||||
searchPlaceholder,
|
||||
renderAfterRow,
|
||||
searchBadge,
|
||||
onRowClick,
|
||||
getRowAttributes,
|
||||
header,
|
||||
toolbarRightContent,
|
||||
}: DataTableProviderProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
// ExpandedState should be a Record<string, boolean> for individual row control
|
||||
// Note: We don't use `true` (boolean) as it makes rows permanently expanded
|
||||
const [expanded, setExpanded] = useState<ExpandedState>(() => {
|
||||
if (typeof defaultExpanded === "object") return defaultExpanded;
|
||||
return {};
|
||||
});
|
||||
|
||||
// Get transition state from context for loading indicator
|
||||
const filterTransition = useFilterTransitionOptional();
|
||||
// Use either context-based pending state or controlled isLoading prop
|
||||
const isPending = (filterTransition?.isPending ?? false) || isLoading;
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
enableSorting: true,
|
||||
enableRowSelection: getRowCanSelect ?? enableRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onRowSelectionChange,
|
||||
manualPagination: true,
|
||||
// Expansion support for hierarchical data
|
||||
getSubRows,
|
||||
getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined,
|
||||
enableSubRowSelection,
|
||||
onExpandedChange: onExpandedChange ?? setExpanded,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
rowSelection: rowSelection ?? {},
|
||||
expanded: controlledExpanded ?? expanded,
|
||||
},
|
||||
});
|
||||
|
||||
// Track whether initial expansion has been applied
|
||||
const hasInitiallyExpanded = useRef(false);
|
||||
|
||||
// Expand all rows on mount when defaultExpanded={true}
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasInitiallyExpanded.current &&
|
||||
defaultExpanded === true &&
|
||||
getSubRows
|
||||
) {
|
||||
table.toggleAllRowsExpanded(true);
|
||||
hasInitiallyExpanded.current = true;
|
||||
}
|
||||
}, [defaultExpanded, getSubRows, table]);
|
||||
|
||||
// Calculate selection key to force header re-render when selection changes
|
||||
const selectionKey = rowSelection
|
||||
? Object.keys(rowSelection).filter((k) => rowSelection[k]).length
|
||||
: 0;
|
||||
|
||||
// Calculate expansion key to force header re-render when expansion changes
|
||||
const currentExpanded = controlledExpanded ?? expanded;
|
||||
const expansionKey =
|
||||
currentExpanded === true
|
||||
? "all"
|
||||
: typeof currentExpanded === "object"
|
||||
? Object.keys(currentExpanded).filter((k) => currentExpanded[k]).length
|
||||
: 0;
|
||||
|
||||
// Format total entries count
|
||||
const totalEntries = metadata?.pagination?.count ?? 0;
|
||||
const formattedTotal = totalEntries.toLocaleString();
|
||||
const showToolbar = showSearch || metadata || toolbarRightContent;
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
const handleRowClick = (row: Row<TData>, target: HTMLElement | null) => {
|
||||
if (!onRowClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target?.closest("a, button, input, [role=menuitem]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
onRowClick(row);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4 transition-opacity duration-200",
|
||||
isPending && "pointer-events-none opacity-60",
|
||||
)}
|
||||
>
|
||||
{header && <div className="w-full">{header}</div>}
|
||||
{/* Table Toolbar */}
|
||||
{showToolbar && (
|
||||
<div
|
||||
data-testid="data-table-toolbar"
|
||||
className="flex flex-col items-start gap-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="w-full md:w-auto">
|
||||
{showSearch && (
|
||||
<DataTableSearch
|
||||
paramPrefix={paramPrefix}
|
||||
controlledValue={controlledSearch}
|
||||
onSearchChange={onSearchChange}
|
||||
onSearchCommit={onSearchCommit}
|
||||
placeholder={searchPlaceholder}
|
||||
badge={searchBadge}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
data-testid="data-table-toolbar-right"
|
||||
className="flex w-full flex-col items-start gap-2 md:ml-auto md:w-auto md:flex-row md:items-center md:gap-4"
|
||||
>
|
||||
{toolbarRightContent}
|
||||
{metadata && (
|
||||
<span className="text-text-neutral-secondary text-sm whitespace-nowrap">
|
||||
{formattedTotal} Total Entries
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Table className={getSubRows ? "table-fixed" : undefined}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={`${headerGroup.id}-${selectionKey}-${expansionKey}`}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const size = header.getSize();
|
||||
const isActionsHeader = header.column.id === ACTIONS_COLUMN_ID;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={getTableColumnClassName(
|
||||
header.column.id,
|
||||
"header",
|
||||
)}
|
||||
style={
|
||||
getSubRows && size !== DEFAULT_COLUMN_SIZE
|
||||
? { width: `${size}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder || isActionsHeader
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence initial={false}>
|
||||
{rows?.length ? (
|
||||
rows.map((row) =>
|
||||
getSubRows && row.depth > 0 ? (
|
||||
<DataTableAnimatedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
isSelected={row.getIsSelected()}
|
||||
isSomeSelected={row.getIsSomeSelected()}
|
||||
/>
|
||||
) : (
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
{...getRowAttributes?.(row)}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn("group", onRowClick && "cursor-pointer")}
|
||||
onClick={(event) =>
|
||||
handleRowClick(row, event.target as HTMLElement)
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getTableColumnClassName(
|
||||
cell.column.id,
|
||||
"cell",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{renderAfterRow?.(row)}
|
||||
</Fragment>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{metadata && (
|
||||
<DataTablePagination
|
||||
metadata={metadata}
|
||||
disableScroll={disableScroll}
|
||||
paramPrefix={paramPrefix}
|
||||
controlledPage={controlledPage}
|
||||
controlledPageSize={controlledPageSize}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
export * from "./data-table";
|
||||
export * from "./data-table-column-header";
|
||||
export * from "./data-table-filter-custom";
|
||||
export * from "./data-table-pagination";
|
||||
export * from "./data-table-search";
|
||||
export * from "./severity-badge";
|
||||
export * from "./status-badge";
|
||||
export * from "./status-finding-badge";
|
||||
export * from "./table";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/table";
|
||||
@@ -1,46 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SeverityValues = {
|
||||
INFORMATIONAL: "informational",
|
||||
LOW: "low",
|
||||
MEDIUM: "medium",
|
||||
HIGH: "high",
|
||||
CRITICAL: "critical",
|
||||
} as const;
|
||||
|
||||
export type Severity = (typeof SeverityValues)[keyof typeof SeverityValues];
|
||||
|
||||
const SEVERITY_CHIP_COLORS = {
|
||||
critical: "bg-bg-data-critical",
|
||||
high: "bg-bg-data-high",
|
||||
medium: "bg-bg-data-medium",
|
||||
low: "bg-bg-data-low",
|
||||
informational: "bg-bg-data-info",
|
||||
} as const;
|
||||
|
||||
const SEVERITY_DISPLAY_NAMES = {
|
||||
critical: "Critical",
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
informational: "Info",
|
||||
} as const;
|
||||
|
||||
interface SeverityBadgeProps {
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
export const SeverityBadge = ({ severity }: SeverityBadgeProps) => {
|
||||
const chipColor =
|
||||
SEVERITY_CHIP_COLORS[severity] || SEVERITY_CHIP_COLORS.informational;
|
||||
const displayName = SEVERITY_DISPLAY_NAMES[severity] || severity;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn("size-3 rounded", chipColor)} />
|
||||
<span className="text-text-neutral-primary text-sm capitalize">
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type Status, StatusBadge } from "./status-badge";
|
||||
|
||||
const cases: Array<{ status: Status; label: string; variant: string }> = [
|
||||
{ status: "available", label: "Queued", variant: "tag" },
|
||||
{ status: "queued", label: "Queued", variant: "tag" },
|
||||
{ status: "scheduled", label: "scheduled", variant: "warning" },
|
||||
{ status: "completed", label: "completed", variant: "success" },
|
||||
{ status: "failed", label: "failed", variant: "error" },
|
||||
{ status: "cancelled", label: "cancelled", variant: "error" },
|
||||
];
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each(cases)(
|
||||
"renders $status as $label with $variant variant",
|
||||
({ status, label }) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("renders the executing state with spinner and progress percentage", () => {
|
||||
render(<StatusBadge status="executing" loadingProgress={42} />);
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits progress when loadingProgress is not provided", () => {
|
||||
render(<StatusBadge status="executing" />);
|
||||
expect(screen.getByText("executing")).toBeInTheDocument();
|
||||
expect(screen.queryByText("%", { exact: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
const { container } = render(
|
||||
<StatusBadge status="completed" className="extra-class" />,
|
||||
);
|
||||
const badge = container.querySelector("[data-slot='badge']");
|
||||
expect(badge?.className).toContain("extra-class");
|
||||
});
|
||||
|
||||
it("applies md size classes when size='md'", () => {
|
||||
const { container } = render(<StatusBadge status="completed" size="md" />);
|
||||
const badge = container.querySelector("[data-slot='badge']");
|
||||
expect(badge?.className).toContain("py-1");
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { SpinnerIcon } from "@/components/icons";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type Status =
|
||||
| "available"
|
||||
| "queued"
|
||||
| "scheduled"
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
const statusDisplayMap: Record<Status, string> = {
|
||||
available: "Queued",
|
||||
queued: "Queued",
|
||||
scheduled: "scheduled",
|
||||
executing: "executing",
|
||||
completed: "completed",
|
||||
failed: "failed",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
type StatusBadgeVariant = "tag" | "warning" | "success" | "error";
|
||||
|
||||
const statusVariantMap: Record<Status, StatusBadgeVariant> = {
|
||||
available: "tag",
|
||||
queued: "tag",
|
||||
scheduled: "warning",
|
||||
executing: "tag",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
cancelled: "error",
|
||||
};
|
||||
|
||||
type StatusBadgeSize = "sm" | "md" | "lg";
|
||||
|
||||
const STATUS_BADGE_SIZE_CLASS: Record<StatusBadgeSize, string> = {
|
||||
sm: "",
|
||||
md: "px-2.5 py-1",
|
||||
lg: "px-3 py-1.5 text-sm",
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
size?: StatusBadgeSize;
|
||||
loadingProgress?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusBadge = ({
|
||||
status,
|
||||
size = "sm",
|
||||
loadingProgress,
|
||||
className,
|
||||
}: StatusBadgeProps) => {
|
||||
const variant = statusVariantMap[status];
|
||||
const label = statusDisplayMap[status] || status;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"max-w-full capitalize",
|
||||
STATUS_BADGE_SIZE_CLASS[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{status === "executing" ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<SpinnerIcon size={12} className="animate-spin" />
|
||||
{loadingProgress !== undefined && (
|
||||
<span className="text-[0.6rem]">{loadingProgress}%</span>
|
||||
)}
|
||||
<span>executing</span>
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const FindingStatusValues = {
|
||||
FAIL: "FAIL",
|
||||
PASS: "PASS",
|
||||
MANUAL: "MANUAL",
|
||||
MUTED: "MUTED",
|
||||
} as const;
|
||||
|
||||
export type FindingStatus =
|
||||
(typeof FindingStatusValues)[keyof typeof FindingStatusValues];
|
||||
|
||||
const STATUS_STYLES = {
|
||||
FAIL: "border-bg-fail text-bg-fail",
|
||||
PASS: "border-bg-pass text-bg-pass",
|
||||
MANUAL: "border-bg-warning text-bg-warning",
|
||||
MUTED: "border-text-neutral-tertiary text-text-neutral-tertiary",
|
||||
} as const;
|
||||
|
||||
interface StatusFindingBadgeProps {
|
||||
status: FindingStatus;
|
||||
size?: "sm" | "md" | "lg";
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
export const StatusFindingBadge = ({
|
||||
status,
|
||||
value,
|
||||
}: StatusFindingBadgeProps) => {
|
||||
const statusStyle = STATUS_STYLES[status] || STATUS_STYLES.MUTED;
|
||||
const displayText =
|
||||
status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded px-0 py-0.5",
|
||||
"border-x border-y-0",
|
||||
"min-w-[38px] text-center text-xs font-bold",
|
||||
statusStyle,
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
{value !== undefined && `: ${value}`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
import {
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
TdHTMLAttributes,
|
||||
ThHTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto rounded-lg">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full caption-bottom border-separate border-spacing-y-4 text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("[&>tr]:first:rounded-lg", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&>tr:last-child>td]:after:hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"font-medium dark:bg-slate-800/50 [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = forwardRef<
|
||||
HTMLTableRowElement,
|
||||
HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
"[&>td:first-child]:rounded-l-full [&>td:last-child]:rounded-r-full",
|
||||
"hover:bg-bg-neutral-tertiary",
|
||||
"data-[state=selected]:bg-bg-neutral-tertiary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-bg-neutral-tertiary border-border-neutral-tertiary text-text-neutral-secondary border-y backdrop-blur-[46px]",
|
||||
"h-11 px-1.5 text-left align-middle text-xs font-medium whitespace-nowrap outline-none",
|
||||
"first:rounded-l-full first:border-l first:pl-3",
|
||||
"last:rounded-r-full last:border-r last:pr-3",
|
||||
"data-[hover=true]:text-foreground-400 data-[focus-visible=true]:outline-focus",
|
||||
"data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-offset-2",
|
||||
"rtl:text-right rtl:first:rounded-l-[unset] rtl:first:rounded-r-full rtl:last:rounded-l-full rtl:last:rounded-r-[unset]",
|
||||
"[&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative px-1.5 py-2 align-middle first:pl-3 last:pr-3 [&:has([role=checkbox])]:pr-0",
|
||||
"after:bg-border-input-primary after:absolute after:right-0 after:-bottom-2 after:left-0 after:h-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-2 text-sm text-slate-500 dark:text-slate-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { CrossIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between gap-2 overflow-hidden rounded-md border border-slate-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-slate-800",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary",
|
||||
destructive:
|
||||
"border border-border-error bg-bg-fail-secondary text-text-neutral-primary",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center self-end rounded-md border border-slate-200 bg-transparent px-3 text-sm font-medium transition-colors group-[.destructive]:border-slate-100/40 hover:bg-slate-100 group-[.destructive]:hover:border-red-500/30 group-[.destructive]:hover:bg-red-500 group-[.destructive]:hover:text-slate-50 focus:ring-1 focus:ring-slate-950 focus:outline-none group-[.destructive]:focus:ring-red-500 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-800 dark:group-[.destructive]:border-slate-800/40 dark:hover:bg-slate-800 dark:group-[.destructive]:hover:border-red-900/30 dark:group-[.destructive]:hover:bg-red-900 dark:group-[.destructive]:hover:text-slate-50 dark:focus:ring-slate-300 dark:group-[.destructive]:focus:ring-red-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute top-1 right-1 rounded-md p-1 text-slate-950/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-slate-950 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600 dark:text-slate-50/50 dark:hover:text-slate-50",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<CrossIcon />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("[&+div]:text-md font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-small opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
type ToastActionElement,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
type ToastProps,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui";
|
||||
|
||||
import { useToast } from "./use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./Toast";
|
||||
export * from "./Toaster";
|
||||
export * from "./use-toast";
|
||||
// Temporary re-export shim for prowler-cloud overlay imports.
|
||||
// Remove after the cloud repo migrates to @/components/shadcn paths.
|
||||
export * from "@/components/shadcn/toast";
|
||||
Loaded 100 of 519 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user