feat: filters relationships in findings and scans page (#8046)

Co-authored-by: Pablo Lara <larabjj@gmail.com>
This commit is contained in:
Alejandro Bailo
2025-06-18 17:19:41 +02:00
committed by GitHub
parent b3f2a1c532
commit 6b7b700a98
22 changed files with 576 additions and 265 deletions

View File

@@ -27,6 +27,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Aligned Next.js version to `v14.2.29` across Prowler and Cloud environments for consistency and improved maintainability [(#7962)](https://github.com/prowler-cloud/prowler/pull/7962)
- Refactor credentials forms with reusable components and error handling [(#7988)](https://github.com/prowler-cloud/prowler/pull/7988)
- Updated the provider details section in Scan and Findings detail pages [(#7968)](https://github.com/prowler-cloud/prowler/pull/7968)
- Improve filter behaviour and relationships between filters in findings page [(#8046)](https://github.com/prowler-cloud/prowler/pull/8046)
### 🐞 Fixed

View File

@@ -9,14 +9,13 @@ import {
} from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { filterFindings } from "@/components/filters/data-filters";
import { FilterControls } from "@/components/filters/filter-controls";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
ColumnFindings,
SkeletonTableFindings,
} from "@/components/findings/table";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { DataTable } from "@/components/ui/table";
import {
createDict,
createScanDetailsMapping,
@@ -28,7 +27,7 @@ import {
createProviderDetailsMapping,
extractProviderUIDs,
} from "@/lib/provider-helpers";
import { ScanProps } from "@/types";
import { FilterEntity, ScanEntity, ScanProps } from "@/types";
import { FindingProps, SearchParamsProps } from "@/types/components";
export default async function Findings({
@@ -61,21 +60,11 @@ export default async function Findings({
// Extract provider UIDs and details using helper functions
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
const providerDetails = providersData
? createProviderDetailsMapping(providerUIDs, providersData)
? (createProviderDetailsMapping(providerUIDs, providersData) as {
[uid: string]: FilterEntity;
}[])
: [];
// Update the Provider UID filter
const updatedFilters = filterFindings.map((filter) => {
if (filter.key === "provider_uid__in") {
return {
...filter,
values: providerUIDs,
valueLabelMapping: providerDetails,
};
}
return filter;
});
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -86,45 +75,24 @@ export default async function Findings({
const completedScanIds =
completedScans?.map((scan: ScanProps) => scan.id) || [];
const scanDetails = createScanDetailsMapping(completedScans, providersData);
const scanDetails = createScanDetailsMapping(
completedScans,
providersData,
) as { [uid: string]: ScanEntity }[];
return (
<ContentLayout title="Findings" icon="carbon:data-view-alt">
<FilterControls search date />
<Spacer y={8} />
<DataTableFilterCustom
filters={[
...updatedFilters,
{
key: "region__in",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 5,
},
{
key: "service__in",
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 6,
},
{
key: "resource_type__in",
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 7,
},
{
key: "scan__in",
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
valueLabelMapping: scanDetails,
index: 9,
},
]}
defaultOpen={true}
<FindingsFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
completedScans={completedScans || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
/>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>

View File

@@ -33,37 +33,35 @@ export default function Home({
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<ContentLayout title="Overview" icon="solar:pie-chart-2-outline">
<Spacer y={4} />
<FilterControls providers />
<div className="mx-auto space-y-8 px-0 py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonProvidersOverview />}>
<SSRProvidersOverview />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
<SSRFindingsBySeverity searchParams={searchParams} />
</Suspense>
</div>
<div className="grid grid-cols-12 gap-12 lg:gap-6">
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonProvidersOverview />}>
<SSRProvidersOverview />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
<SSRFindingsByStatus searchParams={searchParams} />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsBySeverityChart />}>
<SSRFindingsBySeverity searchParams={searchParams} />
</Suspense>
</div>
<div className="col-span-12">
<Spacer y={16} />
<Suspense
key={searchParamsKey}
fallback={<SkeletonTableNewFindings />}
>
<SSRDataNewFindingsTable />
</Suspense>
</div>
<div className="col-span-12 lg:col-span-4">
<Suspense fallback={<SkeletonFindingsByStatusChart />}>
<SSRFindingsByStatus searchParams={searchParams} />
</Suspense>
</div>
<div className="col-span-12">
<Spacer y={16} />
<Suspense
key={searchParamsKey}
fallback={<SkeletonTableNewFindings />}
>
<SSRDataNewFindingsTable />
</Suspense>
</div>
</div>
</ContentLayout>

View File

@@ -10,7 +10,7 @@ import {
SkeletonTableProviders,
} from "@/components/providers/table";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { DataTable } from "@/components/ui/table";
import { ProviderProps, SearchParamsProps } from "@/types";
export default async function Providers({
@@ -22,14 +22,12 @@ export default async function Providers({
return (
<ContentLayout title="Cloud Providers" icon="fluent:cloud-sync-24-regular">
<FilterControls search />
<FilterControls search customFilters={filterProviders || []} />
<Spacer y={8} />
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<AddProviderButton />
</div>
<Spacer y={4} />
<DataTableFilterCustom filters={filterProviders || []} />
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">

View File

@@ -3,17 +3,17 @@ import { Suspense } from "react";
import { getProvider, getProviders } from "@/actions/providers";
import { getScans, getScansByState } from "@/actions/scans";
import { FilterControls, filterScans } from "@/components/filters";
import {
AutoRefresh,
NoProvidersAdded,
NoProvidersConnected,
ScansFilters,
} from "@/components/scans";
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
import { SkeletonTableScans } from "@/components/scans/table";
import { ColumnGetScans } from "@/components/scans/table/scans";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { DataTable } from "@/components/ui/table";
import {
createProviderDetailsMapping,
extractProviderUIDs,
@@ -49,8 +49,7 @@ export default async function Scans({
filters: { "filter[connected]": true },
pageSize: 50,
});
const thereIsNoProviders =
!providersCountConnected?.data || providersCountConnected.data.length === 0;
const thereIsNoProviders = !providersCountConnected?.data;
const thereIsNoProvidersConnected = providersCountConnected?.data?.every(
(provider: ProviderProps) => !provider.attributes.connection.connected,
@@ -71,59 +70,37 @@ export default async function Scans({
? createProviderDetailsMapping(providerUIDs, providersData)
: [];
// Update the Provider UID filter
const updatedFilters = filterScans.map((filter) => {
if (filter.key === "provider_uid__in") {
return {
...filter,
values: providerUIDs,
valueLabelMapping: providerDetails,
};
}
return filter;
});
if (thereIsNoProviders) {
return (
<ContentLayout title="Scans" icon="lucide:scan-search">
<NoProvidersAdded />
</ContentLayout>
);
}
return (
<>
{thereIsNoProviders && (
<>
<Spacer y={4} />
<NoProvidersAdded />
</>
)}
{!thereIsNoProviders && (
<>
{thereIsNoProvidersConnected ? (
<ContentLayout title="Scans" icon="lucide:scan-search">
<Spacer y={8} />
<NoProvidersConnected />
<Spacer y={8} />
</ContentLayout>
) : (
<ContentLayout title="Scans" icon="lucide:scan-search">
<AutoRefresh hasExecutingScan={hasExecutingScan} />
<LaunchScanWorkflow providers={providerInfo} />
<Spacer y={8} />
</ContentLayout>
)}
<div className="grid grid-cols-12 items-start gap-4 px-6 py-4 sm:px-8 xl:px-10">
<div className="col-span-12">
<div className="flex flex-row items-center justify-between">
<DataTableFilterCustom filters={updatedFilters || []} />
<Spacer x={4} />
<FilterControls />
</div>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={searchParams} />
</Suspense>
</div>
</div>
</>
)}
</>
<ContentLayout title="Scans" icon="lucide:scan-search">
<AutoRefresh hasExecutingScan={hasExecutingScan} />
<>
{thereIsNoProvidersConnected ? (
<>
<Spacer y={8} />
<NoProvidersConnected />
<Spacer y={8} />
</>
) : (
<LaunchScanWorkflow providers={providerInfo} />
)}
<ScansFilters
providerUIDs={providerUIDs}
providerDetails={providerDetails}
/>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={searchParams} />
</Suspense>
</>
</ContentLayout>
);
}

View File

@@ -58,11 +58,10 @@ export const ComplianceHeader = ({
<>
{(showProviders || showSearch) && (
<>
<div className="flex items-center justify-start gap-4">
<div className="flex items-start justify-start gap-4">
{showProviders && <DataCompliance scans={scans} />}
{showSearch && <FilterControls search />}
</div>
<Spacer y={8} />
</>
)}
{allFilters.length > 0 && (

View File

@@ -0,0 +1,38 @@
"use client";
import { CrossIcon } from "@/components/icons";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { CustomButton } from "../ui/custom/custom-button";
export interface ClearFiltersButtonProps {
className?: string;
text?: string;
ariaLabel?: string;
}
export const ClearFiltersButton = ({
className = "w-full md:w-fit",
text = "Clear all filters",
ariaLabel = "Reset",
}: ClearFiltersButtonProps) => {
const { clearAllFilters, hasFilters } = useUrlFilters();
if (!hasFilters()) {
return null;
}
return (
<CustomButton
ariaLabel={ariaLabel}
className={className}
onPress={clearAllFilters}
variant="dashed"
size="md"
endContent={<CrossIcon size={24} />}
radius="sm"
>
{text}
</CustomButton>
);
};

View File

@@ -74,7 +74,7 @@ export const CustomSelectProvider: React.FC = () => {
placeholder="Select a provider"
classNames={{
selectorIcon: "right-2",
label: "!z-0",
label: "!z-0 mb-2",
}}
label="Provider"
labelPlacement="inside"

View File

@@ -1,3 +1,5 @@
import { FilterType } from "@/types/filters";
export const filterProviders = [
{
key: "connected",
@@ -12,6 +14,7 @@ export const filterScans = [
key: "provider_type__in",
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
index: 0,
},
{
key: "state__in",
@@ -24,52 +27,43 @@ export const filterScans = [
"failed",
"cancelled",
],
index: 2,
},
{
key: "trigger",
labelCheckboxGroup: "Trigger",
values: ["scheduled", "manual"],
},
{
key: "provider_uid__in",
labelCheckboxGroup: "Provider UID",
values: [],
index: 3,
},
// Add more filter categories as needed
];
//Static filters for findings
export const filterFindings = [
{
key: "severity__in",
key: FilterType.SEVERITY,
labelCheckboxGroup: "Severity",
values: ["critical", "high", "medium", "low", "informational"],
index: 0,
},
{
key: FilterType.STATUS,
labelCheckboxGroup: "Status",
values: ["PASS", "FAIL", "MANUAL"],
index: 1,
},
{
key: "status__in",
labelCheckboxGroup: "Status",
values: ["PASS", "FAIL", "MANUAL"],
index: 2,
},
{
key: "provider_type__in",
key: FilterType.PROVIDER_TYPE,
labelCheckboxGroup: "Cloud Provider",
values: ["aws", "azure", "m365", "gcp", "kubernetes"],
index: 4,
index: 5,
},
{
key: "provider_uid__in",
labelCheckboxGroup: "Provider UID",
values: [],
index: 8,
},
{
key: "delta__in",
key: FilterType.DELTA,
labelCheckboxGroup: "Delta",
values: ["new", "changed"],
index: 3,
index: 2,
},
// Add more filter categories as needed
];
export const filterUsers = [

View File

@@ -1,14 +1,13 @@
"use client";
import { Spacer } from "@nextui-org/react";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { FilterControlsProps } from "@/types";
import { CrossIcon } from "../icons";
import { CustomButton } from "../ui/custom";
import { DataTableFilterCustom } from "../ui/table";
import { ClearFiltersButton } from "./clear-filters-button";
import { CustomAccountSelection } from "./custom-account-selection";
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
import { CustomDatePicker } from "./custom-date-picker";
@@ -26,7 +25,6 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
customFilters,
}) => {
const searchParams = useSearchParams();
const { clearAllFilters } = useUrlFilters();
const [showClearButton, setShowClearButton] = useState(false);
useEffect(() => {
@@ -37,7 +35,7 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
}, [searchParams]);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="grid grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
{search && <CustomSearchInput />}
{providers && <CustomSelectProvider />}
@@ -45,22 +43,16 @@ export const FilterControls: React.FC<FilterControlsProps> = ({
{regions && <CustomRegionSelection />}
{accounts && <CustomAccountSelection />}
{mutedFindings && <CustomCheckboxMutedFindings />}
{showClearButton && (
<CustomButton
ariaLabel="Reset"
className="w-full md:w-fit"
onPress={clearAllFilters}
variant="dashed"
size="md"
endContent={<CrossIcon size={24} />}
radius="sm"
>
Clear all filters
</CustomButton>
)}
{!customFilters && showClearButton && <ClearFiltersButton />}
</div>
{customFilters && <DataTableFilterCustom filters={customFilters} />}
<Spacer y={8} />
{customFilters && (
<DataTableFilterCustom
filters={customFilters}
showClearButton={showClearButton}
defaultOpen
/>
)}
</div>
);
};

View File

@@ -1,3 +1,4 @@
export * from "./clear-filters-button";
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";

View File

@@ -0,0 +1,79 @@
"use client";
import { filterFindings } from "@/components/filters/data-filters";
import { FilterControls } from "@/components/filters/filter-controls";
import { useRelatedFilters } from "@/hooks";
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
interface FindingsFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
completedScans: ScanProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
uniqueServices: string[];
uniqueResourceTypes: string[];
}
export const FindingsFilters = ({
providerUIDs,
providerDetails,
completedScanIds,
scanDetails,
uniqueRegions,
uniqueServices,
uniqueResourceTypes,
}: FindingsFiltersProps) => {
const { availableProviderUIDs, availableScans } = useRelatedFilters({
providerUIDs,
providerDetails,
completedScanIds,
scanDetails,
enableScanRelation: true,
});
return (
<>
<FilterControls
search
date
customFilters={[
...filterFindings,
{
key: FilterType.PROVIDER_UID,
labelCheckboxGroup: "Provider UID",
values: availableProviderUIDs,
valueLabelMapping: providerDetails,
index: 6,
},
{
key: FilterType.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 3,
},
{
key: FilterType.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 4,
},
{
key: FilterType.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 8,
},
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: availableScans,
valueLabelMapping: scanDetails,
index: 7,
},
]}
/>
</>
);
};

View File

@@ -50,21 +50,11 @@ export const ProviderInfo: React.FC<ProviderInfoProps> = ({
};
return (
<div className="dark:bg-prowler-blue-400">
<div className="grid grid-cols-1">
<div className="flex items-center text-sm">
<div className="flex items-center">
<span className="flex items-center justify-center px-4">
{getProviderLogo(provider)}
</span>
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">{getIcon()}</div>
<span className="font-medium">
{providerAlias || providerUID}
</span>
</div>
</div>
</div>
<div className="flex items-center text-sm">
<div className="flex items-center space-x-4">
{getProviderLogo(provider)}
{getIcon()}
<span className="font-medium">{providerAlias || providerUID}</span>
</div>
</div>
);

View File

@@ -2,3 +2,4 @@ export * from "./auto-refresh";
export * from "./link-to-findings-from-scan";
export * from "./no-providers-added";
export * from "./no-providers-connected";
export * from "./scans-filters";

View File

@@ -8,7 +8,7 @@ import { CustomButton } from "../ui/custom";
export const NoProvidersAdded = () => {
return (
<div className="flex min-h-screen items-center justify-center dark:bg-prowler-blue-800">
<div className="flex min-h-screen items-center justify-center">
<div className="mx-auto w-full max-w-7xl px-4">
<Card className="mx-auto w-full max-w-3xl rounded-lg dark:bg-prowler-blue-400">
<CardBody className="flex flex-col items-center space-y-4 p-6 text-center sm:p-8">

View File

@@ -0,0 +1,37 @@
"use client";
import { filterScans } from "@/components/filters/data-filters";
import { FilterControls } from "@/components/filters/filter-controls";
import { useRelatedFilters } from "@/hooks";
import { FilterEntity, FilterType } from "@/types";
interface ScansFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
}
export const ScansFilters = ({
providerUIDs,
providerDetails,
}: ScansFiltersProps) => {
const { availableProviderUIDs } = useRelatedFilters({
providerUIDs,
providerDetails,
enableScanRelation: false,
});
return (
<FilterControls
customFilters={[
...filterScans,
{
key: FilterType.PROVIDER_UID,
labelCheckboxGroup: "Provider UID",
values: availableProviderUIDs,
valueLabelMapping: providerDetails,
index: 1,
},
]}
/>
);
};

View File

@@ -282,7 +282,10 @@ export const CustomDropdownFilter = ({
onValueChange={onSelectionChange}
className="font-bold"
>
{filter?.showSelectAll !== false && (
{filterValues.length === 0 && (
<span className="text-small font-normal">No results found</span>
)}
{filter?.showSelectAll !== false && filterValues.length > 0 && (
<>
<Checkbox
classNames={{
@@ -296,45 +299,49 @@ export const CustomDropdownFilter = ({
<Divider orientation="horizontal" className="mt-2" />
</>
)}
<ScrollShadow
hideScrollBar
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
>
{filterValues.map((value) => {
const entity: FilterEntity | undefined =
filter.valueLabelMapping?.find((entry) => entry[value])?.[
value
];
return (
<Checkbox
classNames={{
label: "text-small font-normal",
wrapper: "checkbox-update",
}}
key={value}
value={value}
>
{entity ? (
isScanEntity(entity as ScanEntity) ? (
<ComplianceScanInfo scan={entity as ScanEntity} />
) : (
<EntityInfoShort
cloudProvider={(entity as ProviderEntity).provider}
entityAlias={
(entity as ProviderEntity).alias ?? undefined
}
entityId={(entity as ProviderEntity).uid}
hideCopyButton
/>
)
) : (
{filterValues.length > 0 && (
<ScrollShadow
hideScrollBar
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
>
{filterValues.map((value) => {
const entity: FilterEntity | undefined =
filter.valueLabelMapping?.find((entry) => entry[value])?.[
value
)}
</Checkbox>
);
})}
</ScrollShadow>
];
return (
<Checkbox
classNames={{
label: "text-small font-normal",
wrapper: "checkbox-update",
}}
key={value}
value={value}
>
{entity ? (
isScanEntity(entity as ScanEntity) ? (
<ComplianceScanInfo scan={entity as ScanEntity} />
) : (
<EntityInfoShort
cloudProvider={
(entity as ProviderEntity).provider
}
entityAlias={
(entity as ProviderEntity).alias ?? undefined
}
entityId={(entity as ProviderEntity).uid}
hideCopyButton
/>
)
) : (
value
)}
</Checkbox>
);
})}
</ScrollShadow>
)}
</CheckboxGroup>
</div>
</PopoverContent>

View File

@@ -3,6 +3,7 @@
import React, { useState } from "react";
import { useCallback, useMemo } from "react";
import { ClearFiltersButton } from "@/components/filters";
import { CustomFilterIcon } from "@/components/icons";
import { CustomButton, CustomDropdownFilter } from "@/components/ui/custom";
import { useUrlFilters } from "@/hooks/use-url-filters";
@@ -11,11 +12,13 @@ import { FilterOption } from "@/types";
export interface DataTableFilterCustomProps {
filters: FilterOption[];
defaultOpen?: boolean;
showClearButton?: boolean;
}
export const DataTableFilterCustom = ({
filters,
defaultOpen = false,
showClearButton = false,
}: DataTableFilterCustomProps) => {
const { updateFilter } = useUrlFilters();
const [showFilters, setShowFilters] = useState(defaultOpen);
@@ -43,39 +46,33 @@ export const DataTableFilterCustom = ({
);
return (
<div
className={`flex ${
sortedFilters.length > 4 ? "flex-col" : "flex-col md:flex-row"
} gap-4`}
>
<CustomButton
ariaLabel={showFilters ? "Hide Filters" : "Show Filters"}
variant="flat"
color={showFilters ? "action" : "primary"}
size="md"
startContent={<CustomFilterIcon size={16} />}
onPress={() => setShowFilters(!showFilters)}
className="w-full max-w-fit"
>
<h3 className="text-small">
{showFilters ? "Hide Filters" : "Show Filters"}
</h3>
</CustomButton>
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-4">
<CustomButton
ariaLabel={showFilters ? "Hide Filters" : "Show Filters"}
variant="flat"
color={showFilters ? "action" : "primary"}
size="md"
startContent={<CustomFilterIcon size={16} />}
onPress={() => setShowFilters(!showFilters)}
className="w-full max-w-fit"
>
<h3 className="text-small">
{showFilters ? "Hide Filters" : "Show Filters"}
</h3>
</CustomButton>
{showClearButton && <ClearFiltersButton />}
</div>
<div
className={`transition-all duration-700 ease-in-out ${
showFilters
? "max-h-96 w-full translate-x-0 overflow-visible opacity-100"
: "max-h-0 -translate-x-full overflow-hidden opacity-0"
? "w-full translate-x-0 overflow-visible opacity-100"
: "mt-[-16px] max-h-0 -translate-x-full overflow-hidden opacity-0"
}`}
>
<div
className={`grid gap-4 ${
sortedFilters.length >= 4
? "grid-cols-1 md:grid-cols-4"
: "grid-cols-1 md:grid-cols-3"
}`}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{sortedFilters.map((filter) => (
<CustomDropdownFilter
key={filter.key}

View File

@@ -1,5 +1,6 @@
export * from "./use-credentials-form";
export * from "./use-form-server-errors";
export * from "./use-local-storage";
export * from "./use-related-filters";
export * from "./use-sidebar";
export * from "./use-store";

View File

@@ -0,0 +1,213 @@
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { isScanEntity } from "@/lib/helper-filters";
import {
FilterEntity,
FilterType,
ProviderEntity,
ProviderType,
ScanEntity,
} from "@/types";
interface UseRelatedFiltersProps {
providerUIDs: string[];
providerDetails: { [uid: string]: FilterEntity }[];
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
enableScanRelation?: boolean;
}
export const useRelatedFilters = ({
providerUIDs,
providerDetails,
completedScanIds = [],
scanDetails = [],
enableScanRelation = false,
}: UseRelatedFiltersProps) => {
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const [availableScans, setAvailableScans] =
useState<string[]>(completedScanIds);
const [availableProviderUIDs, setAvailableProviderUIDs] =
useState<string[]>(providerUIDs);
const previousProviders = useRef<string[]>([]);
const previousProviderTypes = useRef<ProviderType[]>([]);
const isManualDeselection = useRef(false);
const getScanProvider = (scanId: string) => {
if (!enableScanRelation) return null;
const scanDetail = scanDetails.find(
(detail) => Object.keys(detail)[0] === scanId,
);
return scanDetail ? scanDetail[scanId]?.providerInfo?.uid : null;
};
const getScanProviderType = (scanId: string): ProviderType | null => {
if (!enableScanRelation) return null;
const scanDetail = scanDetails.find(
(detail) => Object.keys(detail)[0] === scanId,
);
return scanDetail ? scanDetail[scanId]?.providerInfo?.provider : null;
};
const getProviderType = (providerUid: string): ProviderType | null => {
const providerDetail = providerDetails.find(
(detail) => Object.keys(detail)[0] === providerUid,
);
if (!providerDetail) return null;
const entity = providerDetail[providerUid];
if (!isScanEntity(entity as ScanEntity)) {
return (entity as ProviderEntity).provider;
}
return null;
};
useEffect(() => {
const scanParam = enableScanRelation
? searchParams.get(`filter[${FilterType.SCAN}]`)
: null;
const providerParam = searchParams.get(
`filter[${FilterType.PROVIDER_UID}]`,
);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
);
const currentProviders = providerParam ? providerParam.split(",") : [];
const currentProviderTypes = providerTypeParam
? (providerTypeParam.split(",") as ProviderType[])
: [];
// Detect deselected items
const deselectedProviders = previousProviders.current.filter(
(provider) => !currentProviders.includes(provider),
);
const deselectedProviderTypes = previousProviderTypes.current.filter(
(type) => !currentProviderTypes.includes(type),
);
// Check if it's a manual deselection
if (deselectedProviderTypes.length > 0) {
isManualDeselection.current = true;
} else if (
currentProviderTypes.length === 0 &&
previousProviderTypes.current.length === 0
) {
isManualDeselection.current = false;
}
// Update references
previousProviders.current = currentProviders;
previousProviderTypes.current = currentProviderTypes;
// Handle scan selection logic
if (enableScanRelation && scanParam) {
const scanProviderId = getScanProvider(scanParam);
const scanProviderType = getScanProviderType(scanParam);
const shouldDeselectScan =
(scanProviderId &&
(deselectedProviders.includes(scanProviderId) ||
(currentProviders.length > 0 &&
!currentProviders.includes(scanProviderId)))) ||
(scanProviderType &&
!isManualDeselection.current &&
(deselectedProviderTypes.includes(scanProviderType) ||
(currentProviderTypes.length > 0 &&
!currentProviderTypes.includes(scanProviderType))));
if (shouldDeselectScan) {
updateFilter(FilterType.SCAN, null);
} else {
// Add provider if not already selected
if (scanProviderId && !currentProviders.includes(scanProviderId)) {
updateFilter(FilterType.PROVIDER_UID, [
...currentProviders,
scanProviderId,
]);
}
// Only add provider type if there are none selected
if (
scanProviderType &&
currentProviderTypes.length === 0 &&
!isManualDeselection.current
) {
updateFilter(FilterType.PROVIDER_TYPE, [scanProviderType]);
}
}
}
// Handle provider selection logic
if (
currentProviders.length > 0 &&
deselectedProviders.length === 0 &&
!isManualDeselection.current
) {
const providerTypes = currentProviders
.map(getProviderType)
.filter((type): type is ProviderType => type !== null);
const selectedProviderTypes = Array.from(new Set(providerTypes));
if (
selectedProviderTypes.length > 0 &&
currentProviderTypes.length === 0
) {
updateFilter(FilterType.PROVIDER_TYPE, selectedProviderTypes);
}
}
// Update available providers
if (currentProviderTypes.length > 0) {
const filteredProviderUIDs = providerUIDs.filter((uid) => {
const providerType = getProviderType(uid);
return providerType && currentProviderTypes.includes(providerType);
});
setAvailableProviderUIDs(filteredProviderUIDs);
const validProviders = currentProviders.filter((uid) => {
const providerType = getProviderType(uid);
return providerType && currentProviderTypes.includes(providerType);
});
if (validProviders.length !== currentProviders.length) {
updateFilter(
FilterType.PROVIDER_UID,
validProviders.length > 0 ? validProviders : null,
);
}
} else {
setAvailableProviderUIDs(providerUIDs);
}
// Update available scans
if (enableScanRelation) {
if (currentProviders.length > 0 || currentProviderTypes.length > 0) {
const filteredScans = completedScanIds.filter((scanId) => {
const scanProviderId = getScanProvider(scanId);
const scanProviderType = getScanProviderType(scanId);
return (
(currentProviders.length === 0 ||
(scanProviderId && currentProviders.includes(scanProviderId))) &&
(currentProviderTypes.length === 0 ||
(scanProviderType &&
currentProviderTypes.includes(scanProviderType)))
);
});
setAvailableScans(filteredScans);
} else {
setAvailableScans(completedScanIds);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
return {
availableProviderUIDs,
availableScans,
};
};

View File

@@ -66,9 +66,17 @@ export const useUrlFilters = () => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, searchParams, pathname]);
const hasFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
return Array.from(params.keys()).some(
(key) => key.startsWith("filter[") || key === "sort",
);
}, [searchParams]);
return {
updateFilter,
clearFilter,
clearAllFilters,
hasFilters,
};
};

View File

@@ -28,3 +28,15 @@ export interface FilterControlsProps {
mutedFindings?: boolean;
customFilters?: FilterOption[];
}
export enum FilterType {
SCAN = "scan__in",
PROVIDER_UID = "provider_uid__in",
PROVIDER_TYPE = "provider_type__in",
REGION = "region__in",
SERVICE = "service__in",
RESOURCE_TYPE = "resource_type__in",
SEVERITY = "severity__in",
STATUS = "status__in",
DELTA = "delta__in",
}