fix(ui): improve Resource Inventory cards light mode (#10757)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Hugo Pereira Brito
2026-04-22 11:05:09 +01:00
committed by GitHub
parent 72acc2119d
commit 48060c47ba
4 changed files with 188 additions and 30 deletions
@@ -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();
});
});
});
@@ -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">