diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 9db5f265e3..f25cf41686 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -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) --- diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index c0b7946083..98bb9df9f5 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -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 = { @@ -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]"); + } + } }); }; diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index ab6349fa69..d9457a499b 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -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]"); }); }; diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx index 4384bafe04..546ebed5a8 100644 --- a/ui/components/filters/custom-checkbox-muted-findings.tsx +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -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); + } }); }; diff --git a/ui/hooks/use-related-filters.ts b/ui/hooks/use-related-filters.ts index 0fc4ce2de5..424b360e51 100644 --- a/ui/hooks/use-related-filters.ts +++ b/ui/hooks/use-related-filters.ts @@ -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(completedScanIds); - // Use providerIds if provided (for findings), otherwise use providerUIDs (for scans) const providers = providerIds.length > 0 ? providerIds : providerUIDs; - const [availableProviders, setAvailableProviders] = - useState(providers); - const previousProviders = useRef([]); - const previousProviderTypes = useRef([]); - 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 : [], diff --git a/ui/hooks/use-url-filters.ts b/ui/hooks/use-url-filters.ts index 335facb7fc..549bed9dd9 100644 --- a/ui/hooks/use-url-filters.ts +++ b/ui/hooks/use-url-filters.ts @@ -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, }; };