mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add trial usage sidebar banner (#12420)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
This commit is contained in:
co-authored by
Pablo F.G
parent
7bde42ffb9
commit
d05c9fbb31
@@ -0,0 +1 @@
|
||||
Display the default one-scan free trial and trial expiration in the existing sidebar banner
|
||||
@@ -0,0 +1,217 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
TRIAL_SIDEBAR_BANNER_VARIANT,
|
||||
TrialSidebarBanner,
|
||||
} from "./trial-sidebar-banner";
|
||||
|
||||
describe("TrialSidebarBanner", () => {
|
||||
it("preserves the active day-based trial card", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
|
||||
remaining={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("status", { name: "Active trial" });
|
||||
expect(banner).toHaveTextContent("Unlimited trial");
|
||||
expect(banner).toHaveTextContent("7 days left");
|
||||
expect(banner).toHaveTextContent(
|
||||
"Unlimited accounts, scans, and daily schedules",
|
||||
);
|
||||
expect(banner).toHaveTextContent("Explore plans");
|
||||
expect(banner).toHaveAttribute("data-slot", "sidebar-trial");
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Explore plans for your unlimited trial",
|
||||
}),
|
||||
).toHaveAttribute("href", "/billing");
|
||||
});
|
||||
|
||||
it("preserves an active unlimited trial without a counter", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("status", { name: "Active trial" });
|
||||
expect(banner).toHaveTextContent("Unlimited trial");
|
||||
expect(banner).toHaveTextContent("Trial active");
|
||||
expect(banner).toHaveTextContent(
|
||||
"Unlimited accounts, scans, and daily schedules",
|
||||
);
|
||||
expect(banner).toHaveAttribute("data-urgency", "healthy");
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Explore plans for your unlimited trial",
|
||||
}),
|
||||
).toHaveAttribute("href", "/billing");
|
||||
});
|
||||
|
||||
it("formats a singular remaining day", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
|
||||
remaining={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("status", { name: "Active trial" });
|
||||
expect(banner).toHaveTextContent("1 day left");
|
||||
expect(banner).toHaveAttribute("data-urgency", "critical");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ remaining: 1, copy: "1 scan left", urgency: "healthy" },
|
||||
{ remaining: 5, copy: "5 scans left", urgency: "healthy" },
|
||||
])(
|
||||
"formats $remaining remaining scan(s) in the active card",
|
||||
({ remaining, copy, urgency }) => {
|
||||
// Given / When
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS}
|
||||
remaining={remaining}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("status", { name: "Active trial" });
|
||||
expect(banner).toHaveTextContent(copy);
|
||||
expect(banner).toHaveTextContent("Free trial");
|
||||
expect(banner).not.toHaveTextContent("Unlimited trial");
|
||||
expect(banner).toHaveTextContent(
|
||||
"Choose a plan to keep running scans after your trial ends.",
|
||||
);
|
||||
expect(banner).toHaveAttribute("data-urgency", urgency);
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Explore plans for your free trial",
|
||||
}),
|
||||
).toHaveAttribute("href", "/billing");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ remaining: 0, copy: "0 scans left" },
|
||||
{ remaining: -4, copy: "0 scans left" },
|
||||
])(
|
||||
"marks a scan trial with $remaining remaining as spent",
|
||||
({ remaining, copy }) => {
|
||||
// Given / When
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS}
|
||||
remaining={remaining}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("status", { name: "Active trial" });
|
||||
expect(banner).toHaveTextContent(copy);
|
||||
expect(banner).toHaveAttribute("data-urgency", "critical");
|
||||
expect(screen.getByText("Free trial")).toHaveClass(
|
||||
"bg-bg-fail-secondary",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("renders exhausted scan trials with the existing expired presentation", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<TrialSidebarBanner variant={TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED} />,
|
||||
);
|
||||
|
||||
// Then
|
||||
const banner = screen.getByRole("status", { name: "Expired trial" });
|
||||
expect(banner).toHaveTextContent("Trial expired");
|
||||
expect(banner).toHaveTextContent("Subscription required");
|
||||
expect(banner).toHaveTextContent(
|
||||
"Subscribe to continue scanning and running scheduled scans.",
|
||||
);
|
||||
expect(banner).not.toHaveTextContent("0 scans left");
|
||||
expect(banner).toHaveAttribute("data-urgency", "critical");
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Explore plans after your trial expired",
|
||||
}),
|
||||
).toHaveAttribute("href", "/billing");
|
||||
});
|
||||
|
||||
it("invokes the sidebar selection callback from the billing CTA", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
|
||||
remaining={7}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
document.addEventListener("click", (event) => event.preventDefault(), {
|
||||
once: true,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("link", {
|
||||
name: "Explore plans for your unlimited trial",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// The tilt runs on animation frames and `useReducedMotion` caches its media
|
||||
// query module-globally, so both belong in Browser Mode, not jsdom.
|
||||
it("renders the decorative glow layer", () => {
|
||||
// Given / When
|
||||
const { container } = render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
|
||||
remaining={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const glow = container.querySelector('[data-slot="trial-glow"]');
|
||||
expect(glow).toBeInTheDocument();
|
||||
expect(glow).toHaveClass("motion-reduce:hidden");
|
||||
});
|
||||
|
||||
it.each([{ pointerType: "touch" }, { pointerType: "mouse" }])(
|
||||
"keeps the card usable under $pointerType input",
|
||||
({ pointerType }) => {
|
||||
// Given
|
||||
render(
|
||||
<TrialSidebarBanner
|
||||
variant={TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS}
|
||||
remaining={7}
|
||||
/>,
|
||||
);
|
||||
const card = screen.getByRole("link", {
|
||||
name: "Explore plans for your unlimited trial",
|
||||
});
|
||||
|
||||
// When
|
||||
fireEvent.pointerMove(card, { clientX: 250, clientY: 60, pointerType });
|
||||
fireEvent.pointerLeave(card);
|
||||
|
||||
// Then
|
||||
expect(card).toBeInTheDocument();
|
||||
expect(card).toHaveTextContent("7 days left");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type PointerEvent } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const TRIAL_SIDEBAR_BANNER_VARIANT = {
|
||||
ACTIVE_DAYS: "active_days",
|
||||
ACTIVE_SCANS: "active_scans",
|
||||
ACTIVE_UNLIMITED: "active_unlimited",
|
||||
EXPIRED: "expired",
|
||||
} as const;
|
||||
|
||||
interface TrialSidebarBannerBaseProps {
|
||||
onSelect?: () => HTMLElement | null;
|
||||
}
|
||||
|
||||
interface ActiveTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
interface ActiveDaysTrialSidebarBannerProps
|
||||
extends ActiveTrialSidebarBannerProps {
|
||||
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS;
|
||||
}
|
||||
|
||||
interface ActiveScansTrialSidebarBannerProps
|
||||
extends ActiveTrialSidebarBannerProps {
|
||||
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS;
|
||||
}
|
||||
|
||||
interface ActiveUnlimitedTrialSidebarBannerProps
|
||||
extends TrialSidebarBannerBaseProps {
|
||||
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED;
|
||||
remaining?: never;
|
||||
}
|
||||
|
||||
interface ExpiredTrialSidebarBannerProps extends TrialSidebarBannerBaseProps {
|
||||
variant: typeof TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED;
|
||||
remaining?: never;
|
||||
}
|
||||
|
||||
export type TrialSidebarBannerProps =
|
||||
| ActiveDaysTrialSidebarBannerProps
|
||||
| ActiveScansTrialSidebarBannerProps
|
||||
| ActiveUnlimitedTrialSidebarBannerProps
|
||||
| ExpiredTrialSidebarBannerProps;
|
||||
|
||||
const TRIAL_URGENCY = {
|
||||
HEALTHY: "healthy",
|
||||
WARNING: "warning",
|
||||
CRITICAL: "critical",
|
||||
} as const;
|
||||
|
||||
type TrialUrgency = (typeof TRIAL_URGENCY)[keyof typeof TRIAL_URGENCY];
|
||||
|
||||
const TRIAL_SIDEBAR_BANNER_UNIT = {
|
||||
DAY: "day",
|
||||
SCAN: "scan",
|
||||
} as const;
|
||||
|
||||
type TrialSidebarBannerUnit =
|
||||
(typeof TRIAL_SIDEBAR_BANNER_UNIT)[keyof typeof TRIAL_SIDEBAR_BANNER_UNIT];
|
||||
|
||||
type TrialSidebarBannerVariant =
|
||||
(typeof TRIAL_SIDEBAR_BANNER_VARIANT)[keyof typeof TRIAL_SIDEBAR_BANNER_VARIANT];
|
||||
|
||||
type MeteredTrialVariant = Extract<
|
||||
TrialSidebarBannerProps,
|
||||
{ remaining: number }
|
||||
>["variant"];
|
||||
|
||||
interface TrialSidebarBannerCopy {
|
||||
badge: string;
|
||||
body: string;
|
||||
linkLabel: string;
|
||||
cardLabel: string;
|
||||
}
|
||||
|
||||
const UNLIMITED_COPY = {
|
||||
badge: "Unlimited trial",
|
||||
body: "Unlimited accounts, scans, and daily schedules. Subscribe to keep everything running.",
|
||||
linkLabel: "Explore plans for your unlimited trial",
|
||||
cardLabel: "Active trial",
|
||||
} as const satisfies TrialSidebarBannerCopy;
|
||||
|
||||
// Keyed maps rather than ternaries: a new variant fails to compile until every
|
||||
// string and unit is supplied for it.
|
||||
const TRIAL_SIDEBAR_BANNER_COPY: Record<
|
||||
TrialSidebarBannerVariant,
|
||||
TrialSidebarBannerCopy
|
||||
> = {
|
||||
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS]: UNLIMITED_COPY,
|
||||
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED]: UNLIMITED_COPY,
|
||||
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS]: {
|
||||
badge: "Free trial",
|
||||
body: "Choose a plan to keep running scans after your trial ends.",
|
||||
linkLabel: "Explore plans for your free trial",
|
||||
cardLabel: "Active trial",
|
||||
},
|
||||
[TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED]: {
|
||||
badge: "Trial expired",
|
||||
body: "Subscribe to continue scanning and running scheduled scans.",
|
||||
linkLabel: "Explore plans after your trial expired",
|
||||
cardLabel: "Expired trial",
|
||||
},
|
||||
};
|
||||
|
||||
const TRIAL_SIDEBAR_BANNER_METER: Record<
|
||||
MeteredTrialVariant,
|
||||
TrialSidebarBannerUnit
|
||||
> = {
|
||||
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_DAYS]: TRIAL_SIDEBAR_BANNER_UNIT.DAY,
|
||||
[TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS]: TRIAL_SIDEBAR_BANNER_UNIT.SCAN,
|
||||
};
|
||||
|
||||
interface TrialUrgencyStyles {
|
||||
sidebarBorder: string;
|
||||
sidebarTint: string;
|
||||
sidebarGlow: string;
|
||||
}
|
||||
|
||||
const TRIAL_URGENCY_STYLES: Record<TrialUrgency, TrialUrgencyStyles> = {
|
||||
[TRIAL_URGENCY.HEALTHY]: {
|
||||
sidebarBorder: "border-button-primary",
|
||||
sidebarTint: "bg-button-primary/10",
|
||||
sidebarGlow: "bg-button-primary/20",
|
||||
},
|
||||
[TRIAL_URGENCY.WARNING]: {
|
||||
sidebarBorder: "border-bg-warning",
|
||||
sidebarTint: "bg-bg-warning-secondary/20",
|
||||
sidebarGlow: "bg-bg-warning/20",
|
||||
},
|
||||
[TRIAL_URGENCY.CRITICAL]: {
|
||||
sidebarBorder: "border-border-error",
|
||||
sidebarTint: "bg-bg-fail-secondary/40",
|
||||
sidebarGlow: "bg-bg-fail/20",
|
||||
},
|
||||
};
|
||||
|
||||
const getTrialUrgency = (props: TrialSidebarBannerProps): TrialUrgency => {
|
||||
if (props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED) {
|
||||
return TRIAL_URGENCY.CRITICAL;
|
||||
}
|
||||
if (
|
||||
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS &&
|
||||
props.remaining > 0
|
||||
) {
|
||||
return TRIAL_URGENCY.HEALTHY;
|
||||
}
|
||||
if (props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED) {
|
||||
return TRIAL_URGENCY.HEALTHY;
|
||||
}
|
||||
if (props.remaining <= 1) return TRIAL_URGENCY.CRITICAL;
|
||||
if (props.remaining <= 5) return TRIAL_URGENCY.WARNING;
|
||||
return TRIAL_URGENCY.HEALTHY;
|
||||
};
|
||||
|
||||
const formatRemaining = (remaining: number, unit: TrialSidebarBannerUnit) => {
|
||||
// The API sends a cap and a usage counter, so callers subtract and can go negative.
|
||||
const left = Math.max(0, remaining);
|
||||
|
||||
return `${left} ${unit}${left === 1 ? "" : "s"} left`;
|
||||
};
|
||||
|
||||
const TILT_SPRING = { stiffness: 260, damping: 26, mass: 0.6 } as const;
|
||||
const TILT_RESTING_POINTER = 0.5;
|
||||
const TILT_RANGE_X = 1.5;
|
||||
const TILT_RANGE_Y = 2;
|
||||
const TILT_LIFT = -2;
|
||||
|
||||
export const TrialSidebarBanner = (props: TrialSidebarBannerProps) => {
|
||||
const isExpired = props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.EXPIRED;
|
||||
const isScanBased =
|
||||
props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_SCANS;
|
||||
// The backend keeps a scan-capped trial `active` once its quota is spent.
|
||||
const isExhausted = isScanBased && props.remaining <= 0;
|
||||
const urgency = getTrialUrgency(props);
|
||||
const urgencyStyles = TRIAL_URGENCY_STYLES[urgency];
|
||||
const copy = TRIAL_SIDEBAR_BANNER_COPY[props.variant];
|
||||
const heading = isExpired
|
||||
? "Subscription required"
|
||||
: props.variant === TRIAL_SIDEBAR_BANNER_VARIANT.ACTIVE_UNLIMITED
|
||||
? "Trial active"
|
||||
: formatRemaining(
|
||||
props.remaining,
|
||||
TRIAL_SIDEBAR_BANNER_METER[props.variant],
|
||||
);
|
||||
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
// Normalised pointer position (0..1) over the card.
|
||||
const pointerX = useMotionValue(TILT_RESTING_POINTER);
|
||||
const pointerY = useMotionValue(TILT_RESTING_POINTER);
|
||||
const hover = useMotionValue(0);
|
||||
|
||||
const rotateX = useSpring(
|
||||
useTransform(pointerY, [0, 1], [TILT_RANGE_X, -TILT_RANGE_X]),
|
||||
TILT_SPRING,
|
||||
);
|
||||
const rotateY = useSpring(
|
||||
useTransform(pointerX, [0, 1], [-TILT_RANGE_Y, TILT_RANGE_Y]),
|
||||
TILT_SPRING,
|
||||
);
|
||||
const lift = useSpring(
|
||||
useTransform(hover, [0, 1], [0, TILT_LIFT]),
|
||||
TILT_SPRING,
|
||||
);
|
||||
const glowLeft = useTransform(pointerX, (value) => `${value * 100}%`);
|
||||
const glowTop = useTransform(pointerY, (value) => `${value * 100}%`);
|
||||
|
||||
const resetMotion = () => {
|
||||
pointerX.set(TILT_RESTING_POINTER);
|
||||
pointerY.set(TILT_RESTING_POINTER);
|
||||
hover.set(0);
|
||||
};
|
||||
|
||||
const followPointer = (event: PointerEvent<HTMLAnchorElement>) => {
|
||||
if (prefersReducedMotion || event.pointerType === "touch") return;
|
||||
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
|
||||
pointerX.set((event.clientX - bounds.left) / bounds.width);
|
||||
pointerY.set((event.clientY - bounds.top) / bounds.height);
|
||||
hover.set(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/billing"
|
||||
aria-label={copy.linkLabel}
|
||||
onClick={props.onSelect}
|
||||
onPointerMove={followPointer}
|
||||
onPointerLeave={resetMotion}
|
||||
onPointerCancel={resetMotion}
|
||||
onBlur={resetMotion}
|
||||
className="focus-visible:ring-button-primary/50 group mx-3 mb-4 block rounded-xl focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<motion.div
|
||||
className="rounded-xl"
|
||||
style={{ transformPerspective: 700, rotateX, rotateY, y: lift }}
|
||||
>
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="sm"
|
||||
data-slot="sidebar-trial"
|
||||
data-urgency={urgency}
|
||||
role="status"
|
||||
aria-label={copy.cardLabel}
|
||||
// role="status" is implicitly atomic, which re-reads the whole card
|
||||
// on every counter change.
|
||||
aria-atomic="false"
|
||||
className={cn(
|
||||
"relative gap-3 overflow-hidden transition-colors duration-200",
|
||||
urgencyStyles.sidebarBorder,
|
||||
urgencyStyles.sidebarTint,
|
||||
)}
|
||||
>
|
||||
<motion.span
|
||||
data-slot="trial-glow"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute size-32 rounded-full opacity-0 blur-3xl transition-opacity duration-300 group-hover:opacity-100 motion-reduce:hidden",
|
||||
urgencyStyles.sidebarGlow,
|
||||
)}
|
||||
style={{ left: glowLeft, top: glowTop, x: "-50%", y: "-50%" }}
|
||||
/>
|
||||
<div className="relative z-10 flex min-w-0 items-start gap-2.5">
|
||||
<span className="border-border-neutral-tertiary bg-bg-neutral-secondary flex size-9 shrink-0 items-center justify-center rounded-md border">
|
||||
<Sparkles
|
||||
className={cn(
|
||||
"size-4",
|
||||
isExpired ? "text-text-error-primary" : "text-button-primary",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Badge
|
||||
variant={isExpired || isExhausted ? "error" : "success"}
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
>
|
||||
{copy.badge}
|
||||
</Badge>
|
||||
<strong className="text-text-neutral-primary text-lg leading-none">
|
||||
{heading}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary relative z-10 text-xs leading-4">
|
||||
{copy.body}
|
||||
</p>
|
||||
<span className="text-button-primary relative z-10 flex items-center justify-between text-xs font-semibold">
|
||||
Explore plans
|
||||
<ArrowRight
|
||||
className="size-4 transition-transform duration-200 group-hover:translate-x-0.5 motion-reduce:transform-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { navigationState, routerRefreshMock } = vi.hoisted(() => ({
|
||||
navigationState: { searchParams: new URLSearchParams() },
|
||||
routerRefreshMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: routerRefreshMock }),
|
||||
useSearchParams: () => navigationState.searchParams,
|
||||
}));
|
||||
|
||||
import { AutoRefresh, SCAN_POLL_TICK_EVENT } from "./auto-refresh";
|
||||
|
||||
describe("AutoRefresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
navigationState.searchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("dispatches a poll tick after a successful callback refresh", async () => {
|
||||
// Given
|
||||
let resolveRefresh!: () => void;
|
||||
const refreshPromise = new Promise<void>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
const onRefresh = vi.fn().mockReturnValue(refreshPromise);
|
||||
const eventListener = vi.fn();
|
||||
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
render(<AutoRefresh hasExecutingScan onRefresh={onRefresh} />);
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
// Then
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
|
||||
// When
|
||||
resolveRefresh();
|
||||
await refreshPromise;
|
||||
await Promise.resolve();
|
||||
|
||||
// Then
|
||||
expect(eventListener).toHaveBeenCalledOnce();
|
||||
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
});
|
||||
|
||||
it("dispatches a poll tick after the default router refresh", async () => {
|
||||
// Given
|
||||
const eventListener = vi.fn();
|
||||
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
render(<AutoRefresh hasExecutingScan />);
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
// Then
|
||||
expect(routerRefreshMock).toHaveBeenCalledOnce();
|
||||
expect(eventListener).toHaveBeenCalledOnce();
|
||||
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
});
|
||||
|
||||
it("does not dispatch a poll tick when an async callback rejects", async () => {
|
||||
// Given
|
||||
const onRefresh = vi.fn().mockRejectedValue(new Error("refresh failed"));
|
||||
const eventListener = vi.fn();
|
||||
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
render(<AutoRefresh hasExecutingScan onRefresh={onRefresh} />);
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
// Then
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
hasExecutingScan: false,
|
||||
searchParams: "",
|
||||
condition: "no scan executes",
|
||||
},
|
||||
{
|
||||
hasExecutingScan: true,
|
||||
searchParams: "scanId=scan-executing",
|
||||
condition: "the scan drawer is open",
|
||||
},
|
||||
])(
|
||||
"does not poll when $condition",
|
||||
async ({ hasExecutingScan, searchParams }) => {
|
||||
// Given
|
||||
const onRefresh = vi.fn();
|
||||
const eventListener = vi.fn();
|
||||
navigationState.searchParams = new URLSearchParams(searchParams);
|
||||
window.addEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
render(
|
||||
<AutoRefresh
|
||||
hasExecutingScan={hasExecutingScan}
|
||||
onRefresh={onRefresh}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
// Then
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
window.removeEventListener(SCAN_POLL_TICK_EVENT, eventListener);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,13 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Signals that a poll cycle ran, not that fresh data landed: the default branch
|
||||
* calls the fire-and-forget `router.refresh()`, which reports neither.
|
||||
* Listeners must read authoritative state themselves.
|
||||
*/
|
||||
export const SCAN_POLL_TICK_EVENT = "prowler:scan-poll-tick";
|
||||
|
||||
interface AutoRefreshProps {
|
||||
hasExecutingScan: boolean;
|
||||
/** Optional callback for client-side refresh (used when data is managed in local state) */
|
||||
@@ -21,13 +28,24 @@ export function AutoRefresh({ hasExecutingScan, onRefresh }: AutoRefreshProps) {
|
||||
if (scanId) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (onRefresh) {
|
||||
// Use custom refresh callback for client-side state management
|
||||
onRefresh();
|
||||
} else {
|
||||
// Default: trigger server-side refresh
|
||||
router.refresh();
|
||||
}
|
||||
const refresh = async () => {
|
||||
try {
|
||||
if (onRefresh) {
|
||||
// Use custom refresh callback for client-side state management
|
||||
await onRefresh();
|
||||
} else {
|
||||
// Default: trigger server-side refresh
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Scan auto-refresh failed:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(SCAN_POLL_TICK_EVENT));
|
||||
};
|
||||
|
||||
void refresh();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
|
||||
@@ -595,9 +595,9 @@
|
||||
"section": "dependencies",
|
||||
"name": "sharp",
|
||||
"from": "0.33.5",
|
||||
"to": "0.33.5",
|
||||
"to": "0.35.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2025-10-22T12:36:37.962Z"
|
||||
"generatedAt": "2026-08-11T11:35:35.609Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
|
||||
Reference in New Issue
Block a user