fix(ui): refine scans tabs and provider launch flow (#11407)

This commit is contained in:
Alejandro Bailo
2026-06-01 12:34:11 +02:00
committed by GitHub
parent 67b26072f8
commit e05519ff9f
12 changed files with 203 additions and 41 deletions
@@ -3,6 +3,7 @@ import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import { LaunchStep } from "./launch-step";
@@ -81,5 +82,9 @@ describe("LaunchStep", () => {
title: "Scan Launched",
}),
);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
});
@@ -15,6 +15,7 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
@@ -81,7 +82,7 @@ export function LaunchStep({
: "Single scan launched successfully.",
action: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
</ToastAction>
),
});
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SCAN_JOBS_TAB } from "@/types";
import { ScansFilterBar } from "./scans-filter-bar";
vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
}));
vi.mock("@/components/shadcn", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <div data-value={value}>{children}</div>,
SelectTrigger: ({ children, ...props }: React.ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
SelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
}));
const defaultProps = {
providers: [],
scheduleType: "all",
scanStatus: "all",
showStatusFilter: false,
onScheduleTypeChange: vi.fn(),
onScanStatusChange: vi.fn(),
};
describe("ScansFilterBar", () => {
it("hides the type filter on the scheduled tab", () => {
// Given
render(
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.SCHEDULED} />,
);
// Then
expect(
screen.queryByRole("button", { name: /all types/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Provider account selectors")).toBeInTheDocument();
});
it("shows the type filter outside the scheduled tab", () => {
// Given
render(
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.COMPLETED} />,
);
// Then
expect(screen.getByRole("button", { name: /all types/i })).toBeVisible();
});
});
+16 -13
View File
@@ -8,7 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import type { ScanJobsTab } from "@/types";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
@@ -40,6 +40,7 @@ export function ScansFilterBar({
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment);
const statusFilterOptions = getScanStatusFilterOptions(activeTab);
const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED;
return (
<>
@@ -52,18 +53,20 @@ export function ScansFilterBar({
accountSelectorClassName={filterItemClass}
/>
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showScheduleTypeFilter && (
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{showStatusFilter && (
<Select value={scanStatus} onValueChange={onScanStatusChange}>
@@ -332,4 +332,22 @@ describe("ScansPageShell", () => {
expect(calledUrl).toContain("tab=active");
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
});
it("clears type filter when switching to scheduled scans", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /scheduled/i }));
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=scheduled");
expect(calledUrl).not.toContain("filter%5Btrigger%5D");
});
});
@@ -1,9 +1,22 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
@@ -14,6 +27,11 @@ vi.mock("./no-providers-connected", () => ({
}));
describe("ScansProvidersEmptyState", () => {
afterEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
});
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
@@ -28,6 +46,25 @@ describe("ScansProvidersEmptyState", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("clears the launch scan URL intent before opening the provider wizard", async () => {
// Given
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
scroll: false,
});
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("shows the no connected providers message", () => {
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
@@ -1,8 +1,10 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
@@ -14,12 +16,28 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const openProviderWizard = () => {
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
}
setIsProviderWizardOpen(true);
};
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
<NoProvidersAdded onOpenWizard={openProviderWizard} />
) : (
<NoProvidersConnected />
)}
+12
View File
@@ -94,6 +94,18 @@ describe("scans.utils", () => {
});
});
it("excludes trigger filters from scheduled scans", () => {
expect(
getScanJobsUserFilters({
tab: "scheduled",
"filter[trigger]": "manual",
"filter[provider_uid]": "123456789012",
}),
).toEqual({
"filter[provider_uid]": "123456789012",
});
});
it("formats scan labels and durations for table display", () => {
expect(getScanAlias(makeScan(""))).toBe("-");
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
+6
View File
@@ -77,8 +77,14 @@ function isSearchParamValue(value: unknown): value is string | string[] {
export function getScanJobsUserFilters(
searchParams: SearchParamsProps,
): Record<string, string | string[]> {
const tab = getScanJobsTab(searchParams.tab);
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
(filters, [key, value]) => {
if (tab === SCAN_JOBS_TAB.SCHEDULED && key === "filter[trigger]") {
return filters;
}
if (
key.startsWith("filter[") &&
!isScanStateFilterKey(key) &&
@@ -136,8 +136,6 @@ describe("getScanJobsColumns", () => {
expect(getColumnIds(SCAN_JOBS_TAB.SCHEDULED)).toEqual([
"account",
"scanInfo",
"scanSchedule",
"nextScan",
"actions",
]);
});
@@ -163,4 +161,11 @@ describe("getScanJobsColumns", () => {
expect(screen.getByText("1 min 13 sec")).toBeInTheDocument();
});
it("labels the completed scan schedule column as Type", () => {
renderHeader(SCAN_JOBS_TAB.COMPLETED, "scanSchedule");
expect(screen.getByText("Type")).toBeInTheDocument();
expect(screen.queryByText("Schedule")).not.toBeInTheDocument();
});
});
@@ -39,14 +39,14 @@ const scanInfoColumn: ColumnDef<ScanProps> = {
cell: ({ row }) => <ScanInfoCell scan={row.original} />,
};
const scanScheduleColumn: ColumnDef<ScanProps> = {
const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
id: "scanSchedule",
accessorFn: (row) => row.attributes.trigger,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" param="trigger" />
<DataTableColumnHeader column={column} title={title} param="trigger" />
),
cell: ({ row }) => <ScheduleCell scan={row.original} />,
};
});
const resourcesColumn: ColumnDef<ScanProps> = {
id: "resources",
@@ -86,7 +86,7 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
cell: ({ row }) => <ProgressCell scan={row.original} />,
enableSorting: false,
},
scanScheduleColumn,
getScanScheduleColumn("Schedule"),
{
id: "launched",
header: ({ column }) => (
@@ -118,7 +118,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
cell: ({ row }) => <StatusBadge status={row.original.attributes.state} />,
enableSorting: false,
},
scanScheduleColumn,
getScanScheduleColumn("Type"),
{
id: "scanDate",
accessorFn: (row) => row.attributes.completed_at,
@@ -139,7 +139,6 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scanScheduleColumn,
/*
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
* {
@@ -153,20 +152,6 @@ const scheduledColumns = (): ColumnDef<ScanProps>[] => [
* enableSorting: false,
* },
*/
{
id: "nextScan",
accessorFn: (row) => row.attributes.next_scan_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Next Run"
param="next_scan_at"
/>
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.next_scan_at} />
),
},
actionsColumn,
];
+9 -3
View File
@@ -46,13 +46,19 @@ export function useScansFilters(): UseScansFiltersReturn {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const setTab = (tab: string) =>
updateParams({
const setTab = (tab: string) => {
const isScheduledTab = tab === SCAN_JOBS_TAB.SCHEDULED;
const updates: Record<string, string | null> = {
tab,
sort: null,
"filter[state]": null,
"filter[state__in]": null,
});
};
if (isScheduledTab) updates["filter[trigger]"] = null;
updateParams(updates);
};
const setScheduleType = (value: string) =>
updateParams({ "filter[trigger]": value });