mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c0425729 | |||
| 5828cce644 | |||
| 87bd2e78a1 | |||
| ccae4afe68 | |||
| 0e2bb99f02 | |||
| 8fb59682d5 | |||
| 799f062ee0 | |||
| 51945f5cc5 | |||
| b93e3f9d04 | |||
| ef4d05a782 | |||
| 7185e539c8 | |||
| 74251350bc |
@@ -215,7 +215,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17@sha256:0027bef26712baaee437a4ea48fdf3d2d2e2bc5f0d81615374408ca320f3c7e3
|
||||
image: postgres:17@sha256:2cd82735a36356842d5eb1ef80db3ae8f1154172f0f653db48fde079b2a0b7f7
|
||||
env:
|
||||
POSTGRES_HOST: ${{ env.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ env.POSTGRES_PORT }}
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
image: valkey/valkey:7-alpine3.19
|
||||
env:
|
||||
VALKEY_HOST: ${{ env.VALKEY_HOST }}
|
||||
VALKEY_PORT: ${{ env.VALKEY_PORT }}
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -299,7 +299,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12.13'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@c70ad64367908075211b10dcd2ab9fad4bfa1816 # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.29.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- New Scan Jobs view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Dark mode: pure-black canvas, pure-white primary text, and brighter border / input tokens for clearer separation between cards, tables, and inputs [(#11073)](https://github.com/prowler-cloud/prowler/pull/11073)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
|
||||
@@ -140,6 +141,7 @@ export const scanOnDemand = async (formData: FormData) => {
|
||||
const result = await handleApiResponse(response, "/scans");
|
||||
if (result?.data?.id) {
|
||||
addScanOperation("start", result.data.id);
|
||||
revalidatePath("/scans");
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./poll";
|
||||
export * from "./task.adapter";
|
||||
export * from "./tasks";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getScanErrorDetails } from "./task.adapter";
|
||||
|
||||
describe("getScanErrorDetails", () => {
|
||||
it("returns null when response is not a record", () => {
|
||||
expect(getScanErrorDetails(null)).toBeNull();
|
||||
expect(getScanErrorDetails("oops")).toBeNull();
|
||||
expect(getScanErrorDetails(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when data is missing", () => {
|
||||
expect(getScanErrorDetails({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when attributes.result is missing", () => {
|
||||
expect(getScanErrorDetails({ data: { attributes: {} } })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when result has no recognizable fields", () => {
|
||||
expect(
|
||||
getScanErrorDetails({ data: { attributes: { result: {} } } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses an error with only an exc_type", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: { attributes: { result: { exc_type: "BotoCoreError" } } },
|
||||
});
|
||||
|
||||
expect(details).toEqual({
|
||||
type: "BotoCoreError",
|
||||
messages: ["-"],
|
||||
module: undefined,
|
||||
copyValue: "ErrorType: BotoCoreError\nError: -",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple exc_message entries in copyValue", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: "ScanError",
|
||||
exc_message: ["Failed to connect", "Retry exhausted"],
|
||||
exc_module: "scan.runner",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(details).toEqual({
|
||||
type: "ScanError",
|
||||
messages: ["Failed to connect", "Retry exhausted"],
|
||||
module: "scan.runner",
|
||||
copyValue:
|
||||
"ErrorType: ScanError\nError: Failed to connect\nRetry exhausted",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters non-string entries out of exc_message", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: "ScanError",
|
||||
exc_message: ["valid", 42, null, " ", " trimmed "],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(details?.messages).toEqual(["valid", "trimmed"]);
|
||||
});
|
||||
|
||||
it("returns null when only whitespace fields are present", () => {
|
||||
const details = getScanErrorDetails({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: " ",
|
||||
exc_message: [""],
|
||||
exc_module: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(details).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface ScanErrorDetails {
|
||||
type: string;
|
||||
messages: string[];
|
||||
module?: string;
|
||||
copyValue: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() !== ""
|
||||
? value.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== "");
|
||||
}
|
||||
|
||||
export function buildScanErrorDetails(
|
||||
result: unknown,
|
||||
): ScanErrorDetails | null {
|
||||
if (!isRecord(result)) return null;
|
||||
|
||||
const type = getString(result.exc_type) ?? "-";
|
||||
const messages = getStringList(result.exc_message);
|
||||
const module = getString(result.exc_module);
|
||||
|
||||
if (type === "-" && messages.length === 0 && !module) return null;
|
||||
|
||||
const errorText = messages.length > 0 ? messages.join("\n") : "-";
|
||||
|
||||
return {
|
||||
type,
|
||||
messages: messages.length > 0 ? messages : ["-"],
|
||||
module,
|
||||
copyValue: `ErrorType: ${type}\nError: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getScanErrorDetails(
|
||||
taskResponse: unknown,
|
||||
): ScanErrorDetails | null {
|
||||
if (!isRecord(taskResponse) || !isRecord(taskResponse.data)) return null;
|
||||
if (!isRecord(taskResponse.data.attributes)) return null;
|
||||
|
||||
return buildScanErrorDetails(taskResponse.data.attributes.result);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AccountsSelector } from "./accounts-selector";
|
||||
|
||||
const multiSelectContentSpy = vi.fn();
|
||||
const multiSelectSpy = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
@@ -35,9 +37,25 @@ vi.mock("@/components/icons/providers-badge", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
MultiSelect: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelect: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
multiSelectSpy({ open });
|
||||
return (
|
||||
<div data-open={String(open)}>
|
||||
<button type="button" onClick={() => onOpenChange?.(true)}>
|
||||
Open selector
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
@@ -56,16 +74,26 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
},
|
||||
MultiSelectItem: ({
|
||||
children,
|
||||
disabled,
|
||||
value,
|
||||
keywords,
|
||||
onSelect,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
keywords?: string[];
|
||||
onSelect?: (value: string) => void;
|
||||
}) => (
|
||||
<div data-value={value} data-keywords={keywords?.join("|")}>
|
||||
<button
|
||||
type="button"
|
||||
data-value={value}
|
||||
data-keywords={keywords?.join("|")}
|
||||
data-disabled={disabled ? "true" : "false"}
|
||||
onClick={() => onSelect?.(value)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -114,8 +142,8 @@ describe("AccountsSelector", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
placeholder: "Search Providers...",
|
||||
emptyMessage: "No Providers found.",
|
||||
});
|
||||
expect(screen.getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
@@ -140,12 +168,56 @@ describe("AccountsSelector", () => {
|
||||
).toHaveAttribute("data-keywords", expect.stringContaining("123456789012"));
|
||||
});
|
||||
|
||||
it("can use provider UID values for pages whose API filters by provider_uid__in", () => {
|
||||
render(
|
||||
<AccountsSelector providers={providers} filterKey="provider_uid__in" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute("data-value", "123456789012");
|
||||
});
|
||||
|
||||
it("disables select all when every account is already shown", () => {
|
||||
render(<AccountsSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all accounts/i }),
|
||||
screen.getByRole("option", { name: /select all Providers/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks configured account values as disabled", () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
disabledValues={["provider-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Production AWS").closest("[data-value]"),
|
||||
).toHaveAttribute("data-disabled", "true");
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can close the dropdown after selecting a launch-scan provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
closeOnSelect
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /open selector/i }));
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: true });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /production aws/i }));
|
||||
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -36,6 +37,14 @@ import {
|
||||
type ProviderType,
|
||||
} from "@/types/providers";
|
||||
|
||||
const ACCOUNT_SELECTOR_FILTER = {
|
||||
PROVIDER_ID: "provider_id__in",
|
||||
PROVIDER_UID: "provider_uid__in",
|
||||
} as const;
|
||||
|
||||
type AccountSelectorFilter =
|
||||
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
azure: <AzureProviderBadge width={18} height={18} />,
|
||||
@@ -59,12 +68,10 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
search?: MultiSelectSearchProp;
|
||||
/**
|
||||
* Currently selected provider types (from the pending ProviderTypeSelector state).
|
||||
* Used only for contextual description/empty-state messaging — does NOT narrow
|
||||
* the list of available accounts, which remains independent of provider selection.
|
||||
*/
|
||||
selectedProviderTypes?: string[];
|
||||
filterKey?: AccountSelectorFilter;
|
||||
id?: string;
|
||||
disabledValues?: string[];
|
||||
closeOnSelect?: boolean;
|
||||
}
|
||||
|
||||
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
|
||||
@@ -98,74 +105,79 @@ export function AccountsSelector({
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
selectedProviderTypes,
|
||||
filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID,
|
||||
id = "accounts-selector",
|
||||
disabledValues = [],
|
||||
search = {
|
||||
placeholder: "Search accounts...",
|
||||
emptyMessage: "No accounts found.",
|
||||
placeholder: "Search Providers...",
|
||||
emptyMessage: "No Providers found.",
|
||||
},
|
||||
closeOnSelect = false,
|
||||
}: AccountsSelectorProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
|
||||
const filterKey = "filter[provider_id__in]";
|
||||
const current = searchParams.get(filterKey) || "";
|
||||
const labelId = `${id}-label`;
|
||||
const urlFilterKey = `filter[${filterKey}]`;
|
||||
const current = searchParams.get(urlFilterKey) || "";
|
||||
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
|
||||
const visibleProviders = providers;
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
const getProviderValue = (provider: ProviderProps) =>
|
||||
filterKey === ACCOUNT_SELECTOR_FILTER.PROVIDER_UID
|
||||
? provider.attributes.uid
|
||||
: provider.id;
|
||||
const disabledValuesSet = new Set(disabledValues);
|
||||
|
||||
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
|
||||
const selectedIds = (onBatchChange ? selectedValues : urlSelectedIds).filter(
|
||||
(id) => !disabledValuesSet.has(id),
|
||||
);
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
const enabledIds = ids.filter((id) => !disabledValuesSet.has(id));
|
||||
|
||||
if (onBatchChange) {
|
||||
onBatchChange("provider_id__in", ids);
|
||||
onBatchChange(filterKey, enabledIds);
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
return;
|
||||
}
|
||||
navigateWithParams((params) => {
|
||||
params.delete(filterKey);
|
||||
params.delete(urlFilterKey);
|
||||
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
if (enabledIds.length > 0) {
|
||||
params.set(urlFilterKey, enabledIds.join(","));
|
||||
}
|
||||
});
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedIds.length === 0) return null;
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => pr.id === selectedIds[0]);
|
||||
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
return <span className="truncate">{name}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="truncate">{selectedIds.length} accounts selected</span>
|
||||
<span className="truncate">{selectedIds.length} Providers selected</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Build a contextual description based on currently selected provider types.
|
||||
// This is purely for user guidance (aria label + empty state) and does NOT
|
||||
// narrow the list of available accounts — all providers remain selectable.
|
||||
const filterDescription =
|
||||
selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
: "All connected provider accounts";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label
|
||||
htmlFor="accounts-selector"
|
||||
className="sr-only"
|
||||
id="accounts-label"
|
||||
>
|
||||
Filter by provider account. {filterDescription}. Select one or more
|
||||
accounts to view findings.
|
||||
<label htmlFor={id} className="sr-only" id={labelId}>
|
||||
Filter by Provider. Select one or more Providers to filter results.
|
||||
</label>
|
||||
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
|
||||
<MultiSelectTrigger
|
||||
id="accounts-selector"
|
||||
aria-labelledby="accounts-label"
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All accounts" />}
|
||||
<MultiSelect
|
||||
values={selectedIds}
|
||||
onValuesChange={handleMultiValueChange}
|
||||
open={closeOnSelect ? selectorOpen : undefined}
|
||||
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
|
||||
>
|
||||
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={search}>
|
||||
{visibleProviders.length > 0 ? (
|
||||
@@ -174,7 +186,7 @@ export function AccountsSelector({
|
||||
role="option"
|
||||
aria-selected={selectedIds.length === 0}
|
||||
aria-disabled={selectedIds.length === 0}
|
||||
aria-label="Select all accounts (clears current selection to show all)"
|
||||
aria-label="Select all Providers (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
@@ -192,7 +204,8 @@ export function AccountsSelector({
|
||||
{selectedIds.length === 0 ? "All selected" : "Select All"}
|
||||
</div>
|
||||
{visibleProviders.map((p) => {
|
||||
const id = p.id;
|
||||
const value = getProviderValue(p);
|
||||
const isDisabled = disabledValuesSet.has(value);
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
@@ -205,23 +218,28 @@ export function AccountsSelector({
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
key={p.id}
|
||||
value={value}
|
||||
badgeLabel={displayName}
|
||||
keywords={searchKeywords}
|
||||
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
|
||||
disabled={isDisabled}
|
||||
aria-label={`${displayName} Provider (${providerType.toUpperCase()})`}
|
||||
onSelect={() => {
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span className="truncate">{displayName}</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
|
||||
</span>
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{selectedProviderTypes && selectedProviderTypes.length > 0
|
||||
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
|
||||
: "No connected accounts available"}
|
||||
No connected Providers available
|
||||
</div>
|
||||
)}
|
||||
</MultiSelectContent>
|
||||
|
||||
@@ -114,8 +114,8 @@ describe("ProviderTypeSelector", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(multiSelectContentSpy).toHaveBeenCalledWith({
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
placeholder: "Search Provider Types...",
|
||||
emptyMessage: "No Provider Types found.",
|
||||
});
|
||||
expect(screen.getByText("Amazon Web Services")).toBeInTheDocument();
|
||||
});
|
||||
@@ -141,7 +141,7 @@ describe("ProviderTypeSelector", () => {
|
||||
render(<ProviderTypeSelector providers={providers} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("option", { name: /select all providers/i }),
|
||||
screen.getByRole("option", { name: /select all Provider Types/i }),
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -210,8 +210,8 @@ export const ProviderTypeSelector = ({
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
search = {
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
placeholder: "Search Provider Types...",
|
||||
emptyMessage: "No Provider Types found.",
|
||||
},
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -274,7 +274,7 @@ export const ProviderTypeSelector = ({
|
||||
}
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} providers selected
|
||||
{selectedTypes.length} Provider Types selected
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -286,7 +286,8 @@ export const ProviderTypeSelector = ({
|
||||
className="sr-only"
|
||||
id="provider-type-label"
|
||||
>
|
||||
Filter by provider type. Select one or more providers to view findings.
|
||||
Filter by Provider Type. Select one or more Provider Types to view
|
||||
findings.
|
||||
</label>
|
||||
<MultiSelect
|
||||
values={selectedTypes}
|
||||
@@ -296,7 +297,9 @@ export const ProviderTypeSelector = ({
|
||||
id="provider-type-selector"
|
||||
aria-labelledby="provider-type-label"
|
||||
>
|
||||
{selectedLabel() || <MultiSelectValue placeholder="All providers" />}
|
||||
{selectedLabel() || (
|
||||
<MultiSelectValue placeholder="All Provider Types" />
|
||||
)}
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent search={search}>
|
||||
{availableTypes.length > 0 ? (
|
||||
@@ -305,7 +308,7 @@ export const ProviderTypeSelector = ({
|
||||
role="option"
|
||||
aria-selected={selectedTypes.length === 0}
|
||||
aria-disabled={selectedTypes.length === 0}
|
||||
aria-label="Select all providers (clears current selection to show all)"
|
||||
aria-label="Select all Provider Types (clears current selection to show all)"
|
||||
tabIndex={0}
|
||||
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
|
||||
onClick={() => {
|
||||
@@ -328,7 +331,7 @@ export const ProviderTypeSelector = ({
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
keywords={[providerType, PROVIDER_DATA[providerType].label]}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
@@ -337,7 +340,7 @@ export const ProviderTypeSelector = ({
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
No connected providers available
|
||||
No connected Provider Types available
|
||||
</div>
|
||||
)}
|
||||
</MultiSelectContent>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import {
|
||||
getFindingsBySeverity,
|
||||
SeverityByProviderType,
|
||||
} from "@/actions/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function RiskPipelineViewSSR({
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch providers list to know account types
|
||||
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||
const providersListResponse = await getAllProviders();
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Build severityByProviderType based on filters
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
adaptToRiskPlotData,
|
||||
getProvidersRiskData,
|
||||
} from "@/actions/overview/risk-plot";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { pickFilterParams } from "../../_lib/filter-params";
|
||||
@@ -21,7 +21,7 @@ export async function RiskPlotSSR({
|
||||
const providerIdFilter = filters["filter[provider_id__in]"];
|
||||
|
||||
// Fetch all providers
|
||||
const providersListResponse = await getProviders({ pageSize: 200 });
|
||||
const providersListResponse = await getAllProviders();
|
||||
const allProviders = providersListResponse?.data || [];
|
||||
|
||||
// Filter providers based on search params
|
||||
|
||||
@@ -518,7 +518,7 @@ describe("AlertFormModal", () => {
|
||||
expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3");
|
||||
expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5");
|
||||
expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible();
|
||||
expect(screen.getByText("All accounts")).toBeVisible();
|
||||
expect(screen.getByText("All Providers")).toBeVisible();
|
||||
expect(within(filterControls).getByText("All Delta")).toBeVisible();
|
||||
expect(within(filterControls).getByText("All Resource Type")).toBeVisible();
|
||||
expect(
|
||||
@@ -547,7 +547,9 @@ describe("AlertFormModal", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByLabelText(/provider type/i));
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
|
||||
);
|
||||
const providerOptions = await screen.findAllByText("Google Cloud Platform");
|
||||
const visibleProviderOption = providerOptions.at(-1);
|
||||
expect(visibleProviderOption).toBeDefined();
|
||||
@@ -597,7 +599,9 @@ describe("AlertFormModal", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByLabelText(/provider type/i));
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /filter by Provider Type/i }),
|
||||
);
|
||||
const providerOptions = await screen.findAllByText("Google Cloud Platform");
|
||||
const visibleProviderOption = providerOptions.at(-1);
|
||||
expect(visibleProviderOption).toBeDefined();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getLatestMetadataInfo } from "@/actions/findings";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions";
|
||||
import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager";
|
||||
@@ -58,7 +58,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) {
|
||||
const [result, providersData, scansData, metadataInfoData, editResult] =
|
||||
await Promise.all([
|
||||
listAlerts(toAlertsSearchParams(resolvedSearchParams)),
|
||||
getProviders({ pageSize: 50 }),
|
||||
getAllProviders(),
|
||||
getScans({ pageSize: 50 }),
|
||||
getLatestMetadataInfo({}),
|
||||
editAlertId ? getAlert(editAlertId) : Promise.resolve(null),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getLatestFindingGroups,
|
||||
} from "@/actions/finding-groups";
|
||||
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScan, getScans } from "@/actions/scans";
|
||||
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
|
||||
import { FindingsFilters } from "@/components/findings/findings-filters";
|
||||
@@ -37,7 +37,7 @@ export default async function Findings({
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
const [providersData, scansData] = await Promise.all([
|
||||
getProviders({ pageSize: 50 }),
|
||||
getAllProviders(),
|
||||
getScans({ pageSize: 50 }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
@@ -47,7 +47,7 @@ export default async function S3Integrations({
|
||||
|
||||
const [integrations, providers] = await Promise.all([
|
||||
getIntegrations(urlSearchParams),
|
||||
getProviders({ pageSize: 100 }),
|
||||
getAllProviders(),
|
||||
]);
|
||||
|
||||
const s3Integrations = integrations?.data || [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { getIntegrations } from "@/actions/integrations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
@@ -45,7 +45,7 @@ export default async function SecurityHubIntegrations({
|
||||
|
||||
const [integrations, providers] = await Promise.all([
|
||||
getIntegrations(urlSearchParams),
|
||||
getProviders({ pageSize: 100 }),
|
||||
getAllProviders(),
|
||||
]);
|
||||
|
||||
const securityHubIntegrations = integrations?.data || [];
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { AccountsSelector } from "./_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "./_overview/_components/provider-type-selector";
|
||||
import {
|
||||
AttackSurfaceSkeleton,
|
||||
AttackSurfaceSSR,
|
||||
@@ -39,13 +38,12 @@ export default async function Home({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const providersData = await getProviders({ page: 1, pageSize: 200 });
|
||||
const providersData = await getAllProviders();
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderTypeSelector providers={providersData?.data ?? []} />
|
||||
<AccountsSelector providers={providersData?.data ?? []} />
|
||||
<ProviderAccountSelectors providers={providersData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
getProviderGroupInfoById,
|
||||
getProviderGroups,
|
||||
} from "@/actions/manage-groups/manage-groups";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getRoles } from "@/actions/roles";
|
||||
import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms";
|
||||
import { ColumnGroups } from "@/components/manage-groups/table";
|
||||
@@ -19,7 +19,7 @@ export const ProviderGroupsContent = async ({
|
||||
// Fetch all data in parallel
|
||||
const [providersResponse, rolesResponse, providerGroupsData, editGroupData] =
|
||||
await Promise.all([
|
||||
getProviders({ pageSize: 50 }),
|
||||
getAllProviders(),
|
||||
getRoles({}),
|
||||
fetchGroupsTableData(searchParams),
|
||||
providerGroupId && !Array.isArray(providerGroupId)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const providersActionsMock = vi.hoisted(() => ({
|
||||
getProviders: vi.fn(),
|
||||
getAllProviders: vi.fn(),
|
||||
}));
|
||||
|
||||
const organizationsActionsMock = vi.hoisted(() => ({
|
||||
@@ -619,6 +620,7 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
it("does not call organizations endpoints in OSS", async () => {
|
||||
// Given
|
||||
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
scansActionsMock.getScans.mockResolvedValue({ data: [] });
|
||||
|
||||
// When
|
||||
@@ -662,6 +664,7 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
},
|
||||
})),
|
||||
});
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -724,6 +727,7 @@ describe("loadProvidersAccountsViewData", () => {
|
||||
it("falls back to empty cloud grouping data when organizations endpoints fail", async () => {
|
||||
// Given
|
||||
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
|
||||
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
|
||||
organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({
|
||||
data: [],
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
listOrganizationsSafe,
|
||||
listOrganizationUnitsSafe,
|
||||
} from "@/actions/organizations/organizations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders, getProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import {
|
||||
extractFiltersAndQuery,
|
||||
@@ -467,7 +467,7 @@ export async function loadProvidersAccountsViewData({
|
||||
),
|
||||
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
|
||||
// TODO: Replace with a dedicated lightweight endpoint when available.
|
||||
resolveActionResult(getProviders({ pageSize: 500 })),
|
||||
resolveActionResult(getAllProviders()),
|
||||
// Fetch active scheduled scans to determine daily schedule per provider
|
||||
resolveActionResult(
|
||||
getScans({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
getLatestMetadataInfo,
|
||||
getLatestResources,
|
||||
@@ -44,7 +44,7 @@ export default async function Resources({
|
||||
filters: outputFilters,
|
||||
sort: encodedSort,
|
||||
}),
|
||||
getProviders({ pageSize: 50 }),
|
||||
getAllProviders(),
|
||||
initialResourceId
|
||||
? getResourceById(initialResourceId, { include: ["provider"] })
|
||||
: Promise.resolve(undefined),
|
||||
|
||||
+90
-108
@@ -3,23 +3,54 @@ import { Suspense } from "react";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { auth } from "@/auth.config";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers";
|
||||
import { ScansFilters } from "@/components/scans";
|
||||
import { ScansLaunchSection } from "@/components/scans/scans-launch-section";
|
||||
import {
|
||||
getScanJobsTab,
|
||||
getScanJobsTabFilters,
|
||||
getScanJobsUserFilters,
|
||||
} from "@/components/scans/scans.utils";
|
||||
import { ScansPageShell } from "@/components/scans/scans-page-shell";
|
||||
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ScansTableWithPolling } from "@/components/scans/table/scans";
|
||||
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
} from "@/lib/provider-helpers";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
ProviderProps,
|
||||
SCAN_JOBS_TAB,
|
||||
ScanProps,
|
||||
SearchParamsProps,
|
||||
} from "@/types";
|
||||
|
||||
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
|
||||
|
||||
const getFilterSearchQuery = (
|
||||
filters: Record<string, string | string[]>,
|
||||
): string => {
|
||||
const value = filters["filter[search]"];
|
||||
if (Array.isArray(value)) return value[0] ?? "";
|
||||
|
||||
return value ?? "";
|
||||
};
|
||||
|
||||
const getActiveScanCount = async (
|
||||
searchParams: SearchParamsProps,
|
||||
): Promise<number> => {
|
||||
const userFilters = getScanJobsUserFilters(searchParams);
|
||||
const filters = {
|
||||
...userFilters,
|
||||
...getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE),
|
||||
};
|
||||
|
||||
const scansData = await getScans({
|
||||
query: getFilterSearchQuery(filters),
|
||||
page: 1,
|
||||
pageSize: ACTIVE_SCAN_COUNT_PAGE_SIZE,
|
||||
filters,
|
||||
fields: { scans: "state" },
|
||||
});
|
||||
|
||||
return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0;
|
||||
};
|
||||
|
||||
export default async function Scans({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -27,96 +58,47 @@ export default async function Scans({
|
||||
}) {
|
||||
const session = await auth();
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const filteredParams = { ...resolvedSearchParams };
|
||||
delete filteredParams.scanId;
|
||||
|
||||
const [providersData, completedScansData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getScans({
|
||||
filters: { "filter[state]": "completed" },
|
||||
pageSize: 50,
|
||||
fields: { scans: "name,completed_at,provider" },
|
||||
include: "provider",
|
||||
}),
|
||||
]);
|
||||
const providersData = await getAllProviders();
|
||||
const providers = providersData?.data ?? [];
|
||||
|
||||
const completedScans: ExpandedScanData[] = (completedScansData?.data ?? [])
|
||||
.map((scan: ScanProps) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
const providerData = completedScansData?.included?.find(
|
||||
(item: { type: string; id: string }) =>
|
||||
item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
if (!providerData) return null;
|
||||
return {
|
||||
...scan,
|
||||
providerInfo: {
|
||||
provider: providerData.attributes.provider,
|
||||
uid: providerData.attributes.uid,
|
||||
alias: providerData.attributes.alias,
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ExpandedScanData[];
|
||||
|
||||
const providerInfo =
|
||||
providersData?.data
|
||||
?.filter(
|
||||
(provider: ProviderProps) =>
|
||||
provider.attributes.connection.connected === true,
|
||||
)
|
||||
.map((provider: ProviderProps) => ({
|
||||
providerId: provider.id,
|
||||
alias: provider.attributes.alias,
|
||||
providerType: provider.attributes.provider,
|
||||
uid: provider.attributes.uid,
|
||||
connected: provider.attributes.connection.connected,
|
||||
})) || [];
|
||||
|
||||
const thereIsNoProviders =
|
||||
!providersData?.data || providersData.data.length === 0;
|
||||
|
||||
const thereIsNoProvidersConnected = Boolean(
|
||||
providersData?.data?.every(
|
||||
(provider: ProviderProps) => !provider.attributes.connection.connected,
|
||||
),
|
||||
const connectedProviders = providers.filter(
|
||||
(provider: ProviderProps) =>
|
||||
provider.attributes.connection.connected === true,
|
||||
);
|
||||
const thereIsNoProviders = providers.length === 0;
|
||||
const thereIsNoProvidersConnected =
|
||||
!thereIsNoProviders && connectedProviders.length === 0;
|
||||
|
||||
const hasManageScansPermission = Boolean(
|
||||
session?.user?.permissions?.manage_scans,
|
||||
);
|
||||
|
||||
// Extract provider UIDs and create provider details mapping for filtering
|
||||
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
|
||||
const providerDetails = providersData
|
||||
? createProviderDetailsMapping(providerUIDs, providersData)
|
||||
: [];
|
||||
const activeScanCount =
|
||||
thereIsNoProviders || thereIsNoProvidersConnected
|
||||
? 0
|
||||
: await getActiveScanCount(resolvedSearchParams);
|
||||
|
||||
return (
|
||||
<ContentLayout title="Scans" icon="lucide:timer">
|
||||
<>
|
||||
<ScansLaunchSection
|
||||
providers={providerInfo}
|
||||
<ContentLayout title="Scan Jobs" icon="lucide:timer">
|
||||
{thereIsNoProviders || thereIsNoProvidersConnected ? (
|
||||
<ScansProvidersEmptyState thereIsNoProviders={thereIsNoProviders} />
|
||||
) : (
|
||||
<ScansPageShell
|
||||
providers={providers}
|
||||
hasManageScansPermission={hasManageScansPermission}
|
||||
thereIsNoProviders={thereIsNoProviders}
|
||||
thereIsNoProvidersConnected={thereIsNoProvidersConnected}
|
||||
/>
|
||||
{!thereIsNoProviders && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ScansFilters
|
||||
providerUIDs={providerUIDs}
|
||||
providerDetails={providerDetails}
|
||||
completedScans={completedScans}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<MutedFindingsConfigButton />
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTableScans />}>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
activeScanCount={activeScanCount}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<SkeletonTableScans
|
||||
tab={getScanJobsTab(resolvedSearchParams.tab)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SSRDataTableScans searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</ScansPageShell>
|
||||
)}
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -126,21 +108,27 @@ const SSRDataTableScans = async ({
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const tab = getScanJobsTab(searchParams.tab);
|
||||
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const sort = searchParams.sort?.toString();
|
||||
|
||||
// Extract all filter parameters, excluding scanId
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(searchParams).filter(
|
||||
([key]) => key.startsWith("filter[") && key !== "scanId",
|
||||
),
|
||||
const userFilters = Object.entries(searchParams).filter(([key]) =>
|
||||
key.startsWith("filter["),
|
||||
);
|
||||
const hasUserFilters = userFilters.length > 0;
|
||||
|
||||
const filters = {
|
||||
...getScanJobsUserFilters(searchParams),
|
||||
...getScanJobsTabFilters(
|
||||
tab,
|
||||
searchParams["filter[state__in]"] ?? searchParams["filter[state]"],
|
||||
),
|
||||
};
|
||||
|
||||
// Extract query from filters
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
// Fetch scans data with provider information included
|
||||
const scansData = await getScans({
|
||||
query,
|
||||
page,
|
||||
@@ -158,19 +146,12 @@ const SSRDataTableScans = async ({
|
||||
scans?.map((scan: ScanProps) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
|
||||
if (!providerId) {
|
||||
return { ...scan, providerInfo: null };
|
||||
}
|
||||
|
||||
// Find the provider data in the included array
|
||||
const providerData = included?.find(
|
||||
(item: { type: string; id: string }) =>
|
||||
item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
|
||||
if (!providerData) {
|
||||
return { ...scan, providerInfo: null };
|
||||
}
|
||||
if (!providerData) return scan;
|
||||
|
||||
return {
|
||||
...scan,
|
||||
@@ -183,10 +164,11 @@ const SSRDataTableScans = async ({
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<ScansTableWithPolling
|
||||
initialData={expandedScansData}
|
||||
initialMeta={meta}
|
||||
searchParams={searchParams}
|
||||
<ScanJobsTable
|
||||
data={expandedScansData}
|
||||
meta={meta}
|
||||
tab={tab}
|
||||
hasFilters={hasUserFilters}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ interface ComplianceScanInfoProps {
|
||||
};
|
||||
attributes: {
|
||||
name?: string;
|
||||
completed_at: string;
|
||||
completed_at: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { ProviderAccountSelectors } from "./provider-account-selectors";
|
||||
|
||||
const { selectorProps, navigateWithParamsMock, currentSearchParams } =
|
||||
vi.hoisted(() => ({
|
||||
selectorProps: {
|
||||
providerType: undefined as
|
||||
| {
|
||||
providers: ProviderProps[];
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
selectedValues: string[];
|
||||
}
|
||||
| undefined,
|
||||
accounts: undefined as
|
||||
| {
|
||||
providers: ProviderProps[];
|
||||
filterKey?: string;
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
selectedValues: string[];
|
||||
}
|
||||
| undefined,
|
||||
},
|
||||
navigateWithParamsMock: vi.fn(),
|
||||
currentSearchParams: { value: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(currentSearchParams.value),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-url-filters", () => ({
|
||||
useUrlFilters: () => ({
|
||||
navigateWithParams: navigateWithParamsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
|
||||
ProviderTypeSelector: (props: {
|
||||
providers: ProviderProps[];
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
selectedValues: string[];
|
||||
}) => {
|
||||
selectorProps.providerType = props;
|
||||
return <div>Provider type selector</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
||||
AccountsSelector: (props: {
|
||||
providers: ProviderProps[];
|
||||
filterKey?: string;
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
selectedValues: string[];
|
||||
}) => {
|
||||
selectorProps.accounts = props;
|
||||
return <div>Accounts selector</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
const makeProvider = ({
|
||||
id,
|
||||
provider,
|
||||
uid,
|
||||
alias,
|
||||
}: {
|
||||
id: string;
|
||||
provider: ProviderProps["attributes"]["provider"];
|
||||
uid: string;
|
||||
alias: string;
|
||||
}): ProviderProps => ({
|
||||
id,
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider,
|
||||
uid,
|
||||
alias,
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: null },
|
||||
provider_groups: {
|
||||
meta: { count: 0 },
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const providers = [
|
||||
makeProvider({
|
||||
id: "aws-provider",
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production AWS",
|
||||
}),
|
||||
makeProvider({
|
||||
id: "gcp-provider",
|
||||
provider: "gcp",
|
||||
uid: "prowler-project",
|
||||
alias: "Production GCP",
|
||||
}),
|
||||
];
|
||||
|
||||
const applyLastNavigation = () => {
|
||||
const modifier = navigateWithParamsMock.mock.calls.at(-1)?.[0] as
|
||||
| ((params: URLSearchParams) => void)
|
||||
| undefined;
|
||||
const params = new URLSearchParams(currentSearchParams.value);
|
||||
|
||||
if (!modifier) throw new Error("Expected navigateWithParams to be called");
|
||||
|
||||
modifier(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
describe("ProviderAccountSelectors", () => {
|
||||
beforeEach(() => {
|
||||
currentSearchParams.value = "";
|
||||
selectorProps.providerType = undefined;
|
||||
selectorProps.accounts = undefined;
|
||||
navigateWithParamsMock.mockClear();
|
||||
});
|
||||
|
||||
it("filters account options by selected provider types in instant mode", () => {
|
||||
currentSearchParams.value = "filter%5Bprovider_type__in%5D=aws";
|
||||
|
||||
render(<ProviderAccountSelectors providers={providers} />);
|
||||
|
||||
expect(selectorProps.accounts?.providers).toEqual([providers[0]]);
|
||||
});
|
||||
|
||||
it("cleans incompatible selected accounts in the same instant navigation", () => {
|
||||
currentSearchParams.value =
|
||||
"filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_id__in%5D=aws-provider";
|
||||
|
||||
render(<ProviderAccountSelectors providers={providers} />);
|
||||
|
||||
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
|
||||
|
||||
const params = applyLastNavigation();
|
||||
|
||||
expect(params.get("filter[provider_type__in]")).toBe("gcp");
|
||||
expect(params.get("filter[provider_id__in]")).toBeNull();
|
||||
});
|
||||
|
||||
it("cleans incompatible UID accounts in the same instant navigation", () => {
|
||||
currentSearchParams.value =
|
||||
"filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_uid__in%5D=123456789012&page=2&scanId=scan-1";
|
||||
|
||||
render(
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
accountFilterKey="provider_uid__in"
|
||||
accountValue="uid"
|
||||
paramsToDeleteOnChange={["page", "scanId"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
|
||||
|
||||
const params = applyLastNavigation();
|
||||
|
||||
expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in");
|
||||
expect(params.get("filter[provider_type__in]")).toBe("gcp");
|
||||
expect(params.get("filter[provider_uid__in]")).toBeNull();
|
||||
expect(params.get("page")).toBeNull();
|
||||
expect(params.get("scanId")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters account options by selected provider types in batch mode", () => {
|
||||
render(
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={["aws"]}
|
||||
selectedAccounts={[]}
|
||||
onBatchChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(selectorProps.accounts?.providers).toEqual([providers[0]]);
|
||||
});
|
||||
|
||||
it("cleans incompatible selected accounts in batch mode", () => {
|
||||
const onBatchChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={["aws"]}
|
||||
selectedAccounts={["aws-provider", "gcp-provider"]}
|
||||
onBatchChange={onBatchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
|
||||
|
||||
expect(onBatchChange).toHaveBeenCalledWith("provider_type__in", ["gcp"]);
|
||||
expect(onBatchChange).toHaveBeenCalledWith("provider_id__in", [
|
||||
"gcp-provider",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses provider UID values when accountValue is uid", () => {
|
||||
const onBatchChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
accountFilterKey="provider_uid__in"
|
||||
accountValue="uid"
|
||||
selectedProviderTypes={["aws"]}
|
||||
selectedAccounts={["123456789012", "prowler-project"]}
|
||||
onBatchChange={onBatchChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]);
|
||||
|
||||
expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in");
|
||||
expect(onBatchChange).toHaveBeenCalledWith("provider_uid__in", [
|
||||
"prowler-project",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
const ACCOUNT_FILTER_KEY = {
|
||||
PROVIDER_ID: "provider_id__in",
|
||||
PROVIDER_UID: "provider_uid__in",
|
||||
} as const;
|
||||
|
||||
const ACCOUNT_VALUE = {
|
||||
ID: "id",
|
||||
UID: "uid",
|
||||
} as const;
|
||||
|
||||
type AccountFilterKey =
|
||||
(typeof ACCOUNT_FILTER_KEY)[keyof typeof ACCOUNT_FILTER_KEY];
|
||||
type AccountValue = (typeof ACCOUNT_VALUE)[keyof typeof ACCOUNT_VALUE];
|
||||
|
||||
interface ProviderAccountSelectorsBaseProps {
|
||||
providers: ProviderProps[];
|
||||
accountFilterKey?: AccountFilterKey;
|
||||
accountValue?: AccountValue;
|
||||
providerSelectorClassName?: string;
|
||||
accountSelectorClassName?: string;
|
||||
paramsToDeleteOnChange?: string[];
|
||||
}
|
||||
|
||||
interface ProviderAccountSelectorsInstantProps
|
||||
extends ProviderAccountSelectorsBaseProps {
|
||||
mode?: "instant";
|
||||
selectedProviderTypes?: never;
|
||||
selectedAccounts?: never;
|
||||
onBatchChange?: never;
|
||||
}
|
||||
|
||||
interface ProviderAccountSelectorsBatchProps
|
||||
extends ProviderAccountSelectorsBaseProps {
|
||||
mode: "batch";
|
||||
selectedProviderTypes: string[];
|
||||
selectedAccounts: string[];
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
}
|
||||
|
||||
type ProviderAccountSelectorsProps =
|
||||
| ProviderAccountSelectorsInstantProps
|
||||
| ProviderAccountSelectorsBatchProps;
|
||||
|
||||
const toFilterKey = (filterKey: string) => `filter[${filterKey}]`;
|
||||
|
||||
const getAccountValue = (
|
||||
provider: ProviderProps,
|
||||
accountValue: AccountValue,
|
||||
): string =>
|
||||
accountValue === ACCOUNT_VALUE.UID ? provider.attributes.uid : provider.id;
|
||||
|
||||
const getCsvValues = (value: string | null): string[] =>
|
||||
value ? value.split(",").filter(Boolean) : [];
|
||||
|
||||
const getFilteredProviders = (
|
||||
providers: ProviderProps[],
|
||||
selectedProviderTypes: string[],
|
||||
): ProviderProps[] => {
|
||||
if (selectedProviderTypes.length === 0) return providers;
|
||||
|
||||
return providers.filter((provider) =>
|
||||
selectedProviderTypes.includes(provider.attributes.provider),
|
||||
);
|
||||
};
|
||||
|
||||
const getCompatibleAccounts = ({
|
||||
providers,
|
||||
selectedAccounts,
|
||||
selectedProviderTypes,
|
||||
accountValue,
|
||||
}: {
|
||||
providers: ProviderProps[];
|
||||
selectedAccounts: string[];
|
||||
selectedProviderTypes: string[];
|
||||
accountValue: AccountValue;
|
||||
}): string[] => {
|
||||
if (selectedAccounts.length === 0) return [];
|
||||
if (selectedProviderTypes.length === 0) return selectedAccounts;
|
||||
|
||||
const compatibleValues = new Set(
|
||||
getFilteredProviders(providers, selectedProviderTypes).map((provider) =>
|
||||
getAccountValue(provider, accountValue),
|
||||
),
|
||||
);
|
||||
|
||||
return selectedAccounts.filter((account) => compatibleValues.has(account));
|
||||
};
|
||||
|
||||
export function ProviderAccountSelectors({
|
||||
providers,
|
||||
accountFilterKey = ACCOUNT_FILTER_KEY.PROVIDER_ID,
|
||||
accountValue = ACCOUNT_VALUE.ID,
|
||||
providerSelectorClassName,
|
||||
accountSelectorClassName,
|
||||
paramsToDeleteOnChange = [],
|
||||
...props
|
||||
}: ProviderAccountSelectorsProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
const isBatchMode = props.mode === "batch";
|
||||
const selectedProviderTypes = isBatchMode
|
||||
? props.selectedProviderTypes
|
||||
: getCsvValues(searchParams.get(toFilterKey("provider_type__in")));
|
||||
const selectedAccounts = isBatchMode
|
||||
? props.selectedAccounts
|
||||
: getCsvValues(searchParams.get(toFilterKey(accountFilterKey)));
|
||||
const filteredProviders = getFilteredProviders(
|
||||
providers,
|
||||
selectedProviderTypes,
|
||||
);
|
||||
|
||||
const handleProviderTypeChange = (
|
||||
filterKey: string,
|
||||
values: string[],
|
||||
): void => {
|
||||
const compatibleAccounts = getCompatibleAccounts({
|
||||
providers,
|
||||
selectedAccounts,
|
||||
selectedProviderTypes: values,
|
||||
accountValue,
|
||||
});
|
||||
|
||||
if (isBatchMode) {
|
||||
props.onBatchChange(filterKey, values);
|
||||
|
||||
if (compatibleAccounts.length !== selectedAccounts.length) {
|
||||
props.onBatchChange(accountFilterKey, compatibleAccounts);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
navigateWithParams((params) => {
|
||||
const providerFilterKey = toFilterKey(filterKey);
|
||||
const accountUrlFilterKey = toFilterKey(accountFilterKey);
|
||||
|
||||
if (values.length > 0) {
|
||||
params.set(providerFilterKey, values.join(","));
|
||||
} else {
|
||||
params.delete(providerFilterKey);
|
||||
}
|
||||
|
||||
if (compatibleAccounts.length > 0) {
|
||||
params.set(accountUrlFilterKey, compatibleAccounts.join(","));
|
||||
} else {
|
||||
params.delete(accountUrlFilterKey);
|
||||
}
|
||||
|
||||
paramsToDeleteOnChange.forEach((key) => params.delete(key));
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccountChange = (filterKey: string, values: string[]): void => {
|
||||
if (isBatchMode) {
|
||||
props.onBatchChange(filterKey, values);
|
||||
return;
|
||||
}
|
||||
|
||||
navigateWithParams((params) => {
|
||||
const accountUrlFilterKey = toFilterKey(filterKey);
|
||||
|
||||
if (values.length > 0) {
|
||||
params.set(accountUrlFilterKey, values.join(","));
|
||||
} else {
|
||||
params.delete(accountUrlFilterKey);
|
||||
}
|
||||
|
||||
paramsToDeleteOnChange.forEach((key) => params.delete(key));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={providerSelectorClassName}>
|
||||
<ProviderTypeSelector
|
||||
providers={providers}
|
||||
onBatchChange={handleProviderTypeChange}
|
||||
selectedValues={selectedProviderTypes}
|
||||
/>
|
||||
</div>
|
||||
<div className={accountSelectorClassName}>
|
||||
<AccountsSelector
|
||||
providers={filteredProviders}
|
||||
filterKey={accountFilterKey}
|
||||
onBatchChange={handleAccountChange}
|
||||
selectedValues={selectedAccounts}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
|
||||
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
@@ -15,6 +13,7 @@ import {
|
||||
FilterChip,
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
} from "./findings-filters.utils";
|
||||
|
||||
interface FindingsFiltersProps {
|
||||
/** Provider data for ProviderTypeSelector and AccountsSelector */
|
||||
/** Provider data for provider/account filter controls. */
|
||||
providers: ProviderProps[];
|
||||
completedScanIds: string[];
|
||||
scanDetails: { [key: string]: ScanEntity }[];
|
||||
@@ -96,7 +95,7 @@ export const FindingsFilterBatchControls = ({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isAlertsEdit = variant === "alerts-edit";
|
||||
|
||||
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
|
||||
// Custom filters for the expandable section.
|
||||
const customFilters = [
|
||||
...filterFindings
|
||||
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
|
||||
@@ -203,37 +202,23 @@ export const FindingsFilterBatchControls = ({
|
||||
? pendingDateValues[0]
|
||||
: undefined;
|
||||
|
||||
const providerTypeControl = (className: string) => (
|
||||
<div className={className}>
|
||||
<ProviderTypeSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const accountsControl = (className: string) => (
|
||||
<div className={className}>
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_id__in]")}
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
const providerAccountControls = (className: string) => (
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||
onBatchChange={setPending}
|
||||
providerSelectorClassName={className}
|
||||
accountSelectorClassName={className}
|
||||
/>
|
||||
);
|
||||
|
||||
const alertEditFilterGrid = hasCustomFilters ? (
|
||||
<DataTableFilterCustom
|
||||
gridClassName="w-full gap-3 xl:grid-cols-3 2xl:grid-cols-3"
|
||||
filters={customFilters}
|
||||
prependElement={
|
||||
<>
|
||||
{providerTypeControl(FILTER_GRID_ITEM_CLASS)}
|
||||
{accountsControl(FILTER_GRID_ITEM_CLASS)}
|
||||
</>
|
||||
}
|
||||
prependElement={providerAccountControls(FILTER_GRID_ITEM_CLASS)}
|
||||
hideClearButton
|
||||
mode={DATA_TABLE_FILTER_MODE.BATCH}
|
||||
onBatchChange={setPending}
|
||||
@@ -303,8 +288,7 @@ export const FindingsFilterBatchControls = ({
|
||||
alertEditFilterGrid
|
||||
) : (
|
||||
<>
|
||||
{providerTypeControl(FILTER_CONTROL_COLUMN_CLASS)}
|
||||
{accountsControl(FILTER_CONTROL_COLUMN_CLASS)}
|
||||
{providerAccountControls(FILTER_CONTROL_COLUMN_CLASS)}
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
@@ -213,109 +213,112 @@ export function InlineResourceContainer({
|
||||
onMuteComplete: handleMuteComplete,
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<motion.tr
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<td colSpan={columnCount} className="p-0">
|
||||
<AnimatePresence initial>
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
<ResourceSkeletonRow
|
||||
key={i}
|
||||
isEmptyStateSized={filteredResourceCount === 0}
|
||||
/>
|
||||
))
|
||||
) : rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(
|
||||
"a, button, input, [role=menuitem]",
|
||||
)
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
<ResourceSkeletonRow
|
||||
key={i}
|
||||
isEmptyStateSized={filteredResourceCount === 0}
|
||||
/>
|
||||
))
|
||||
) : rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest(
|
||||
"a, button, input, [role=menuitem]",
|
||||
)
|
||||
return;
|
||||
drawer.openDrawer(row.index);
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{getFindingGroupEmptyStateMessage(group, filters)}
|
||||
</TableCell>
|
||||
)
|
||||
return;
|
||||
drawer.openDrawer(row.index);
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{getFindingGroupEmptyStateMessage(group, filters)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Loading state for infinite scroll (subsequent pages only) */}
|
||||
{isLoading && rows.length > 0 && (
|
||||
<LoadingState label="Loading resources..." />
|
||||
)}
|
||||
{/* Loading state for infinite scroll (subsequent pages only) */}
|
||||
{isLoading && rows.length > 0 && (
|
||||
<LoadingState label="Loading resources..." />
|
||||
)}
|
||||
|
||||
{/* Sentinel for scroll hint detection */}
|
||||
<div
|
||||
ref={scrollHintSentinelRef}
|
||||
aria-hidden
|
||||
className="h-px shrink-0"
|
||||
/>
|
||||
{/* Sentinel for scroll hint detection */}
|
||||
<div
|
||||
ref={scrollHintSentinelRef}
|
||||
aria-hidden
|
||||
className="h-px shrink-0"
|
||||
/>
|
||||
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
{/* Sentinel for infinite scroll */}
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
|
||||
{/* Gradients rendered after scroll container so they paint on top */}
|
||||
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
|
||||
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
|
||||
{/* Gradients rendered after scroll container so they paint on top */}
|
||||
<div className="from-bg-neutral-secondary pointer-events-none absolute top-0 right-0 left-6 z-20 h-6 bg-gradient-to-b to-transparent" />
|
||||
<div className="from-bg-neutral-secondary pointer-events-none absolute right-0 bottom-0 left-6 z-20 h-6 bg-gradient-to-t to-transparent" />
|
||||
|
||||
{/* Scroll hint */}
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
|
||||
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
|
||||
<ChevronsDown className="inline size-3.5" /> Scroll for
|
||||
more
|
||||
</div>
|
||||
{/* Scroll hint */}
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute right-0 bottom-0 left-6 z-30">
|
||||
<div className="absolute inset-x-0 bottom-2 flex justify-center">
|
||||
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary animate-bounce rounded-full px-3 py-1 text-xs shadow-md">
|
||||
<ChevronsDown className="inline size-3.5" /> Scroll for
|
||||
more
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</td>
|
||||
</tr>
|
||||
</motion.tr>
|
||||
|
||||
<ResourceDetailDrawer
|
||||
open={drawer.isOpen}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
|
||||
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
|
||||
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
|
||||
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
|
||||
@@ -12,6 +10,7 @@ import {
|
||||
FilterChip,
|
||||
FilterSummaryStrip,
|
||||
} from "@/components/filters/filter-summary-strip";
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { ExpandableSection } from "@/components/ui/expandable-section";
|
||||
import { DataTableFilterCustom } from "@/components/ui/table";
|
||||
@@ -170,23 +169,15 @@ export const ResourcesFilters = ({
|
||||
controlsClassName="gap-3"
|
||||
controls={
|
||||
<>
|
||||
<div className={FILTER_CONTROL_COLUMN_CLASS}>
|
||||
<ProviderTypeSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_type__in]")}
|
||||
/>
|
||||
</div>
|
||||
<div className={FILTER_CONTROL_COLUMN_CLASS}>
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={setPending}
|
||||
selectedValues={getFilterValue("filter[provider_id__in]")}
|
||||
selectedProviderTypes={getFilterValue(
|
||||
"filter[provider_type__in]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
mode="batch"
|
||||
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
|
||||
selectedAccounts={getFilterValue("filter[provider_id__in]")}
|
||||
onBatchChange={setPending}
|
||||
providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
|
||||
accountSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
|
||||
/>
|
||||
{hasCustomFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { refreshMock, updateScanMock, toastMock } = vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
updateScanMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: refreshMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans", () => ({
|
||||
updateScan: updateScanMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/toast", () => ({
|
||||
toast: toastMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { EditAliasModal } from "./edit-alias-modal";
|
||||
|
||||
describe("EditAliasModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateScanMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("seeds the input with the current alias", () => {
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
scanId="scan-1"
|
||||
currentAlias="Production audit"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Alias")).toHaveValue("Production audit");
|
||||
});
|
||||
|
||||
it("rejects an unchanged alias before calling the action", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
scanId="scan-1"
|
||||
currentAlias="Production audit"
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/new alias must be different from the current one/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(updateScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only edit of the current alias", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
scanId="scan-1"
|
||||
currentAlias="Production audit"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Alias");
|
||||
await user.type(input, " ");
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/new alias must be different from the current one/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(updateScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits the new alias as scanName", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
scanId="scan-1"
|
||||
currentAlias="Old name"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Alias");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Brand new name");
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(updateScanMock).toHaveBeenCalled());
|
||||
|
||||
const formData = updateScanMock.mock.calls[0][0] as FormData;
|
||||
expect(formData.get("scanId")).toBe("scan-1");
|
||||
expect(formData.get("scanName")).toBe("Brand new name");
|
||||
expect(toastMock).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("accepts aliases up to the API limit of 100 characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
const alias = "a".repeat(100);
|
||||
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
scanId="scan-1"
|
||||
currentAlias="Old name"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Alias");
|
||||
await user.clear(input);
|
||||
await user.type(input, alias);
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() => expect(updateScanMock).toHaveBeenCalled());
|
||||
|
||||
const formData = updateScanMock.mock.calls[0][0] as FormData;
|
||||
expect(formData.get("scanName")).toBe(alias);
|
||||
});
|
||||
|
||||
it("rejects aliases over the API limit of 100 characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
scanId="scan-1"
|
||||
currentAlias="Old name"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Alias");
|
||||
await user.clear(input);
|
||||
await user.type(input, "a".repeat(101));
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/alias must not exceed 100 characters/i),
|
||||
).toBeInTheDocument();
|
||||
expect(updateScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces server-side errors on the alias field", async () => {
|
||||
const user = userEvent.setup();
|
||||
updateScanMock.mockResolvedValueOnce({
|
||||
errors: [{ detail: "Alias already in use" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<EditAliasModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
scanId="scan-1"
|
||||
currentAlias="Old name"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Alias");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Conflicting");
|
||||
await user.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
expect(await screen.findByText("Alias already in use")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { updateScan } from "@/actions/scans";
|
||||
import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
|
||||
import { scanAliasSchema } from "./scan-alias-validation";
|
||||
|
||||
const buildEditAliasSchema = (currentAlias: string) =>
|
||||
z.object({
|
||||
alias: scanAliasSchema.refine(
|
||||
(value) => value.trim() !== currentAlias.trim(),
|
||||
"The new alias must be different from the current one.",
|
||||
),
|
||||
});
|
||||
|
||||
type EditAliasFormValues = { alias: string };
|
||||
|
||||
interface EditAliasModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
scanId: string;
|
||||
currentAlias: string;
|
||||
}
|
||||
|
||||
interface EditAliasFormProps {
|
||||
scanId: string;
|
||||
currentAlias: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function EditAliasForm({ scanId, currentAlias, onClose }: EditAliasFormProps) {
|
||||
const router = useRouter();
|
||||
const form = useForm<EditAliasFormValues>({
|
||||
resolver: zodResolver(buildEditAliasSchema(currentAlias)),
|
||||
defaultValues: { alias: currentAlias },
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ alias }) => {
|
||||
const trimmed = alias.trim();
|
||||
const formData = new FormData();
|
||||
formData.set("scanId", scanId);
|
||||
formData.set("scanName", trimmed);
|
||||
|
||||
const result = await updateScan(formData);
|
||||
|
||||
if (result?.errors && result.errors.length > 0) {
|
||||
form.setError("alias", {
|
||||
message: String(result.errors[0]?.detail ?? "Failed to update alias."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Alias updated",
|
||||
description: "The scan alias was updated successfully.",
|
||||
});
|
||||
onClose();
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
const aliasError = form.formState.errors.alias?.message;
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="text-text-neutral-secondary size-4" />
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
Current alias:{" "}
|
||||
<span className="text-text-neutral-primary font-medium">
|
||||
{currentAlias || "Unnamed"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-alias-input">Alias</FieldLabel>
|
||||
<Input
|
||||
id="edit-alias-input"
|
||||
aria-label="Alias"
|
||||
placeholder={currentAlias || "Enter scan alias"}
|
||||
{...form.register("alias")}
|
||||
/>
|
||||
{aliasError && <FieldError>{aliasError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<FormButtons
|
||||
onCancel={onClose}
|
||||
submitText={isSubmitting ? "Saving..." : "Save"}
|
||||
loadingText="Saving..."
|
||||
isDisabled={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAliasModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
scanId,
|
||||
currentAlias,
|
||||
}: EditAliasModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Edit Alias"
|
||||
size="xl"
|
||||
className="gap-8"
|
||||
>
|
||||
<EditAliasForm
|
||||
scanId={scanId}
|
||||
currentAlias={currentAlias}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateScan } from "@/actions/scans";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import { editScanFormSchema } from "@/types";
|
||||
|
||||
export const EditScanForm = ({
|
||||
scanId,
|
||||
scanName,
|
||||
setIsOpen,
|
||||
}: {
|
||||
scanId: string;
|
||||
scanName: string;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const formSchema = editScanFormSchema(scanName);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
scanId: scanId,
|
||||
scanName: scanName || "",
|
||||
},
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
|
||||
Object.entries(values).forEach(
|
||||
([key, value]) => value !== undefined && formData.append(key, value),
|
||||
);
|
||||
|
||||
const data = await updateScan(formData);
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
const error = data.errors[0];
|
||||
const errorMessage = `${error.detail}`;
|
||||
// show error
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: errorMessage,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The scan was updated successfully.",
|
||||
});
|
||||
setIsOpen(false); // Close the modal on success
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="text-md">
|
||||
Current name:{" "}
|
||||
<span className="font-bold">{scanName || "Unnamed"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="scanName"
|
||||
type="text"
|
||||
label="Name"
|
||||
labelPlacement="outside"
|
||||
placeholder={scanName || "Enter scan name"}
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="scanId" value={scanId} />
|
||||
|
||||
<FormButtons setIsOpen={setIsOpen} isDisabled={isLoading} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./edit-scan-form";
|
||||
export * from "./schedule-form";
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateProvider } from "@/actions/providers";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import { scheduleScanFormSchema } from "@/types";
|
||||
|
||||
export const ScheduleForm = ({
|
||||
providerId,
|
||||
scheduleDate,
|
||||
setIsOpen,
|
||||
}: {
|
||||
providerId: string;
|
||||
scheduleDate: string;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const formSchema = scheduleScanFormSchema();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providerId: providerId,
|
||||
scheduleDate: scheduleDate,
|
||||
},
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
|
||||
Object.entries(values).forEach(
|
||||
([key, value]) => value !== undefined && formData.append(key, value),
|
||||
);
|
||||
const data = await updateProvider(formData);
|
||||
|
||||
if (data?.errors && data.errors.length > 0) {
|
||||
const error = data.errors[0];
|
||||
const errorMessage = `${error.detail}`;
|
||||
// show error
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: errorMessage,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The scan was scheduled successfully.",
|
||||
});
|
||||
setIsOpen(false); // Close the modal on success
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<input type="hidden" name="providerId" value={providerId} />
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="scheduleDate"
|
||||
type="date"
|
||||
label="Schedule Date"
|
||||
labelPlacement="inside"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
|
||||
<FormButtons
|
||||
setIsOpen={setIsOpen}
|
||||
submitText="Schedule"
|
||||
isDisabled={true}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1 @@
|
||||
export * from "./auto-refresh";
|
||||
export * from "./no-providers-added";
|
||||
export * from "./no-providers-connected";
|
||||
export * from "./scans-filters";
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { refreshMock, scanOnDemandMock, searchParamsValue, toastMock } =
|
||||
vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
scanOnDemandMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: refreshMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scans", () => ({
|
||||
scanOnDemand: scanOnDemandMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/toast", () => ({
|
||||
ToastAction: ({ children, ...props }: ComponentProps<"button">) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
toast: toastMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
EntityInfo: ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
}: {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
}) => <>{entityAlias || entityId}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
|
||||
AccountsSelector: ({
|
||||
disabledValues = [],
|
||||
providers,
|
||||
onBatchChange,
|
||||
selectedValues,
|
||||
id,
|
||||
}: {
|
||||
disabledValues?: string[];
|
||||
providers: { id: string; attributes: { alias: string; uid: string } }[];
|
||||
onBatchChange: (filterKey: string, values: string[]) => void;
|
||||
selectedValues: string[];
|
||||
id?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<input aria-label="Search Providers" placeholder="Search Providers..." />
|
||||
<select
|
||||
id={id}
|
||||
aria-label="Providers"
|
||||
value={selectedValues[0] ?? ""}
|
||||
onChange={(event) =>
|
||||
onBatchChange("provider_id__in", [event.target.value])
|
||||
}
|
||||
>
|
||||
<option value="">All Providers</option>
|
||||
{providers.map((provider) => (
|
||||
<option
|
||||
key={provider.id}
|
||||
value={provider.id}
|
||||
disabled={disabledValues.includes(provider.id)}
|
||||
>
|
||||
{provider.attributes.alias || provider.attributes.uid}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { LaunchScanModal } from "./launch-scan-modal";
|
||||
|
||||
const provider = {
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed" as const,
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const disconnectedProvider = {
|
||||
...provider,
|
||||
id: "provider-2",
|
||||
attributes: {
|
||||
...provider.attributes,
|
||||
alias: "Disconnected",
|
||||
uid: "210987654321",
|
||||
connection: {
|
||||
connected: false,
|
||||
last_checked_at: "2026-05-20T11:46:38.834045Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("LaunchScanModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
|
||||
});
|
||||
|
||||
it("shows a searchable provider selector", () => {
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible();
|
||||
});
|
||||
|
||||
it("disables disconnected providers in the launch selector", () => {
|
||||
render(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
providers={[provider, disconnectedProvider]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("option", { name: "Disconnected" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("submits alias as scanName so the API stores it as the scan alias", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.type(screen.getByLabelText("Alias"), "Production audit");
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled());
|
||||
|
||||
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
|
||||
expect(formData.get("providerId")).toBe(provider.id);
|
||||
expect(formData.get("scanName")).toBe("Production audit");
|
||||
expect(formData.get("scanNote")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts scan aliases up to the API limit of 100 characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
const alias = "a".repeat(100);
|
||||
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.type(screen.getByLabelText("Alias"), alias);
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalled());
|
||||
|
||||
const formData = scanOnDemandMock.mock.calls[0][0] as FormData;
|
||||
expect(formData.get("scanName")).toBe(alias);
|
||||
});
|
||||
|
||||
it("adds a toast action to view the scan in progress when another tab is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
searchParamsValue.current =
|
||||
"tab=completed&filter%5Bstate__in%5D=failed&page=3";
|
||||
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
await waitFor(() => expect(toastMock).toHaveBeenCalled());
|
||||
|
||||
const toastPayload = toastMock.mock.calls[0]?.[0];
|
||||
expect(toastPayload.action).toBeDefined();
|
||||
expect(toastPayload.action.props.children.props.href).toBe(
|
||||
"/scans?tab=active",
|
||||
);
|
||||
expect(toastPayload.action.props.children.props.children).toBe("View scan");
|
||||
});
|
||||
|
||||
it("does not add a toast action when the in progress tab is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
searchParamsValue.current = "tab=active";
|
||||
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
await waitFor(() => expect(toastMock).toHaveBeenCalled());
|
||||
|
||||
const toastPayload = toastMock.mock.calls[0]?.[0];
|
||||
expect(toastPayload.action).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects scan aliases over the API limit of 100 characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.type(screen.getByLabelText("Alias"), "a".repeat(101));
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/alias must not exceed 100 characters/i),
|
||||
).toBeInTheDocument();
|
||||
expect(scanOnDemandMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show the old scan note label", () => {
|
||||
render(
|
||||
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Scan Note")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Scan Note (optional)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces JSON:API errors from scanOnDemand and skips the success toast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
scanOnDemandMock.mockResolvedValueOnce({
|
||||
errors: [{ detail: "Provider already has a scan in progress" }],
|
||||
});
|
||||
|
||||
render(
|
||||
<LaunchScanModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
providers={[provider]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
|
||||
await user.click(screen.getByRole("button", { name: /launch scan/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Provider already has a scan in progress"),
|
||||
).toBeInTheDocument();
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CloudCog, Rocket } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { scanOnDemand } from "@/actions/scans";
|
||||
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
|
||||
import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
import { toast, ToastAction } from "@/components/ui/toast";
|
||||
import { SCAN_JOBS_TAB } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { scanAliasSchema } from "./scan-alias-validation";
|
||||
import { getScanJobsTab } from "./scans.utils";
|
||||
|
||||
const launchScanSchema = z.object({
|
||||
providerId: z.string().min(1, "Select a provider to launch a scan."),
|
||||
scanAlias: scanAliasSchema.optional(),
|
||||
});
|
||||
|
||||
type LaunchScanFormValues = z.infer<typeof launchScanSchema>;
|
||||
|
||||
interface LaunchScanModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: ProviderProps[];
|
||||
}
|
||||
|
||||
interface LaunchScanFormProps {
|
||||
providers: ProviderProps[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function LaunchScanForm({ providers, onClose }: LaunchScanFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const form = useForm<LaunchScanFormValues>({
|
||||
resolver: zodResolver(launchScanSchema),
|
||||
defaultValues: { providerId: "", scanAlias: "" },
|
||||
});
|
||||
|
||||
const providerId = form.watch("providerId");
|
||||
const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined);
|
||||
const shouldShowActiveTabAction = activeTab !== SCAN_JOBS_TAB.ACTIVE;
|
||||
const disconnectedProviderIds = providers
|
||||
.filter((provider) => provider.attributes.connection.connected !== true)
|
||||
.map((provider) => provider.id);
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ providerId, scanAlias }) => {
|
||||
const formData = new FormData();
|
||||
formData.set("providerId", providerId);
|
||||
const trimmedAlias = scanAlias?.trim();
|
||||
if (trimmedAlias) {
|
||||
formData.set("scanName", trimmedAlias);
|
||||
}
|
||||
|
||||
const result = await scanOnDemand(formData);
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", { message: String(result.error) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.errors && result.errors.length > 0) {
|
||||
form.setError("root", {
|
||||
message: String(result.errors[0]?.detail ?? "Failed to launch scan."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Scan launched",
|
||||
description: "The scan was launched successfully.",
|
||||
action: shouldShowActiveTabAction ? (
|
||||
<ToastAction altText="View scan in progress" asChild>
|
||||
<Link href="/scans?tab=active">View scan</Link>
|
||||
</ToastAction>
|
||||
) : undefined,
|
||||
});
|
||||
onClose();
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
const providerError = form.formState.errors.providerId?.message;
|
||||
const aliasError = form.formState.errors.scanAlias?.message;
|
||||
const rootError = form.formState.errors.root?.message;
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudCog className="text-text-neutral-secondary size-4" />
|
||||
<span className="text-text-neutral-secondary text-sm">
|
||||
Select the provider you would like to scan
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="launch-scan-account">Providers</FieldLabel>
|
||||
<AccountsSelector
|
||||
id="launch-scan-account"
|
||||
providers={providers}
|
||||
disabledValues={disconnectedProviderIds}
|
||||
onBatchChange={(_, values) =>
|
||||
form.setValue("providerId", values.at(-1) ?? "", {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
selectedValues={providerId ? [providerId] : []}
|
||||
closeOnSelect
|
||||
/>
|
||||
{providerError && <FieldError>{providerError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="launch-scan-alias">Alias (optional)</FieldLabel>
|
||||
<Input
|
||||
id="launch-scan-alias"
|
||||
aria-label="Alias"
|
||||
{...form.register("scanAlias")}
|
||||
/>
|
||||
{aliasError && <FieldError>{aliasError}</FieldError>}
|
||||
</Field>
|
||||
|
||||
{rootError && <FieldError>{rootError}</FieldError>}
|
||||
|
||||
<FormButtons
|
||||
onCancel={onClose}
|
||||
submitText={isSubmitting ? "Launching..." : "Launch Scan"}
|
||||
loadingText="Launching..."
|
||||
isDisabled={isSubmitting || !providers.length}
|
||||
rightIcon={<Rocket className="size-4" />}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaunchScanModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
providers,
|
||||
}: LaunchScanModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Launch A Scan"
|
||||
size="xl"
|
||||
className="gap-8"
|
||||
>
|
||||
<LaunchScanForm
|
||||
providers={providers}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./launch-scan-workflow-form";
|
||||
export * from "./select-scan-provider";
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { scanOnDemand } from "@/actions/scans";
|
||||
import { RocketIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { onDemandScanFormSchema, ScanProviderInfo } from "@/types";
|
||||
|
||||
import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling";
|
||||
import { SelectScanProvider } from "./select-scan-provider";
|
||||
|
||||
export const LaunchScanWorkflow = ({
|
||||
providers,
|
||||
}: {
|
||||
providers: ScanProviderInfo[];
|
||||
}) => {
|
||||
const formSchema = z.object({
|
||||
...onDemandScanFormSchema().shape,
|
||||
scanName: z
|
||||
.union([
|
||||
z
|
||||
.string()
|
||||
.min(3, "Must be at least 3 characters")
|
||||
.max(32, "Must not exceed 32 characters"),
|
||||
z.literal(""),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
providerId: "",
|
||||
scanName: "",
|
||||
scannerArgs: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const providerId = useWatch({ control: form.control, name: "providerId" });
|
||||
const hasProviderSelected = Boolean(providerId);
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
|
||||
const onSubmitClient = async (values: z.infer<typeof formSchema>) => {
|
||||
const formValues = { ...values };
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Loop through form values and add to formData
|
||||
Object.entries(formValues).forEach(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
formData.append(
|
||||
key,
|
||||
typeof value === "object" ? JSON.stringify(value) : value,
|
||||
),
|
||||
);
|
||||
|
||||
const data = await scanOnDemand(formData);
|
||||
|
||||
if (data?.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: data.error,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: "The scan was launched successfully.",
|
||||
});
|
||||
// Reset form after successful submission
|
||||
form.reset();
|
||||
// Notify the scans table to refresh and pick up the new scan
|
||||
window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitClient)}
|
||||
className="flex flex-wrap justify-start gap-4"
|
||||
>
|
||||
<div className="w-72">
|
||||
<SelectScanProvider
|
||||
providers={providers}
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{hasProviderSelected && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-6 md:gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-[3.4rem] min-w-[15.2rem] self-end"
|
||||
>
|
||||
<CustomInput
|
||||
control={form.control}
|
||||
name="scanName"
|
||||
type="text"
|
||||
label="Scan label (optional)"
|
||||
labelPlacement="outside"
|
||||
placeholder="Scan label"
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-end gap-4"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
size="default"
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
{!isLoading && <RocketIcon size={16} />}
|
||||
{isLoading ? "Loading..." : "Start now"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => form.reset()}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
|
||||
import { ScanProviderInfo } from "@/types";
|
||||
|
||||
interface SelectScanProviderProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> {
|
||||
providers: ScanProviderInfo[];
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
}
|
||||
|
||||
export const SelectScanProvider = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
providers,
|
||||
control,
|
||||
name,
|
||||
}: SelectScanProviderProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => {
|
||||
const selectedItem = providers.find(
|
||||
(item) => item.providerId === field.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
Select a provider to launch a scan
|
||||
</span>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a provider">
|
||||
{selectedItem ? (
|
||||
<EntityInfo
|
||||
cloudProvider={
|
||||
selectedItem.providerType as
|
||||
| "aws"
|
||||
| "azure"
|
||||
| "gcp"
|
||||
| "kubernetes"
|
||||
}
|
||||
entityAlias={selectedItem.alias}
|
||||
entityId={selectedItem.uid}
|
||||
showCopyAction={false}
|
||||
/>
|
||||
) : (
|
||||
"Choose a provider"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((item) => (
|
||||
<SelectItem key={item.providerId} value={item.providerId}>
|
||||
<EntityInfo
|
||||
cloudProvider={
|
||||
item.providerType as
|
||||
| "aws"
|
||||
| "azure"
|
||||
| "gcp"
|
||||
| "kubernetes"
|
||||
}
|
||||
entityAlias={item.alias}
|
||||
entityId={item.uid}
|
||||
showCopyAction={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage className="text-sm text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Card, CardContent } from "@/components/shadcn";
|
||||
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
|
||||
interface EmptyStateCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const EMPTY_STATE_COPY: Record<ScanJobsTab, EmptyStateCopy> = {
|
||||
[SCAN_JOBS_TAB.ACTIVE]: {
|
||||
title: "No scans in progress",
|
||||
description:
|
||||
"Scans currently running or queued will appear here when available.",
|
||||
hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.COMPLETED]} to review past results, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.SCHEDULED]}.`,
|
||||
},
|
||||
[SCAN_JOBS_TAB.COMPLETED]: {
|
||||
title: "No completed scans yet",
|
||||
description:
|
||||
"Finished, failed, or cancelled scans will appear here once they wrap up.",
|
||||
hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.ACTIVE]} to monitor ongoing scans, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.SCHEDULED]} to plan future runs.`,
|
||||
},
|
||||
[SCAN_JOBS_TAB.SCHEDULED]: {
|
||||
title: "No scheduled scans",
|
||||
description: "Scans scheduled to run later will appear here.",
|
||||
hint: `Switch to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.ACTIVE]} to monitor ongoing scans, or to ${SCAN_TAB_LABELS[SCAN_JOBS_TAB.COMPLETED]} to review past results.`,
|
||||
},
|
||||
};
|
||||
|
||||
interface NoScansEmptyStateProps {
|
||||
tab: ScanJobsTab;
|
||||
}
|
||||
|
||||
export function NoScansEmptyState({ tab }: NoScansEmptyStateProps) {
|
||||
const copy = EMPTY_STATE_COPY[tab];
|
||||
|
||||
return (
|
||||
<Card variant="base">
|
||||
<CardContent className="flex w-full flex-col items-center gap-3 px-4 py-10 text-center">
|
||||
<InfoIcon className="h-8 w-8 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-white">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="max-w-prose text-sm text-gray-600 dark:text-gray-300">
|
||||
{copy.description}
|
||||
</p>
|
||||
<p className="max-w-prose text-sm text-gray-600 dark:text-gray-300">
|
||||
{copy.hint}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const SCAN_ALIAS_MIN_LENGTH = 3;
|
||||
export const SCAN_ALIAS_MAX_LENGTH = 100;
|
||||
|
||||
export const scanAliasSchema = z
|
||||
.string()
|
||||
.max(
|
||||
SCAN_ALIAS_MAX_LENGTH,
|
||||
`Alias must not exceed ${SCAN_ALIAS_MAX_LENGTH} characters.`,
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.trim().length === 0 || value.trim().length >= SCAN_ALIAS_MIN_LENGTH,
|
||||
`Alias must be empty or have at least ${SCAN_ALIAS_MIN_LENGTH} characters.`,
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ScanErrorDetailsModal,
|
||||
type ScanErrorDetailsState,
|
||||
} from "./scan-error-details-modal";
|
||||
|
||||
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
|
||||
CodeSnippet: ({
|
||||
value,
|
||||
formatter,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
formatter?: (value: string) => string;
|
||||
ariaLabel?: string;
|
||||
}) => (
|
||||
<>
|
||||
<span>{formatter ? formatter(value) : value}</span>
|
||||
<button type="button" aria-label={ariaLabel ?? "Copy to clipboard"}>
|
||||
copy
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
const loadedState: ScanErrorDetailsState = {
|
||||
kind: "loaded",
|
||||
details: {
|
||||
type: "ValidationError",
|
||||
messages: ["Missing cloud credentials", "Retry scan setup"],
|
||||
module: "scan.runner",
|
||||
copyValue:
|
||||
"ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup",
|
||||
},
|
||||
};
|
||||
|
||||
describe("ScanErrorDetailsModal", () => {
|
||||
it("renders nothing visible when closed", () => {
|
||||
render(
|
||||
<ScanErrorDetailsModal
|
||||
open={false}
|
||||
onOpenChange={vi.fn()}
|
||||
state={{ kind: "idle" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loading placeholder while state is loading", () => {
|
||||
render(
|
||||
<ScanErrorDetailsModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
state={{ kind: "loading" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/loading error details/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the error message when state is error", () => {
|
||||
render(
|
||||
<ScanErrorDetailsModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
state={{ kind: "error", message: "Task not found" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Task not found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error type, module and messages when loaded", () => {
|
||||
render(
|
||||
<ScanErrorDetailsModal open onOpenChange={vi.fn()} state={loadedState} />,
|
||||
);
|
||||
expect(screen.getByText("ValidationError")).toBeInTheDocument();
|
||||
expect(screen.getByText("scan.runner")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the copy action only when state is loaded", () => {
|
||||
const { rerender } = render(
|
||||
<ScanErrorDetailsModal
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
state={{ kind: "loading" }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /copy error details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ScanErrorDetailsModal open onOpenChange={vi.fn()} state={loadedState} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /copy error details/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { ScanErrorDetails } from "@/actions/task/task.adapter";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
|
||||
import { ScanErrorDetailsView } from "./scan-error-details-view";
|
||||
|
||||
export type ScanErrorDetailsState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "loaded"; details: ScanErrorDetails };
|
||||
|
||||
interface ScanErrorDetailsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
state: ScanErrorDetailsState;
|
||||
}
|
||||
|
||||
function LoadingView() {
|
||||
return <LoadingState label="Loading error details..." />;
|
||||
}
|
||||
|
||||
function ErrorView({ message }: { message: string }) {
|
||||
return (
|
||||
<Card variant="danger">
|
||||
<CardContent>
|
||||
<p className="text-text-error-primary text-sm">{message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScanErrorDetailsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
state,
|
||||
}: ScanErrorDetailsModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Scan Error Details"
|
||||
description="Failure details returned by the scan task."
|
||||
size="2xl"
|
||||
>
|
||||
{state.kind === "loading" && <LoadingView />}
|
||||
{state.kind === "error" && <ErrorView message={state.message} />}
|
||||
{state.kind === "loaded" && (
|
||||
<ScanErrorDetailsView details={state.details} />
|
||||
)}
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanErrorDetails } from "@/actions/task/task.adapter";
|
||||
|
||||
import { ScanErrorDetailsView } from "./scan-error-details-view";
|
||||
|
||||
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
|
||||
CodeSnippet: ({
|
||||
value,
|
||||
formatter,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
formatter?: (value: string) => string;
|
||||
ariaLabel?: string;
|
||||
}) => (
|
||||
<>
|
||||
<span>{formatter ? formatter(value) : value}</span>
|
||||
<button type="button" aria-label={ariaLabel ?? "Copy to clipboard"}>
|
||||
copy
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
const details: ScanErrorDetails = {
|
||||
type: "ValidationError",
|
||||
messages: ["Missing cloud credentials", "Retry scan setup"],
|
||||
module: "scan.runner",
|
||||
copyValue:
|
||||
"ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup",
|
||||
};
|
||||
|
||||
describe("ScanErrorDetailsView", () => {
|
||||
it("renders error type, module and joined messages", () => {
|
||||
render(<ScanErrorDetailsView details={details} />);
|
||||
|
||||
expect(screen.getByText("Error Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("ValidationError")).toBeInTheDocument();
|
||||
expect(screen.getByText("Module")).toBeInTheDocument();
|
||||
expect(screen.getByText("scan.runner")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the module field when not provided", () => {
|
||||
render(
|
||||
<ScanErrorDetailsView details={{ ...details, module: undefined }} />,
|
||||
);
|
||||
expect(screen.queryByText("Module")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the provided copy aria label", () => {
|
||||
render(
|
||||
<ScanErrorDetailsView details={details} copyAriaLabel="Copy custom" />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /copy custom/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults the copy aria label to 'Copy error details'", () => {
|
||||
render(<ScanErrorDetailsView details={details} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /copy error details/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { ScanErrorDetails } from "@/actions/task/task.adapter";
|
||||
import { Field, FieldLabel, LabeledField } from "@/components/shadcn";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
|
||||
interface ScanErrorDetailsViewProps {
|
||||
details: ScanErrorDetails;
|
||||
copyAriaLabel?: string;
|
||||
}
|
||||
|
||||
export function ScanErrorDetailsView({
|
||||
details,
|
||||
copyAriaLabel = "Copy error details",
|
||||
}: ScanErrorDetailsViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<LabeledField label="Error Type">{details.type}</LabeledField>
|
||||
{details.module && (
|
||||
<LabeledField label="Module">{details.module}</LabeledField>
|
||||
)}
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Error</FieldLabel>
|
||||
<CodeSnippet
|
||||
value={details.copyValue}
|
||||
formatter={() => details.messages.join("\n")}
|
||||
multiline
|
||||
ariaLabel={copyAriaLabel}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import type { ScanJobsTab } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import {
|
||||
getScanStatusFilterOptions,
|
||||
getScanTriggerFilterOptions,
|
||||
} from "./scans.utils";
|
||||
|
||||
interface ScansFilterBarProps {
|
||||
providers: ProviderProps[];
|
||||
activeTab: ScanJobsTab;
|
||||
scheduleType: string;
|
||||
scanStatus: string;
|
||||
showStatusFilter: boolean;
|
||||
onScheduleTypeChange: (value: string) => void;
|
||||
onScanStatusChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
|
||||
|
||||
export function ScansFilterBar({
|
||||
providers,
|
||||
activeTab,
|
||||
scheduleType,
|
||||
scanStatus,
|
||||
showStatusFilter,
|
||||
onScheduleTypeChange,
|
||||
onScanStatusChange,
|
||||
}: ScansFilterBarProps) {
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment);
|
||||
const statusFilterOptions = getScanStatusFilterOptions(activeTab);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProviderAccountSelectors
|
||||
providers={providers}
|
||||
accountFilterKey="provider_uid__in"
|
||||
accountValue="uid"
|
||||
paramsToDeleteOnChange={["page", "scanId"]}
|
||||
providerSelectorClassName={filterItemClass}
|
||||
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>
|
||||
|
||||
{showStatusFilter && (
|
||||
<Select value={scanStatus} onValueChange={onScanStatusChange}>
|
||||
<SelectTrigger aria-label="All Statuses" className={filterItemClass}>
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ScanSelector } from "@/components/compliance/compliance-header";
|
||||
import { filterScans } from "@/components/filters/data-filters";
|
||||
import { FilterControls } from "@/components/filters/filter-controls";
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { useRelatedFilters } from "@/hooks";
|
||||
import { ExpandedScanData, FilterEntity, FilterType } from "@/types";
|
||||
|
||||
interface ScansFiltersProps {
|
||||
providerUIDs: string[];
|
||||
providerDetails: { [uid: string]: FilterEntity }[];
|
||||
completedScans?: ExpandedScanData[];
|
||||
}
|
||||
|
||||
export const ScansFilters = ({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
completedScans = [],
|
||||
}: ScansFiltersProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const idFilter = searchParams.get("filter[id__in]");
|
||||
|
||||
const { availableProviderUIDs } = useRelatedFilters({
|
||||
providerUIDs,
|
||||
providerDetails,
|
||||
enableScanRelation: false,
|
||||
providerFilterType: FilterType.PROVIDER_UID,
|
||||
});
|
||||
|
||||
const handleDismissIdFilter = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("filter[id__in]");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleScanChange = (selectedScanId: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("filter[id__in]", selectedScanId);
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const scanIdElement = idFilter ? (
|
||||
completedScans.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<ScanSelector
|
||||
scans={completedScans}
|
||||
selectedScanId={idFilter}
|
||||
onSelectionChange={handleScanChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear scan filter"
|
||||
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0"
|
||||
onClick={handleDismissIdFilter}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="max-w-[300px] shrink-0 cursor-default gap-1 truncate"
|
||||
>
|
||||
<span className="text-text-neutral-secondary mr-1 text-xs">
|
||||
Scan:
|
||||
</span>
|
||||
<span className="truncate">{idFilter}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear scan filter"
|
||||
className="hover:text-text-neutral-primary ml-0.5 shrink-0"
|
||||
onClick={handleDismissIdFilter}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<FilterControls
|
||||
customFilters={[
|
||||
...filterScans,
|
||||
{
|
||||
key: FilterType.PROVIDER_UID,
|
||||
labelCheckboxGroup: "Provider UID",
|
||||
values: availableProviderUIDs,
|
||||
valueLabelMapping: providerDetails,
|
||||
index: 1,
|
||||
},
|
||||
]}
|
||||
prependElement={scanIdElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ScansLaunchSection } from "./scans-launch-section";
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/launch-workflow", () => ({
|
||||
LaunchScanWorkflow: () => <div>Launch scan workflow</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/no-providers-connected", () => ({
|
||||
NoProvidersConnected: () => <div>No providers connected</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-banner", () => ({
|
||||
CustomBanner: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
}));
|
||||
|
||||
const connectedProvider = {
|
||||
providerId: "provider-1",
|
||||
alias: "Production",
|
||||
providerType: "aws",
|
||||
uid: "123456789012",
|
||||
connected: true,
|
||||
};
|
||||
|
||||
describe("ScansLaunchSection", () => {
|
||||
it("should keep the provider wizard open when providers data refreshes after adding the first provider", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<ScansLaunchSection
|
||||
providers={[]}
|
||||
hasManageScansPermission
|
||||
thereIsNoProviders
|
||||
thereIsNoProvidersConnected
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
rerender(
|
||||
<ScansLaunchSection
|
||||
providers={[connectedProvider]}
|
||||
hasManageScansPermission
|
||||
thereIsNoProviders={false}
|
||||
thereIsNoProvidersConnected={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
expect(screen.getByText("Launch scan workflow")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
|
||||
import { NoProvidersAdded } from "@/components/scans/no-providers-added";
|
||||
import { NoProvidersConnected } from "@/components/scans/no-providers-connected";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import { ScanProviderInfo } from "@/types";
|
||||
|
||||
interface ScansLaunchSectionProps {
|
||||
providers: ScanProviderInfo[];
|
||||
hasManageScansPermission: boolean;
|
||||
thereIsNoProviders: boolean;
|
||||
thereIsNoProvidersConnected: boolean;
|
||||
}
|
||||
|
||||
export function ScansLaunchSection({
|
||||
providers,
|
||||
hasManageScansPermission,
|
||||
thereIsNoProviders,
|
||||
thereIsNoProvidersConnected,
|
||||
}: ScansLaunchSectionProps) {
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
|
||||
) : !hasManageScansPermission ? (
|
||||
<CustomBanner
|
||||
title={"Access Denied"}
|
||||
message={"You don't have permission to launch the scan."}
|
||||
/>
|
||||
) : thereIsNoProvidersConnected ? (
|
||||
<NoProvidersConnected />
|
||||
) : (
|
||||
<LaunchScanWorkflow providers={providers} />
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={setIsProviderWizardOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useScansStore } from "@/store";
|
||||
|
||||
import { ScansPageShell } from "./scans-page-shell";
|
||||
|
||||
const { pushMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
pushMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
const { scansFilterBarSpy } = vi.hoisted(() => ({
|
||||
scansFilterBarSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("./scans-filter-bar", () => ({
|
||||
ScansFilterBar: (props: {
|
||||
showStatusFilter: boolean;
|
||||
onScheduleTypeChange: (value: string) => void;
|
||||
onScanStatusChange: (value: string) => void;
|
||||
}) => {
|
||||
scansFilterBarSpy(props);
|
||||
return (
|
||||
<>
|
||||
<div>Shared scan filters</div>
|
||||
<select
|
||||
aria-label="All Types"
|
||||
onChange={(event) => props.onScheduleTypeChange(event.target.value)}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
</select>
|
||||
{props.showStatusFilter && (
|
||||
<select
|
||||
aria-label="All Statuses"
|
||||
onChange={(event) => props.onScanStatusChange(event.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./launch-scan-modal", () => ({
|
||||
LaunchScanModal: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog">
|
||||
Launch scan
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
MutedFindingsConfigButton: () => <a href="/mutelist">Configure Mutelist</a>,
|
||||
}));
|
||||
|
||||
const providers = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers" as const,
|
||||
attributes: {
|
||||
provider: "aws" as const,
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed" as const,
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("ScansPageShell", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
useScansStore.getState().closeLaunchScanModal();
|
||||
});
|
||||
|
||||
it("does not render an imported findings tab", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("tab", { name: /imported findings/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /import findings/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the shared scan filter bar for scan filters", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Shared scan filters")).toBeInTheDocument();
|
||||
expect(scansFilterBarSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providers,
|
||||
scheduleType: "all",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the active sort when switching tabs", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "tab=active&sort=trigger";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /completed/i }));
|
||||
|
||||
expect(pushMock).toHaveBeenCalled();
|
||||
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
|
||||
expect(calledUrl).toContain("tab=completed");
|
||||
expect(calledUrl).not.toContain("sort=");
|
||||
});
|
||||
|
||||
it("uses a generic type filter label in Cloud", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps launch scan with filters and mutelist with tabs", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("group", { name: /scan filters and actions/i }),
|
||||
).toContainElement(screen.getByRole("button", { name: /launch scan/i }));
|
||||
expect(
|
||||
screen.getByRole("group", { name: /scan filters and actions/i }),
|
||||
).not.toContainElement(
|
||||
screen.getByRole("link", { name: /configure mutelist/i }),
|
||||
);
|
||||
expect(screen.getByRole("group", { name: /scan tabs/i })).toContainElement(
|
||||
screen.getByRole("link", { name: /configure mutelist/i }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the active scans count in the in progress tab", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
|
||||
render(
|
||||
<ScansPageShell
|
||||
providers={providers}
|
||||
hasManageScansPermission
|
||||
activeScanCount={3}
|
||||
>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("tab", { name: /in progress \(3\)/i }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("tab", { name: /^completed$/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it("opens the launch scan modal from the URL", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "launchScan=true";
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
|
||||
});
|
||||
|
||||
it("strips the launchScan URL param when closing the URL-opened modal", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "tab=completed&launchScan=true";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(replaceMock).toHaveBeenCalledWith(
|
||||
"/scans?tab=completed",
|
||||
expect.objectContaining({ scroll: false }),
|
||||
);
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens and closes the launch scan modal from client state without navigation", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
const user = userEvent.setup();
|
||||
useScansStore.getState().openLaunchScanModal();
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent(/launch scan/i);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(pushMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the status filter only on the completed tab", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "tab=completed";
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /all statuses/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("hides the status filter outside of the completed tab", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "tab=active";
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: /all statuses/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears status filter when switching scan tabs", async () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
searchParamsValue.current = "tab=completed&filter%5Bstate__in%5D=failed";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ScansPageShell providers={providers} hasManageScansPermission>
|
||||
<div>Scans table</div>
|
||||
</ScansPageShell>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: /in progress/i }));
|
||||
|
||||
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
|
||||
expect(calledUrl).toContain("tab=active");
|
||||
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
LAUNCH_SCAN_SEARCH_PARAM,
|
||||
LAUNCH_SCAN_SEARCH_VALUE,
|
||||
} from "@/lib/scans-navigation";
|
||||
import { useScansStore } from "@/store";
|
||||
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
|
||||
import type { ProviderProps } from "@/types/providers";
|
||||
|
||||
import { LaunchScanModal } from "./launch-scan-modal";
|
||||
import { ScansFilterBar } from "./scans-filter-bar";
|
||||
import { useScansFilters } from "./use-scans-filters";
|
||||
|
||||
interface ScansPageShellProps {
|
||||
providers: ProviderProps[];
|
||||
hasManageScansPermission: boolean;
|
||||
activeScanCount?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ScansPageShell({
|
||||
providers,
|
||||
hasManageScansPermission,
|
||||
activeScanCount = 0,
|
||||
children,
|
||||
}: ScansPageShellProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [urlLaunchOpen, setUrlLaunchOpen] = useState(
|
||||
() =>
|
||||
searchParams.get(LAUNCH_SCAN_SEARCH_PARAM) === LAUNCH_SCAN_SEARCH_VALUE,
|
||||
);
|
||||
const isLaunchScanModalOpen = useScansStore(
|
||||
(state) => state.isLaunchScanModalOpen,
|
||||
);
|
||||
const setLaunchScanModalOpen = useScansStore(
|
||||
(state) => state.setLaunchScanModalOpen,
|
||||
);
|
||||
const filters = useScansFilters();
|
||||
const hasConnectedProviders = providers.some(
|
||||
(provider) => provider.attributes.connection.connected === true,
|
||||
);
|
||||
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
|
||||
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
|
||||
|
||||
const getTabLabel = (tab: ScanJobsTab) => {
|
||||
const label = SCAN_TAB_LABELS[tab];
|
||||
if (tab !== SCAN_JOBS_TAB.ACTIVE) return label;
|
||||
|
||||
return `${label} (${activeScanCount})`;
|
||||
};
|
||||
|
||||
const handleLaunchOpenChange = (open: boolean) => {
|
||||
setLaunchScanModalOpen(open);
|
||||
if (open) return;
|
||||
setUrlLaunchOpen(false);
|
||||
if (!searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Scan filters and actions"
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
>
|
||||
<ScansFilterBar
|
||||
providers={providers}
|
||||
activeTab={filters.activeTab}
|
||||
scheduleType={filters.scheduleType}
|
||||
scanStatus={filters.scanStatus}
|
||||
showStatusFilter={filters.showStatusFilter}
|
||||
onScheduleTypeChange={filters.setScheduleType}
|
||||
onScanStatusChange={filters.setScanStatus}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={() => handleLaunchOpenChange(true)}
|
||||
disabled={launchDisabled}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Launch Scan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={filters.activeTab}
|
||||
onValueChange={filters.setTab}
|
||||
className="flex flex-col gap-[18px]"
|
||||
>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Scan tabs"
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<TabsList className="overflow-x-auto">
|
||||
{Object.values(SCAN_JOBS_TAB).map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{getTabLabel(tab as ScanJobsTab)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="shrink-0">
|
||||
<MutedFindingsConfigButton />
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value={filters.activeTab} className="mt-0">
|
||||
{children}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<LaunchScanModal
|
||||
open={launchOpen}
|
||||
onOpenChange={handleLaunchOpenChange}
|
||||
providers={providers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("./no-providers-connected", () => ({
|
||||
NoProvidersConnected: () => <div>No Connected Providers</div>,
|
||||
}));
|
||||
|
||||
describe("ScansProvidersEmptyState", () => {
|
||||
it("shows the add provider message and opens the provider wizard", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("shows the no connected providers message", () => {
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
|
||||
|
||||
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
|
||||
import { NoProvidersAdded } from "./no-providers-added";
|
||||
import { NoProvidersConnected } from "./no-providers-connected";
|
||||
|
||||
interface ScansProvidersEmptyStateProps {
|
||||
thereIsNoProviders: boolean;
|
||||
}
|
||||
|
||||
export function ScansProvidersEmptyState({
|
||||
thereIsNoProviders,
|
||||
}: ScansProvidersEmptyStateProps) {
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
|
||||
) : (
|
||||
<NoProvidersConnected />
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={setIsProviderWizardOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SCAN_JOBS_TAB,
|
||||
type ScanAttributes,
|
||||
type ScanProps,
|
||||
type ScanTrigger,
|
||||
} from "@/types";
|
||||
|
||||
import {
|
||||
formatScanDuration,
|
||||
getScanAlias,
|
||||
getScanFindingsSummary,
|
||||
getScanJobsTab,
|
||||
getScanJobsTabFilters,
|
||||
getScanJobsUserFilters,
|
||||
getScanScheduleLabel,
|
||||
getScanStatusLabel,
|
||||
getScanTriggerFilterOptions,
|
||||
} from "./scans.utils";
|
||||
|
||||
const makeScan = (
|
||||
name: string | null,
|
||||
trigger: ScanTrigger = "manual",
|
||||
): ScanProps => ({
|
||||
type: "scans",
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
name: name ?? "",
|
||||
trigger,
|
||||
state: "completed",
|
||||
unique_resource_count: 0,
|
||||
progress: 100,
|
||||
scanner_args: null,
|
||||
duration: 0,
|
||||
started_at: "",
|
||||
inserted_at: "",
|
||||
completed_at: "",
|
||||
scheduled_at: "",
|
||||
next_scan_at: "",
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
task: { data: { type: "tasks", id: "task-1" } },
|
||||
},
|
||||
});
|
||||
|
||||
describe("scans.utils", () => {
|
||||
it("falls back to completed tab for unknown tab values", () => {
|
||||
expect(getScanJobsTab("unknown")).toBe(SCAN_JOBS_TAB.COMPLETED);
|
||||
expect(getScanJobsTab(SCAN_JOBS_TAB.COMPLETED)).toBe(
|
||||
SCAN_JOBS_TAB.COMPLETED,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps scan job tabs to the state filters expected by the API", () => {
|
||||
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE)).toEqual({
|
||||
"filter[state__in]": "available,executing",
|
||||
});
|
||||
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED)).toEqual({
|
||||
"filter[state__in]": "completed,failed,cancelled",
|
||||
});
|
||||
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.SCHEDULED)).toEqual({
|
||||
"filter[state__in]": "scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows tab state filters when a matching status is selected", () => {
|
||||
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed")).toEqual({
|
||||
"filter[state__in]": "failed",
|
||||
});
|
||||
expect(
|
||||
getScanJobsTabFilters(SCAN_JOBS_TAB.COMPLETED, "failed,cancelled"),
|
||||
).toEqual({
|
||||
"filter[state__in]": "failed,cancelled",
|
||||
});
|
||||
expect(getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE, "failed")).toEqual({
|
||||
"filter[state__in]": "available,executing",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps user filters while excluding scan state filters", () => {
|
||||
expect(
|
||||
getScanJobsUserFilters({
|
||||
tab: "completed",
|
||||
page: "2",
|
||||
"filter[provider_uid]": "123456789012",
|
||||
"filter[state__in]": "failed,cancelled",
|
||||
"filter[search]": "production",
|
||||
}),
|
||||
).toEqual({
|
||||
"filter[provider_uid]": "123456789012",
|
||||
"filter[search]": "production",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats scan labels and durations for table display", () => {
|
||||
expect(getScanAlias(makeScan(""))).toBe("-");
|
||||
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
|
||||
"scheduled scan",
|
||||
);
|
||||
expect(getScanAlias(makeScan("", "scheduled"))).toBe("scheduled scan");
|
||||
expect(getScanAlias(makeScan("Production scan"))).toBe("Production scan");
|
||||
expect(formatScanDuration(73)).toBe("1 min 13 sec");
|
||||
expect(formatScanDuration(null)).toBe("-");
|
||||
});
|
||||
|
||||
it("maps trigger and state values to product labels", () => {
|
||||
expect(getScanScheduleLabel("manual")).toBe("Manual");
|
||||
expect(getScanScheduleLabel("scheduled")).toBe("Scheduled");
|
||||
expect(getScanScheduleLabel("imported")).toBe("Imported");
|
||||
expect(getScanStatusLabel("available")).toBe("Queued");
|
||||
expect(getScanStatusLabel("completed")).toBe("Completed");
|
||||
});
|
||||
|
||||
it("includes imported in the trigger filter only for Cloud", () => {
|
||||
expect(getScanTriggerFilterOptions(false)).toEqual([
|
||||
{ value: "all", label: "All Types" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
]);
|
||||
expect(getScanTriggerFilterOptions(true)).toEqual([
|
||||
{ value: "all", label: "All Types" },
|
||||
{ value: "manual", label: "Manual" },
|
||||
{ value: "scheduled", label: "Scheduled" },
|
||||
{ value: "imported", label: "Imported" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads findings summary from root or nested API fields", () => {
|
||||
expect(
|
||||
getScanFindingsSummary({
|
||||
fail: 2,
|
||||
pass: 3,
|
||||
fail_new: 1,
|
||||
} as unknown as ScanAttributes),
|
||||
).toEqual({ fail: 2, pass: 3, failNew: 1 });
|
||||
|
||||
expect(
|
||||
getScanFindingsSummary({
|
||||
findings: {
|
||||
failed_findings: 4,
|
||||
passed_findings: 8,
|
||||
new_passed_findings: 2,
|
||||
},
|
||||
} as unknown as ScanAttributes),
|
||||
).toEqual({ fail: 4, pass: 8, passNew: 2 });
|
||||
|
||||
expect(getScanFindingsSummary(makeScan("x").attributes)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
DEFAULT_SCAN_JOBS_TAB,
|
||||
SCAN_JOBS_TAB,
|
||||
SCAN_STATE,
|
||||
SCAN_TRIGGER,
|
||||
type ScanAttributes,
|
||||
type ScanFindingsSummary,
|
||||
type ScanJobsTab,
|
||||
type ScanProps,
|
||||
type ScanState,
|
||||
type ScanTrigger,
|
||||
type SearchParamsProps,
|
||||
} from "@/types";
|
||||
|
||||
export const SCAN_STATE_FILTER_KEYS = [
|
||||
"filter[state]",
|
||||
"filter[state__in]",
|
||||
] as const;
|
||||
|
||||
const ALL_VALUE = "all";
|
||||
|
||||
const SCAN_JOBS_TAB_STATES: Record<ScanJobsTab, ScanState[]> = {
|
||||
[SCAN_JOBS_TAB.ACTIVE]: [SCAN_STATE.AVAILABLE, SCAN_STATE.EXECUTING],
|
||||
[SCAN_JOBS_TAB.COMPLETED]: [
|
||||
SCAN_STATE.COMPLETED,
|
||||
SCAN_STATE.FAILED,
|
||||
SCAN_STATE.CANCELLED,
|
||||
],
|
||||
[SCAN_JOBS_TAB.SCHEDULED]: [SCAN_STATE.SCHEDULED],
|
||||
};
|
||||
|
||||
const toStateFilter = (states: ScanState[]): Record<string, string> => ({
|
||||
"filter[state__in]": states.join(","),
|
||||
});
|
||||
|
||||
const SCAN_JOBS_TAB_FILTERS = Object.fromEntries(
|
||||
Object.entries(SCAN_JOBS_TAB_STATES).map(([tab, states]) => [
|
||||
tab,
|
||||
toStateFilter(states),
|
||||
]),
|
||||
) as Record<ScanJobsTab, Record<string, string>>;
|
||||
|
||||
export interface ScanTriggerFilterOption {
|
||||
value: typeof ALL_VALUE | ScanTrigger;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ScanStatusFilterOption {
|
||||
value: typeof ALL_VALUE | ScanState;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function getScanTriggerFilterOptions(
|
||||
isCloudEnvironment: boolean,
|
||||
): ScanTriggerFilterOption[] {
|
||||
const options: ScanTriggerFilterOption[] = [
|
||||
{ value: ALL_VALUE, label: "All Types" },
|
||||
{ value: SCAN_TRIGGER.MANUAL, label: "Manual" },
|
||||
{ value: SCAN_TRIGGER.SCHEDULED, label: "Scheduled" },
|
||||
];
|
||||
|
||||
if (isCloudEnvironment) {
|
||||
options.push({ value: SCAN_TRIGGER.IMPORTED, label: "Imported" });
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function isScanStateFilterKey(key: string): boolean {
|
||||
return SCAN_STATE_FILTER_KEYS.some((filterKey) => filterKey === key);
|
||||
}
|
||||
|
||||
function isSearchParamValue(value: unknown): value is string | string[] {
|
||||
return typeof value === "string" || Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getScanJobsUserFilters(
|
||||
searchParams: SearchParamsProps,
|
||||
): Record<string, string | string[]> {
|
||||
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
|
||||
(filters, [key, value]) => {
|
||||
if (
|
||||
key.startsWith("filter[") &&
|
||||
!isScanStateFilterKey(key) &&
|
||||
isSearchParamValue(value)
|
||||
) {
|
||||
filters[key] = value;
|
||||
}
|
||||
|
||||
return filters;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function parseStateFilter(value?: string | string[]): ScanState[] {
|
||||
const rawValue = Array.isArray(value) ? value.join(",") : value;
|
||||
if (!rawValue || rawValue === ALL_VALUE) return [];
|
||||
|
||||
return rawValue
|
||||
.split(",")
|
||||
.filter((item): item is ScanState =>
|
||||
Object.values(SCAN_STATE).includes(item as ScanState),
|
||||
);
|
||||
}
|
||||
|
||||
export function getScanJobsTab(value?: string | string[]): ScanJobsTab {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value;
|
||||
const tabs = Object.values(SCAN_JOBS_TAB);
|
||||
|
||||
return tabs.includes(rawValue as ScanJobsTab)
|
||||
? (rawValue as ScanJobsTab)
|
||||
: DEFAULT_SCAN_JOBS_TAB;
|
||||
}
|
||||
|
||||
export function getScanJobsTabFilters(
|
||||
tab: ScanJobsTab,
|
||||
stateFilter?: string | string[],
|
||||
): Record<string, string> {
|
||||
const selectedStates = parseStateFilter(stateFilter);
|
||||
const allowedStates = SCAN_JOBS_TAB_STATES[tab];
|
||||
const matchingStates = selectedStates.filter((state) =>
|
||||
allowedStates.includes(state),
|
||||
);
|
||||
|
||||
if (matchingStates.length === 0) return { ...SCAN_JOBS_TAB_FILTERS[tab] };
|
||||
|
||||
return { "filter[state__in]": matchingStates.join(",") };
|
||||
}
|
||||
|
||||
export function getScanAlias(scan: ScanProps): string {
|
||||
if (scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED)
|
||||
return "scheduled scan";
|
||||
return scan.attributes.name?.trim() || "-";
|
||||
}
|
||||
|
||||
export function formatScanDuration(duration?: number | null): string {
|
||||
if (duration === null || duration === undefined || duration < 0) return "-";
|
||||
|
||||
const totalSeconds = Math.round(duration);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
|
||||
if (hours > 0) return `${hours}h ${remainingMinutes}m ${seconds}s`;
|
||||
if (minutes > 0) return `${minutes} min ${seconds} sec`;
|
||||
return `${seconds} sec`;
|
||||
}
|
||||
|
||||
export function getScanScheduleLabel(trigger?: ScanTrigger | string): string {
|
||||
if (trigger === "scheduled") return "Scheduled";
|
||||
if (trigger === "manual") return "Manual";
|
||||
if (trigger === "imported") return "Imported";
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function getScanStatusLabel(state?: ScanState | string): string {
|
||||
if (state === "available") return "Queued";
|
||||
if (!state) return "-";
|
||||
return state.charAt(0).toUpperCase() + state.slice(1);
|
||||
}
|
||||
|
||||
export function getScanStatusFilterOptions(
|
||||
tab: ScanJobsTab,
|
||||
): ScanStatusFilterOption[] {
|
||||
return [
|
||||
{ value: ALL_VALUE, label: "All Statuses" },
|
||||
...SCAN_JOBS_TAB_STATES[tab].map((state) => ({
|
||||
value: state,
|
||||
label: getScanStatusLabel(state),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function getNumericValue(
|
||||
source: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getScanFindingsSummary(
|
||||
attributes: ScanAttributes,
|
||||
): ScanFindingsSummary | null {
|
||||
const root = attributes as unknown as Record<string, unknown>;
|
||||
const nested =
|
||||
typeof root.findings === "object" && root.findings !== null
|
||||
? (root.findings as Record<string, unknown>)
|
||||
: {};
|
||||
const source = { ...root, ...nested };
|
||||
|
||||
const fail = getNumericValue(source, [
|
||||
"fail",
|
||||
"failed",
|
||||
"failed_findings",
|
||||
"fail_findings",
|
||||
]);
|
||||
const pass = getNumericValue(source, [
|
||||
"pass",
|
||||
"passed",
|
||||
"passed_findings",
|
||||
"pass_findings",
|
||||
]);
|
||||
|
||||
if (fail === undefined || pass === undefined) return null;
|
||||
|
||||
return {
|
||||
fail,
|
||||
pass,
|
||||
failNew: getNumericValue(source, ["fail_new", "new_failed_findings"]),
|
||||
passNew: getNumericValue(source, ["pass_new", "new_passed_findings"]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import type { ProviderType, ScanProps } from "@/types";
|
||||
|
||||
export function AccountCell({ scan }: { scan: ScanProps }) {
|
||||
const providerInfo = scan.providerInfo;
|
||||
|
||||
if (!providerInfo) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[240px] min-w-0">
|
||||
<EntityInfo
|
||||
cloudProvider={providerInfo.provider as ProviderType}
|
||||
entityAlias={providerInfo.alias}
|
||||
entityId={providerInfo.uid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { AccountCell } from "./account-cell";
|
||||
export { ProgressCell } from "./progress-cell";
|
||||
export { ResourceCountCell } from "./resource-count-cell";
|
||||
export { ScanInfoCell } from "./scan-info-cell";
|
||||
export { ScheduleCell } from "./schedule-cell";
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Progress } from "@/components/shadcn";
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
export function ProgressCell({ scan }: { scan: ScanProps }) {
|
||||
const progress = scan.attributes.progress ?? 0;
|
||||
const isQueued = scan.attributes.state === "available";
|
||||
|
||||
if (isQueued) {
|
||||
return <Badge variant="warning">Queued for scan</Badge>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[220px] items-center gap-3">
|
||||
<Progress value={progress} className="h-2 min-w-[140px]" />
|
||||
<span className="text-text-neutral-secondary min-w-9 text-xs font-medium">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/shadcn";
|
||||
|
||||
export function ResourceCountCell({ count }: { count?: number }) {
|
||||
return (
|
||||
<Badge variant="tag" className="rounded text-sm">
|
||||
<span className="font-bold">{(count ?? 0).toLocaleString()}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { getScanAlias } from "@/components/scans/scans.utils";
|
||||
import { EntityInfo } from "@/components/ui/entities";
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
export function ScanInfoCell({ scan }: { scan: ScanProps }) {
|
||||
return (
|
||||
<div className="max-w-[240px] min-w-0">
|
||||
<EntityInfo
|
||||
entityAlias={getScanAlias(scan)}
|
||||
entityId={scan.id}
|
||||
idLabel="ID"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { getScanScheduleLabel } from "@/components/scans/scans.utils";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
export function ScheduleCell({ scan }: { scan: ScanProps }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-text-neutral-primary text-sm">
|
||||
{getScanScheduleLabel(scan.attributes.trigger)}
|
||||
</span>
|
||||
{scan.attributes.scheduled_at && (
|
||||
<DateWithTime dateTime={scan.attributes.scheduled_at} showTime />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,11 @@
|
||||
export * from "./scan-detail";
|
||||
export {
|
||||
AccountCell,
|
||||
ProgressCell,
|
||||
ResourceCountCell,
|
||||
ScanInfoCell,
|
||||
ScheduleCell,
|
||||
} from "./cells";
|
||||
export * from "./scan-jobs-columns";
|
||||
export * from "./scan-jobs-row-actions";
|
||||
export * from "./scan-jobs-table";
|
||||
export * from "./skeleton-table-scans";
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
|
||||
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
|
||||
import { StatusBadge } from "@/components/ui/table/status-badge";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types";
|
||||
|
||||
const renderValue = (value: string | null | undefined) => {
|
||||
return value && value.trim() !== "" ? value : "-";
|
||||
};
|
||||
|
||||
export const ScanDetail = ({
|
||||
scanDetails,
|
||||
}: {
|
||||
scanDetails: ScanProps & {
|
||||
taskDetails?: TaskDetails;
|
||||
// TODO: Remove the "?" once we have a proper provider details type
|
||||
providerDetails?: ProviderProps;
|
||||
};
|
||||
}) => {
|
||||
const scan = scanDetails.attributes;
|
||||
const taskDetails = scanDetails.taskDetails;
|
||||
const providerDetails = scanDetails.providerDetails?.attributes;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center">
|
||||
<StatusBadge
|
||||
size="md"
|
||||
className="w-fit"
|
||||
status={scan.state}
|
||||
loadingProgress={scan.progress}
|
||||
/>
|
||||
</div>
|
||||
<EntityInfo
|
||||
cloudProvider={providerDetails?.provider as ProviderType}
|
||||
entityAlias={providerDetails?.alias}
|
||||
entityId={providerDetails?.uid}
|
||||
showConnectionStatus={providerDetails?.connection.connected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scan Details */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Scan Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Scan Name">{renderValue(scan.name)}</InfoField>
|
||||
<InfoField label="Resources Scanned">
|
||||
{scan.unique_resource_count}
|
||||
</InfoField>
|
||||
<InfoField label="Progress">{scan.progress}%</InfoField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Trigger">{renderValue(scan.trigger)}</InfoField>
|
||||
<InfoField label="State">{renderValue(scan.state)}</InfoField>
|
||||
<InfoField label="Duration">
|
||||
{formatDuration(scan.duration)}
|
||||
</InfoField>
|
||||
</div>
|
||||
|
||||
<InfoField label="Scan ID" variant="simple">
|
||||
<CodeSnippet value={scanDetails.id} />
|
||||
</InfoField>
|
||||
|
||||
{scan.state === "failed" && taskDetails?.attributes.result && (
|
||||
<>
|
||||
{taskDetails.attributes.result.exc_message && (
|
||||
<InfoField label="Error Message" variant="simple">
|
||||
<CodeSnippet
|
||||
value={taskDetails.attributes.result.exc_message.join("\n")}
|
||||
multiline
|
||||
/>
|
||||
</InfoField>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<InfoField label="Error Type">
|
||||
{renderValue(taskDetails.attributes.result.exc_type)}
|
||||
</InfoField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoField label="Started At">
|
||||
<DateWithTime inline dateTime={scan.started_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Completed At">
|
||||
<DateWithTime inline dateTime={scan.completed_at || "-"} />
|
||||
</InfoField>
|
||||
<InfoField label="Scheduled At">
|
||||
<DateWithTime inline dateTime={scan.scheduled_at || "-"} />
|
||||
</InfoField>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { CellContext, HeaderContext } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
Progress: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: () => <time />,
|
||||
EntityInfo: ({
|
||||
entityAlias,
|
||||
entityId,
|
||||
idLabel,
|
||||
}: {
|
||||
entityAlias?: string;
|
||||
entityId?: string;
|
||||
idLabel?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{entityAlias}</span>
|
||||
<span>
|
||||
{idLabel}: {entityId}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom", () => ({
|
||||
TableLink: ({
|
||||
href,
|
||||
isDisabled,
|
||||
label,
|
||||
}: {
|
||||
href: string;
|
||||
isDisabled?: boolean;
|
||||
label: string;
|
||||
}) => (isDisabled ? <span>{label}</span> : <a href={href}>{label}</a>),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./scan-jobs-row-actions", () => ({
|
||||
ScanJobsRowActions: () => <button type="button" />,
|
||||
}));
|
||||
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||
|
||||
import { getScanJobsColumns } from "./scan-jobs-columns";
|
||||
|
||||
const getColumnIds = (tab: ScanJobsTab) =>
|
||||
getScanJobsColumns({ tab }).map((column) => column.id);
|
||||
|
||||
const makeCompletedScan = (): ScanProps => ({
|
||||
type: "scans",
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
name: "Production scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
unique_resource_count: 7,
|
||||
progress: 100,
|
||||
scanner_args: null,
|
||||
duration: 73,
|
||||
started_at: "2026-01-01T10:00:00Z",
|
||||
inserted_at: "2026-01-01T10:00:00Z",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
scheduled_at: "",
|
||||
next_scan_at: "",
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
task: { data: { type: "tasks", id: "task-1" } },
|
||||
},
|
||||
});
|
||||
|
||||
const renderCell = (
|
||||
columnId: string,
|
||||
scan: ScanProps,
|
||||
tab: ScanJobsTab = SCAN_JOBS_TAB.COMPLETED,
|
||||
) => {
|
||||
const column = getScanJobsColumns({
|
||||
tab,
|
||||
}).find((item) => item.id === columnId);
|
||||
const cell = column?.cell as
|
||||
| ((context: CellContext<ScanProps, unknown>) => React.ReactNode)
|
||||
| undefined;
|
||||
|
||||
if (!cell) throw new Error(`Column ${columnId} does not define a cell`);
|
||||
|
||||
render(
|
||||
<>{cell({ row: { original: scan } } as CellContext<ScanProps, unknown>)}</>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderHeader = (tab: ScanJobsTab, columnId: string) => {
|
||||
const column = getScanJobsColumns({ tab }).find(
|
||||
(item) => item.id === columnId,
|
||||
);
|
||||
const header = column?.header;
|
||||
|
||||
if (typeof header !== "function") {
|
||||
throw new Error(`Column ${columnId} does not define a header`);
|
||||
}
|
||||
|
||||
render(<>{header({ column: {} } as HeaderContext<ScanProps, unknown>)}</>);
|
||||
};
|
||||
|
||||
describe("getScanJobsColumns", () => {
|
||||
it("uses the expected columns for each scan tab", () => {
|
||||
expect(getColumnIds(SCAN_JOBS_TAB.ACTIVE)).toEqual([
|
||||
"account",
|
||||
"scanInfo",
|
||||
"progress",
|
||||
"scanSchedule",
|
||||
"launched",
|
||||
"actions",
|
||||
]);
|
||||
expect(getColumnIds(SCAN_JOBS_TAB.COMPLETED)).toEqual([
|
||||
"account",
|
||||
"scanInfo",
|
||||
"resources",
|
||||
"duration",
|
||||
"status",
|
||||
"scanSchedule",
|
||||
"scanDate",
|
||||
"actions",
|
||||
]);
|
||||
expect(getColumnIds(SCAN_JOBS_TAB.SCHEDULED)).toEqual([
|
||||
"account",
|
||||
"scanInfo",
|
||||
"scanSchedule",
|
||||
"nextScan",
|
||||
"actions",
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels the scan info column as Info in scan tables", () => {
|
||||
renderHeader(SCAN_JOBS_TAB.ACTIVE, "scanInfo");
|
||||
renderHeader(SCAN_JOBS_TAB.COMPLETED, "scanInfo");
|
||||
|
||||
expect(screen.getAllByText("Info")).toHaveLength(2);
|
||||
expect(screen.queryByText("Alias")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Scan Note")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the scan alias with the scan id underneath", () => {
|
||||
renderCell("scanInfo", makeCompletedScan());
|
||||
|
||||
expect(screen.getByText("Production scan")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: scan-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the completed duration column", () => {
|
||||
renderCell("duration", makeCompletedScan());
|
||||
|
||||
expect(screen.getByText("1 min 13 sec")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { StatusBadge } from "@/components/ui/table/status-badge";
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab, type ScanProps } from "@/types";
|
||||
|
||||
import { formatScanDuration } from "../scans.utils";
|
||||
import {
|
||||
AccountCell,
|
||||
ProgressCell,
|
||||
ResourceCountCell,
|
||||
ScanInfoCell,
|
||||
ScheduleCell,
|
||||
} from "./cells";
|
||||
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
|
||||
|
||||
interface GetScanJobsColumnsOptions {
|
||||
tab: ScanJobsTab;
|
||||
}
|
||||
|
||||
const accountColumn: ColumnDef<ScanProps> = {
|
||||
id: "account",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Provider" />
|
||||
),
|
||||
cell: ({ row }) => <AccountCell scan={row.original} />,
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const scanInfoColumn: ColumnDef<ScanProps> = {
|
||||
id: "scanInfo",
|
||||
accessorFn: (row) => row.attributes.name,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Info" param="name" />
|
||||
),
|
||||
cell: ({ row }) => <ScanInfoCell scan={row.original} />,
|
||||
};
|
||||
|
||||
const scanScheduleColumn: ColumnDef<ScanProps> = {
|
||||
id: "scanSchedule",
|
||||
accessorFn: (row) => row.attributes.trigger,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Schedule" param="trigger" />
|
||||
),
|
||||
cell: ({ row }) => <ScheduleCell scan={row.original} />,
|
||||
};
|
||||
|
||||
const resourcesColumn: ColumnDef<ScanProps> = {
|
||||
id: "resources",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Resources" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ResourceCountCell count={row.original.attributes.unique_resource_count} />
|
||||
),
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<ScanProps> = {
|
||||
id: "actions",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
|
||||
cell: ({ row }) => <ScanJobsRowActions scan={row.original} />,
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const durationColumn: ColumnDef<ScanProps> = {
|
||||
id: "duration",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Duration" />
|
||||
),
|
||||
cell: ({ row }) => formatScanDuration(row.original.attributes.duration),
|
||||
enableSorting: false,
|
||||
};
|
||||
|
||||
const activeColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
{
|
||||
id: "progress",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Progress" />
|
||||
),
|
||||
cell: ({ row }) => <ProgressCell scan={row.original} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
scanScheduleColumn,
|
||||
{
|
||||
id: "launched",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Launched" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime
|
||||
dateTime={
|
||||
row.original.attributes.started_at ||
|
||||
row.original.attributes.inserted_at
|
||||
}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
actionsColumn,
|
||||
];
|
||||
|
||||
const completedColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
resourcesColumn,
|
||||
durationColumn,
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.attributes.state} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
scanScheduleColumn,
|
||||
{
|
||||
id: "scanDate",
|
||||
accessorFn: (row) => row.attributes.completed_at,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title="Completed"
|
||||
param="updated_at"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DateWithTime dateTime={row.original.attributes.completed_at} />
|
||||
),
|
||||
},
|
||||
actionsColumn,
|
||||
];
|
||||
|
||||
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
|
||||
accountColumn,
|
||||
scanInfoColumn,
|
||||
scanScheduleColumn,
|
||||
/*
|
||||
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
|
||||
* {
|
||||
* id: "lastScan",
|
||||
* header: ({ column }) => (
|
||||
* <DataTableColumnHeader column={column} title="Last Run" />
|
||||
* ),
|
||||
* cell: ({ row }) => (
|
||||
* <DateWithTime dateTime={row.original.attributes.completed_at} />
|
||||
* ),
|
||||
* 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,
|
||||
];
|
||||
|
||||
export function getScanJobsColumns(
|
||||
options: GetScanJobsColumnsOptions,
|
||||
): ColumnDef<ScanProps>[] {
|
||||
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) return scheduledColumns();
|
||||
if (options.tab === SCAN_JOBS_TAB.ACTIVE) return activeColumns();
|
||||
return completedColumns();
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
|
||||
|
||||
const { downloadScanZipMock, getTaskMock, pushMock, toastMock } = vi.hoisted(
|
||||
() => ({
|
||||
downloadScanZipMock: vi.fn(),
|
||||
getTaskMock: vi.fn(),
|
||||
pushMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/task", () => ({
|
||||
getTask: getTaskMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
downloadScanZip: downloadScanZipMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/date-utils", () => ({
|
||||
toLocalDateString: (value: string | null | undefined) =>
|
||||
value ? "2026-01-01" : undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/scans/edit-alias-modal", () => ({
|
||||
EditAliasModal: ({
|
||||
open,
|
||||
currentAlias,
|
||||
}: {
|
||||
open: boolean;
|
||||
currentAlias: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Edit Alias">
|
||||
Editing {currentAlias}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const makeScan = (
|
||||
overrides: Partial<ScanProps["attributes"]> = {},
|
||||
): ScanProps => ({
|
||||
type: "scans",
|
||||
id: "scan-1",
|
||||
attributes: {
|
||||
name: "Production scan",
|
||||
trigger: "scheduled",
|
||||
state: "scheduled",
|
||||
unique_resource_count: 0,
|
||||
progress: 0,
|
||||
scanner_args: null,
|
||||
duration: 0,
|
||||
started_at: "",
|
||||
inserted_at: "",
|
||||
completed_at: "",
|
||||
scheduled_at: "",
|
||||
next_scan_at: "",
|
||||
...overrides,
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
task: { data: { type: "tasks", id: "task-1" } },
|
||||
},
|
||||
});
|
||||
|
||||
describe("ScanJobsRowActions", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens the Edit modal seeded with the current scan name", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan()} />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /^edit$/i }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: /edit alias/i }),
|
||||
).toHaveTextContent("Editing Production scan");
|
||||
});
|
||||
|
||||
it("does not render the legacy Edit Scan Schedule option", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan()} />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /edit scan schedule/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render cancel scan while the scan cancellation API is missing", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan()} />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /cancel scan/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links completed scans to compliance from the actions menu", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("menuitem", { name: /view compliance/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(pushMock).toHaveBeenCalledWith("/compliance?scanId=scan-1");
|
||||
});
|
||||
|
||||
it("renames the completed scan report download action", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /download scan reports/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /download findings/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links completed scans to filtered findings", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: /view findings/i }));
|
||||
|
||||
// Then
|
||||
expect(pushMock).toHaveBeenCalledWith(
|
||||
"/findings?filter[scan]=scan-1&filter[inserted_at]=2026-01-01&filter[status__in]=FAIL",
|
||||
);
|
||||
});
|
||||
|
||||
it("triggers downloadScanZip with the scan id when downloading reports", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({
|
||||
state: "completed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("menuitem", { name: /download scan reports/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(downloadScanZipMock).toHaveBeenCalledWith("scan-1", toastMock);
|
||||
});
|
||||
|
||||
it("opens failed scan error details from the actions menu", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
getTaskMock.mockResolvedValue({
|
||||
data: {
|
||||
attributes: {
|
||||
result: {
|
||||
exc_type: "ValidationError",
|
||||
exc_message: ["Missing cloud credentials", "Retry scan setup"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ScanJobsRowActions
|
||||
scan={makeScan({
|
||||
state: "failed",
|
||||
completed_at: "2026-01-01T10:05:00Z",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("menuitem", { name: /view error details/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(getTaskMock).toHaveBeenCalledWith("task-1");
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: /scan error details/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ValidationError")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Missing cloud credentials/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Retry scan setup/)).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /copy error details/i }),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"ErrorType: ValidationError\nError: Missing cloud credentials\nRetry scan setup",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show error details for non-failed scans", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScanJobsRowActions scan={makeScan({ state: "completed" })} />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open actions menu/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: /view error details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getTask } from "@/actions/task";
|
||||
import { getScanErrorDetails } from "@/actions/task/task.adapter";
|
||||
import { EditAliasModal } from "@/components/scans/edit-alias-modal";
|
||||
import {
|
||||
ScanErrorDetailsModal,
|
||||
type ScanErrorDetailsState,
|
||||
} from "@/components/scans/scan-error-details-modal";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { toLocalDateString } from "@/lib/date-utils";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
import type { ScanProps } from "@/types";
|
||||
|
||||
interface ScanJobsRowActionsProps {
|
||||
scan: ScanProps;
|
||||
}
|
||||
|
||||
export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
const [errorState, setErrorState] = useState<ScanErrorDetailsState>({
|
||||
kind: "idle",
|
||||
});
|
||||
const scanState = scan.attributes.state;
|
||||
const isCompleted = scanState === "completed";
|
||||
const isFailed = scanState === "failed";
|
||||
const taskId = scan.relationships.task.data?.id;
|
||||
const scanDate = toLocalDateString(scan.attributes.completed_at);
|
||||
|
||||
const openFindings = () => {
|
||||
if (!isCompleted || !scanDate) return;
|
||||
router.push(
|
||||
`/findings?filter[scan]=${scan.id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`,
|
||||
);
|
||||
};
|
||||
|
||||
const openCompliance = () => {
|
||||
if (!isCompleted) return;
|
||||
router.push(`/compliance?scanId=${scan.id}`);
|
||||
};
|
||||
|
||||
const openErrorDetails = async () => {
|
||||
setErrorOpen(true);
|
||||
setErrorState({ kind: "loading" });
|
||||
|
||||
if (!taskId) {
|
||||
setErrorState({
|
||||
kind: "error",
|
||||
message: "Task ID is not available for this scan.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: unknown = await getTask(taskId);
|
||||
|
||||
if (
|
||||
typeof response === "object" &&
|
||||
response !== null &&
|
||||
"error" in response &&
|
||||
typeof (response as { error: unknown }).error === "string"
|
||||
) {
|
||||
setErrorState({
|
||||
kind: "error",
|
||||
message: (response as { error: string }).error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const details = getScanErrorDetails(response);
|
||||
|
||||
if (!details) {
|
||||
setErrorState({
|
||||
kind: "error",
|
||||
message: "No error details were found for this failed scan.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorState({ kind: "loaded", details });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<ActionDropdown>
|
||||
{isCompleted && (
|
||||
<>
|
||||
<ActionDropdownItem
|
||||
icon={<Eye />}
|
||||
label="View Findings"
|
||||
onSelect={openFindings}
|
||||
disabled={!isCompleted || !scanDate}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<ShieldCheck />}
|
||||
label="View Compliance"
|
||||
onSelect={openCompliance}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Download />}
|
||||
label="Download Scan Reports"
|
||||
onSelect={() => downloadScanZip(scan.id, toast)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isFailed && (
|
||||
<ActionDropdownItem
|
||||
icon={<TriangleAlert />}
|
||||
label="View error details"
|
||||
onSelect={() => void openErrorDetails()}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Expand Edit to also cover schedule once the backend exposes a schedule update endpoint. */}
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit"
|
||||
onSelect={() => setEditOpen(true)}
|
||||
/>
|
||||
{/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */}
|
||||
</ActionDropdown>
|
||||
|
||||
<EditAliasModal
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
scanId={scan.id}
|
||||
currentAlias={scan.attributes.name ?? ""}
|
||||
/>
|
||||
|
||||
<ScanErrorDetailsModal
|
||||
open={errorOpen}
|
||||
onOpenChange={setErrorOpen}
|
||||
state={errorState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SCAN_JOBS_TAB, type ScanProps } from "@/types";
|
||||
|
||||
import { ScanJobsTable } from "./scan-jobs-table";
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTable: ({ data }: { data: ScanProps[] }) => (
|
||||
<div data-testid="scan-jobs-data-table">{data.length}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./scan-jobs-columns", () => ({
|
||||
getScanJobsColumns: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../auto-refresh", () => ({
|
||||
AutoRefresh: ({ hasExecutingScan }: { hasExecutingScan: boolean }) => (
|
||||
<div data-testid="scan-jobs-auto-refresh">{String(hasExecutingScan)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../no-scans-empty-state", () => ({
|
||||
NoScansEmptyState: ({ tab }: { tab: string }) => (
|
||||
<div data-testid="no-scans-empty-state">{tab}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const makeScan = (state: ScanProps["attributes"]["state"]): ScanProps => ({
|
||||
type: "scans",
|
||||
id: `scan-${state}`,
|
||||
attributes: {
|
||||
name: "Production scan",
|
||||
trigger: "manual",
|
||||
state,
|
||||
unique_resource_count: 0,
|
||||
progress: 100,
|
||||
scanner_args: null,
|
||||
duration: 0,
|
||||
started_at: "",
|
||||
inserted_at: "",
|
||||
completed_at: "",
|
||||
scheduled_at: "",
|
||||
next_scan_at: "",
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "provider-1" } },
|
||||
task: { data: { type: "tasks", id: "task-1" } },
|
||||
},
|
||||
});
|
||||
|
||||
describe("ScanJobsTable", () => {
|
||||
it("enables auto refresh while queued or executing scans are visible", () => {
|
||||
render(
|
||||
<ScanJobsTable
|
||||
data={[makeScan("available"), makeScan("completed")]}
|
||||
tab={SCAN_JOBS_TAB.ACTIVE}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("scan-jobs-auto-refresh")).toHaveTextContent(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables auto refresh when visible scans are not running", () => {
|
||||
render(
|
||||
<ScanJobsTable
|
||||
data={[makeScan("completed"), makeScan("failed")]}
|
||||
tab={SCAN_JOBS_TAB.COMPLETED}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("scan-jobs-auto-refresh")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the empty state when there are no scans and no filters applied", () => {
|
||||
render(<ScanJobsTable data={[]} tab={SCAN_JOBS_TAB.ACTIVE} />);
|
||||
|
||||
expect(screen.getByTestId("no-scans-empty-state")).toHaveTextContent(
|
||||
SCAN_JOBS_TAB.ACTIVE,
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("scan-jobs-data-table"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the data table when there are no scans but filters are applied", () => {
|
||||
render(<ScanJobsTable data={[]} tab={SCAN_JOBS_TAB.ACTIVE} hasFilters />);
|
||||
|
||||
expect(screen.getByTestId("scan-jobs-data-table")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("no-scans-empty-state"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import type { MetaDataProps, ScanJobsTab, ScanProps } from "@/types";
|
||||
|
||||
import { AutoRefresh } from "../auto-refresh";
|
||||
import { NoScansEmptyState } from "../no-scans-empty-state";
|
||||
import { getScanJobsColumns } from "./scan-jobs-columns";
|
||||
|
||||
interface ScanJobsTableProps {
|
||||
data: ScanProps[];
|
||||
meta?: MetaDataProps;
|
||||
tab: ScanJobsTab;
|
||||
hasFilters?: boolean;
|
||||
}
|
||||
|
||||
const REFRESHING_STATES = ["available", "executing"] as const;
|
||||
|
||||
export function ScanJobsTable({
|
||||
data,
|
||||
meta,
|
||||
tab,
|
||||
hasFilters = false,
|
||||
}: ScanJobsTableProps) {
|
||||
const hasRefreshingScan = data.some((scan) =>
|
||||
REFRESHING_STATES.includes(
|
||||
scan.attributes.state as (typeof REFRESHING_STATES)[number],
|
||||
),
|
||||
);
|
||||
const columns = getScanJobsColumns({ tab });
|
||||
const showEmptyState = data.length === 0 && !hasFilters;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoRefresh hasExecutingScan={hasRefreshingScan} />
|
||||
{showEmptyState ? (
|
||||
<NoScansEmptyState tab={tab} />
|
||||
) : (
|
||||
<DataTable
|
||||
key={`scan-jobs-${tab}-${meta?.pagination?.page ?? 1}`}
|
||||
columns={columns}
|
||||
data={data}
|
||||
metadata={meta}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("column-get-scans", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "column-get-scans.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("links scan findings to the historical finding-groups filters", () => {
|
||||
expect(source).toContain("filter[scan]=");
|
||||
expect(source).toContain("filter[inserted_at]=");
|
||||
expect(source).not.toContain("filter[scan__in]");
|
||||
});
|
||||
|
||||
it("links the findings filter against the scan's completed_at (what the backend expects)", () => {
|
||||
expect(source).toMatch(/attributes:\s*{\s*completed_at\s*}/);
|
||||
expect(source).toMatch(/toLocalDateString\(completed_at\)/);
|
||||
});
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { InfoIcon } from "@/components/icons";
|
||||
import { TableLink } from "@/components/ui/custom";
|
||||
import { DateWithTime, EntityInfo } from "@/components/ui/entities";
|
||||
import { TriggerSheet } from "@/components/ui/sheet";
|
||||
import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table";
|
||||
import { toLocalDateString } from "@/lib/date-utils";
|
||||
import { ProviderType, ScanProps } from "@/types";
|
||||
|
||||
import { TriggerIcon } from "../../trigger-icon";
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { DataTableRowDetails } from "./data-table-row-details";
|
||||
|
||||
const getScanData = (row: { original: ScanProps }) => {
|
||||
return row.original;
|
||||
};
|
||||
|
||||
const ScanDetailsCell = ({ row }: { row: any }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const scanId = searchParams.get("scanId");
|
||||
const isOpen = scanId === row.original.id;
|
||||
const scanState = row.original.attributes?.state;
|
||||
const isExecuting = scanState === "executing" || scanState === "available";
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (isExecuting) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (open) {
|
||||
params.set("scanId", row.original.id);
|
||||
} else {
|
||||
params.delete("scanId");
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-9 items-center justify-center">
|
||||
<TriggerSheet
|
||||
triggerComponent={
|
||||
<InfoIcon
|
||||
className={
|
||||
isExecuting ? "cursor-default text-gray-400" : "text-primary"
|
||||
}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
title="Scan Details"
|
||||
description="View the scan details"
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{isOpen && <DataTableRowDetails entityId={row.original.id} />}
|
||||
</TriggerSheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnGetScans: ColumnDef<ScanProps>[] = [
|
||||
{
|
||||
id: "moreInfo",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Details" />
|
||||
),
|
||||
cell: ({ row }) => <ScanDetailsCell row={row} />,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "cloudProvider",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Provider" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const providerInfo = row.original.providerInfo;
|
||||
|
||||
if (!providerInfo) {
|
||||
return <span className="font-medium">No provider info</span>;
|
||||
}
|
||||
|
||||
const { provider, uid, alias } = providerInfo;
|
||||
|
||||
return (
|
||||
<EntityInfo
|
||||
cloudProvider={provider as ProviderType}
|
||||
entityAlias={alias}
|
||||
entityId={uid}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { state },
|
||||
} = getScanData(row);
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<StatusBadge
|
||||
status={state}
|
||||
loadingProgress={row.original.attributes.progress}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "findings",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Findings" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
id,
|
||||
attributes: { completed_at },
|
||||
} = getScanData(row);
|
||||
const scanState = row.original.attributes?.state;
|
||||
// Source is `completed_at` (scan finish time) because findings are
|
||||
// persisted when the scan ends — that's when their `inserted_at` is
|
||||
// written. The URL key stays `filter[inserted_at]` because the findings
|
||||
// table is partitioned by the finding's `inserted_at` date; this filter
|
||||
// is the partition hint the backend uses to avoid scanning every
|
||||
// partition. Names differ by design: scan.completed_at ≈ finding.inserted_at.
|
||||
const scanDate = toLocalDateString(completed_at);
|
||||
return (
|
||||
<TableLink
|
||||
href={`/findings?filter[scan]=${id}&filter[inserted_at]=${scanDate}&filter[status__in]=FAIL`}
|
||||
isDisabled={scanState !== "completed" || !scanDate}
|
||||
label="See Findings"
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "compliance",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Compliance" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { id } = getScanData(row);
|
||||
const scanState = row.original.attributes?.state;
|
||||
return (
|
||||
<TableLink
|
||||
href={`/compliance?scanId=${id}`}
|
||||
isDisabled={!["completed"].includes(scanState)}
|
||||
label="See Compliance"
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "resources",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Impacted Resources" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { unique_resource_count },
|
||||
} = getScanData(row);
|
||||
return (
|
||||
<div className="flex w-fit items-center justify-center">
|
||||
<span className="text-xs font-medium">{unique_resource_count}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "started_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Started at" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { started_at },
|
||||
} = getScanData(row);
|
||||
|
||||
return (
|
||||
<div className="w-[100px]">
|
||||
<DateWithTime dateTime={started_at} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "scheduled_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Scheduled at" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { scheduled_at },
|
||||
} = getScanData(row);
|
||||
return <DateWithTime dateTime={scheduled_at} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "completed_at",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Completed at"}
|
||||
param="updated_at"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { completed_at },
|
||||
} = getScanData(row);
|
||||
return <DateWithTime dateTime={completed_at} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trigger",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader
|
||||
column={column}
|
||||
title={"Trigger"}
|
||||
param="trigger"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { trigger },
|
||||
} = getScanData(row);
|
||||
return (
|
||||
<div className="flex w-9 items-center justify-center">
|
||||
<TriggerIcon trigger={trigger} iconSize={16} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "scanName",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={"Scan name"} param="name" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const {
|
||||
attributes: { name },
|
||||
} = getScanData(row);
|
||||
|
||||
if (!name || name.length === 0) {
|
||||
return <span className="font-medium">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex w-fit items-center justify-center">
|
||||
<span className="text-xs font-medium">
|
||||
{name === "Daily scheduled scan" ? "scheduled scan" : name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
|
||||
cell: ({ row }) => {
|
||||
return <DataTableRowActions row={row} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Download, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
|
||||
import { EditScanForm } from "../../forms";
|
||||
|
||||
interface DataTableRowActionsProps<ScanProps> {
|
||||
row: Row<ScanProps>;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<ScanProps>({
|
||||
row,
|
||||
}: DataTableRowActionsProps<ScanProps>) {
|
||||
const { toast } = useToast();
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const scanId = (row.original as { id: string }).id;
|
||||
const scanName = (row.original as any).attributes?.name;
|
||||
const scanState = (row.original as any).attributes?.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={isEditOpen}
|
||||
onOpenChange={setIsEditOpen}
|
||||
title="Edit Scan Name"
|
||||
>
|
||||
<EditScanForm
|
||||
scanId={scanId}
|
||||
scanName={scanName}
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Download />}
|
||||
label="Download .zip"
|
||||
description="Available only for completed scans"
|
||||
onSelect={() => downloadScanZip(scanId, toast)}
|
||||
disabled={scanState !== "completed"}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Scan Name"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getProvider } from "@/actions/providers";
|
||||
import { getScan } from "@/actions/scans";
|
||||
import { getTask } from "@/actions/task";
|
||||
import { ScanDetail } from "@/components/scans/table";
|
||||
import { checkTaskStatus } from "@/lib";
|
||||
import { ScanProps } from "@/types";
|
||||
|
||||
import { SkeletonScanDetail } from "./skeleton-scan-detail";
|
||||
|
||||
export const DataTableRowDetails = ({ entityId }: { entityId: string }) => {
|
||||
const [scanDetails, setScanDetails] = useState<ScanProps | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScanDetails = async () => {
|
||||
try {
|
||||
const result = await getScan(entityId);
|
||||
|
||||
const taskId = result.data.relationships.task?.data?.id;
|
||||
const providerId = result.data.relationships.provider?.data?.id;
|
||||
|
||||
let providerDetails = null;
|
||||
if (providerId) {
|
||||
const formData = new FormData();
|
||||
formData.append("id", providerId);
|
||||
const providerResult = await getProvider(formData);
|
||||
providerDetails = providerResult.data;
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
const taskResult = await checkTaskStatus(taskId);
|
||||
|
||||
if (taskResult.completed !== undefined) {
|
||||
const task = await getTask(taskId);
|
||||
setScanDetails({
|
||||
...result.data,
|
||||
taskDetails: task.data,
|
||||
providerDetails: providerDetails,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setScanDetails({
|
||||
...result.data,
|
||||
providerDetails: providerDetails,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in fetchScanDetails:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScanDetails();
|
||||
}, [entityId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <SkeletonScanDetail />;
|
||||
}
|
||||
|
||||
if (!scanDetails) {
|
||||
return <div>No scan details available</div>;
|
||||
}
|
||||
|
||||
return <ScanDetail scanDetails={scanDetails} />;
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./column-get-scans";
|
||||
export * from "./data-table-row-actions";
|
||||
export * from "./data-table-row-details";
|
||||
export * from "./scans-table-with-polling";
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types";
|
||||
|
||||
import { ColumnGetScans } from "./column-get-scans";
|
||||
|
||||
export const SCAN_LAUNCHED_EVENT = "scan-launched";
|
||||
|
||||
interface ScansTableWithPollingProps {
|
||||
initialData: ScanProps[];
|
||||
initialMeta?: MetaDataProps;
|
||||
searchParams: SearchParamsProps;
|
||||
}
|
||||
|
||||
const EXECUTING_STATES = ["executing", "available"] as const;
|
||||
|
||||
function expandScansWithProviderInfo(
|
||||
scans: ScanProps[],
|
||||
included?: Array<{ type: string; id: string; attributes: any }>,
|
||||
) {
|
||||
return (
|
||||
scans?.map((scan) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
|
||||
if (!providerId) {
|
||||
return { ...scan, providerInfo: undefined };
|
||||
}
|
||||
|
||||
const providerData = included?.find(
|
||||
(item) => item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
|
||||
if (!providerData) {
|
||||
return { ...scan, providerInfo: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
...scan,
|
||||
providerInfo: {
|
||||
provider: providerData.attributes.provider,
|
||||
uid: providerData.attributes.uid,
|
||||
alias: providerData.attributes.alias,
|
||||
},
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
}
|
||||
|
||||
export function ScansTableWithPolling({
|
||||
initialData,
|
||||
initialMeta,
|
||||
searchParams,
|
||||
}: ScansTableWithPollingProps) {
|
||||
const [scansData, setScansData] = useState<ScanProps[]>(initialData);
|
||||
const [meta, setMeta] = useState<MetaDataProps | undefined>(initialMeta);
|
||||
|
||||
// Sync state with server data when props change (e.g., pagination or filter changes).
|
||||
// useState only uses its argument on first mount, so without this effect,
|
||||
// navigating to page 2 would change the URL but keep showing page 1 data.
|
||||
useEffect(() => {
|
||||
setScansData(initialData);
|
||||
setMeta(initialMeta);
|
||||
}, [initialData, initialMeta]);
|
||||
|
||||
const hasExecutingScan = scansData.some((scan) =>
|
||||
EXECUTING_STATES.includes(
|
||||
scan.attributes.state as (typeof EXECUTING_STATES)[number],
|
||||
),
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const sort = searchParams.sort?.toString();
|
||||
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(searchParams).filter(
|
||||
([key]) => key.startsWith("filter[") && key !== "scanId",
|
||||
),
|
||||
);
|
||||
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
const result = await getScans({
|
||||
query,
|
||||
page,
|
||||
sort,
|
||||
filters,
|
||||
pageSize,
|
||||
include: "provider",
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
const expanded = expandScansWithProviderInfo(
|
||||
result.data,
|
||||
result.included,
|
||||
);
|
||||
setScansData(expanded);
|
||||
|
||||
if (result && "meta" in result) {
|
||||
setMeta(result.meta as MetaDataProps);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Listen for scan launch events to trigger an immediate refresh
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
handleRefresh();
|
||||
};
|
||||
window.addEventListener(SCAN_LAUNCHED_EVENT, handler);
|
||||
return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler);
|
||||
}, [handleRefresh]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoRefresh
|
||||
hasExecutingScan={hasExecutingScan}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<DataTable
|
||||
key={`scans-${scansData.length}-${meta?.pagination?.page}`}
|
||||
columns={ColumnGetScans}
|
||||
data={scansData}
|
||||
metadata={meta}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
export const SkeletonScanDetail = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 rounded-lg">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-8 w-24 rounded-full" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scan Details Section Skeleton */}
|
||||
<Card variant="base" padding="lg">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{/* First grid row */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={`grid1-${index}`} className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Second grid row */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={`grid2-${index}`} className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scan ID field */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
|
||||
{/* Third grid row */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={`grid3-${index}`} className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +1,247 @@
|
||||
import React from "react";
|
||||
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import {
|
||||
DEFAULT_SCAN_JOBS_TAB,
|
||||
SCAN_JOBS_TAB,
|
||||
type ScanJobsTab,
|
||||
} from "@/types";
|
||||
|
||||
const AccountCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="size-9 rounded-xl" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-3.5 w-20 rounded" />
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex h-6 w-32 items-center gap-1 rounded-full border-2 px-2">
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="h-3.5 w-3.5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ScanInfoCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-3.5 w-32 rounded" />
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary flex h-6 w-32 items-center gap-1 rounded-full border-2 px-2">
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="h-3.5 w-3.5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ProgressCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex min-w-[220px] items-center gap-3">
|
||||
<Skeleton className="h-2 w-[140px] rounded-full" />
|
||||
<Skeleton className="h-3.5 w-9 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ScheduleCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
<Skeleton className="h-3 w-32 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const DateCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ResourcesCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-6 w-12 rounded" />
|
||||
</td>
|
||||
);
|
||||
|
||||
const DurationCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
</td>
|
||||
);
|
||||
|
||||
const StatusCellSkeleton = () => (
|
||||
<td className="px-3 py-4">
|
||||
<Skeleton className="h-6 w-20 rounded-md" />
|
||||
</td>
|
||||
);
|
||||
|
||||
const ActionsCellSkeleton = () => (
|
||||
<td className="px-2 py-4">
|
||||
<Skeleton className="size-7 rounded" />
|
||||
</td>
|
||||
);
|
||||
|
||||
const HeaderLabel = ({
|
||||
width,
|
||||
sortable = false,
|
||||
}: {
|
||||
width: string;
|
||||
sortable?: boolean;
|
||||
}) => (
|
||||
<div className="flex h-8 items-center gap-1">
|
||||
<Skeleton className={`h-4 ${width} rounded`} />
|
||||
{sortable && <Skeleton className="size-3.5 rounded" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ColumnDescriptor {
|
||||
headerWidth: string;
|
||||
sortable?: boolean;
|
||||
Cell: () => React.JSX.Element;
|
||||
}
|
||||
|
||||
const ACCOUNT_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-14",
|
||||
Cell: AccountCellSkeleton,
|
||||
};
|
||||
const SCAN_INFO_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-8",
|
||||
sortable: true,
|
||||
Cell: ScanInfoCellSkeleton,
|
||||
};
|
||||
const PROGRESS_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-14",
|
||||
Cell: ProgressCellSkeleton,
|
||||
};
|
||||
const SCHEDULE_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-14",
|
||||
sortable: true,
|
||||
Cell: ScheduleCellSkeleton,
|
||||
};
|
||||
const LAUNCHED_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-14",
|
||||
Cell: DateCellSkeleton,
|
||||
};
|
||||
const RESOURCES_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-16",
|
||||
Cell: ResourcesCellSkeleton,
|
||||
};
|
||||
const DURATION_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-14",
|
||||
Cell: DurationCellSkeleton,
|
||||
};
|
||||
const STATUS_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-10",
|
||||
Cell: StatusCellSkeleton,
|
||||
};
|
||||
const COMPLETED_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-16",
|
||||
sortable: true,
|
||||
Cell: DateCellSkeleton,
|
||||
};
|
||||
const NEXT_RUN_COLUMN: ColumnDescriptor = {
|
||||
headerWidth: "w-14",
|
||||
sortable: true,
|
||||
Cell: DateCellSkeleton,
|
||||
};
|
||||
|
||||
const COLUMNS_BY_TAB: Record<ScanJobsTab, ColumnDescriptor[]> = {
|
||||
[SCAN_JOBS_TAB.ACTIVE]: [
|
||||
ACCOUNT_COLUMN,
|
||||
SCAN_INFO_COLUMN,
|
||||
PROGRESS_COLUMN,
|
||||
SCHEDULE_COLUMN,
|
||||
LAUNCHED_COLUMN,
|
||||
],
|
||||
[SCAN_JOBS_TAB.COMPLETED]: [
|
||||
ACCOUNT_COLUMN,
|
||||
SCAN_INFO_COLUMN,
|
||||
RESOURCES_COLUMN,
|
||||
DURATION_COLUMN,
|
||||
STATUS_COLUMN,
|
||||
SCHEDULE_COLUMN,
|
||||
COMPLETED_COLUMN,
|
||||
],
|
||||
[SCAN_JOBS_TAB.SCHEDULED]: [
|
||||
ACCOUNT_COLUMN,
|
||||
SCAN_INFO_COLUMN,
|
||||
SCHEDULE_COLUMN,
|
||||
NEXT_RUN_COLUMN,
|
||||
],
|
||||
};
|
||||
|
||||
interface SkeletonTableScansProps {
|
||||
tab?: ScanJobsTab;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const SkeletonTableScans = ({
|
||||
tab = DEFAULT_SCAN_JOBS_TAB,
|
||||
rows = 6,
|
||||
}: SkeletonTableScansProps = {}) => {
|
||||
const columns = COLUMNS_BY_TAB[tab];
|
||||
|
||||
export const SkeletonTableScans = () => {
|
||||
return (
|
||||
<Card variant="base" padding="md" className="flex flex-col gap-4">
|
||||
{/* Table headers */}
|
||||
<div className="hidden gap-4 md:flex">
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-2/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<Skeleton className="h-8 w-1/12" />
|
||||
<div className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col justify-between gap-4 overflow-hidden border p-4">
|
||||
{/* Toolbar — mirrors DataTable's flex-col → md:flex-row layout (no search, only total entries) */}
|
||||
<div className="flex flex-col items-start gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="w-full md:w-auto" />
|
||||
<div className="flex w-full flex-col items-start gap-2 md:ml-auto md:w-auto md:flex-row md:items-center md:gap-4">
|
||||
<Skeleton className="h-4 w-28 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table body */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<Skeleton className="h-12 w-full md:w-1/12" />
|
||||
<Skeleton className="h-12 w-full md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-2/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-1/12" />
|
||||
<Skeleton className="hidden h-12 md:block md:w-1/12" />
|
||||
{/* Table */}
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-border-neutral-secondary border-b">
|
||||
{columns.map((column, i) => (
|
||||
<th key={i} className="px-3 py-3 text-left">
|
||||
<HeaderLabel
|
||||
width={column.headerWidth}
|
||||
sortable={column.sortable}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
{/* Actions - empty header */}
|
||||
<th className="w-10 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className="border-border-neutral-secondary border-b last:border-b-0"
|
||||
>
|
||||
{columns.map(({ Cell }, colIdx) => (
|
||||
<Cell key={colIdx} />
|
||||
))}
|
||||
<ActionsCellSkeleton />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination — mirrors DataTablePagination's "justify-end gap-6 py-1.5" */}
|
||||
<div className="flex w-full items-center justify-end gap-6 py-1.5">
|
||||
{/* Rows per page group */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<Skeleton className="h-8 w-12 rounded-full" />
|
||||
</div>
|
||||
{/* Page info + 4 chevron nav buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="hidden h-3 w-20 rounded sm:block" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-6 rounded" />
|
||||
<Skeleton className="size-6 rounded" />
|
||||
<Skeleton className="size-6 rounded" />
|
||||
<Skeleton className="size-6 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Tooltip } from "@heroui/tooltip";
|
||||
|
||||
import { ManualIcon, ScheduleIcon } from "@/components/icons";
|
||||
|
||||
interface TriggerIconProps {
|
||||
trigger: "scheduled" | "manual";
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export function TriggerIcon({ trigger, iconSize = 24 }: TriggerIconProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
className="text-xs"
|
||||
content={trigger === "scheduled" ? "Scheduled" : "Manual"}
|
||||
>
|
||||
<div className="h-fit">
|
||||
{trigger === "scheduled" ? (
|
||||
<ScheduleIcon size={iconSize} />
|
||||
) : (
|
||||
<ManualIcon size={iconSize} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { getScanJobsTab } from "@/components/scans/scans.utils";
|
||||
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
|
||||
|
||||
const ALL_VALUE = "all";
|
||||
|
||||
function getFirstFilterValue(value: string | null): string {
|
||||
return value?.split(",")[0] || ALL_VALUE;
|
||||
}
|
||||
|
||||
export interface UseScansFiltersReturn {
|
||||
activeTab: ScanJobsTab;
|
||||
scheduleType: string;
|
||||
scanStatus: string;
|
||||
showStatusFilter: boolean;
|
||||
setTab: (tab: string) => void;
|
||||
setScheduleType: (value: string) => void;
|
||||
setScanStatus: (value: string) => void;
|
||||
}
|
||||
|
||||
export function useScansFilters(): UseScansFiltersReturn {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const activeTab = getScanJobsTab(searchParams.get("tab") ?? undefined);
|
||||
const showStatusFilter = activeTab === SCAN_JOBS_TAB.COMPLETED;
|
||||
const scheduleType = getFirstFilterValue(searchParams.get("filter[trigger]"));
|
||||
const scanStatus = getFirstFilterValue(
|
||||
searchParams.get("filter[state__in]") ?? searchParams.get("filter[state]"),
|
||||
);
|
||||
|
||||
const updateParams = (updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (!value || value === ALL_VALUE) params.delete(key);
|
||||
else params.set(key, value);
|
||||
});
|
||||
|
||||
params.delete("page");
|
||||
params.delete("scanId");
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const setTab = (tab: string) =>
|
||||
updateParams({
|
||||
tab,
|
||||
sort: null,
|
||||
"filter[state]": null,
|
||||
"filter[state__in]": null,
|
||||
});
|
||||
|
||||
const setScheduleType = (value: string) =>
|
||||
updateParams({ "filter[trigger]": value });
|
||||
|
||||
const setScanStatus = (value: string) =>
|
||||
updateParams({ "filter[state]": null, "filter[state__in]": value });
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
scheduleType,
|
||||
scanStatus,
|
||||
showStatusFilter,
|
||||
setTab,
|
||||
setScheduleType,
|
||||
setScanStatus,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,12 @@ const badgeVariants = cva(
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
tag: "bg-bg-tag border-border-tag text-text-neutral-primary",
|
||||
success:
|
||||
"border-transparent bg-bg-pass-secondary text-text-success-primary",
|
||||
warning:
|
||||
"border-bg-warning/30 bg-bg-warning-secondary/20 text-text-warning-primary",
|
||||
error:
|
||||
"border-transparent bg-bg-fail-secondary text-text-error-primary",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -3,7 +3,24 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Button } from "./button";
|
||||
|
||||
describe("shadcn Button", () => {
|
||||
describe("Button", () => {
|
||||
it("uses semibold text for primary buttons", () => {
|
||||
const { rerender } = render(<Button>Primary</Button>);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Primary" })).toHaveClass(
|
||||
"font-semibold",
|
||||
);
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Outline" })).toHaveClass(
|
||||
"font-medium",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Outline" })).not.toHaveClass(
|
||||
"font-semibold",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports extra-small link buttons", () => {
|
||||
render(
|
||||
<Button variant="link" size="link-xs">
|
||||
@@ -15,4 +32,53 @@ describe("shadcn Button", () => {
|
||||
"text-xs",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the shared press and reduced-motion contract to button-like variants", () => {
|
||||
// Given
|
||||
render(<Button>Start scan</Button>);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: "Start scan" });
|
||||
|
||||
// Then
|
||||
expect(button).toHaveClass(
|
||||
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
|
||||
"duration-150",
|
||||
"ease-out",
|
||||
"active:scale-[0.98]",
|
||||
"motion-reduce:active:scale-100",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(button).not.toHaveClass("transition-all");
|
||||
});
|
||||
|
||||
it("keeps link buttons from scaling on press", () => {
|
||||
// Given
|
||||
render(<Button variant="link">Open details</Button>);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: "Open details" });
|
||||
|
||||
// Then
|
||||
expect(button).toHaveClass("active:scale-100");
|
||||
expect(button).not.toHaveClass("active:scale-[0.98]");
|
||||
});
|
||||
|
||||
it("keeps menu buttons on the shared targeted transition recipe", () => {
|
||||
// Given
|
||||
render(<Button variant="menu">Open menu</Button>);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", { name: "Open menu" });
|
||||
|
||||
// Then
|
||||
expect(button).toHaveClass(
|
||||
"transition-[background-color,border-color,color,box-shadow,transform,scale]",
|
||||
"duration-200",
|
||||
"active:scale-[0.98]",
|
||||
"motion-reduce:active:scale-100",
|
||||
);
|
||||
expect(button).not.toHaveClass("transition-all");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,12 @@ import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-[background-color,border-color,color,box-shadow,transform,scale] duration-150 ease-out active:scale-[0.98] disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary motion-reduce:active:scale-100 motion-reduce:transform-none motion-reduce:transition-none outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border border-transparent bg-button-primary text-black hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
|
||||
"border border-transparent bg-button-primary text-black font-semibold hover:bg-button-primary-hover active:bg-button-primary-press focus-visible:ring-button-primary/50",
|
||||
secondary:
|
||||
"border border-transparent bg-button-secondary text-white hover:bg-button-secondary/90 active:bg-button-secondary-press focus-visible:ring-button-secondary/50 dark:text-black",
|
||||
tertiary:
|
||||
@@ -21,19 +21,19 @@ const buttonVariants = cva(
|
||||
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
|
||||
ghost:
|
||||
"border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
|
||||
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
|
||||
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover active:scale-100 disabled:bg-transparent",
|
||||
// Menu variant like secondary but more padding and the back is almost transparent
|
||||
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
|
||||
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
|
||||
"menu-active":
|
||||
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
|
||||
"backdrop-blur-xl bg-white/50 dark:bg-white/5 border border-black/[0.08] dark:border-white/10 text-text-neutral-primary dark:text-white shadow-sm hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/[0.12] dark:hover:border-white/30 active:bg-white/70 dark:active:bg-white/15 focus-visible:ring-button-primary/50 duration-200",
|
||||
"menu-inactive":
|
||||
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-border-neutral-secondary/50 transition-all duration-200",
|
||||
"text-text-neutral-primary border border-transparent hover:backdrop-blur-xl hover:bg-white/40 dark:hover:bg-white/5 hover:border-black/[0.08] dark:hover:border-white/10 hover:shadow-sm active:bg-white/50 dark:active:bg-white/15 focus-visible:ring-border-neutral-secondary/50 duration-200",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
xl: "h-12 rounded-md px-8 text-base has-[>svg]:px-6",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
||||
xl: "h-12 px-8 text-base has-[>svg]:px-6",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
function ControlledCheckbox() {
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
aria-label="Select provider"
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => setChecked(value === true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Checkbox", () => {
|
||||
it("animates the background and check mark as one state change", async () => {
|
||||
// Given - A controlled checkbox in the unchecked state
|
||||
const user = userEvent.setup();
|
||||
render(<ControlledCheckbox />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: /select provider/i });
|
||||
const indicator = checkbox.querySelector(
|
||||
"[data-slot='checkbox-indicator']",
|
||||
);
|
||||
|
||||
// When - The user checks the checkbox
|
||||
await user.click(checkbox);
|
||||
|
||||
// Then - The background and check mark transitions use the same timing
|
||||
expect(checkbox).toHaveClass(
|
||||
"transition-colors",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
expect(indicator).toHaveClass(
|
||||
"transition-[opacity,transform]",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=checked]:scale-100",
|
||||
"data-[state=checked]:opacity-100",
|
||||
"data-[state=unchecked]:scale-75",
|
||||
"data-[state=unchecked]:opacity-0",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ function Checkbox({
|
||||
checked={indeterminate ? "indeterminate" : checked}
|
||||
className={cn(
|
||||
// Base styles
|
||||
"peer shrink-0 rounded-sm border transition-all outline-none",
|
||||
"peer shrink-0 rounded-sm border transition-colors duration-200 ease-out outline-none motion-reduce:transition-none",
|
||||
sizeStyles.root,
|
||||
// Default state
|
||||
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
|
||||
@@ -58,8 +58,9 @@ function Checkbox({
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
forceMount
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
className="grid place-content-center text-current transition-[opacity,transform] duration-200 ease-out data-[state=checked]:scale-100 data-[state=checked]:opacity-100 data-[state=indeterminate]:scale-100 data-[state=indeterminate]:opacity-100 data-[state=unchecked]:scale-75 data-[state=unchecked]:opacity-0 motion-reduce:scale-100 motion-reduce:transition-none"
|
||||
>
|
||||
{indeterminate || checked === "indeterminate" ? (
|
||||
<MinusIcon className={sizeStyles.icon} />
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { Combobox } from "./combobox";
|
||||
|
||||
const options = [
|
||||
{ value: "aws", label: "AWS" },
|
||||
{ value: "azure", label: "Azure" },
|
||||
{ value: "gcp", label: "GCP" },
|
||||
];
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combobox", () => {
|
||||
it("renders a selectable combobox trigger", () => {
|
||||
// Given
|
||||
render(<Combobox options={options} placeholder="Select provider" />);
|
||||
|
||||
// When
|
||||
const trigger = screen.getByRole("combobox", { name: /select provider/i });
|
||||
|
||||
// Then
|
||||
expect(trigger).toBeVisible();
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("uses visible trigger and chevron open-state motion", () => {
|
||||
// Given
|
||||
render(<Combobox options={options} placeholder="Select provider" />);
|
||||
|
||||
// When
|
||||
const trigger = screen.getByRole("combobox", { name: /select provider/i });
|
||||
const icon = trigger.querySelector("svg");
|
||||
|
||||
// Then
|
||||
expect(trigger).toHaveClass(
|
||||
"group",
|
||||
"transition-[background-color,border-color,color,box-shadow]",
|
||||
);
|
||||
expect(icon).toHaveClass(
|
||||
"transition-transform",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"group-aria-expanded:rotate-180",
|
||||
"motion-reduce:rotate-0",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens with the shared Popover content motion contract", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<Combobox options={options} placeholder="Select provider" />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /select provider/i }),
|
||||
);
|
||||
const content = document.querySelector("[data-slot='popover-content']");
|
||||
|
||||
// Then
|
||||
expect(content).toHaveClass(
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -113,8 +113,10 @@ export function Combobox({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-label={selectedOption ? selectedOption.label : placeholder}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group transition-[background-color,border-color,color,box-shadow]",
|
||||
comboboxTriggerVariants({ variant }),
|
||||
triggerClassName,
|
||||
className,
|
||||
@@ -123,7 +125,7 @@ export function Combobox({
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 transition-transform duration-200 ease-out group-aria-expanded:rotate-180 motion-reduce:rotate-0 motion-reduce:transition-none" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./dialog";
|
||||
|
||||
function renderOpenDialog() {
|
||||
return render(
|
||||
<Dialog open>
|
||||
<DialogTrigger>Open modal</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Launch scan</DialogTitle>
|
||||
<DialogDescription>Configure scan settings</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dialog", () => {
|
||||
it("renders controlled content through the Radix Dialog API", () => {
|
||||
// Given
|
||||
renderOpenDialog();
|
||||
|
||||
// When
|
||||
const dialog = screen.getByRole("dialog", { name: "Launch scan" });
|
||||
|
||||
// Then
|
||||
expect(dialog).toBeVisible();
|
||||
expect(dialog).toHaveAttribute("data-slot", "dialog-content");
|
||||
expect(screen.getByText("Configure scan settings")).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses an intentional overlay motion contract", () => {
|
||||
// Given
|
||||
renderOpenDialog();
|
||||
|
||||
// When
|
||||
const overlay = document.querySelector("[data-slot='dialog-overlay']");
|
||||
|
||||
// Then
|
||||
expect(overlay).toHaveClass(
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:duration-100",
|
||||
"data-[state=closed]:ease-in",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses an intentional content motion contract", () => {
|
||||
// Given
|
||||
renderOpenDialog();
|
||||
|
||||
// When
|
||||
const dialog = screen.getByRole("dialog", { name: "Launch scan" });
|
||||
|
||||
// Then
|
||||
expect(dialog).toHaveClass(
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:zoom-out-95",
|
||||
"data-[state=closed]:duration-100",
|
||||
"data-[state=closed]:ease-in",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -59,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./drawer";
|
||||
|
||||
function renderOpenDrawer() {
|
||||
return render(
|
||||
<Drawer open>
|
||||
<DrawerTrigger>Open drawer</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerTitle>Resource details</DrawerTitle>
|
||||
<DrawerDescription>Review resource metadata</DrawerDescription>
|
||||
</DrawerContent>
|
||||
</Drawer>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Drawer", () => {
|
||||
it("renders controlled content through the Vaul Drawer API", () => {
|
||||
// Given
|
||||
renderOpenDrawer();
|
||||
|
||||
// When
|
||||
const drawer = screen.getByRole("dialog", { name: "Resource details" });
|
||||
|
||||
// Then
|
||||
expect(drawer).toBeVisible();
|
||||
expect(drawer).toHaveAttribute("data-slot", "drawer-content");
|
||||
expect(screen.getByText("Review resource metadata")).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses an intentional overlay motion contract", () => {
|
||||
// Given
|
||||
renderOpenDrawer();
|
||||
|
||||
// When
|
||||
const overlay = document.querySelector("[data-slot='drawer-overlay']");
|
||||
|
||||
// Then
|
||||
expect(overlay).toHaveClass(
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:duration-100",
|
||||
"data-[state=closed]:ease-in",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses direction-aware drawer content motion", () => {
|
||||
// Given
|
||||
renderOpenDrawer();
|
||||
|
||||
// When
|
||||
const drawer = screen.getByRole("dialog", { name: "Resource details" });
|
||||
|
||||
// Then
|
||||
expect(drawer).toHaveClass(
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:duration-100",
|
||||
"data-[state=closed]:ease-in",
|
||||
"data-[vaul-drawer-direction=bottom]:slide-in-from-bottom-full",
|
||||
"data-[vaul-drawer-direction=bottom]:data-[state=closed]:slide-out-to-bottom-full",
|
||||
"data-[vaul-drawer-direction=top]:slide-in-from-top-full",
|
||||
"data-[vaul-drawer-direction=top]:data-[state=closed]:slide-out-to-top-full",
|
||||
"data-[vaul-drawer-direction=right]:slide-in-from-right-full",
|
||||
"data-[vaul-drawer-direction=right]:data-[state=closed]:slide-out-to-right-full",
|
||||
"data-[vaul-drawer-direction=left]:slide-in-from-left-full",
|
||||
"data-[vaul-drawer-direction=left]:data-[state=closed]:slide-out-to-left-full",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ function DrawerOverlay({
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -54,11 +54,11 @@ function DrawerContent({
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
|
||||
"group/drawer-content bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex h-auto flex-col duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
|
||||
"data-[vaul-drawer-direction=top]:slide-in-from-top-full data-[vaul-drawer-direction=top]:data-[state=closed]:slide-out-to-top-full data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:slide-in-from-bottom-full data-[vaul-drawer-direction=bottom]:data-[state=closed]:slide-out-to-bottom-full data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:border-l-border-neutral-secondary data-[vaul-drawer-direction=right]:slide-in-from-right-full data-[vaul-drawer-direction=right]:data-[state=closed]:slide-out-to-right-full data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:border-l",
|
||||
"data-[vaul-drawer-direction=left]:slide-in-from-left-full data-[vaul-drawer-direction=left]:data-[state=closed]:slide-out-to-left-full data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:border-r",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown";
|
||||
|
||||
function renderActionsDropdown() {
|
||||
return render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DropdownMenu", () => {
|
||||
it("renders open menu content through the Radix DropdownMenu API", () => {
|
||||
// Given
|
||||
renderActionsDropdown();
|
||||
|
||||
// When
|
||||
const menu = screen.getByRole("menu");
|
||||
|
||||
// Then
|
||||
expect(menu).toBeVisible();
|
||||
expect(menu).toHaveAttribute("data-slot", "dropdown-menu-content");
|
||||
expect(screen.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses an intentional open and close motion contract", () => {
|
||||
// Given
|
||||
renderActionsDropdown();
|
||||
|
||||
// When
|
||||
const menu = screen.getByRole("menu");
|
||||
|
||||
// Then
|
||||
expect(menu).toHaveClass(
|
||||
"origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0",
|
||||
"data-[state=closed]:zoom-out-95",
|
||||
"data-[state=closed]:duration-100",
|
||||
"data-[state=closed]:ease-in",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes transform-heavy menu motion for reduced-motion users", () => {
|
||||
// Given
|
||||
renderActionsDropdown();
|
||||
|
||||
// When
|
||||
const menu = screen.getByRole("menu");
|
||||
|
||||
// Then
|
||||
expect(menu).toHaveClass(
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the same motion contract to submenu content", () => {
|
||||
// Given
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Archive</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
|
||||
// When
|
||||
const submenuContent = screen
|
||||
.getByRole("menuitem", { name: "Archive" })
|
||||
.closest("[data-slot='dropdown-menu-sub-content']");
|
||||
|
||||
// Then
|
||||
expect(submenuContent).toHaveClass(
|
||||
"origin-(--radix-dropdown-menu-content-transform-origin)",
|
||||
"duration-200",
|
||||
"ease-out",
|
||||
"data-[state=open]:animate-in",
|
||||
"data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:duration-100",
|
||||
"data-[state=closed]:ease-in",
|
||||
"motion-reduce:animate-none",
|
||||
"motion-reduce:transform-none",
|
||||
"motion-reduce:transition-none",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -227,7 +227,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg duration-200 ease-out data-[state=closed]:duration-100 data-[state=closed]:ease-in motion-reduce:transform-none motion-reduce:animate-none motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Field({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -15,7 +17,7 @@ function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
|
||||
<label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary text-xs font-light tracking-tight",
|
||||
"text-text-neutral-tertiary text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -33,4 +35,24 @@ function FieldError({ className, ...props }: React.ComponentProps<"p">) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Field, FieldError, FieldLabel };
|
||||
interface LabeledFieldProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function LabeledField({ label, children, className }: LabeledFieldProps) {
|
||||
return (
|
||||
<Field className={className}>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<span
|
||||
data-slot="field-value"
|
||||
className="text-text-neutral-primary text-sm"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export { Field, FieldError, FieldLabel, LabeledField };
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { FileUp } from "lucide-react";
|
||||
import { type DragEvent, type ReactNode, useId, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileUploadDropzoneProps {
|
||||
file?: File | null;
|
||||
onFileSelect: (file?: File) => void;
|
||||
accept?: string;
|
||||
className?: string;
|
||||
title?: string;
|
||||
emptyDescription?: string;
|
||||
selectText?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function FileUploadDropzone({
|
||||
file,
|
||||
onFileSelect,
|
||||
accept,
|
||||
className,
|
||||
title = "Drag and drop your file here",
|
||||
emptyDescription = "or",
|
||||
selectText = "Select File",
|
||||
icon = <FileUp className="text-text-neutral-secondary size-6" />,
|
||||
}: FileUploadDropzoneProps) {
|
||||
const inputId = useId();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
onFileSelect(event.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"border-border-neutral-tertiary bg-bg-neutral-primary hover:bg-bg-neutral-tertiary flex min-h-[132px] cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors",
|
||||
isDragging &&
|
||||
"border-border-input-primary-press bg-bg-neutral-tertiary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
{file ? file.name : title}
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary text-xs">
|
||||
{file
|
||||
? `${Math.ceil(file.size / 1024).toLocaleString()} KB`
|
||||
: emptyDescription}
|
||||
</span>
|
||||
{!file && (
|
||||
<span className="text-button-tertiary text-sm font-medium">
|
||||
{selectText}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="sr-only"
|
||||
onChange={(event) => onFileSelect(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user