mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
fix(ui): improve Resource Inventory cards light mode (#10757)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
committed by
GitHub
parent
72acc2119d
commit
48060c47ba
+120
@@ -0,0 +1,120 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Shield } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ResourceInventoryItem } from "@/actions/overview";
|
||||
|
||||
import { ResourcesInventoryCardItem } from "./resources-inventory-card-item";
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const baseItem: ResourceInventoryItem = {
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: Shield,
|
||||
totalResources: 616,
|
||||
totalFindings: 319,
|
||||
failedFindings: 319,
|
||||
newFailedFindings: 64,
|
||||
severity: {
|
||||
critical: 12,
|
||||
high: 44,
|
||||
medium: 108,
|
||||
low: 155,
|
||||
informational: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe("ResourcesInventoryCardItem", () => {
|
||||
describe("when the group has resources and failed findings", () => {
|
||||
it("builds a resources link that forwards current page filters", () => {
|
||||
render(
|
||||
<ResourcesInventoryCardItem
|
||||
item={baseItem}
|
||||
filters={{
|
||||
"filter[provider_id__in]": "aws-provider",
|
||||
"filter[account_id__in]": "account-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("/resources?"),
|
||||
);
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("filter%5Bgroups__in%5D=security"),
|
||||
);
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("filter%5Bprovider__in%5D=aws-provider"),
|
||||
);
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringContaining("filter%5Baccount_id__in%5D=account-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a fail accent bar so the card is theme-agnostic", () => {
|
||||
render(<ResourcesInventoryCardItem item={baseItem} />);
|
||||
|
||||
const card = screen.getByText("Security").closest("[data-slot='card']");
|
||||
const accent = card?.querySelector(
|
||||
"[data-slot='resource-stats-card-accent']",
|
||||
);
|
||||
|
||||
expect(card).not.toBeNull();
|
||||
expect(accent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the group has resources but no failed findings", () => {
|
||||
it("renders a pass accent bar and the ShieldCheck badge", () => {
|
||||
render(
|
||||
<ResourcesInventoryCardItem
|
||||
item={{
|
||||
...baseItem,
|
||||
totalFindings: 0,
|
||||
failedFindings: 0,
|
||||
newFailedFindings: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByText("Security").closest("[data-slot='card']");
|
||||
const accent = card?.querySelector(
|
||||
"[data-slot='resource-stats-card-accent']",
|
||||
);
|
||||
|
||||
expect(accent).not.toBeNull();
|
||||
expect(screen.getByRole("link")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the group has no resources", () => {
|
||||
it("renders the empty state without a link", () => {
|
||||
render(
|
||||
<ResourcesInventoryCardItem
|
||||
item={{
|
||||
...baseItem,
|
||||
totalResources: 0,
|
||||
totalFindings: 0,
|
||||
failedFindings: 0,
|
||||
newFailedFindings: 0,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No Findings to display")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-27
@@ -1,8 +1,9 @@
|
||||
import { Bell, TriangleAlert } from "lucide-react";
|
||||
import { Bell, ShieldCheck, TriangleAlert } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ResourceInventoryItem } from "@/actions/overview";
|
||||
import { CardVariant, ResourceStatsCard, StatItem } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ResourcesInventoryCardItemProps {
|
||||
item: ResourceInventoryItem;
|
||||
@@ -15,6 +16,7 @@ export function ResourcesInventoryCardItem({
|
||||
}: ResourcesInventoryCardItemProps) {
|
||||
const hasFailedFindings = item.failedFindings > 0;
|
||||
const hasResources = item.totalResources > 0;
|
||||
const accent = hasFailedFindings ? CardVariant.fail : CardVariant.pass;
|
||||
|
||||
// Build URL with current filters + resource group specific filters
|
||||
const buildResourcesUrl = () => {
|
||||
@@ -49,52 +51,49 @@ export function ResourcesInventoryCardItem({
|
||||
});
|
||||
}
|
||||
|
||||
// Empty state when no resources
|
||||
const header = {
|
||||
icon: item.icon,
|
||||
title: item.label,
|
||||
resourceCount: `${item.totalResources.toLocaleString()} Resources`,
|
||||
};
|
||||
|
||||
if (!hasResources) {
|
||||
const cardContent = (
|
||||
return (
|
||||
<ResourceStatsCard
|
||||
header={{
|
||||
icon: item.icon,
|
||||
title: item.label,
|
||||
resourceCount: item.totalResources,
|
||||
}}
|
||||
emptyState={{
|
||||
message: "No Findings to display",
|
||||
}}
|
||||
header={header}
|
||||
emptyState={{ message: "No Findings to display" }}
|
||||
className="flex-1"
|
||||
/>
|
||||
);
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
|
||||
// Card with findings data
|
||||
const cardContent = (
|
||||
<ResourceStatsCard
|
||||
header={{
|
||||
icon: item.icon,
|
||||
title: item.label,
|
||||
resourceCount: item.totalResources,
|
||||
}}
|
||||
header={header}
|
||||
badge={{
|
||||
icon: TriangleAlert,
|
||||
icon: hasFailedFindings ? TriangleAlert : ShieldCheck,
|
||||
count: item.failedFindings,
|
||||
variant: CardVariant.fail,
|
||||
variant: hasFailedFindings ? CardVariant.fail : CardVariant.pass,
|
||||
}}
|
||||
label="Fail Findings"
|
||||
stats={stats}
|
||||
variant={hasFailedFindings ? CardVariant.fail : CardVariant.default}
|
||||
className={
|
||||
accent={accent}
|
||||
className={cn(
|
||||
"flex-1 cursor-pointer shadow-sm transition-[transform,border-color,box-shadow] duration-200",
|
||||
"hover:-translate-y-0.5 hover:shadow-md",
|
||||
hasFailedFindings
|
||||
? "hover:border-bg-fail/60 flex-1 cursor-pointer transition-all"
|
||||
: "flex-1"
|
||||
}
|
||||
? "hover:border-border-error-primary"
|
||||
: "hover:border-border-neutral-primary",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (resourcesUrl) {
|
||||
return (
|
||||
<Link href={resourcesUrl} className="flex flex-1">
|
||||
<Link
|
||||
href={resourcesUrl}
|
||||
className="focus-visible:ring-border-neutral-primary/40 flex flex-1 rounded-xl focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
{cardContent}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
function ResourceCardSkeleton() {
|
||||
return (
|
||||
<div className="border-border-neutral-tertiary bg-bg-neutral-tertiary flex flex-1 flex-col gap-2 rounded-xl border px-3 py-2">
|
||||
<div className="border-border-neutral-secondary bg-bg-neutral-secondary relative flex flex-1 flex-col gap-2 overflow-hidden rounded-xl border px-3 py-2 shadow-sm">
|
||||
<Skeleton className="absolute inset-x-0 top-0 h-1 rounded-none" />
|
||||
{/* Header */}
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
|
||||
@@ -38,6 +38,15 @@ const cardVariants = cva("", {
|
||||
},
|
||||
});
|
||||
|
||||
// Neutral surface + colored top bar; reads well in both light and dark modes.
|
||||
const accentBarByVariant: Record<CardVariant, string> = {
|
||||
[CardVariant.default]: "",
|
||||
[CardVariant.fail]: "bg-bg-fail-primary",
|
||||
[CardVariant.pass]: "bg-bg-pass-primary",
|
||||
[CardVariant.warning]: "bg-bg-warning-primary",
|
||||
[CardVariant.info]: "bg-bg-data-info",
|
||||
};
|
||||
|
||||
export interface ResourceStatsCardProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
|
||||
VariantProps<typeof cardVariants> {
|
||||
@@ -66,6 +75,11 @@ export interface ResourceStatsCardProps
|
||||
// Vertical accent line color (optional, auto-determined from variant)
|
||||
accentColor?: string;
|
||||
|
||||
// Horizontal top accent bar. When set, the card renders on a neutral surface
|
||||
// with a colored bar across the top using design tokens. Prefer this over
|
||||
// `variant` when the surface needs to read well in both light and dark modes.
|
||||
accent?: CardVariant;
|
||||
|
||||
// Sub-statistics array (flexible items)
|
||||
stats?: StatItem[];
|
||||
|
||||
@@ -82,6 +96,7 @@ export const ResourceStatsCard = ({
|
||||
badge,
|
||||
label,
|
||||
accentColor,
|
||||
accent,
|
||||
stats = [],
|
||||
variant = CardVariant.default,
|
||||
size = "md",
|
||||
@@ -93,7 +108,14 @@ export const ResourceStatsCard = ({
|
||||
// Resolve size to ensure it's not null (CVA can return null but we need a defined value)
|
||||
const resolvedSize = size || "md";
|
||||
|
||||
// If containerless, render without outer wrapper
|
||||
// `accent` takes precedence: it forces a neutral surface and a colored top bar,
|
||||
// so the card reads well in both themes regardless of `variant`.
|
||||
const resolvedVariant = accent ? CardVariant.default : variant;
|
||||
const accentClassName = accent ? accentBarByVariant[accent] : "";
|
||||
|
||||
// If containerless, render without outer wrapper. `accent` is ignored in this
|
||||
// mode because the caller supplies the container; consumers that need the
|
||||
// accent bar can render it themselves or drop containerless.
|
||||
if (containerless) {
|
||||
return (
|
||||
<div
|
||||
@@ -129,9 +151,25 @@ export const ResourceStatsCard = ({
|
||||
<Card
|
||||
ref={ref}
|
||||
variant="inner"
|
||||
className={cn(cardVariants({ variant, size }), "flex-col", className)}
|
||||
className={cn(
|
||||
cardVariants({ variant: resolvedVariant, size }),
|
||||
"flex-col",
|
||||
accent &&
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary relative overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{accent && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="resource-stats-card-accent"
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-0 h-1 rounded-t-[inherit]",
|
||||
accentClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
|
||||
Reference in New Issue
Block a user