feat(ui): migrate from HeroUI to shadcn/ui (#11532)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-07-08 16:57:02 +02:00
1 parent c665005790
commit 52875b5c7c
519 files changed
+4798 -6997

No files matched your search

-167
View File
@@ -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";
+3
View File
@@ -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";
+3
View File
@@ -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,
};
+3
View File
@@ -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";
+1 -1
View File
@@ -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: {
-50
View File
@@ -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 };
+3
View File
@@ -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>
);
}
+3 -1
View File
@@ -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";
+3 -3
View File
@@ -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",
-365
View File
@@ -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,
};
+3
View File
@@ -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>
);
};
+3
View File
@@ -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>
</>
);
}
+3
View File
@@ -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>
);
};
-49
View File
@@ -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>
);
};
-124
View File
@@ -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");
});
});
-78
View File
@@ -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>
);
};
-50
View File
@@ -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" />
</>
)}
/>
);
};
+3 -8
View File
@@ -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,
};
+3
View File
@@ -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>;
}
};
-99
View File
@@ -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";
}
};
+3 -4
View File
@@ -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";
-28
View File
@@ -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>
);
};
+3 -39
View File
@@ -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";
-65
View File
@@ -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");
});
});
-190
View File
@@ -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,
};
-26
View File
@@ -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 };
-127
View File
@@ -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>
);
};
+3 -3
View File
@@ -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";
+3
View File
@@ -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
View File
@@ -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";
-26
View File
@@ -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 };
+3
View File
@@ -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";
+3
View File
@@ -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>
);
}
+3
View File
@@ -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();
});
});
-149
View File
@@ -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>
);
}
-32
View File
@@ -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;
}
+3
View File
@@ -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 };
+3 -2
View File
@@ -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";
-140
View File
@@ -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,
};
-43
View File
@@ -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>
);
};
+3 -7
View File
@@ -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";
-83
View File
@@ -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>
);
};
-256
View File
@@ -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();
});
});
-219
View File
@@ -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>
);
}
-43
View File
@@ -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>
);
}
-71
View File
@@ -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);
});
});
-130
View File
@@ -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>
);
};
+1 -1
View File
@@ -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>
);
};
-176
View File
@@ -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");
});
});
});
-397
View File
@@ -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>
);
}
+3 -9
View File
@@ -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";
-46
View File
@@ -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");
});
});
-82
View File
@@ -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>
);
};
-142
View File
@@ -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,
};
-130
View File
@@ -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,
};
-36
View File
@@ -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>
);
}
+3 -3
View File
@@ -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