diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index b87211a69b..31dc7fc349 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler API** are documented in this file. ## [1.19.0] (Prowler UNRELEASED) +### Added + +- `provider_id` and `provider_id__in` filters for resources endpoints (`GET /resources` and `GET /resources/metadata/latest`) [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) + ### 🔄 Changed - Lazy-load providers and compliance data to reduce API/worker startup memory and time [(#9857)](https://github.com/prowler-cloud/prowler/pull/9857) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 579888e206..bf34950156 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -453,6 +453,8 @@ class Meta: class ResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") @@ -540,6 +542,8 @@ def filter_tag(self, queryset, name, value): class LatestResourceFilter(ProviderRelationshipFilterSet): + provider_id = UUIDFilter(field_name="provider__id", lookup_expr="exact") + provider_id__in = UUIDInFilter(field_name="provider__id", lookup_expr="in") tag_key = CharFilter(method="filter_tag_key") tag_value = CharFilter(method="filter_tag_value") tag = CharFilter(method="filter_tag") diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 7008c1477e..cd7723e229 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -8186,6 +8186,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -8588,6 +8603,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -8884,6 +8914,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: @@ -9186,6 +9231,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index a05345aaac..27684978ec 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -4353,6 +4353,108 @@ def test_resources_metadata_latest( assert attributes["types"] == [latest_scan_resource.type] assert "groups" in attributes + def test_resources_latest_filter_by_provider_id( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id__in filter works on latest resources endpoint.""" + provider = latest_scan_resource.provider + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_latest_filter_by_provider_id_in_multiple( + self, authenticated_client, providers_fixture + ): + """Test that provider_id__in filter works with multiple provider IDs.""" + provider1, provider2 = providers_fixture[0], providers_fixture[1] + tenant_id = str(provider1.tenant_id) + + # Create completed scans for both providers + scan1 = Scan.objects.create( + name="scan for provider 1", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + scan2 = Scan.objects.create( + name="scan for provider 2", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + + # Create resources for each provider + resource1 = Resource.objects.create( + tenant_id=tenant_id, + provider=provider1, + uid="resource_provider_1", + name="Resource Provider 1", + region="us-east-1", + service="ec2", + type="instance", + ) + resource2 = Resource.objects.create( + tenant_id=tenant_id, + provider=provider2, + uid="resource_provider_2", + name="Resource Provider 2", + region="us-west-2", + service="s3", + type="bucket", + ) + + # Test filtering by both providers + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": f"{provider1.id},{provider2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + # Test filtering by single provider returns only that provider's resource + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id__in]": str(provider1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["uid"] == resource1.uid + + def test_resources_latest_filter_by_provider_id_no_match( + self, authenticated_client, latest_scan_resource + ): + """Test that provider_id filter returns empty when no match.""" + non_existent_id = str(uuid4()) + response = authenticated_client.get( + reverse("resource-latest"), + {"filter[provider_id]": non_existent_id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + @pytest.mark.django_db class TestFindingViewSet: diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c1868e0ee8..4605a74c39 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.18.0] (Prowler v5.18.0 UNRELEASED) + +### 🔄 Changed + +- Restyle resources view with improved resource detail drawer [(#9864)](https://github.com/prowler-cloud/prowler/pull/9864) + +--- + ## [1.17.0] (Prowler v5.17.0) ### 🚀 Added diff --git a/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx b/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx index 864c00585d..cf25498d58 100644 --- a/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx +++ b/ui/app/(prowler)/_overview/severity-over-time/_components/time-range-selector.tsx @@ -32,7 +32,7 @@ export const TimeRangeSelector = ({ isLoading = false, }: TimeRangeSelectorProps) => { return ( -
+
{Object.entries(TIME_RANGE_OPTIONS).map(([key, range]) => (
- +
diff --git a/ui/app/(prowler)/mutelist/_components/simple/mute-rules-table-client.tsx b/ui/app/(prowler)/mutelist/_components/simple/mute-rules-table-client.tsx index a88d57972a..b5e9e45e74 100644 --- a/ui/app/(prowler)/mutelist/_components/simple/mute-rules-table-client.tsx +++ b/ui/app/(prowler)/mutelist/_components/simple/mute-rules-table-client.tsx @@ -8,8 +8,8 @@ import { useActionState, useEffect, useRef, useState } from "react"; import { deleteMuteRule } from "@/actions/mute-rules"; import { MuteRuleData } from "@/actions/mute-rules/types"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTable } from "@/components/ui/table"; import { MetaDataProps } from "@/types"; @@ -82,8 +82,8 @@ export function MuteRulesTableClient({ {/* Edit Modal */} {selectedMuteRule && ( - - + )} {/* Delete Confirmation Modal */} {selectedMuteRule && ( - - + )} ); diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index 7c572f207a..ee3c29e35d 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -5,14 +5,15 @@ import { getLatestMetadataInfo, getLatestResources, getMetadataInfo, + getResourceById, getResources, } from "@/actions/resources"; -import { FilterControls } from "@/components/filters"; +import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet"; import { ResourcesFilters } from "@/components/resources/resources-filters"; import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; -import { ColumnResources } from "@/components/resources/table/column-resources"; +import { ResourcesTableWithSelection } from "@/components/resources/table"; import { ContentLayout } from "@/components/ui"; -import { DataTable } from "@/components/ui/table"; +import { FilterTransitionWrapper } from "@/contexts"; import { createDict, extractFiltersAndQuery, @@ -20,10 +21,6 @@ import { hasDateOrScanFilter, replaceFieldKey, } from "@/lib"; -import { - createProviderDetailsMappingById, - extractProviderIds, -} from "@/lib/provider-helpers"; import { ResourceProps, SearchParamsProps } from "@/types"; export default async function Resources({ @@ -32,49 +29,73 @@ export default async function Resources({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const { searchParamsKey, encodedSort } = - extractSortAndKey(resolvedSearchParams); + const { encodedSort } = extractSortAndKey(resolvedSearchParams); const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at"); // Check if the searchParams contain any date or scan filter const hasDateOrScan = hasDateOrScanFilter(resolvedSearchParams); - const [metadataInfoData, providersData] = await Promise.all([ - (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ - query, - filters: outputFilters, - sort: encodedSort, - }), - getProviders({ pageSize: 50 }), - ]); + // Check if there's a specific resource ID to fetch + const resourceId = resolvedSearchParams.resourceId?.toString(); + + const [metadataInfoData, providersData, resourceByIdData] = await Promise.all( + [ + (hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({ + query, + filters: outputFilters, + sort: encodedSort, + }), + getProviders({ pageSize: 50 }), + resourceId + ? getResourceById(resourceId, { include: ["provider"] }) + : Promise.resolve(null), + ], + ); + + // Process the resource data to match the expected structure + const processedResource = resourceByIdData?.data + ? (() => { + const resource = resourceByIdData.data; + const providerDict = createDict("providers", resourceByIdData); + + const provider = { + data: providerDict[resource.relationships?.provider?.data?.id], + }; + + return { + ...resource, + relationships: { + ...resource.relationships, + provider, + }, + } as ResourceProps; + })() + : null; // Extract unique regions, services, groups from the metadata endpoint const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueGroups = metadataInfoData?.data?.attributes?.groups || []; - // Extract provider IDs and details - const providerIds = providersData ? extractProviderIds(providersData) : []; - const providerDetails = providersData - ? createProviderDetailsMappingById(providerIds, providersData) - : []; - return ( - -
- - }> + +
+ +
+ }> -
+ + {processedResource && ( + + )}
); } @@ -150,9 +171,7 @@ const SSRDataTable = async ({

{resourcesData.errors[0].detail}

)} - diff --git a/ui/components/findings/mute-findings-modal.tsx b/ui/components/findings/mute-findings-modal.tsx index 0f4b95e6ad..a3db0e81df 100644 --- a/ui/components/findings/mute-findings-modal.tsx +++ b/ui/components/findings/mute-findings-modal.tsx @@ -4,15 +4,16 @@ import { Input, Textarea } from "@heroui/input"; import { Dispatch, SetStateAction, - useActionState, useEffect, useRef, + useState, + useTransition, } from "react"; import { createMuteRule } from "@/actions/mute-rules"; import { MuteRuleActionState } from "@/actions/mute-rules/types"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { FormButtons } from "@/components/ui/form"; interface MuteFindingsModalProps { @@ -29,6 +30,8 @@ export function MuteFindingsModal({ onComplete, }: MuteFindingsModalProps) { const { toast } = useToast(); + const [state, setState] = useState(null); + const [isPending, startTransition] = useTransition(); // Use refs to avoid stale closures in useEffect const onCompleteRef = useRef(onComplete); @@ -37,18 +40,12 @@ export function MuteFindingsModal({ const onOpenChangeRef = useRef(onOpenChange); onOpenChangeRef.current = onOpenChange; - const [state, formAction, isPending] = useActionState< - MuteRuleActionState, - FormData - >(createMuteRule, null); - useEffect(() => { if (state?.success) { toast({ title: "Success", description: state.success, }); - // Call onComplete BEFORE closing the modal to ensure router.refresh() executes onCompleteRef.current?.(); onOpenChangeRef.current(false); } else if (state?.errors?.general) { @@ -65,13 +62,26 @@ export function MuteFindingsModal({ }; return ( - - + { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + startTransition(() => { + void (async () => { + const result = await createMuteRule(null, formData); + setState(result); + })(); + }); + }} + > - + ); } diff --git a/ui/components/findings/send-to-jira-modal.tsx b/ui/components/findings/send-to-jira-modal.tsx index aae94afe4d..a692fe20b7 100644 --- a/ui/components/findings/send-to-jira-modal.tsx +++ b/ui/components/findings/send-to-jira-modal.tsx @@ -15,8 +15,8 @@ import { sendFindingToJira, } from "@/actions/integrations/jira-dispatch"; import { JiraIcon } from "@/components/icons/services/IconServices"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { CustomBanner } from "@/components/ui/custom/custom-banner"; import { Form, @@ -215,8 +215,8 @@ export const SendToJiraModal = ({ // }, [issueTypes, searchIssueTypeValue]); return ( - - + ); }; diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx index e465930413..3d0e6073ed 100644 --- a/ui/components/findings/table/data-table-row-actions.tsx +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -1,12 +1,5 @@ "use client"; -import { - Dropdown, - DropdownItem, - DropdownMenu, - DropdownSection, - DropdownTrigger, -} from "@heroui/dropdown"; import { Row } from "@tanstack/react-table"; import { VolumeOff, VolumeX } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -16,15 +9,23 @@ import { MuteFindingsModal } from "@/components/findings/mute-findings-modal"; import { SendToJiraModal } from "@/components/findings/send-to-jira-modal"; import { VerticalDotsIcon } from "@/components/icons"; import { JiraIcon } from "@/components/icons/services/IconServices"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; import type { FindingProps } from "@/types/components"; import { FindingsSelectionContext } from "./findings-selection-context"; interface DataTableRowActionsProps { row: Row; + onMuteComplete?: (findingIds: string[]) => void; } -export function DataTableRowActions({ row }: DataTableRowActionsProps) { +export function DataTableRowActions({ + row, + onMuteComplete, +}: DataTableRowActionsProps) { const router = useRouter(); const finding = row.original; const [isJiraModalOpen, setIsJiraModalOpen] = useState(false); @@ -67,11 +68,31 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { return "Mute this finding"; }; + const getMuteLabel = () => { + if (isMuted) return "Muted"; + if (!isMuted && isCurrentSelected && hasMultipleSelected) { + return ( + <> + Mute + + ({selectedFindingIds.length}) + + + ); + } + return "Mute"; + }; + const handleMuteComplete = () => { // Always clear selection when a finding is muted because: // 1. If the muted finding was selected, its index now points to a different finding // 2. rowSelection uses indices (0, 1, 2...) not IDs, so after refresh the wrong findings would appear selected clearSelection(); + if (onMuteComplete) { + onMuteComplete(getMuteIds()); + return; + } + router.refresh(); }; @@ -92,61 +113,43 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { />
- + + + } + ariaLabel="Finding actions" > - - - - - - - ) : ( - - ) - } - onPress={() => setIsMuteModalOpen(true)} - > - {isMuted ? "Muted" : "Mute"} - {!isMuted && isCurrentSelected && hasMultipleSelected && ( - - ({selectedFindingIds.length}) - - )} - - - } - onPress={() => setIsJiraModalOpen(true)} - > - Send to Jira - - - - + + ) : ( + + ) + } + label={getMuteLabel()} + description={getMuteDescription()} + disabled={isMuted} + onSelect={() => { + setIsMuteModalOpen(true); + }} + /> + } + label="Send to Jira" + description="Create a Jira issue for this finding" + onSelect={() => setIsJiraModalOpen(true)} + /> +
); diff --git a/ui/components/integrations/jira/jira-integrations-manager.tsx b/ui/components/integrations/jira/jira-integrations-manager.tsx index 0ad5f58f9a..3802ad4080 100644 --- a/ui/components/integrations/jira/jira-integrations-manager.tsx +++ b/ui/components/integrations/jira/jira-integrations-manager.tsx @@ -16,8 +16,8 @@ import { IntegrationSkeleton, } from "@/components/integrations/shared"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; @@ -209,8 +209,8 @@ export const JiraIntegrationsManager = ({ return ( <> - - + - - +
{/* Header with Add Button */} diff --git a/ui/components/integrations/s3/s3-integrations-manager.tsx b/ui/components/integrations/s3/s3-integrations-manager.tsx index 6246f941fe..493ae6fc70 100644 --- a/ui/components/integrations/s3/s3-integrations-manager.tsx +++ b/ui/components/integrations/s3/s3-integrations-manager.tsx @@ -16,8 +16,8 @@ import { IntegrationSkeleton, } from "@/components/integrations/shared"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; @@ -209,8 +209,8 @@ export const S3IntegrationsManager = ({ return ( <> -
- + - - +
{/* Header with Add Button */} diff --git a/ui/components/integrations/saml/saml-integration-card.tsx b/ui/components/integrations/saml/saml-integration-card.tsx index ca5c752154..f1b5c2c966 100644 --- a/ui/components/integrations/saml/saml-integration-card.tsx +++ b/ui/components/integrations/saml/saml-integration-card.tsx @@ -5,8 +5,8 @@ import { useState } from "react"; import { deleteSamlConfig } from "@/actions/integrations"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { Card, CardContent, CardHeader } from "../../shadcn"; @@ -53,8 +53,8 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { return ( <> {/* Configure SAML Modal */} - @@ -62,11 +62,11 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => { setIsOpen={setIsSamlModalOpen} samlConfig={samlConfig} /> - + {/* Delete Confirmation Modal */} - {
- + diff --git a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx index 270318008a..40f8c9dc9c 100644 --- a/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx +++ b/ui/components/integrations/security-hub/security-hub-integrations-manager.tsx @@ -17,8 +17,8 @@ import { IntegrationSkeleton, } from "@/components/integrations/shared"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; -import { CustomAlertModal } from "@/components/ui/custom"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper"; import { MetaDataProps } from "@/types"; @@ -259,8 +259,8 @@ export const SecurityHubIntegrationsManager = ({ return ( <> - - + - - +
diff --git a/ui/components/invitations/table/data-table-row-actions.tsx b/ui/components/invitations/table/data-table-row-actions.tsx index 320f84fc35..4ec5f50e79 100644 --- a/ui/components/invitations/table/data-table-row-actions.tsx +++ b/ui/components/invitations/table/data-table-row-actions.tsx @@ -19,7 +19,7 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom"; +import { Modal } from "@/components/shadcn/modal"; import { DeleteForm, EditForm } from "../forms"; @@ -44,8 +44,8 @@ export function DataTableRowActions({ return ( <> - @@ -56,15 +56,15 @@ export function DataTableRowActions({ roles={roles || []} setIsOpen={setIsEditOpen} /> - - + - +
({ return ( <> - - +
({ return ( <> - @@ -71,15 +71,15 @@ export function DataTableRowActions({ providerAlias={providerAlias} setIsOpen={setIsEditOpen} /> - - + - +
{ + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("resourceId"); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + } + }; + + return ( + + + + Resource Details + + View the resource details + + + + + + ); +}; diff --git a/ui/components/resources/resources-filters.tsx b/ui/components/resources/resources-filters.tsx index 142796bbde..7b1520d78d 100644 --- a/ui/components/resources/resources-filters.tsx +++ b/ui/components/resources/resources-filters.tsx @@ -1,50 +1,93 @@ "use client"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { CustomDatePicker } from "@/components/filters/custom-date-picker"; +import { Button } from "@/components/shadcn"; +import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table"; import { getGroupLabel } from "@/lib/categories"; -import { FilterEntity } from "@/types"; +import { ProviderProps } from "@/types/providers"; interface ResourcesFiltersProps { - providerIds: string[]; - providerDetails: { [id: string]: FilterEntity }[]; + providers: ProviderProps[]; uniqueRegions: string[]; uniqueServices: string[]; uniqueGroups: string[]; } export const ResourcesFilters = ({ - providerIds, - providerDetails, + providers, uniqueRegions, uniqueServices, uniqueGroups, }: ResourcesFiltersProps) => { + const [isExpanded, setIsExpanded] = useState(false); + + // Custom filters for the expandable section + const customFilters = [ + { + key: "region__in", + labelCheckboxGroup: "Region", + values: uniqueRegions, + index: 1, + }, + { + key: "service__in", + labelCheckboxGroup: "Service", + values: uniqueServices, + index: 2, + }, + { + key: "groups__in", + labelCheckboxGroup: "Group", + values: uniqueGroups, + labelFormatter: getGroupLabel, + index: 3, + }, + ]; + + const hasCustomFilters = customFilters.length > 0; + return ( - +
+ {/* First row: Provider selectors + More Filters button + Clear Filters */} +
+
+ +
+
+ +
+ {hasCustomFilters && ( + + )} + +
+ + {/* Expandable filters section */} + {hasCustomFilters && ( + + } + hideClearButton + /> + + )} +
); }; diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx index d26fe4f5e5..c66ccec427 100644 --- a/ui/components/resources/table/column-resources.tsx +++ b/ui/components/resources/table/column-resources.tsx @@ -1,12 +1,20 @@ "use client"; +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, +} from "@heroui/dropdown"; +import { Snippet } from "@heroui/snippet"; import { ColumnDef } from "@tanstack/react-table"; -import { Database } from "lucide-react"; +import { AlertTriangle, Eye, MoreVertical } from "lucide-react"; import { useSearchParams } from "next/navigation"; +import { useState } from "react"; -import { InfoIcon } from "@/components/icons"; -import { EntityInfo, SnippetChip } from "@/components/ui/entities"; -import { TriggerSheet } from "@/components/ui/sheet"; +import { CopyIcon, DoneIcon } from "@/components/icons"; +import { EntityInfo } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; import { ProviderType, ResourceProps } from "@/types"; @@ -19,12 +27,6 @@ const getResourceData = ( return row.original.attributes?.[field]; }; -const getChipStyle = (count: number) => { - if (count === 0) return "bg-green-100 text-green-800"; - if (count >= 10) return "bg-red-100 text-red-800"; - if (count >= 1) return "bg-yellow-100 text-yellow-800"; -}; - const getProviderData = ( row: { original: ResourceProps }, field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"], @@ -35,61 +37,149 @@ const getProviderData = ( ); }; -const ResourceDetailsCell = ({ row }: { row: any }) => { +// Component for resource name that opens the detail drawer +const ResourceNameCell = ({ row }: { row: { original: ResourceProps } }) => { const searchParams = useSearchParams(); const resourceId = searchParams.get("resourceId"); const isOpen = resourceId === row.original.id; + const resourceName = row.original.attributes?.name; + const resourceUid = row.original.attributes?.uid; + const displayName = + typeof resourceName === "string" && resourceName.trim().length > 0 + ? resourceName + : "Unnamed resource"; return ( -
- - } - title="Resource Details" - description="View the Resource details" +
+ - +

+ {displayName} +

+
+ } + /> + {resourceUid && ( + } + checkIcon={} + codeString={resourceUid} /> -
+ )}
); }; +// Component for failed findings badge with warning style +const FailedFindingsBadge = ({ count }: { count: number }) => { + if (count === 0) { + return ( + + 0 + + ); + } + + return ( + + + {count} + + ); +}; + +// Row actions dropdown +const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const resourceName = row.original.attributes?.name || "Resource"; + + return ( + <> +
+ + + + + + + } + onPress={() => setIsDrawerOpen(true)} + > + View details + + + + +
+ + } + /> + + ); +}; + +// Column definitions for resources table export const ColumnResources: ColumnDef[] = [ + // Resource Name column { - id: "moreInfo", + accessorKey: "resourceName", header: ({ column }) => ( - + ), - cell: ({ row }) => , + cell: ({ row }) => , enableSorting: false, }, + // Provider Account column { - accessorKey: "resourceName", + accessorKey: "provider", header: ({ column }) => ( - + ), cell: ({ row }) => { - const resourceName = getResourceData(row, "name"); - const displayName = - typeof resourceName === "string" && resourceName.trim().length > 0 - ? resourceName - : "Unnamed resource"; - + const provider = getProviderData(row, "provider"); + const alias = getProviderData(row, "alias"); + const uid = getProviderData(row, "uid"); return ( - } + ); }, enableSorting: false, }, + // Failed Findings column { accessorKey: "failedFindings", header: ({ column }) => ( @@ -101,84 +191,67 @@ export const ColumnResources: ColumnDef[] = [ "failed_findings_count", ) as number; - return ( - - {failedFindingsCount} - - ); + return ; }, enableSorting: false, }, + // Resource Type column { - accessorKey: "region", + accessorKey: "type", header: ({ column }) => ( - + ), cell: ({ row }) => { - const region = getResourceData(row, "region"); + const type = getResourceData(row, "type"); return ( -
- {typeof region === "string" ? region : "Invalid region"} -
+

+ {typeof type === "string" ? type : "-"} +

); }, }, + // Region column { - accessorKey: "type", + accessorKey: "region", header: ({ column }) => ( - + ), cell: ({ row }) => { - const type = getResourceData(row, "type"); + const region = getResourceData(row, "region"); return ( -
- {typeof type === "string" ? type : "Invalid type"} -
+

+ {typeof region === "string" ? region : "-"} +

); }, }, + // Service column { accessorKey: "service", header: ({ column }) => ( - + ), cell: ({ row }) => { const service = getResourceData(row, "service"); return ( -
- {typeof service === "string" ? service : "Invalid region"} -
+

