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
2025-06-02 08:33:33 +05:30
23 changed files with 361 additions and 164 deletions
+4
View File
@@ -24,6 +24,10 @@ POSTGRES_USER=prowler
POSTGRES_PASSWORD=postgres
POSTGRES_DB=prowler_db
# Celery-Prowler task settings
TASK_RETRY_DELAY_SECONDS=0.1
TASK_RETRY_ATTEMPTS=5
# Valkey settings
# If running Valkey and celery on host, use localhost, else use 'valkey'
VALKEY_HOST=valkey
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
- name: Set short git commit SHA
id: vars
run: |
shortSha=$(git rev-parse --short ${{ github.sha }})
shortSha=$(git rev-parse --short ${{ github.event.pull_request.merge_commit_sha }})
echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV
- name: Trigger pull request
@@ -28,7 +28,7 @@ jobs:
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
client-payload: '{
"PROWLER_COMMIT_SHA": "${{ github.sha }}",
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
"PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}",
"PROWLER_PR_TITLE": "${{ github.event.pull_request.title }}",
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
+5
View File
@@ -10,12 +10,17 @@ All notable changes to the **Prowler API** are documented in this file.
### Changed
- Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784)
### Fixed
- Fixed the connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831)
---
## [v1.8.2] (Prowler v5.7.2)
### Fixed
- Fixed task lookup to use task_kwargs instead of task_args for scan report resolution. [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830)
- Fixed Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871)
- Fixed a race condition when creating background tasks [(#7876)](https://github.com/prowler-cloud/prowler/pull/7876).
---
+41 -1
View File
@@ -1,7 +1,9 @@
import json
import re
import time
from uuid import UUID, uuid4
from config.env import env
from cryptography.fernet import Fernet
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
@@ -242,7 +244,7 @@ class Provider(RowLevelSecurityProtectedModel):
@staticmethod
def validate_kubernetes_uid(value):
if not re.match(
r"^[a-z0-9][A-Za-z0-9_.:\/-]{1,250}$",
r"^[a-zA-Z0-9][a-zA-Z0-9._@:\/-]{1,250}$",
value,
):
raise ModelValidationError(
@@ -352,6 +354,42 @@ class ProviderGroupMembership(RowLevelSecurityProtectedModel):
resource_name = "provider_groups-provider"
class TaskManager(models.Manager):
def get_with_retry(
self,
id: str,
max_retries: int = None,
delay_seconds: float = None,
):
"""
Retry fetching a Task by ID in case it hasn't been created yet.
Args:
id (str): The Celery task ID (expected to match Task model PK).
max_retries (int, optional): Number of retry attempts. Defaults to env TASK_RETRY_ATTEMPTS or 5.
delay_seconds (float, optional): Delay between retries in seconds. Defaults to env TASK_RETRY_DELAY_SECONDS or 0.1.
Returns:
Task: The retrieved Task instance.
Raises:
Task.DoesNotExist: If the task is not found after all retries.
"""
max_retries = max_retries or env.int("TASK_RETRY_ATTEMPTS", default=5)
delay_seconds = delay_seconds or env.float(
"TASK_RETRY_DELAY_SECONDS", default=0.1
)
for _attempt in range(max_retries):
try:
return self.get(id=id)
except self.model.DoesNotExist:
time.sleep(delay_seconds)
raise self.model.DoesNotExist(
f"Task with ID {id} not found after {max_retries} retries."
)
class Task(RowLevelSecurityProtectedModel):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
@@ -364,6 +402,8 @@ class Task(RowLevelSecurityProtectedModel):
blank=True,
)
objects = TaskManager()
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "tasks"
+36 -1
View File
@@ -1,6 +1,9 @@
import uuid
from unittest import mock
import pytest
from api.models import Resource, ResourceTag
from api.models import Resource, ResourceTag, Task
@pytest.mark.django_db
@@ -120,3 +123,35 @@ class TestResourceModel:
# compliance={},
# )
# assert Finding.objects.filter(uid=long_uid).exists()
@pytest.mark.django_db
class TestTaskManager:
def test_get_with_retry_success(self):
task_id = uuid.uuid4()
call_counter = {"count": 0}
def side_effect(*args, **kwargs):
if call_counter["count"] < 2:
call_counter["count"] += 1
raise Task.DoesNotExist()
return Task(id=task_id)
with mock.patch.object(Task.objects, "get", side_effect=side_effect):
task = Task.objects.get_with_retry(
task_id, max_retries=5, delay_seconds=0.01
)
assert task.id == task_id
assert call_counter["count"] == 2
def test_get_with_retry_fail(self):
non_existent_id = uuid.uuid4()
with mock.patch.object(Task.objects, "get", side_effect=Task.DoesNotExist):
with pytest.raises(Task.DoesNotExist) as excinfo:
Task.objects.get_with_retry(
non_existent_id, max_retries=3, delay_seconds=0.01
)
assert str(non_existent_id) in str(excinfo.value)
+11 -1
View File
@@ -914,6 +914,16 @@ class TestProviderViewSet:
"uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789",
"alias": "GKE",
},
{
"provider": "kubernetes",
"uid": "gke_project/cluster-name",
"alias": "GKE",
},
{
"provider": "kubernetes",
"uid": "admin@k8s-demo",
"alias": "test",
},
{
"provider": "azure",
"uid": "8851db6b-42e5-4533-aa9e-30a32d67e875",
@@ -921,7 +931,7 @@ class TestProviderViewSet:
},
{
"provider": "m365",
"uid": "TestingPro.onMirosoft.com",
"uid": "TestingPro.onmicrosoft.com",
"alias": "test",
},
{
+4 -4
View File
@@ -1086,7 +1086,7 @@ class ProviderViewSet(BaseRLSViewSet):
task = check_provider_connection_task.delay(
provider_id=pk, tenant_id=self.request.tenant_id
)
prowler_task = Task.objects.get(id=task.id)
prowler_task = Task.objects.get_with_retry(id=task.id)
serializer = TaskSerializer(prowler_task)
return Response(
data=serializer.data,
@@ -1109,7 +1109,7 @@ class ProviderViewSet(BaseRLSViewSet):
task = delete_provider_task.delay(
provider_id=pk, tenant_id=self.request.tenant_id
)
prowler_task = Task.objects.get(id=task.id)
prowler_task = Task.objects.get_with_retry(id=task.id)
serializer = TaskSerializer(prowler_task)
return Response(
data=serializer.data,
@@ -1489,10 +1489,10 @@ class ScanViewSet(BaseRLSViewSet):
},
)
prowler_task = Task.objects.get_with_retry(id=task.id)
scan.task_id = task.id
scan.save(update_fields=["task_id"])
prowler_task = Task.objects.get(id=task.id)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
@@ -2823,7 +2823,7 @@ class ScheduleViewSet(BaseRLSViewSet):
with transaction.atomic():
task = schedule_provider_scan(provider_instance)
prowler_task = Task.objects.get(id=task.id)
prowler_task = Task.objects.get_with_retry(id=task.id)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
+7 -1
View File
@@ -124,6 +124,7 @@ def perform_prowler_scan(
unique_resources = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
start_time = time.time()
exc = None
with rls_transaction(tenant_id):
provider_instance = Provider.objects.get(pk=provider_id)
@@ -139,7 +140,7 @@ def perform_prowler_scan(
provider_instance.connected = True
except Exception as e:
provider_instance.connected = False
raise ValueError(
exc = ValueError(
f"Provider {provider_instance.provider} is not connected: {e}"
)
finally:
@@ -148,6 +149,11 @@ def perform_prowler_scan(
)
provider_instance.save()
# If the provider is not connected, raise an exception outside the transaction.
# If raised within the transaction, the transaction will be rolled back and the provider will not be marked as not connected.
if exc:
raise exc
prowler_scan = ProwlerScan(provider=prowler_provider, checks=checks_to_execute)
resource_cache = {}
+5
View File
@@ -1,5 +1,6 @@
import json
import uuid
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
@@ -206,6 +207,10 @@ class TestPerformScan:
scan.refresh_from_db()
assert scan.state == StateChoices.FAILED
provider.refresh_from_db()
assert provider.connected is False
assert isinstance(provider.connection_last_checked_at, datetime)
@pytest.mark.parametrize(
"last_status, new_status, expected_delta",
[
+2 -2
View File
@@ -70,7 +70,7 @@ The other three cases does not need additional configuration, `--az-cli-auth` an
Prowler for Azure needs two types of permission scopes to be set:
- **Microsoft Entra ID permissions**: used to retrieve metadata from the identity assumed by Prowler and specific Entra checks (not mandatory to have access to execute the tool). The permissions required by the tool are the following:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication)
- **Subscription scope permissions**: required to launch the checks against your resources, mandatory to launch the tool. It is required to add the following RBAC builtin roles per subscription to the entity that is going to be assumed by the tool:
@@ -199,7 +199,7 @@ Since this is a delegated permission authentication method, necessary permission
Prowler for M365 requires two types of permission scopes to be set (if you want to run the full provider including PowerShell checks). Both must be configured using Microsoft Entra ID:
- **Service Principal Application Permissions**: These are set at the **application** level and are used to retrieve data from the identity being assessed:
- `Directory.Read.All`: Required for all services.
- `Domain.Read.All`: Required for all services.
- `Policy.Read.All`: Required for all services.
- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
@@ -40,7 +40,7 @@ az ad sp create-for-rbac --name "ProwlerApp"
To allow Prowler to retrieve metadata from the identity assumed and run specific Entra checks, it is needed to assign the following permissions:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication)
@@ -58,7 +58,7 @@ To assign the permissions you can make it from the Azure Portal or using the Azu
5. Then click on "+ Add a permission" and select "Microsoft Graph"
6. Once in the "Microsoft Graph" view, select "Application permissions"
7. Finally, search for "Directory", "Policy" and "UserAuthenticationMethod" select the following permissions:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
8. Click on "Add permissions" to apply the new permissions.
@@ -90,7 +90,7 @@ A Service Principal is required to grant Prowler the necessary privileges.
Assign the following Microsoft Graph permissions:
- Directory.Read.All
- Domain.Read.All
- Policy.Read.All
@@ -107,7 +107,7 @@ Assign the following Microsoft Graph permissions:
3. Search and select:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `UserAuthenticationMethod.Read.All`
@@ -96,7 +96,7 @@ With this done you will have all the needed keys, summarized in the following ta
Assign the following Microsoft Graph permissions:
- `Directory.Read.All`: Required for all services.
- `Domain.Read.All`: Required for all services.
- `Policy.Read.All`: Required for all services.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
- `AuditLog.Read.All`: Required for Entra service.
@@ -114,7 +114,7 @@ Follow these steps to assign the permissions:
3. Search and select every permission below and once all are selected click on `Add permissions`:
- `Directory.Read.All`
- `Domain.Read.All`
- `Policy.Read.All`
- `SharePointTenantSettings.Read.All`
- `AuditLog.Read.All`: Required for Entra service.
@@ -176,20 +176,6 @@ Follow these steps to assign the role:
![App Overview](./img/app-overview.png)
???+ warning
For Prowler Cloud encrypted password is still needed (when we update Prowler Cloud and regular password is accepted this warning will be deleted), so the password that you paste in the next step should be generated following this steps:
- UNIX: Open a PowerShell cmd with a [supported version](../../getting-started/requirements.md#supported-powershell-versions) and then run the following command:
```console
$securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force
$encryptedPassword = $securePassword | ConvertFrom-SecureString
Write-Output $encryptedPassword
6500780061006d0070006c006500700061007300730077006f0072006400
```
- Windows: Install WSL using `wsl --install -d Ubuntu-22.04`, then open the Ubuntu terminal, install powershell and run the same command above.
2. Go to Prowler Cloud/App and paste:
+2
View File
@@ -21,12 +21,14 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add `repository_default_branch_requires_codeowners_review` check for GitHub provider. [(#7753)](https://github.com/prowler-cloud/prowler/pull/7753)
- Add NIS 2 compliance framework for AWS. [(7839)](https://github.com/prowler-cloud/prowler/pull/7839)
- Add NIS 2 compliance framework for Azure. [(7857)](https://github.com/prowler-cloud/prowler/pull/7857)
- Add search bar in Dashboard Overview page. [(#7804)](https://github.com/prowler-cloud/prowler/pull/7804)
### Fixed
- Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761)
- Fix `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license. [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779)
- Fix `m365_powershell` to close the PowerShell sessions in msgraph services. [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816)
- Fix `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity. [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862)
- Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive. [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888)
---
+2 -3
View File
@@ -329,9 +329,8 @@ class CheckMetadata(BaseModel):
checks = set()
if service:
# This is a special case for the AWS provider since `lambda` is a reserved keyword in Python
if service == "awslambda":
service = "lambda"
if service == "lambda":
service = "awslambda"
checks = {
check_name
for check_name, check_metadata in bulk_checks_metadata.items()
+3 -3
View File
@@ -515,9 +515,9 @@ class GcpProvider(Provider):
credentials=session,
)
# Test the connection using the Service Usage API since it is enabled by default
client = discovery.build("serviceusage", "v1", credentials=session)
request = client.services().list(parent=f"projects/{project_id}")
# Test the connection using OAuth2 API to verify token validity
client = discovery.build("oauth2", "v2", credentials=session)
request = client.tokeninfo()
request.execute()
return Connection(is_connected=True)
@@ -36,7 +36,7 @@ class admincenter_groups_not_public_visibility(Check):
report.status = "FAIL"
report.status_extended = f"Group {group.name} has {group.visibility} visibility and should be Private."
if group.visibility != "Public":
if group.visibility and group.visibility != "Public":
report.status = "PASS"
report.status_extended = (
f"Group {group.name} has {group.visibility} visibility."
@@ -11,8 +11,6 @@ from prowler.providers.m365.m365_provider import M365Provider
class AdminCenter(M365Service):
def __init__(self, provider: M365Provider):
super().__init__(provider)
if self.powershell:
self.powershell.close()
self.organization_config = None
self.sharing_policy = None
@@ -203,7 +201,7 @@ class DirectoryRole(BaseModel):
class Group(BaseModel):
id: str
name: str
visibility: str
visibility: Optional[str]
class Domain(BaseModel):
+1 -1
View File
@@ -37,7 +37,7 @@ mock_metadata_lambda = CheckMetadata(
CheckID="awslambda_function_url_public",
CheckTitle="Check 1",
CheckType=["type1"],
ServiceName="lambda",
ServiceName="awslambda",
SubServiceName="subservice1",
ResourceIdTemplate="template1",
Severity="high",
@@ -114,3 +114,91 @@ class Test_admincenter_groups_not_public_visibility:
assert result[0].resource_name == "Group1"
assert result[0].resource_id == id_group1
assert result[0].location == "global"
def test_admincenter_group_public_visibility(self):
admincenter_client = mock.MagicMock
admincenter_client.audited_tenant = "audited_tenant"
admincenter_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client",
new=admincenter_client,
),
):
from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import (
admincenter_groups_not_public_visibility,
)
from prowler.providers.m365.services.admincenter.admincenter_service import (
Group,
)
id_group1 = str(uuid4())
admincenter_client.groups = {
id_group1: Group(id=id_group1, name="Group1", visibility="Public"),
}
check = admincenter_groups_not_public_visibility()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Group Group1 has Public visibility and should be Private."
)
assert result[0].resource == admincenter_client.groups[id_group1].dict()
assert result[0].resource_name == "Group1"
assert result[0].resource_id == id_group1
assert result[0].location == "global"
def test_admincenter_group_none_visibility(self):
admincenter_client = mock.MagicMock
admincenter_client.audited_tenant = "audited_tenant"
admincenter_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client",
new=admincenter_client,
),
):
from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import (
admincenter_groups_not_public_visibility,
)
from prowler.providers.m365.services.admincenter.admincenter_service import (
Group,
)
id_group1 = str(uuid4())
admincenter_client.groups = {
id_group1: Group(id=id_group1, name="Group1", visibility=None),
}
check = admincenter_groups_not_public_visibility()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Group Group1 has None visibility and should be Private."
)
assert result[0].resource == admincenter_client.groups[id_group1].dict()
assert result[0].resource_name == "Group1"
assert result[0].resource_id == id_group1
assert result[0].location == "global"
+4
View File
@@ -12,8 +12,12 @@ All notable changes to the **Prowler UI** are documented in this file.
- Add `Provider UID` filter to scans page. [(#7820)](https://github.com/prowler-cloud/prowler/pull/7820)
- Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758)
- Missing KISA and ProwlerThreat icons added to the compliance page. [(#7860)(https://github.com/prowler-cloud/prowler/pull/7860)]
- Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)]
- Improve `Scan ID` filter by adding more context and enhancing the UI/UX. [(#7827)](https://github.com/prowler-cloud/prowler/pull/7827/)
### 🐞 Fixes
- Retrieve more than 10 scans in /compliance page. [(#7865)](https://github.com/prowler-cloud/prowler/pull/7865)
---
## [v1.7.1] (Prowler v5.7.1)
+1
View File
@@ -33,6 +33,7 @@ export default async function Compliance({
filters: {
"filter[state]": "completed",
},
pageSize: 50,
});
if (!scansData?.data) {
+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