mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
fix(ui): fix findings filter silent reverts by replacing useRelatedFilters effect with pure derivation (#10021)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
- Filter navigations not coordinating with Suspense boundaries due to missing startTransition in ProviderTypeSelector, AccountsSelector, and muted findings checkbox [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
|
||||
- Scans page pagination not updating table data because ScansTableWithPolling kept stale state from initial mount [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
|
||||
- Duplicate `filter[search]` parameter in findings and scans API calls [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
|
||||
- Filter navigations silently reverting in production due to shared `useTransition()` context wrapping `router.push()` — each filter now uses a local transition while signaling the shared context for the DataTable loading indicator [(#10017)](https://github.com/prowler-cloud/prowler/pull/10017)
|
||||
- All filters on `/findings` silently reverting on first click in production [(#10021)](https://github.com/prowler-cloud/prowler/pull/10021)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { ReactNode, useTransition } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useFilterTransitionOptional } from "@/contexts";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import type { ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
@@ -43,13 +43,8 @@ interface AccountsSelectorProps {
|
||||
}
|
||||
|
||||
export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Signal shared pending state for DataTable loading indicator
|
||||
const filterTransition = useFilterTransitionOptional();
|
||||
const [, startTransition] = useTransition();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const filterKey = "filter[provider_id__in]";
|
||||
const current = searchParams.get(filterKey) || "";
|
||||
@@ -67,45 +62,36 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
);
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(filterKey);
|
||||
navigateWithParams((params) => {
|
||||
params.delete(filterKey);
|
||||
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
}
|
||||
|
||||
// Auto-deselect provider types that no longer have any selected accounts
|
||||
if (selectedTypesList.length > 0) {
|
||||
// Get provider types of currently selected accounts
|
||||
const selectedProviders = providers.filter((p) => ids.includes(p.id));
|
||||
const selectedProviderTypes = new Set(
|
||||
selectedProviders.map((p) => p.attributes.provider),
|
||||
);
|
||||
|
||||
// Keep only provider types that still have selected accounts
|
||||
const remainingProviderTypes = selectedTypesList.filter((type) =>
|
||||
selectedProviderTypes.has(type as ProviderType),
|
||||
);
|
||||
|
||||
// Update provider_type__in filter
|
||||
if (remainingProviderTypes.length > 0) {
|
||||
params.set(
|
||||
"filter[provider_type__in]",
|
||||
remainingProviderTypes.join(","),
|
||||
);
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to page 1 when changing filter
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
// Auto-deselect provider types that no longer have any selected accounts
|
||||
if (selectedTypesList.length > 0) {
|
||||
// Get provider types of currently selected accounts
|
||||
const selectedProviders = providers.filter((p) => ids.includes(p.id));
|
||||
const selectedProviderTypes = new Set(
|
||||
selectedProviders.map((p) => p.attributes.provider),
|
||||
);
|
||||
|
||||
filterTransition?.signalFilterChange();
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
// Keep only provider types that still have selected accounts
|
||||
const remainingProviderTypes = selectedTypesList.filter((type) =>
|
||||
selectedProviderTypes.has(type as ProviderType),
|
||||
);
|
||||
|
||||
// Update provider_type__in filter
|
||||
if (remainingProviderTypes.length > 0) {
|
||||
params.set(
|
||||
"filter[provider_type__in]",
|
||||
remainingProviderTypes.join(","),
|
||||
);
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { lazy, Suspense, useTransition } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
MultiSelect,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useFilterTransitionOptional } from "@/contexts";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
@@ -123,13 +123,8 @@ type ProviderTypeSelectorProps = {
|
||||
export const ProviderTypeSelector = ({
|
||||
providers,
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Signal shared pending state for DataTable loading indicator
|
||||
const filterTransition = useFilterTransitionOptional();
|
||||
const [, startTransition] = useTransition();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
|
||||
const selectedTypes = currentProviders
|
||||
@@ -137,27 +132,17 @@ export const ProviderTypeSelector = ({
|
||||
: [];
|
||||
|
||||
const handleMultiValueChange = (values: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
navigateWithParams((params) => {
|
||||
// Update provider_type__in
|
||||
if (values.length > 0) {
|
||||
params.set("filter[provider_type__in]", values.join(","));
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
|
||||
// Update provider_type__in
|
||||
if (values.length > 0) {
|
||||
params.set("filter[provider_type__in]", values.join(","));
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
|
||||
// Clear account selection when changing provider types
|
||||
// User should manually select accounts if they want to filter by specific accounts
|
||||
params.delete("filter[provider_id__in]");
|
||||
|
||||
// Reset to page 1 when changing filter
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
filterTransition?.signalFilterChange();
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
// Clear account selection when changing provider types
|
||||
// User should manually select accounts if they want to filter by specific accounts
|
||||
params.delete("filter[provider_id__in]");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { useFilterTransitionOptional } from "@/contexts";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
// Constants for muted filter URL values
|
||||
const MUTED_FILTER_VALUES = {
|
||||
@@ -13,16 +12,10 @@ const MUTED_FILTER_VALUES = {
|
||||
} as const;
|
||||
|
||||
export const CustomCheckboxMutedFindings = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Signal shared pending state for DataTable loading indicator
|
||||
const filterTransition = useFilterTransitionOptional();
|
||||
const [, startTransition] = useTransition();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
// Get the current muted filter value from URL
|
||||
// Middleware ensures filter[muted] is always present when navigating to /findings
|
||||
const mutedFilterValue = searchParams.get("filter[muted]");
|
||||
|
||||
// URL states:
|
||||
@@ -32,24 +25,15 @@ export const CustomCheckboxMutedFindings = () => {
|
||||
|
||||
const handleMutedChange = (checked: boolean | "indeterminate") => {
|
||||
const isChecked = checked === true;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
|
||||
} else {
|
||||
// Exclude muted: apply filter to show only non-muted
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
|
||||
}
|
||||
|
||||
// Reset to page 1 when changing filter
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
filterTransition?.signalFilterChange();
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
navigateWithParams((params) => {
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
|
||||
} else {
|
||||
// Exclude muted: apply filter to show only non-muted
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -21,6 +19,19 @@ interface UseRelatedFiltersProps {
|
||||
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives available providers and scans based on the current URL filters.
|
||||
*
|
||||
* Pure computation — no effects, no state, no navigation. The returned
|
||||
* lists update automatically when searchParams change because the component
|
||||
* re-renders with new searchParams from Next.js.
|
||||
*
|
||||
* Cascading filter cleanup (e.g. auto-clearing a scan when its provider is
|
||||
* deselected) is handled atomically by the filter components themselves
|
||||
* (ProviderTypeSelector clears provider_id__in, AccountsSelector updates
|
||||
* provider_type__in). This avoids the production bug where router.push()
|
||||
* calls inside useEffect would silently abort pending navigations.
|
||||
*/
|
||||
export const useRelatedFilters = ({
|
||||
providerIds = [],
|
||||
providerUIDs = [],
|
||||
@@ -31,33 +42,18 @@ export const useRelatedFilters = ({
|
||||
providerFilterType = FilterType.PROVIDER,
|
||||
}: UseRelatedFiltersProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const { updateFilter } = useUrlFilters();
|
||||
const [availableScans, setAvailableScans] =
|
||||
useState<string[]>(completedScanIds);
|
||||
|
||||
// Use providerIds if provided (for findings), otherwise use providerUIDs (for scans)
|
||||
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
|
||||
const [availableProviders, setAvailableProviders] =
|
||||
useState<string[]>(providers);
|
||||
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 providerParam = searchParams.get(`filter[${providerFilterType}]`);
|
||||
const providerTypeParam = searchParams.get(
|
||||
`filter[${FilterType.PROVIDER_TYPE}]`,
|
||||
);
|
||||
|
||||
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 currentProviders = providerParam ? providerParam.split(",") : [];
|
||||
const currentProviderTypes = providerTypeParam
|
||||
? (providerTypeParam.split(",") as ProviderType[])
|
||||
: [];
|
||||
|
||||
const getProviderType = (providerKey: string): ProviderType | null => {
|
||||
const providerDetail = providerDetails.find(
|
||||
@@ -72,128 +68,28 @@ export const useRelatedFilters = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scanParam = enableScanRelation
|
||||
? searchParams.get(`filter[${FilterType.SCAN}]`)
|
||||
: null;
|
||||
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
|
||||
const providerTypeParam = searchParams.get(
|
||||
`filter[${FilterType.PROVIDER_TYPE}]`,
|
||||
);
|
||||
// Derive available providers filtered by selected provider types
|
||||
const availableProviders =
|
||||
currentProviderTypes.length > 0
|
||||
? providers.filter((key) => {
|
||||
const providerType = getProviderType(key);
|
||||
return providerType && currentProviderTypes.includes(providerType);
|
||||
})
|
||||
: providers;
|
||||
|
||||
const currentProviders = providerParam ? providerParam.split(",") : [];
|
||||
const currentProviderTypes = providerTypeParam
|
||||
? (providerTypeParam.split(",") as ProviderType[])
|
||||
: [];
|
||||
// Derive available scans filtered by selected providers and provider types
|
||||
const availableScans = enableScanRelation
|
||||
? currentProviders.length > 0 || currentProviderTypes.length > 0
|
||||
? completedScanIds.filter((scanId) => {
|
||||
const scanDetail = scanDetails.find(
|
||||
(detail) => Object.keys(detail)[0] === scanId,
|
||||
);
|
||||
if (!scanDetail) return false;
|
||||
|
||||
// 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 filteredProviders = providers.filter((key) => {
|
||||
const providerType = getProviderType(key);
|
||||
return providerType && currentProviderTypes.includes(providerType);
|
||||
});
|
||||
setAvailableProviders(filteredProviders);
|
||||
|
||||
const validProviders = currentProviders.filter((key) => {
|
||||
const providerType = getProviderType(key);
|
||||
return providerType && currentProviderTypes.includes(providerType);
|
||||
});
|
||||
|
||||
if (validProviders.length !== currentProviders.length) {
|
||||
updateFilter(
|
||||
providerFilterType,
|
||||
validProviders.length > 0 ? validProviders : null,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setAvailableProviders(providers);
|
||||
}
|
||||
|
||||
// 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);
|
||||
const scanProviderId = scanDetail[scanId]?.providerInfo?.uid ?? null;
|
||||
const scanProviderType =
|
||||
(scanDetail[scanId]?.providerInfo?.provider as ProviderType) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
(currentProviders.length === 0 ||
|
||||
@@ -202,14 +98,9 @@ export const useRelatedFilters = ({
|
||||
(scanProviderType &&
|
||||
currentProviderTypes.includes(scanProviderType)))
|
||||
);
|
||||
});
|
||||
setAvailableScans(filteredScans);
|
||||
} else {
|
||||
setAvailableScans(completedScanIds);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
})
|
||||
: completedScanIds
|
||||
: completedScanIds;
|
||||
|
||||
return {
|
||||
availableProviderIds: providerIds.length > 0 ? availableProviders : [],
|
||||
|
||||
@@ -100,11 +100,33 @@ export const useUrlFilters = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Low-level navigation function for complex filter updates that need
|
||||
* to modify multiple params atomically (e.g., setting provider_type
|
||||
* while clearing provider_id). The modifier receives a mutable
|
||||
* URLSearchParams; page is auto-reset if already present.
|
||||
*/
|
||||
const navigateWithParams = (modifier: (params: URLSearchParams) => void) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
modifier(params);
|
||||
|
||||
// Only reset page to 1 if page parameter already exists
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
filterTransition?.signalFilterChange();
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
updateFilter,
|
||||
clearFilter,
|
||||
clearAllFilters,
|
||||
hasFilters,
|
||||
isPending,
|
||||
navigateWithParams,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user