+ {typeof service === "string" ? service : "-"} +

); }, }, + // Actions column { - accessorKey: "provider", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const provider = getProviderData(row, "provider"); - const alias = getProviderData(row, "alias"); - const uid = getProviderData(row, "uid"); - return ( - <> - - - ); - }, + id: "actions", + header: () =>
, + cell: ({ row }) => , enableSorting: false, }, ]; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts index 5f18c9b1d4..36626064c1 100644 --- a/ui/components/resources/table/index.ts +++ b/ui/components/resources/table/index.ts @@ -1,3 +1,4 @@ export * from "../skeleton/skeleton-table-resources"; export * from "./column-resources"; export * from "./resource-detail"; +export * from "./resources-table-with-selection"; diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx index 6c37f64dc3..5d3af81795 100644 --- a/ui/components/resources/table/resource-detail.tsx +++ b/ui/components/resources/table/resource-detail.tsx @@ -1,52 +1,76 @@ "use client"; -import { Snippet } from "@heroui/snippet"; -import { Spinner } from "@heroui/spinner"; -import { Tooltip } from "@heroui/tooltip"; -import { ExternalLink, InfoIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table"; +import { Check, Copy, ExternalLink, Link, Loader2, X } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; -import { getFindingById } from "@/actions/findings"; +import { getFindingById, getLatestFindings } from "@/actions/findings"; import { getResourceById } from "@/actions/resources"; +import { FloatingMuteButton } from "@/components/findings/floating-mute-button"; +import { DataTableRowActions } from "@/components/findings/table"; import { FindingDetail } from "@/components/findings/table/finding-detail"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; +import { + DeltaType, + NotificationIndicator, +} from "@/components/findings/table/notification-indicator"; +import { + Checkbox, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { DateWithTime, getProviderLogo, InfoField, } from "@/components/ui/entities"; -import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { + DataTable, + DataTableColumnHeader, + Severity, + SeverityBadge, + StatusFindingBadge, +} from "@/components/ui/table"; import { createDict } from "@/lib"; import { buildGitFileUrl } from "@/lib/iac-utils"; -import { FindingProps, ProviderType, ResourceProps } from "@/types"; - -const SEVERITY_ORDER = { - critical: 0, - high: 1, - medium: 2, - low: 3, - informational: 4, -} as const; - -type SeverityLevel = keyof typeof SEVERITY_ORDER; +import { + FindingProps, + MetaDataProps, + ProviderType, + ResourceProps, +} from "@/types"; interface ResourceFinding { type: "findings"; id: string; attributes: { status: "PASS" | "FAIL" | "MANUAL"; - severity: SeverityLevel; + severity: Severity; + muted?: boolean; + muted_reason?: string; + delta?: DeltaType; + updated_at?: string; check_metadata?: { checktitle?: string; }; }; } -interface FindingReference { - id: string; -} - const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; }; @@ -97,133 +121,373 @@ const buildCustomBreadcrumbs = ( return breadcrumbs; }; +// Column definitions for findings table +const getResourceFindingsColumns = ( + rowSelection: RowSelectionState, + selectableRowCount: number, + onNavigate: (id: string) => void, + onMuteComplete?: (findingIds: string[]) => void, +): ColumnDef[] => { + const selectedCount = Object.values(rowSelection).filter(Boolean).length; + const isAllSelected = + selectedCount > 0 && selectedCount === selectableRowCount; + const isSomeSelected = + selectedCount > 0 && selectedCount < selectableRowCount; + + return [ + { + id: "notification", + header: () => null, + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + id: "select", + header: ({ table }) => ( +
+ + table.toggleAllPageRowsSelected(checked === true) + } + aria-label="Select all" + disabled={selectableRowCount === 0} + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(checked === true)} + aria-label="Select row" + /> +
+ ), + enableSorting: false, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + accessorKey: "finding", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + accessorKey: "severity", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + accessorKey: "updated_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: false, + }, + { + id: "actions", + header: () =>
, + cell: ({ row }) => ( + } + onMuteComplete={onMuteComplete} + /> + ), + enableSorting: false, + }, + ]; +}; + +interface ResourceDetailProps { + resourceDetails: ResourceProps; + trigger?: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + export const ResourceDetail = ({ - resourceId, - initialResourceData, -}: { - resourceId: string; - initialResourceData: ResourceProps; -}) => { + resourceDetails, + trigger, + open: controlledOpen, + defaultOpen = false, + onOpenChange, +}: ResourceDetailProps) => { const [findingsData, setFindingsData] = useState([]); + const [findingsMetadata, setFindingsMetadata] = + useState(null); const [resourceTags, setResourceTags] = useState>({}); const [findingsLoading, setFindingsLoading] = useState(true); + const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); + const [findingsReloadNonce, setFindingsReloadNonce] = useState(0); const [selectedFindingId, setSelectedFindingId] = useState( null, ); const [findingDetails, setFindingDetails] = useState( null, ); + const [findingDetailLoading, setFindingDetailLoading] = useState(false); + const [rowSelection, setRowSelection] = useState({}); + const [activeTab, setActiveTab] = useState("overview"); + const [metadataCopied, setMetadataCopied] = useState(false); + // Track internal open state for uncontrolled drawer (when using trigger) + const [internalOpen, setInternalOpen] = useState(defaultOpen); + // Drawer-local pagination and search state (not in URL to avoid page re-renders) + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [searchQuery, setSearchQuery] = useState(""); + const findingFetchRef = useRef(null); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Determine if drawer is actually open: + // - If controlled (open prop provided), use that + // - If uncontrolled (using trigger), use internal state + // - If no trigger (inline mode), always consider it "open" + const isDrawerOpen = !trigger || (controlledOpen ?? internalOpen); + + // Handle open state changes for uncontrolled mode + const handleOpenChange = (newOpen: boolean) => { + setInternalOpen(newOpen); + onOpenChange?.(newOpen); + + // Reset all drawer state when closing + if (!newOpen) { + setActiveTab("overview"); + setSelectedFindingId(null); + setFindingDetails(null); + setFindingDetailLoading(false); + setRowSelection({}); + setCurrentPage(1); + setPageSize(10); + setSearchQuery(""); + setHasInitiallyLoaded(false); + } + }; - useEffect(() => { - const loadFindings = async () => { - setFindingsLoading(true); + const resource = resourceDetails; + const resourceId = resource.id; + const attributes = resource.attributes; + const providerData = resource.relationships.provider.data.attributes; + const copyResourceUrl = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set("resourceId", resourceId); + const url = `${window.location.origin}${pathname}?${params.toString()}`; + navigator.clipboard.writeText(url); + }; + + const copyMetadata = async (metadata: Record) => { + await navigator.clipboard.writeText(JSON.stringify(metadata, null, 2)); + setMetadataCopied(true); + setTimeout(() => setMetadataCopied(false), 2000); + }; + + // Load resource tags (separate from findings) - only when drawer is open + useEffect(() => { + const loadResourceTags = async () => { try { const resourceData = await getResourceById(resourceId, { - include: ["findings"], - fields: ["tags", "findings"], + fields: ["tags"], }); - if (resourceData?.data) { - // Get tags from the detailed resource data setResourceTags(resourceData.data.attributes.tags || {}); + } + } catch (err) { + console.error("Error loading resource tags:", err); + setResourceTags({}); + } + }; + + if (resourceId && isDrawerOpen) { + loadResourceTags(); + } + }, [resourceId, isDrawerOpen]); + + // Load findings with server-side pagination and search - only when drawer is open + useEffect(() => { + const loadFindings = async () => { + setFindingsLoading(true); - // Create dictionary for findings and expand them - if (resourceData.data.relationships?.findings) { - const findingsDict = createDict("findings", resourceData); - const findings = - resourceData.data.relationships.findings.data?.map( - (finding: FindingReference) => findingsDict[finding.id], - ) || []; - setFindingsData(findings as ResourceFinding[]); - } else { - setFindingsData([]); - } + try { + const findingsResponse = await getLatestFindings({ + page: currentPage, + pageSize, + query: searchQuery, + sort: "severity,-inserted_at", + filters: { + "filter[resource_uid]": attributes.uid, + "filter[status]": "FAIL", + }, + }); + + if (findingsResponse?.data) { + setFindingsMetadata(findingsResponse.meta || null); + setFindingsData(findingsResponse.data as ResourceFinding[]); } else { setFindingsData([]); - setResourceTags({}); + setFindingsMetadata(null); } } catch (err) { console.error("Error loading findings:", err); setFindingsData([]); - setResourceTags({}); + setFindingsMetadata(null); } finally { setFindingsLoading(false); + setHasInitiallyLoaded(true); } }; - if (resourceId) { + if (attributes.uid && isDrawerOpen) { loadFindings(); } - }, [resourceId]); + }, [ + attributes.uid, + currentPage, + pageSize, + searchQuery, + isDrawerOpen, + findingsReloadNonce, + ]); const navigateToFinding = async (findingId: string) => { + // Cancel any in-flight request + if (findingFetchRef.current) { + findingFetchRef.current.abort(); + } + findingFetchRef.current = new AbortController(); + setSelectedFindingId(findingId); + setFindingDetailLoading(true); try { const findingData = await getFindingById( findingId, "resources,scan.provider", ); + + // Check if request was aborted + if (findingFetchRef.current?.signal.aborted) { + return; + } + if (findingData?.data) { - // Create dictionaries for resources, scans, and providers const resourceDict = createDict("resources", findingData); const scanDict = createDict("scans", findingData); const providerDict = createDict("providers", findingData); - // Expand the finding with its corresponding resource, scan, and provider const finding = findingData.data; const scan = scanDict[finding.relationships?.scan?.data?.id]; - const resource = + const foundResource = resourceDict[finding.relationships?.resources?.data?.[0]?.id]; const provider = providerDict[scan?.relationships?.provider?.data?.id]; const expandedFinding = { ...finding, - relationships: { scan, resource, provider }, + relationships: { scan, resource: foundResource, provider }, }; setFindingDetails(expandedFinding); } } catch (error) { + // Ignore abort errors + if (error instanceof Error && error.name === "AbortError") { + return; + } console.error("Error fetching finding:", error); + } finally { + // Only update loading state if this request wasn't aborted + if (!findingFetchRef.current?.signal.aborted) { + setFindingDetailLoading(false); + } } }; const handleBackToResource = () => { setSelectedFindingId(null); setFindingDetails(null); + setFindingDetailLoading(false); }; - if (!initialResourceData) { - return ( -
- -

