Skip to content

Commit 9b42fb7

Browse files
refactor(ui): redesign compliance page layout and components
- Horizontal ThreatScore card with always-visible pillar breakdown and ActionDropdown - Client-side search for compliance frameworks via ComplianceOverviewGrid - Compact scan selector Badge trigger, full info kept in dropdown items - Responsive compliance filters: full-width on mobile, inline on sm+ - Download-started toast for CSV/PDF exports in downloadCompliance helpers - Enhanced compliance cards: truncated title with Tooltip and mobile-first buttons - Replace HeroUI Progress with shadcn Progress (radix-ui) - Alert-based empty/error states replacing ComplianceOverviewPanel wrappers - Add optional width field to FilterOption type (consumed by ComplianceHeader)
1 parent 276a5d6 commit 9b42fb7

27 files changed

Lines changed: 871 additions & 302 deletions

ui/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to the **Prowler UI** are documented in this file.
44

5+
## [1.25.0] (Prowler UNRELEASED)
6+
7+
### 🔄 Changed
8+
9+
- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
10+
11+
---
12+
513
## [1.24.1] (Prowler v5.24.1)
614

715
### 🔒 Security

ui/actions/compliances/compliances.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,10 @@ import { handleApiResponse } from "@/lib/server-actions-helper";
66
export const getCompliancesOverview = async ({
77
scanId,
88
region,
9-
query,
109
filters = {},
1110
}: {
1211
scanId?: string;
1312
region?: string | string[];
14-
query?: string;
1513
filters?: Record<string, string | string[] | undefined>;
1614
} = {}) => {
1715
const headers = await getAuthHeaders({ contentType: false });
@@ -31,8 +29,6 @@ export const getCompliancesOverview = async ({
3129

3230
setParam("filter[scan_id]", scanId);
3331
setParam("filter[region__in]", region);
34-
if (query) url.searchParams.set("filter[search]", query);
35-
3632
try {
3733
const response = await fetch(url.toString(), {
3834
headers,
@@ -46,15 +42,16 @@ export const getCompliancesOverview = async ({
4642
};
4743

4844
export const getComplianceOverviewMetadataInfo = async ({
49-
query = "",
5045
sort = "",
5146
filters = {},
52-
}) => {
47+
}: {
48+
sort?: string;
49+
filters?: Record<string, string | string[] | undefined>;
50+
} = {}) => {
5351
const headers = await getAuthHeaders({ contentType: false });
5452

5553
const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`);
5654

57-
if (query) url.searchParams.append("filter[search]", query);
5855
if (sort) url.searchParams.append("sort", sort);
5956

6057
Object.entries(filters).forEach(([key, value]) => {

ui/app/(prowler)/compliance/[compliancetitle]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export default async function ComplianceDetail({
7878
await Promise.all([
7979
getComplianceOverviewMetadataInfo({
8080
filters: {
81-
"filter[scan_id]": selectedScanId,
81+
"filter[scan_id]": selectedScanId ?? undefined,
8282
},
8383
}),
8484
getComplianceAttributes(complianceId),
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { readFileSync } from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
import { describe, expect, it } from "vitest";
6+
7+
describe("Compliance overview page", () => {
8+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
9+
const filePath = path.join(currentDir, "page.tsx");
10+
const source = readFileSync(filePath, "utf8");
11+
12+
it("delegates client-side search to ComplianceOverviewGrid", () => {
13+
expect(source).toContain("ComplianceOverviewGrid");
14+
expect(source).not.toContain("filter[search]");
15+
});
16+
});

ui/app/(prowler)/compliance/page.tsx

Lines changed: 76 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Info } from "lucide-react";
12
import { Suspense } from "react";
23

34
import {
@@ -7,12 +8,14 @@ import {
78
import { getThreatScore } from "@/actions/overview";
89
import { getScans } from "@/actions/scans";
910
import {
10-
ComplianceCard,
1111
ComplianceSkeletonGrid,
1212
NoScansAvailable,
1313
ThreatScoreBadge,
1414
} from "@/components/compliance";
15-
import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header";
15+
import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters";
16+
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
17+
import { Alert, AlertDescription } from "@/components/shadcn/alert";
18+
import { Card, CardContent } from "@/components/shadcn/card/card";
1619
import { ContentLayout } from "@/components/ui";
1720
import {
1821
ExpandedScanData,
@@ -30,12 +33,6 @@ export default async function Compliance({
3033
const resolvedSearchParams = await searchParams;
3134
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
3235

33-
const filters = Object.fromEntries(
34-
Object.entries(resolvedSearchParams).filter(([key]) =>
35-
key.startsWith("filter["),
36-
),
37-
);
38-
3936
const scansData = await getScans({
4037
filters: {
4138
"filter[state]": "completed",
@@ -81,7 +78,6 @@ export default async function Compliance({
8178
// Use scanId from URL, or select the first scan if not provided
8279
const selectedScanId =
8380
resolvedSearchParams.scanId || expandedScansData[0]?.id || null;
84-
const query = (filters["filter[search]"] as string) || "";
8581

8682
// Find the selected scan
8783
const selectedScan = expandedScansData.find(
@@ -102,7 +98,6 @@ export default async function Compliance({
10298
// Fetch metadata if we have a selected scan
10399
const metadataInfoData = selectedScanId
104100
? await getComplianceOverviewMetadataInfo({
105-
query,
106101
filters: {
107102
"filter[scan_id]": selectedScanId,
108103
},
@@ -131,28 +126,38 @@ export default async function Compliance({
131126
<ContentLayout title="Compliance" icon="lucide:shield-check">
132127
{selectedScanId ? (
133128
<>
134-
<div className="mb-6 flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
135-
<div className="min-w-0 flex-1">
136-
<ComplianceHeader
137-
scans={expandedScansData}
138-
uniqueRegions={uniqueRegions}
139-
/>
140-
</div>
141-
{threatScoreData &&
142-
typeof selectedScanId === "string" &&
143-
selectedScan && (
144-
<div className="w-full lg:w-[360px] lg:flex-shrink-0">
145-
<ThreatScoreBadge
146-
score={threatScoreData.score}
147-
scanId={selectedScanId}
148-
provider={selectedScan.providerInfo.provider}
149-
selectedScan={selectedScanData}
150-
sectionScores={threatScoreData.sectionScores}
151-
/>
152-
</div>
153-
)}
129+
{/* Row 1: Filters */}
130+
<div className="mb-6">
131+
<ComplianceFilters
132+
scans={expandedScansData}
133+
uniqueRegions={uniqueRegions}
134+
/>
154135
</div>
155-
<Suspense key={searchParamsKey} fallback={<ComplianceSkeletonGrid />}>
136+
137+
{/* Row 2: ThreatScore card — full width, horizontal */}
138+
{threatScoreData &&
139+
typeof selectedScanId === "string" &&
140+
selectedScan && (
141+
<div className="mb-6">
142+
<ThreatScoreBadge
143+
score={threatScoreData.score}
144+
scanId={selectedScanId}
145+
provider={selectedScan.providerInfo.provider}
146+
selectedScan={selectedScanData}
147+
sectionScores={threatScoreData.sectionScores}
148+
/>
149+
</div>
150+
)}
151+
152+
{/* Row 3: Compliance grid with client-side search */}
153+
<Suspense
154+
key={searchParamsKey}
155+
fallback={
156+
<ComplianceOverviewPanel>
157+
<ComplianceSkeletonGrid />
158+
</ComplianceOverviewPanel>
159+
}
160+
>
156161
<SSRComplianceGrid
157162
searchParams={resolvedSearchParams}
158163
selectedScan={selectedScanData}
@@ -176,25 +181,23 @@ const SSRComplianceGrid = async ({
176181
const scanId = searchParams.scanId?.toString() || "";
177182
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
178183

179-
// Extract all filter parameters
180-
const filters = Object.fromEntries(
181-
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
182-
);
183-
184-
// Extract query from filters
185-
const query = (filters["filter[search]"] as string) || "";
186-
187184
// Only fetch compliance data if we have a valid scanId
188185
const compliancesData =
189186
scanId && scanId.trim() !== ""
190187
? await getCompliancesOverview({
191188
scanId,
192189
region: regionFilter,
193-
query,
194190
})
195191
: { data: [], errors: [] };
196192

197193
const type = compliancesData?.data?.type;
194+
const frameworks = compliancesData?.data
195+
?.filter((compliance: ComplianceOverviewData) => {
196+
return compliance.attributes.framework !== "ProwlerThreatScore";
197+
})
198+
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
199+
a.attributes.framework.localeCompare(b.attributes.framework),
200+
);
198201

199202
// Check if the response contains no data
200203
if (
@@ -204,58 +207,49 @@ const SSRComplianceGrid = async ({
204207
type === "tasks"
205208
) {
206209
return (
207-
<div className="flex h-full items-center">
208-
<div className="text-default-500 text-sm">
209-
No compliance data available for the selected scan.
210-
</div>
211-
</div>
210+
<Alert variant="info">
211+
<Info className="size-4" />
212+
<AlertDescription>
213+
This scan has no compliance data available yet, please select a
214+
different one.
215+
</AlertDescription>
216+
</Alert>
212217
);
213218
}
214219

215220
// Handle errors returned by the API
216221
if (compliancesData?.errors?.length > 0) {
217222
return (
218-
<div className="flex h-full items-center">
219-
<div className="text-default-500 text-sm">Provide a valid scan ID.</div>
220-
</div>
223+
<Alert variant="info">
224+
<Info className="size-4" />
225+
<AlertDescription>Provide a valid scan ID.</AlertDescription>
226+
</Alert>
221227
);
222228
}
223229

224230
return (
225-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
226-
{compliancesData.data
227-
.filter((compliance: ComplianceOverviewData) => {
228-
// Filter out ProwlerThreatScore from the grid
229-
return compliance.attributes.framework !== "ProwlerThreatScore";
230-
})
231-
.sort((a: ComplianceOverviewData, b: ComplianceOverviewData) =>
232-
a.attributes.framework.localeCompare(b.attributes.framework),
233-
)
234-
.map((compliance: ComplianceOverviewData) => {
235-
const { attributes, id } = compliance;
236-
const {
237-
framework,
238-
version,
239-
requirements_passed,
240-
total_requirements,
241-
} = attributes;
231+
<ComplianceOverviewPanel>
232+
<ComplianceOverviewGrid
233+
frameworks={frameworks}
234+
scanId={scanId}
235+
selectedScan={selectedScan}
236+
/>
237+
</ComplianceOverviewPanel>
238+
);
239+
};
242240

243-
return (
244-
<ComplianceCard
245-
key={id}
246-
title={framework}
247-
version={version}
248-
passingRequirements={requirements_passed}
249-
totalRequirements={total_requirements}
250-
prevPassingRequirements={requirements_passed}
251-
prevTotalRequirements={total_requirements}
252-
scanId={scanId}
253-
complianceId={id}
254-
id={id}
255-
selectedScan={selectedScan}
256-
/>
257-
);
258-
})}
259-
</div>
241+
const ComplianceOverviewPanel = ({
242+
children,
243+
}: {
244+
children: React.ReactNode;
245+
}) => {
246+
return (
247+
<Card
248+
variant="base"
249+
padding="none"
250+
className="minimal-scrollbar shadow-small relative z-0 w-full gap-4 overflow-auto"
251+
>
252+
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
253+
</Card>
260254
);
261255
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { readFileSync } from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
import { describe, expect, it } from "vitest";
6+
7+
describe("ComplianceCard", () => {
8+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
9+
const filePath = path.join(currentDir, "compliance-card.tsx");
10+
const source = readFileSync(filePath, "utf8");
11+
12+
it("keeps the shadcn Card base variant", () => {
13+
expect(source).toContain('variant="base"');
14+
});
15+
16+
it("uses a responsive stacked layout for narrow screens", () => {
17+
expect(source).toContain("flex-col");
18+
expect(source).toContain("sm:flex-row");
19+
});
20+
21+
it("uses the shadcn progress component instead of Hero UI", () => {
22+
expect(source).toContain('from "@/components/shadcn/progress"');
23+
expect(source).not.toContain("@heroui/progress");
24+
});
25+
26+
it("places compact actions in the icon column on larger screens", () => {
27+
expect(source).toContain('orientation="column"');
28+
expect(source).toContain('buttonWidth="icon"');
29+
});
30+
});

0 commit comments

Comments
 (0)