mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
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:
commit
1dc8636276
23 files changed
+361
-164
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user