mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(ui): refine scans tabs and provider launch flow (#11407)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user