fix(ui): restore finding delta colors and shorten update labels (#12160)

This commit is contained in:
Alejandro Bailo
2026-07-29 10:31:22 +02:00
committed by GitHub
parent 2ae1062e76
commit 59a7d30a3e
19 changed files with 439 additions and 20 deletions
@@ -0,0 +1 @@
Finding delta colors and integration update button labels restored
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { FILTER_FIELD, FILTER_SELECTION_MODE } from "@/types/filters";
import { filterFindings } from "./data-filters";
describe("filterFindings", () => {
it("configures delta as a single-select filter", () => {
const deltaFilter = filterFindings.find(
(filter) => filter.key === FILTER_FIELD.DELTA,
);
expect(deltaFilter).toMatchObject({
selectionMode: FILTER_SELECTION_MODE.SINGLE,
values: ["new", "changed"],
});
});
});
+7 -2
View File
@@ -1,5 +1,9 @@
import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters";
import { FILTER_FIELD, FilterOption } from "@/types/filters";
import {
FILTER_FIELD,
FILTER_SELECTION_MODE,
type FilterOption,
} from "@/types/filters";
import {
PROVIDER_DISPLAY_NAMES,
PROVIDER_TYPES,
@@ -79,9 +83,10 @@ export const filterFindings = [
key: FILTER_FIELD.DELTA,
labelCheckboxGroup: "Delta",
values: ["new", "changed"],
selectionMode: FILTER_SELECTION_MODE.SINGLE,
index: 2,
},
];
] satisfies FilterOption[];
export const filterUsers = [
{
@@ -0,0 +1,37 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/shadcn/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
import { DeltaIndicator } from "./delta-indicator";
describe("DeltaIndicator", () => {
it("uses the design-system fail color for new findings", () => {
// Given
const { container } = render(<DeltaIndicator delta="new" />);
// When
const deltaDot = container.querySelector(".rounded-full");
// Then
expect(deltaDot).toHaveClass("bg-bg-fail");
});
it("uses the design-system warning color for changed findings", () => {
// Given
const { container } = render(<DeltaIndicator delta="changed" />);
// When
const deltaDot = container.querySelector(".rounded-full");
// Then
expect(deltaDot).toHaveClass("bg-bg-warning");
});
});
@@ -19,9 +19,9 @@ export const DeltaIndicator = ({ delta }: DeltaIndicatorProps) => {
className={cn(
"h-2 w-2 min-w-2 cursor-pointer rounded-full",
delta === "new"
? "bg-bg-data-high"
? "bg-bg-fail"
: delta === "changed"
? "bg-bg-data-low"
? "bg-bg-warning"
: "bg-text-neutral-tertiary",
)}
/>
@@ -1,4 +1,4 @@
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
@@ -61,6 +61,39 @@ vi.mock("@/components/shadcn/tooltip", () => ({
import { NotificationIndicator } from "./notification-indicator";
describe("NotificationIndicator", () => {
it("does not show a ring around the delta trigger", () => {
// Given
render(<NotificationIndicator delta="new" />);
// When
const deltaTrigger = screen.getByRole("button");
// Then
expect(deltaTrigger).toHaveClass("outline-none", "ring-0");
});
it("uses the design-system fail color for new findings", () => {
// Given
const { container } = render(<NotificationIndicator delta="new" />);
// When
const deltaDot = container.querySelector(".rounded-full");
// Then
expect(deltaDot).toHaveClass("bg-bg-fail");
});
it("uses the design-system warning color for changed findings", () => {
// Given
const { container } = render(<NotificationIndicator delta="changed" />);
// When
const deltaDot = container.querySelector(".rounded-full");
// Then
expect(deltaDot).toHaveClass("bg-bg-warning");
});
it("reserves the muted slot for delta-only rows when requested", () => {
const { container } = render(
<NotificationIndicator delta="new" showDeltaWhenMuted reserveMutedSlot />,
@@ -96,12 +96,12 @@ function DeltaIndicator({
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
className="flex w-2 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0"
className="flex w-2 shrink-0 cursor-pointer items-center justify-center bg-transparent p-0 ring-0 outline-none"
>
<div
className={cn(
"size-1.5 rounded-full",
delta === DeltaValues.NEW ? "bg-bg-data-high" : "bg-bg-data-low",
delta === DeltaValues.NEW ? "bg-bg-fail" : "bg-bg-warning",
)}
/>
</button>
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import { JiraIntegrationForm } from "./jira-integration-form";
vi.mock("@/actions/integrations", () => ({
createIntegration: vi.fn(),
updateIntegration: vi.fn(),
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({
toast: vi.fn(),
}),
}));
const integration: IntegrationProps = {
type: "integrations",
id: "integration-1",
attributes: {
inserted_at: "2026-07-28T00:00:00Z",
updated_at: "2026-07-28T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-07-28T00:00:00Z",
integration_type: "jira",
configuration: {
domain: "prowler.atlassian.net",
},
},
links: {
self: "/integrations/integration-1",
},
};
describe("JiraIntegrationForm", () => {
it("uses the short update label when editing credentials", () => {
render(
<JiraIntegrationForm
integration={integration}
onSuccess={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: "Update" })).toBeVisible();
expect(
screen.queryByRole("button", { name: "Update Credentials" }),
).not.toBeInTheDocument();
});
});
@@ -209,7 +209,7 @@ export const JiraIntegrationForm = ({
const getButtonLabel = () => {
if (isEditing) {
return "Update Credentials";
return "Update";
}
return "Create Integration";
};
@@ -226,6 +226,10 @@ describe("S3IntegrationForm", () => {
expect(
screen.queryByLabelText(/Bucket owner account ID/i),
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeVisible();
expect(
screen.queryByRole("button", { name: "Update Configuration" }),
).not.toBeInTheDocument();
});
it("should allow changing the bucket owner account for credential updates", () => {
@@ -240,5 +244,9 @@ describe("S3IntegrationForm", () => {
expect(
screen.getByLabelText(/Bucket owner account ID/i),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeVisible();
expect(
screen.queryByRole("button", { name: "Update Credentials" }),
).not.toBeInTheDocument();
});
});
@@ -433,11 +433,9 @@ export const S3IntegrationForm = ({
const renderStepButtons = () => {
// Single edit mode (configuration or credentials)
if (isEditingConfig || isEditingCredentials) {
const updateText = isEditingConfig
? "Update Configuration"
: "Update Credentials";
const updateText = "Update";
const loadingText = isEditingConfig
? "Updating Configuration..."
? "Updating..."
: "Updating Credentials...";
return (
@@ -0,0 +1,106 @@
import { render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import { SecurityHubIntegrationForm } from "./security-hub-integration-form";
vi.mock("@/actions/integrations", () => ({
createIntegration: vi.fn(),
updateIntegration: vi.fn(),
}));
vi.mock("next-auth/react", () => ({
useSession: () => ({
data: {
tenantId: "tenant-id",
},
}),
}));
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({
toast: vi.fn(),
}),
}));
vi.mock(
"@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form",
() => ({
AWSRoleCredentialsForm: () => null,
}),
);
vi.mock("@/lib", () => ({
getAWSCredentialsTemplateLinks: () => ({
cloudformation: "https://example.com/cloudformation",
terraform: "https://example.com/terraform",
cloudformationQuickLink: "https://example.com/quick-create",
}),
}));
function renderSecurityHubIntegrationForm(
props?: Partial<ComponentProps<typeof SecurityHubIntegrationForm>>,
) {
return render(
<SecurityHubIntegrationForm
providers={[]}
onSuccess={vi.fn()}
onCancel={vi.fn()}
{...props}
/>,
);
}
const integration: IntegrationProps = {
type: "integrations",
id: "integration-1",
attributes: {
inserted_at: "2026-07-28T00:00:00Z",
updated_at: "2026-07-28T00:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-07-28T00:00:00Z",
integration_type: "aws_security_hub",
configuration: {
send_only_fails: true,
archive_previous_findings: false,
},
},
relationships: {
providers: {
data: [{ type: "providers", id: "aws-provider" }],
},
},
links: {
self: "/integrations/integration-1",
},
};
describe("SecurityHubIntegrationForm", () => {
it("uses the short update label when editing configuration", () => {
renderSecurityHubIntegrationForm({
integration,
editMode: "configuration",
});
expect(screen.getByRole("button", { name: "Update" })).toBeVisible();
expect(
screen.queryByRole("button", { name: "Update Configuration" }),
).not.toBeInTheDocument();
});
it("uses the short update label when editing credentials", () => {
renderSecurityHubIntegrationForm({
integration,
editMode: "credentials",
});
expect(screen.getByRole("button", { name: "Update" })).toBeVisible();
expect(
screen.queryByRole("button", { name: "Update Credentials" }),
).not.toBeInTheDocument();
});
});
@@ -481,11 +481,9 @@ export const SecurityHubIntegrationForm = ({
const renderStepButtons = () => {
if (isEditingConfig || isEditingCredentials) {
const updateText = isEditingConfig
? "Update Configuration"
: "Update Credentials";
const updateText = "Update";
const loadingText = isEditingConfig
? "Updating Configuration..."
? "Updating..."
: "Updating Credentials...";
return (
@@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilterOption } from "@/types/filters";
import type { FilterOption } from "@/types/filters";
// ── next/navigation mock ────────────────────────────────────────────────────
const mockPush = vi.fn();
@@ -94,6 +94,37 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
}) => <option value={value}>{children}</option>,
}));
vi.mock("@/components/shadcn/select/select", () => ({
Select: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value?: string;
onValueChange?: (value: string) => void;
}) => (
<select
data-testid="single-select"
value={value}
onChange={(event) => onValueChange?.(event.target.value)}
>
<option value="">All</option>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => children,
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
}));
// ── ClearFiltersButton stub ─────────────────────────────────────────────────
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button type="button">Clear</button>,
@@ -137,6 +168,13 @@ const scanFilter: FilterOption = {
width: "wide",
};
const deltaFilter = {
key: "filter[delta]",
labelCheckboxGroup: "Delta",
values: ["new", "changed"],
selectionMode: "single",
} as const satisfies FilterOption;
describe("DataTableFilterCustom — batch vs instant mode", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -270,6 +308,26 @@ describe("DataTableFilterCustom — batch vs instant mode", () => {
const multiselect = screen.getByTestId("multiselect");
expect(multiselect).toHaveAttribute("data-values", JSON.stringify([]));
});
it("should replace the delta value through a single-select control", async () => {
const user = userEvent.setup();
const onBatchChange = vi.fn();
render(
<DataTableFilterCustom
filters={[deltaFilter]}
mode="batch"
onBatchChange={onBatchChange}
getFilterValue={() => ["new"]}
/>,
);
expect(screen.queryByTestId("multiselect")).not.toBeInTheDocument();
await user.selectOptions(screen.getByTestId("single-select"), "changed");
expect(onBatchChange).toHaveBeenCalledWith("filter[delta]", ["changed"]);
});
});
// ── hideClearButton ──────────────────────────────────────────────────────
@@ -15,6 +15,13 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn/select/select";
import { useUrlFilters } from "@/hooks/use-url-filters";
import {
getScanEntityLabel,
@@ -28,7 +35,11 @@ import {
ProviderEntity,
ScanEntity,
} from "@/types";
import { DATA_TABLE_FILTER_MODE, DataTableFilterMode } from "@/types/filters";
import {
DATA_TABLE_FILTER_MODE,
FILTER_SELECTION_MODE,
type DataTableFilterMode,
} from "@/types/filters";
import { ProviderConnectionStatus } from "@/types/providers";
function isNonEmptyString(value: string | null | undefined): value is string {
@@ -234,6 +245,43 @@ export const DataTableFilterCustom = ({
{sortedFilters().map((filter) => {
const selectedValues = getSelectedValues(filter);
if (filter.selectionMode === FILTER_SELECTION_MODE.SINGLE) {
const selectedValue = selectedValues[0] ?? "";
return (
<Select
key={filter.key}
allowDeselect
open={openFilterKey === filter.key}
onOpenChange={(open) =>
setOpenFilterKey(open ? filter.key : null)
}
value={selectedValue}
onValueChange={(value) =>
pushDropdownFilter(filter, value ? [value] : [])
}
>
<SelectTrigger aria-label={filter.labelCheckboxGroup}>
<SelectValue placeholder={`All ${filter.labelCheckboxGroup}`} />
</SelectTrigger>
<SelectContent width={filter.width ?? "default"}>
{filter.values.map((value) => {
const entity = getEntityForValue(filter, value);
const displayLabel = filter.labelFormatter
? filter.labelFormatter(value)
: value;
return (
<SelectItem key={value} value={value}>
{entity ? renderEntityContent(entity) : displayLabel}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
return (
<MultiSelect
key={filter.key}
+4 -1
View File
@@ -107,7 +107,10 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
className={cn(
"max-h-48 overflow-x-hidden overflow-y-auto text-sm break-all whitespace-pre-wrap opacity-90",
className,
)}
{...props}
/>
));
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Toaster } from "./Toaster";
const LONG_ERROR =
"AWSAssumeRoleError[1012]: arn:aws:sts::106908755756:assumed-role/prowler-cloud-task-20241002104346361000000003/00f08a82c8ee4e07ba";
vi.mock("./use-toast", () => ({
useToast: () => ({
toasts: [
{
id: "long-error",
title: "Connection test failed",
description: LONG_ERROR,
variant: "destructive",
open: true,
},
],
}),
}));
describe("Toaster", () => {
it("wraps long error messages inside the toast width", () => {
render(<Toaster />);
const description = screen.getByText(LONG_ERROR);
expect(description).toHaveClass(
"max-h-48",
"overflow-x-hidden",
"overflow-y-auto",
"break-all",
"whitespace-pre-wrap",
);
expect(description.parentElement).toHaveClass(
"min-w-0",
"max-w-full",
"flex-1",
"overflow-x-hidden",
);
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ export function Toaster() {
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
<div className="grid max-w-full min-w-0 flex-1 gap-1 overflow-x-hidden">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
+9
View File
@@ -11,10 +11,19 @@ export type FilterEntity =
| ProviderConnectionStatus
| GroupFilterEntity;
export const FILTER_SELECTION_MODE = {
SINGLE: "single",
MULTIPLE: "multiple",
} as const;
export type FilterSelectionMode =
(typeof FILTER_SELECTION_MODE)[keyof typeof FILTER_SELECTION_MODE];
export interface FilterOption {
key: string;
labelCheckboxGroup: string;
values: string[];
selectionMode?: FilterSelectionMode;
width?: "default" | "wide";
valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>;
labelFormatter?: (value: string) => string;