Merge branch 'master' into PRWLR-7160-findings-page-scan-id-filter-improvement

# Conflicts:
#	ui/CHANGELOG.md
#	ui/components/ui/custom/custom-dropdown-filter.tsx
This commit is contained in:
sumit_chaturvedi committed 2025-06-02 08:33:33 +05:30
commit 1dc8636276
23 files changed
+361 -164

No files matched your search

+135 -121
View File
@@ -10,166 +10,183 @@ import {
PopoverTrigger,
ScrollShadow,
} from "@nextui-org/react";
import { XCircle } from "lucide-react";
import { ChevronDown, X } from "lucide-react";
import { useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ComplianceScanInfo } from "@/components/compliance";
import { PlusCircleIcon } from "@/components/icons";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { CustomDropdownFilterProps } from "@/types";
const filterSelectedClass =
"inline-flex items-center border py-1 text-xs transition-colors border-transparent bg-default-500 text-secondary-foreground hover:bg-default-500/80 rounded-md px-2 font-normal";
import { ComplianceScanInfo } from "@/components/compliance";
export const CustomDropdownFilter: React.FC<CustomDropdownFilterProps> = ({
export const CustomDropdownFilter = ({
filter,
onFilterChange,
}) => {
}: CustomDropdownFilterProps) => {
const searchParams = useSearchParams();
const { clearFilter } = useUrlFilters();
const [groupSelected, setGroupSelected] = useState(new Set<string>());
const [pendingClearFilter, setPendingClearFilter] = useState<string | null>(
null,
const [isOpen, setIsOpen] = useState(false);
const filterValues = useMemo(() => filter?.values || [], [filter?.values]);
const selectedValues = Array.from(groupSelected).filter(
(value) => value !== "all",
);
const isAllSelected =
selectedValues.length === filterValues.length && filterValues.length > 0;
const allFilterKeys = useMemo(() => filter?.values || [], [filter?.values]);
const getActiveFilter = useMemo(() => {
const currentFilters: Record<string, string> = {};
Array.from(searchParams.entries()).forEach(([key, value]) => {
if (key.startsWith("filter[") && key.endsWith("]")) {
const filterKey = key.slice(7, -1);
if (filter && filter.key === filterKey) {
// eslint-disable-next-line security/detect-object-injection
currentFilters[filterKey] = value;
}
}
});
return currentFilters;
}, [searchParams, filter]);
const memoizedFilterValues = useMemo(
() => filter?.values || [],
[filter?.values],
);
const activeFilterValue = useMemo(() => {
const filterParam = searchParams.get(`filter[${filter?.key}]`);
return filterParam ? filterParam.split(",") : [];
}, [searchParams, filter?.key]);
// Sync URL state with component state
useEffect(() => {
if (filter && getActiveFilter[filter.key]) {
const activeValues = getActiveFilter[filter.key].split(",");
const newSelection = new Set(activeValues);
if (newSelection.size === memoizedFilterValues.length) {
if (activeFilterValue.length > 0) {
const newSelection = new Set(activeFilterValue);
if (newSelection.size === filterValues.length) {
newSelection.add("all");
}
setGroupSelected(newSelection);
} else {
setGroupSelected(new Set());
}
}, [getActiveFilter, filter?.key, memoizedFilterValues, filter]);
}, [activeFilterValue, filterValues.length]);
const updateSelection = useCallback(
(newValues: string[]) => {
const actualValues = newValues.filter((key) => key !== "all");
const newSelection = new Set(actualValues);
// Auto-add "all" if all items are selected
if (
actualValues.length === filterValues.length &&
filterValues.length > 0
) {
newSelection.add("all");
}
setGroupSelected(newSelection);
// Notify parent with actual values (excluding "all")
onFilterChange?.(filter.key, actualValues);
},
[filterValues.length, onFilterChange, filter.key],
);
const onSelectionChange = useCallback(
(keys: string[]) => {
setGroupSelected((prevGroupSelected) => {
const newSelection = new Set(keys);
const currentSelection = Array.from(groupSelected);
const newKeys = new Set(keys);
const oldKeys = new Set(currentSelection);
if (
newSelection.size === allFilterKeys.length &&
!newSelection.has("all")
) {
return new Set(["all", ...allFilterKeys]);
} else if (prevGroupSelected.has("all")) {
newSelection.delete("all");
return new Set(allFilterKeys.filter((key) => newSelection.has(key)));
}
return newSelection;
});
// Check if "all" was just toggled
const allWasSelected = oldKeys.has("all");
const allIsSelected = newKeys.has("all");
if (onFilterChange && filter) {
const selectedValues = keys.filter((key) => key !== "all");
onFilterChange(filter.key, selectedValues);
if (allIsSelected && !allWasSelected) {
// "all" was just selected - select all items
updateSelection(filterValues);
} else if (!allIsSelected && allWasSelected) {
// "all" was just deselected - deselect all items
updateSelection([]);
} else if (allIsSelected && allWasSelected) {
// "all" was already selected, but individual items changed
// Remove "all" and keep only the individual selections
const individualSelections = keys.filter((key) => key !== "all");
updateSelection(individualSelections);
} else {
// Normal individual selection without "all"
updateSelection(keys);
}
},
[allFilterKeys, onFilterChange, filter],
[groupSelected, updateSelection, filterValues],
);
const handleSelectAllClick = useCallback(() => {
setGroupSelected((prevGroupSelected: Set<string>) => {
const newSelection: Set<string> = prevGroupSelected.has("all")
? new Set()
: new Set(["all", ...allFilterKeys]);
const handleClearAll = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
updateSelection([]);
},
[updateSelection],
);
if (onFilterChange && filter) {
const selectedValues = Array.from(newSelection).filter(
(key) => key !== "all",
);
onFilterChange(filter.key, selectedValues);
}
return newSelection;
});
}, [allFilterKeys, onFilterChange, filter]);
// Update the pending clear filter
const onClearFilter = useCallback((filterKey: string) => {
setPendingClearFilter(filterKey);
}, []);
// Execute the update in the router after the render
useEffect(() => {
if (pendingClearFilter && filter) {
clearFilter(pendingClearFilter);
setPendingClearFilter(null); // Reset the state
}
}, [pendingClearFilter, clearFilter, filter]);
const getDisplayLabel = useCallback(
(value: string) => {
const entity = filter.valueLabelMapping?.find((entry) => entry[value])?.[
value
];
return entity?.alias || entity?.uid || value;
},
[filter.valueLabelMapping],
);
return (
<div className="relative flex w-full flex-col gap-2">
<Button
isIconOnly
variant="light"
onPress={() => onClearFilter(filter.key)}
className={`absolute right-2 top-1/2 z-40 -translate-y-1/2 ${
groupSelected.size === 0 ? "hidden" : ""
}`}
<div className="flex w-full flex-col gap-2">
<Popover
backdrop="transparent"
placement="bottom-start"
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<XCircle className="h-4 w-4 text-default-400" />
</Button>
<Popover backdrop="transparent" placement="bottom-start">
<PopoverTrigger>
<Button
className="border-input hover:bg-accent hover:text-accent-foreground inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md border border-dashed bg-background px-3 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none disabled:opacity-50 dark:bg-prowler-blue-800"
startContent={<PlusCircleIcon size={16} />}
className="border-input hover:bg-accent hover:text-accent-foreground inline-flex h-auto min-h-10 items-center justify-between whitespace-nowrap rounded-md border border-dashed bg-background px-3 py-2 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none disabled:opacity-50 dark:bg-prowler-blue-800"
endContent={
<ChevronDown
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
}
size="md"
variant="flat"
>
<h3 className="text-small">{filter?.labelCheckboxGroup}</h3>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="flex-shrink-0 text-small">
{filter?.labelCheckboxGroup}
</span>
{groupSelected.size > 0 && (
<>
<Divider orientation="vertical" className="mx-2 h-4" />
<div className="flex items-center gap-2">
<div className="no-scrollbar hidden max-w-24 space-x-1 overflow-x-auto lg:flex">
{groupSelected.size > 3 ? (
<span className={filterSelectedClass}>
{`+${groupSelected.size - 2} selected`}
{selectedValues.length > 0 && (
<>
<Divider
orientation="vertical"
className="h-4 flex-shrink-0"
/>
<div className="flex min-w-0 flex-shrink items-center">
{selectedValues.length <= 2 ? (
<span
className="max-w-32 truncate text-xs text-default-500"
title={selectedValues.map(getDisplayLabel).join(", ")}
>
{selectedValues.map(getDisplayLabel).join(", ")}
</span>
) : (
Array.from(groupSelected)
.filter((value) => value !== "all")
.map((value) => (
<div key={value} className={filterSelectedClass}>
{value}
</div>
))
<span className="truncate text-xs text-default-500">
{isAllSelected
? "All selected"
: `${selectedValues.length} selected`}
</span>
)}
<div
onClick={handleClearAll}
className="ml-1 flex h-4 w-4 flex-shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-default-200"
aria-label="Clear selection"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClearAll(e as any);
}
}}
>
<X className="h-3 w-3 text-default-400 hover:text-default-600" />
</div>
</div>
</div>
</>
)}
</>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[20rem] dark:bg-prowler-blue-800">
<div className="flex w-full flex-col gap-6 p-2">
<div className="flex w-full flex-col gap-4 p-2">
<CheckboxGroup
color="default"
label={filter?.labelCheckboxGroup}
@@ -183,8 +200,6 @@ export const CustomDropdownFilter: React.FC<CustomDropdownFilterProps> = ({
wrapper: "checkbox-update",
}}
value="all"
isSelected={groupSelected.has("all")}
onClick={handleSelectAllClick}
>
Select All
</Checkbox>
@@ -193,12 +208,11 @@ export const CustomDropdownFilter: React.FC<CustomDropdownFilterProps> = ({
hideScrollBar
className="flex max-h-96 max-w-full flex-col gap-y-2 py-2"
>
{memoizedFilterValues.map((value) => {
// Find the corresponding entity from valueLabelMapping
const matchingEntry = filter.valueLabelMapping?.find(
{filterValues.map((value) => {
const entity = filter.valueLabelMapping?.find(
(entry) => entry[value],
);
const scanData = matchingEntry?.[value];
const scanData = entity?.[value];
return (
<Checkbox