- Loading resource details... -

-
- ); - } + const handleMuteComplete = (_findingIds?: string[]) => { + const ids = + _findingIds && _findingIds.length > 0 ? _findingIds : selectedFindingIds; - const resource = initialResourceData; - const attributes = resource.attributes; - const providerData = resource.relationships.provider.data.attributes; + setRowSelection({}); + if (ids.length > 0) setFindingsReloadNonce((v) => v + 1); + router.refresh(); + }; - // Filter only failed findings and sort by severity - const failedFindings = findingsData - .filter( - (finding: ResourceFinding) => finding?.attributes?.status === "FAIL", - ) - .sort((a: ResourceFinding, b: ResourceFinding) => { - const severityA = (a?.attributes?.severity?.toLowerCase() || - "informational") as SeverityLevel; - const severityB = (b?.attributes?.severity?.toLowerCase() || - "informational") as SeverityLevel; - return ( - (SEVERITY_ORDER[severityA] ?? 999) - (SEVERITY_ORDER[severityB] ?? 999) - ); - }); + // Findings are already filtered (FAIL only) and sorted by the server + const failedFindings = findingsData; + + const selectableRowCount = failedFindings.filter( + (f) => !f.attributes.muted, + ).length; + + // Reset selection when page changes + useEffect(() => { + setRowSelection({}); + }, [currentPage, pageSize]); + + // Calculate total findings from metadata for tab title + const totalFindings = findingsMetadata?.pagination?.count || 0; + + const getRowCanSelect = (row: Row): boolean => + !row.original.attributes.muted; + + const selectedFindingIds = Object.keys(rowSelection) + .filter((key) => rowSelection[key]) + .map((idx) => failedFindings[parseInt(idx)]?.id) + .filter(Boolean); + + const columns = getResourceFindingsColumns( + rowSelection, + selectableRowCount, + navigateToFinding, + handleMuteComplete, + ); // Build Git URL for IaC resources const gitUrl = @@ -236,60 +500,107 @@ export const ResourceDetail = ({ ) : null; - if (selectedFindingId) { - const findingTitle = - findingDetails?.attributes?.check_metadata?.checktitle || - "Finding Detail"; - - return ( -
- + // Content when viewing a finding detail (breadcrumb navigation) + const findingTitle = + findingDetails?.attributes?.check_metadata?.checktitle || "Finding Detail"; - {findingDetails && } -
- ); - } + const findingContent = ( +
+ + + {findingDetailLoading ? ( +
+ +

+ Loading finding details... +

+
+ ) : ( + findingDetails && + )} +
+ ); - return ( -
- {/* Resource Details section */} - - -
- Resource Details - {providerData.provider === "iac" && gitUrl && ( - - + {/* Header */} +
+ {/* Provider logo */} +
+ {getProviderLogo(providerData.provider as ProviderType)} +
+ + {/* Details column */} +
+ {/* Title with copy link and optional IaC link */} +
+

+ {renderValue(attributes.name)} +

+ + +
+ + Copy resource link to clipboard + + {providerData.provider === "iac" && gitUrl && ( + + + + + View in Repository + + + + Go to Resource in the Repository + )}
- {getProviderLogo(providerData.provider as ProviderType)} - - + + {/* Last Updated */} +
+ + Last Updated: + + +
+
+
+ + {/* Tabs */} + + + Overview + + Findings {totalFindings > 0 && `(${totalFindings})`} + + + + {/* Overview Tab */} + - - - {renderValue(attributes.uid)} - - +
@@ -331,16 +642,19 @@ export const ResourceDetail = ({ Object.entries(parsedMetadata).length > 0 ? (
- copyMetadata(parsedMetadata)} + className="text-text-neutral-secondary hover:text-text-neutral-primary absolute top-2 right-2 z-10 cursor-pointer transition-colors" + aria-label="Copy metadata to clipboard" > - {JSON.stringify(parsedMetadata, null, 2)} - -
+                    {metadataCopied ? (
+                      
+                    ) : (
+                      
+                    )}
+                  
+                  
                     {JSON.stringify(parsedMetadata, null, 2)}
                   
@@ -350,7 +664,7 @@ export const ResourceDetail = ({ {resourceTags && Object.entries(resourceTags).length > 0 ? (
-

+

Tags

@@ -362,79 +676,82 @@ export const ResourceDetail = ({
) : null} - - - - {/* Failed findings associated with this resource section */} - - - Failed findings associated with this resource - - - {findingsLoading ? ( + + + {/* Findings Tab */} + + {findingsLoading && !hasInitiallyLoaded ? (
- -

+ +

Loading findings...

- ) : failedFindings.length > 0 ? ( -
-

- Total failed findings: {failedFindings.length} -

- {failedFindings.map((finding: ResourceFinding, index: number) => { - const { attributes: findingAttrs, id } = finding; - - // Handle cases where finding might not have all attributes - if (!findingAttrs) { - return ( -
-

- Finding {id} - No attributes available -

-
- ); - } - - const { severity, check_metadata, status } = findingAttrs; - const checktitle = - check_metadata?.checktitle || "Unknown check"; - - return ( - - ); - })} -
) : ( -

- No failed findings found for this resource. -

+ <> + { + setSearchQuery(value); + setCurrentPage(1); // Reset to first page on search + }} + controlledPage={currentPage} + controlledPageSize={pageSize} + onPageChange={setCurrentPage} + onPageSizeChange={setPageSize} + isLoading={findingsLoading} + /> + {selectedFindingIds.length > 0 && ( + + )} + )} -
-
+ +
); + + // Determine which content to show + const content = selectedFindingId ? findingContent : resourceContent; + + // If no trigger, render content directly (inline mode) + if (!trigger) { + return content; + } + + // With trigger, wrap in Drawer + return ( + + {trigger} + + + Resource Details + View the resource details + + + + Close + + {content} + + + ); }; diff --git a/ui/components/resources/table/resources-table-with-selection.tsx b/ui/components/resources/table/resources-table-with-selection.tsx new file mode 100644 index 0000000000..1383d165e4 --- /dev/null +++ b/ui/components/resources/table/resources-table-with-selection.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { DataTable } from "@/components/ui/table"; +import { MetaDataProps, ResourceProps } from "@/types"; + +import { ColumnResources } from "./column-resources"; + +interface ResourcesTableWithSelectionProps { + data: ResourceProps[]; + metadata?: MetaDataProps; +} + +export function ResourcesTableWithSelection({ + data, + metadata, +}: ResourcesTableWithSelectionProps) { + // Ensure data is always an array for safe operations + const safeData = data ?? []; + + return ( + + ); +} diff --git a/ui/components/roles/table/data-table-row-actions.tsx b/ui/components/roles/table/data-table-row-actions.tsx index 7e6f0e94ff..0eb7864a51 100644 --- a/ui/components/roles/table/data-table-row-actions.tsx +++ b/ui/components/roles/table/data-table-row-actions.tsx @@ -18,7 +18,7 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; +import { Modal } from "@/components/shadcn/modal"; import { DeleteRoleForm } from "../workflow/forms"; interface DataTableRowActionsProps { @@ -34,14 +34,14 @@ export function DataTableRowActions({ const roleId = (row.original as { id: string }).id; return ( <> - - +
({ return ( <> - @@ -49,7 +49,7 @@ export function DataTableRowActions({ scanName={scanName} setIsOpen={setIsEditOpen} /> - +
+ + {trigger ?? ( + + )} + + + {label && ( + <> + {label} + + + )} + {children} + + + ); +} + +interface ActionDropdownItemProps + extends Omit, "children"> { + /** Icon displayed before the label */ + icon?: ReactNode; + /** Main label text */ + label: ReactNode; + /** Optional description text below the label */ + description?: string; + /** Whether the item is destructive (danger styling) */ + destructive?: boolean; +} + +export function ActionDropdownItem({ + icon, + label, + description, + destructive = false, + className, + ...props +}: ActionDropdownItemProps) { + return ( + + {icon && ( + svg]:size-5", + destructive && "text-destructive", + )} + > + {icon} + + )} +
+ {label} + {description && ( + + {description} + + )} +
+
+ ); +} + +// Re-export commonly used components for convenience +export { + DropdownMenuLabel as ActionDropdownLabel, + DropdownMenuSeparator as ActionDropdownSeparator, +} from "./dropdown"; diff --git a/ui/components/shadcn/dropdown/index.ts b/ui/components/shadcn/dropdown/index.ts new file mode 100644 index 0000000000..d21a2ff982 --- /dev/null +++ b/ui/components/shadcn/dropdown/index.ts @@ -0,0 +1,23 @@ +export { + ActionDropdown, + ActionDropdownItem, + ActionDropdownLabel, + ActionDropdownSeparator, +} from "./action-dropdown"; +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./dropdown"; diff --git a/ui/components/shadcn/modal/index.ts b/ui/components/shadcn/modal/index.ts new file mode 100644 index 0000000000..18fc26b76e --- /dev/null +++ b/ui/components/shadcn/modal/index.ts @@ -0,0 +1 @@ +export { Modal } from "./modal"; diff --git a/ui/components/shadcn/modal/modal.tsx b/ui/components/shadcn/modal/modal.tsx new file mode 100644 index 0000000000..24e441ca23 --- /dev/null +++ b/ui/components/shadcn/modal/modal.tsx @@ -0,0 +1,67 @@ +import { ReactNode } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/shadcn/dialog"; +import { cn } from "@/lib/utils"; + +const SIZE_CLASSES = { + sm: "sm:max-w-sm", + md: "sm:max-w-md", + lg: "sm:max-w-lg", + xl: "sm:max-w-xl", + "2xl": "sm:max-w-2xl", + "3xl": "sm:max-w-3xl", + "4xl": "sm:max-w-4xl", + "5xl": "sm:max-w-5xl", +} as const; + +type ModalSize = keyof typeof SIZE_CLASSES; + +interface ModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + description?: string; + children: ReactNode; + size?: ModalSize; + className?: string; +} + +export const Modal = ({ + open, + onOpenChange, + title, + description, + children, + size = "xl", + className, +}: ModalProps) => { + return ( + + + {title && ( + + {title} + {description && ( + + {description} + + )} + + )} + {children} + + + ); +}; diff --git a/ui/components/ui/code-snippet/code-snippet.tsx b/ui/components/ui/code-snippet/code-snippet.tsx index f63ab792c9..895e886a32 100644 --- a/ui/components/ui/code-snippet/code-snippet.tsx +++ b/ui/components/ui/code-snippet/code-snippet.tsx @@ -1,13 +1,44 @@ -import { Snippet } from "@heroui/snippet"; - -export const CodeSnippet = ({ value }: { value: string }) => ( - - {value} - -); +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/lib/utils"; + +interface CodeSnippetProps { + value: string; + className?: string; +} + +export const CodeSnippet = ({ value, className }: CodeSnippetProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {value} + +
+ ); +}; diff --git a/ui/components/ui/custom/custom-alert-modal.tsx b/ui/components/ui/custom/custom-alert-modal.tsx deleted file mode 100644 index 940b773502..0000000000 --- a/ui/components/ui/custom/custom-alert-modal.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Modal, ModalBody, ModalContent, ModalHeader } from "@heroui/modal"; -import React, { ReactNode } from "react"; - -interface CustomAlertModalProps { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - title?: string; - description?: string; - children: ReactNode; - size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl"; -} - -export const CustomAlertModal: React.FC = ({ - isOpen, - onOpenChange, - title, - description, - children, - size = "xl", -}) => { - return ( - - - {(_onClose) => ( - <> - {title} - - {description && ( -

- {description} -

- )} - {children} -
- - )} -
-
- ); -}; diff --git a/ui/components/ui/custom/index.ts b/ui/components/ui/custom/index.ts index 19e8fef2f4..0007e08580 100644 --- a/ui/components/ui/custom/index.ts +++ b/ui/components/ui/custom/index.ts @@ -1,4 +1,3 @@ -export * from "./custom-alert-modal"; export * from "./custom-banner"; export * from "./custom-dropdown-selection"; export * from "./custom-input"; diff --git a/ui/components/ui/sidebar/menu-item.tsx b/ui/components/ui/sidebar/menu-item.tsx index 45337b6bf3..8ae2659931 100644 --- a/ui/components/ui/sidebar/menu-item.tsx +++ b/ui/components/ui/sidebar/menu-item.tsx @@ -34,7 +34,10 @@ export const MenuItem = ({ highlight, }: MenuItemProps) => { const pathname = usePathname(); - const isActive = active !== undefined ? active : pathname.startsWith(href); + // Extract only the pathname from href (without query parameters) for comparison + const hrefPathname = href.split("?")[0]; + const isActive = + active !== undefined ? active : pathname.startsWith(hrefPathname); // Show tooltip always for Prowler Hub, or when sidebar is collapsed const showTooltip = label === "Prowler Hub" ? !!tooltip : !isOpen; @@ -55,8 +58,8 @@ export const MenuItem = ({ {isOpen && ( -

- {label} +

+ {label} {highlight && ( NEW diff --git a/ui/components/ui/table/data-table-pagination.tsx b/ui/components/ui/table/data-table-pagination.tsx index a7fc143fc3..6ba2d9a158 100644 --- a/ui/components/ui/table/data-table-pagination.tsx +++ b/ui/components/ui/table/data-table-pagination.tsx @@ -24,6 +24,18 @@ import { MetaDataProps } from "@/types"; interface DataTablePaginationProps { metadata?: MetaDataProps; disableScroll?: boolean; + /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */ + paramPrefix?: string; + + /* + * Controlled mode: Use these props to manage pagination via React state + * instead of URL params. Useful for tables in drawers/modals to avoid + * triggering page re-renders when paginating. + */ + controlledPage?: number; + controlledPageSize?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; } const NAV_BUTTON_STYLES = { @@ -35,18 +47,44 @@ const NAV_BUTTON_STYLES = { export function DataTablePagination({ metadata, disableScroll = false, + paramPrefix = "", + controlledPage, + controlledPageSize, + onPageChange, + onPageSizeChange, }: DataTablePaginationProps) { const pathname = usePathname(); const searchParams = useSearchParams(); const router = useRouter(); - const initialPageSize = searchParams.get("pageSize") ?? "50"; + + // Determine if we're in controlled mode + const isControlled = controlledPage !== undefined && onPageChange; + + // Determine param names based on prefix + const pageParam = paramPrefix ? `${paramPrefix}Page` : "page"; + const pageSizeParam = paramPrefix ? `${paramPrefix}PageSize` : "pageSize"; + + const initialPageSize = isControlled + ? String(controlledPageSize ?? 10) + : (searchParams.get(pageSizeParam) ?? "10"); const [selectedPageSize, setSelectedPageSize] = useState(initialPageSize); if (!metadata) return null; - const { currentPage, totalPages, totalEntries, itemsPerPageOptions } = - getPaginationInfo(metadata); + const { + currentPage: metaCurrentPage, + totalPages, + totalEntries, + itemsPerPageOptions, + } = getPaginationInfo(metadata); + + // For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata + const currentPage = isControlled + ? controlledPage + : paramPrefix + ? parseInt(searchParams.get(pageParam) || "1", 10) + : metaCurrentPage; const createPageUrl = (pageNumber: number | string) => { const params = new URLSearchParams(searchParams); @@ -60,7 +98,7 @@ export function DataTablePagination({ return `${pathname}?${params.toString()}`; } - params.set("page", pageNumber.toString()); + params.set(pageParam, pageNumber.toString()); // Ensure that scanId, id and version are preserved if (scanId) params.set("scanId", scanId); @@ -70,6 +108,20 @@ export function DataTablePagination({ return `${pathname}?${params.toString()}`; }; + // Handle page navigation for controlled mode + const handlePageChange = (pageNumber: number) => { + if (isControlled) { + onPageChange(pageNumber); + } else { + const url = createPageUrl(pageNumber); + if (disableScroll) { + router.push(url, { scroll: false }); + } else { + router.push(url); + } + } + }; + const isFirstPage = currentPage === 1; const isLastPage = currentPage === totalPages; @@ -87,6 +139,12 @@ export function DataTablePagination({ onValueChange={(value) => { setSelectedPageSize(value); + if (isControlled) { + onPageSizeChange?.(parseInt(value, 10)); + onPageChange(1); // Reset to first page + return; + } + const params = new URLSearchParams(searchParams); // Preserve all important parameters @@ -94,8 +152,8 @@ export function DataTablePagination({ const id = searchParams.get("id"); const version = searchParams.get("version"); - params.set("pageSize", value); - params.set("page", "1"); + params.set(pageSizeParam, value); + params.set(pageParam, "1"); // Ensure that scanId, id and version are preserved if (scanId) params.set("scanId", scanId); @@ -137,82 +195,145 @@ export function DataTablePagination({ Page {currentPage} of {totalPages}

- isFirstPage && e.preventDefault()} - > -
diff --git a/ui/components/ui/table/data-table-search.tsx b/ui/components/ui/table/data-table-search.tsx index 37eba09a7d..7b29ccff09 100644 --- a/ui/components/ui/table/data-table-search.tsx +++ b/ui/components/ui/table/data-table-search.tsx @@ -1,19 +1,38 @@ "use client"; import { LoaderCircleIcon, SearchIcon } from "lucide-react"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useId, useRef, useState } from "react"; import { Input } from "@/components/shadcn/input/input"; import { useUrlFilters } from "@/hooks/use-url-filters"; import { cn } from "@/lib/utils"; -const SEARCH_DEBOUNCE_MS = 300; - -export const DataTableSearch = () => { +const SEARCH_DEBOUNCE_MS = 500; + +interface DataTableSearchProps { + /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsSearch") */ + paramPrefix?: string; + + /* + * Controlled mode: Use these props to manage search via React state + * instead of URL params. Useful for tables in drawers/modals to avoid + * triggering page re-renders when searching. + */ + controlledValue?: string; + onSearchChange?: (value: string) => void; +} + +export const DataTableSearch = ({ + paramPrefix = "", + controlledValue, + onSearchChange, +}: DataTableSearchProps) => { const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); const { updateFilter } = useUrlFilters(); - const [value, setValue] = useState(""); + const [internalValue, setInternalValue] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [isFocused, setIsFocused] = useState(false); @@ -21,36 +40,83 @@ export const DataTableSearch = () => { const debounceTimeoutRef = useRef(null); const inputRef = useRef(null); + // Use controlled value if provided, otherwise internal state + const isControlled = controlledValue !== undefined && onSearchChange; + const value = isControlled ? controlledValue : internalValue; + const setValue = isControlled + ? (_v: string) => { + /* no-op for controlled, handled in handleChange */ + } + : setInternalValue; + + // Determine param names based on prefix + const searchParam = paramPrefix ? `${paramPrefix}Search` : "filter[search]"; + const pageParam = paramPrefix ? `${paramPrefix}Page` : "page"; + // Keep expanded if there's a value or input is focused const shouldStayExpanded = value.length > 0 || isFocused; - // Sync with URL on mount + // Sync with URL on mount (only for uncontrolled mode) useEffect(() => { - const searchFromUrl = searchParams.get("filter[search]") || ""; - setValue(searchFromUrl); + if (isControlled) return; + const searchFromUrl = searchParams.get(searchParam) || ""; + setInternalValue(searchFromUrl); // If there's a search value, start expanded if (searchFromUrl) { setIsExpanded(true); } - }, [searchParams]); + }, [searchParams, searchParam, isControlled]); // Handle input change with debounce const handleChange = (newValue: string) => { + // For controlled mode, update internal display immediately and debounce callback + if (isControlled) { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + setIsLoading(true); + debounceTimeoutRef.current = setTimeout(() => { + onSearchChange(newValue); + setIsLoading(false); + }, SEARCH_DEBOUNCE_MS); + // Update display immediately for responsive feel + onSearchChange(newValue); + setIsLoading(false); + return; + } + setValue(newValue); if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } - if (newValue) { + // If using prefix, handle URL updates directly instead of useUrlFilters + if (paramPrefix) { setIsLoading(true); debounceTimeoutRef.current = setTimeout(() => { - updateFilter("search", newValue); + const params = new URLSearchParams(searchParams.toString()); + if (newValue) { + params.set(searchParam, newValue); + } else { + params.delete(searchParam); + } + params.set(pageParam, "1"); // Reset to first page + router.push(`${pathname}?${params.toString()}`, { scroll: false }); setIsLoading(false); }, SEARCH_DEBOUNCE_MS); } else { - setIsLoading(false); - updateFilter("search", null); + // Original behavior for non-prefixed search + if (newValue) { + setIsLoading(true); + debounceTimeoutRef.current = setTimeout(() => { + updateFilter("search", newValue); + setIsLoading(false); + }, SEARCH_DEBOUNCE_MS); + } else { + setIsLoading(false); + updateFilter("search", null); + } } }; diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index 6420ff3cc2..c34f446e05 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -6,7 +6,6 @@ import { flexRender, getCoreRowModel, getFilteredRowModel, - getPaginationRowModel, getSortedRowModel, OnChangeFn, Row, @@ -26,6 +25,8 @@ import { } from "@/components/ui/table"; import { DataTablePagination } from "@/components/ui/table/data-table-pagination"; import { DataTableSearch } from "@/components/ui/table/data-table-search"; +import { useFilterTransitionOptional } from "@/contexts"; +import { cn } from "@/lib"; import { FilterOption, MetaDataProps } from "@/types"; interface DataTableProviderProps { @@ -41,6 +42,39 @@ interface DataTableProviderProps { getRowCanSelect?: (row: Row) => boolean; /** Show search bar in the table toolbar */ showSearch?: boolean; + /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */ + paramPrefix?: string; + + /* + * Controlled Mode Props + * --------------------- + * By default, DataTable uses URL params for pagination/search (via paramPrefix). + * This causes Next.js page re-renders on every interaction. + * + * For tables inside drawers/modals, use controlled mode instead: + * - Pass controlledPage, controlledPageSize, controlledSearch as state values + * - Pass onPageChange, onPageSizeChange, onSearchChange as state setters + * - This keeps state local, avoiding URL changes and unnecessary page re-renders + * + * Example: + * const [page, setPage] = useState(1); + * const [search, setSearch] = useState(""); + * + */ + controlledSearch?: string; + onSearchChange?: (value: string) => void; + controlledPage?: number; + controlledPageSize?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + /** Show loading state with opacity overlay (for controlled mode) */ + isLoading?: boolean; } export function DataTable({ @@ -53,18 +87,29 @@ export function DataTable({ onRowSelectionChange, getRowCanSelect, showSearch = false, + paramPrefix = "", + controlledSearch, + onSearchChange, + controlledPage, + controlledPageSize, + onPageChange, + onPageSizeChange, + isLoading = false, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); + // Get transition state from context for loading indicator + const filterTransition = useFilterTransitionOptional(); + // Use either context-based pending state or controlled isLoading prop + const isPending = (filterTransition?.isPending ?? false) || isLoading; + const table = useReactTable({ data, columns, enableSorting: true, - // Use getRowCanSelect function if provided, otherwise use boolean enableRowSelection: getRowCanSelect ?? enableRowSelection, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, @@ -86,13 +131,29 @@ export function DataTable({ // Format total entries count const totalEntries = metadata?.pagination?.count ?? 0; const formattedTotal = totalEntries.toLocaleString(); + const showToolbar = showSearch || metadata; + + const rows = table.getRowModel().rows; return ( -
+
{/* Table Toolbar */} - {(showSearch || metadata) && ( + {showToolbar && (
-
{showSearch && }
+
+ {showSearch && ( + + )} +
{metadata && ( {formattedTotal} Total Entries @@ -120,8 +181,8 @@ export function DataTable({ ))} - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( + {rows?.length ? ( + rows.map((row) => ( ({ )}
diff --git a/ui/components/users/profile/api-key-success-modal.tsx b/ui/components/users/profile/api-key-success-modal.tsx index 4379b0b076..c3e039cab8 100644 --- a/ui/components/users/profile/api-key-success-modal.tsx +++ b/ui/components/users/profile/api-key-success-modal.tsx @@ -3,8 +3,8 @@ import { Snippet } from "@heroui/snippet"; import { Button } from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; import { Alert, AlertDescription } from "@/components/ui/alert/Alert"; -import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; interface ApiKeySuccessModalProps { isOpen: boolean; @@ -18,8 +18,8 @@ export const ApiKeySuccessModal = ({ apiKey, }: ApiKeySuccessModalProps) => { return ( - !open && onClose()} title="API Key Created Successfully" > @@ -56,6 +56,6 @@ export const ApiKeySuccessModal = ({ Acknowledged
- + ); }; diff --git a/ui/components/users/profile/create-api-key-modal.tsx b/ui/components/users/profile/create-api-key-modal.tsx index 5f99c1d0fb..a76fe8d53b 100644 --- a/ui/components/users/profile/create-api-key-modal.tsx +++ b/ui/components/users/profile/create-api-key-modal.tsx @@ -5,9 +5,9 @@ import { useForm } from "react-hook-form"; import * as z from "zod"; import { createApiKey } from "@/actions/api-keys/api-keys"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; -import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { Form, FormButtons } from "@/components/ui/form"; @@ -86,8 +86,8 @@ export const CreateApiKeyModal = ({ }; return ( - !open && handleClose()} title="Create API Key" size="lg" @@ -139,6 +139,6 @@ export const CreateApiKeyModal = ({ /> - + ); }; diff --git a/ui/components/users/profile/edit-api-key-name-modal.tsx b/ui/components/users/profile/edit-api-key-name-modal.tsx index 460c39419e..b015f7b732 100644 --- a/ui/components/users/profile/edit-api-key-name-modal.tsx +++ b/ui/components/users/profile/edit-api-key-name-modal.tsx @@ -6,9 +6,9 @@ import { useForm } from "react-hook-form"; import * as z from "zod"; import { updateApiKey } from "@/actions/api-keys/api-keys"; +import { Modal } from "@/components/shadcn/modal"; import { useToast } from "@/components/ui"; import { CustomInput } from "@/components/ui/custom"; -import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; import { Form, FormButtons } from "@/components/ui/form"; import { EnrichedApiKey } from "./api-keys/types"; @@ -92,8 +92,8 @@ export const EditApiKeyNameModal = ({ }; return ( - !open && handleClose()} title="Edit API Key Name" size="lg" @@ -129,6 +129,6 @@ export const EditApiKeyNameModal = ({ /> - + ); }; diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx index 1099e326ae..b13574ad8e 100644 --- a/ui/components/users/profile/membership-item.tsx +++ b/ui/components/users/profile/membership-item.tsx @@ -4,7 +4,7 @@ import { Chip } from "@heroui/chip"; import { useState } from "react"; import { Button, Card } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom"; +import { Modal } from "@/components/shadcn/modal"; import { DateWithTime, InfoField } from "@/components/ui/entities"; import { MembershipDetailData } from "@/types/users"; @@ -25,17 +25,13 @@ export const MembershipItem = ({ return ( <> - + - +
diff --git a/ui/components/users/profile/revoke-api-key-modal.tsx b/ui/components/users/profile/revoke-api-key-modal.tsx index ff5bf507fa..781440bc92 100644 --- a/ui/components/users/profile/revoke-api-key-modal.tsx +++ b/ui/components/users/profile/revoke-api-key-modal.tsx @@ -4,12 +4,12 @@ import { Snippet } from "@heroui/snippet"; import { Trash2Icon } from "lucide-react"; import { revokeApiKey } from "@/actions/api-keys/api-keys"; +import { Modal } from "@/components/shadcn/modal"; import { Alert, AlertDescription, AlertTitle, } from "@/components/ui/alert/Alert"; -import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal"; import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons"; import { FALLBACK_VALUES } from "./api-keys/constants"; @@ -49,8 +49,8 @@ export const RevokeApiKeyModal = ({ }); return ( - !open && handleClose()} title="Revoke API Key" size="lg" @@ -95,6 +95,6 @@ export const RevokeApiKeyModal = ({ submitColor="danger" submitIcon={} /> - + ); }; diff --git a/ui/components/users/table/data-table-row-actions.tsx b/ui/components/users/table/data-table-row-actions.tsx index 23b673cf98..41f81290da 100644 --- a/ui/components/users/table/data-table-row-actions.tsx +++ b/ui/components/users/table/data-table-row-actions.tsx @@ -17,7 +17,7 @@ import { useState } from "react"; import { VerticalDotsIcon } from "@/components/icons"; import { Button } from "@/components/shadcn"; -import { CustomAlertModal } from "@/components/ui/custom"; +import { Modal } from "@/components/shadcn/modal"; import { DeleteForm, EditForm } from "../forms"; @@ -41,8 +41,8 @@ export function DataTableRowActions({ return ( <> - @@ -55,15 +55,15 @@ export function DataTableRowActions({ roles={roles || []} setIsOpen={setIsEditOpen} /> - - + - +
(undefined); + +export const useFilterTransition = () => { + const context = useContext(FilterTransitionContext); + if (!context) { + throw new Error( + "useFilterTransition must be used within a FilterTransitionProvider", + ); + } + return context; +}; + +/** + * Optional hook that returns undefined if not within a provider. + * Useful for components that may or may not be within a provider. + */ +export const useFilterTransitionOptional = () => { + return useContext(FilterTransitionContext); +}; + +interface FilterTransitionProviderProps { + children: ReactNode; +} + +export const FilterTransitionProvider = ({ + children, +}: FilterTransitionProviderProps) => { + const [isPending, startTransition] = useTransition(); + + return ( + + {children} + + ); +}; + +/** + * Convenience wrapper that provides filter transition context. + * Use this in pages to enable coordinated loading states across + * all filter components and the DataTable. + */ +export const FilterTransitionWrapper = ({ + children, +}: FilterTransitionProviderProps) => { + return {children}; +}; diff --git a/ui/contexts/index.ts b/ui/contexts/index.ts new file mode 100644 index 0000000000..90fb0ea1b5 --- /dev/null +++ b/ui/contexts/index.ts @@ -0,0 +1,6 @@ +export { + FilterTransitionProvider, + FilterTransitionWrapper, + useFilterTransition, + useFilterTransitionOptional, +} from "./filter-transition-context"; diff --git a/ui/hooks/use-url-filters.ts b/ui/hooks/use-url-filters.ts index 9886d1a974..911aa61558 100644 --- a/ui/hooks/use-url-filters.ts +++ b/ui/hooks/use-url-filters.ts @@ -1,17 +1,33 @@ "use client"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useTransition } from "react"; + +import { useFilterTransitionOptional } from "@/contexts"; /** * Custom hook to handle URL filters and automatically reset * pagination when filters change. + * + * Uses useTransition to prevent full page reloads when filters change, + * keeping the current UI visible while the new data loads. + * + * When used within a FilterTransitionProvider, the transition state is shared + * across all components using this hook, enabling coordinated loading indicators. */ export const useUrlFilters = () => { const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); + // Use shared context if available, otherwise fall back to local transition + const sharedTransition = useFilterTransitionOptional(); + const [localIsPending, localStartTransition] = useTransition(); + + const isPending = sharedTransition?.isPending ?? localIsPending; + const startTransition = + sharedTransition?.startTransition ?? localStartTransition; + const updateFilter = useCallback( (key: string, value: string | string[] | null) => { const params = new URLSearchParams(searchParams.toString()); @@ -41,9 +57,11 @@ export const useUrlFilters = () => { params.set(filterKey, nextValue); } - router.push(`${pathname}?${params.toString()}`, { scroll: false }); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); }, - [router, searchParams, pathname], + [router, searchParams, pathname, startTransition], ); const clearFilter = useCallback( @@ -58,9 +76,11 @@ export const useUrlFilters = () => { params.set("page", "1"); } - router.push(`${pathname}?${params.toString()}`, { scroll: false }); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); }, - [router, searchParams, pathname], + [router, searchParams, pathname, startTransition], ); const clearAllFilters = useCallback(() => { @@ -73,8 +93,10 @@ export const useUrlFilters = () => { params.delete("page"); - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }, [router, searchParams, pathname]); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }, [router, searchParams, pathname, startTransition]); const hasFilters = useCallback(() => { const params = new URLSearchParams(searchParams.toString()); @@ -88,5 +110,6 @@ export const useUrlFilters = () => { clearFilter, clearAllFilters, hasFilters, + isPending, }; };