fix(ui): resolve finding IDs before muting in grouped findings view

This commit is contained in:
alejandrobailo
2026-03-20 12:03:47 +01:00
parent 62e92e3f85
commit df3bd045b4
6 changed files with 242 additions and 42 deletions

View File

@@ -3,6 +3,66 @@
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiResponse } from "@/lib/server-actions-helper";
/**
* Resolves resource UIDs + check ID into actual finding UUIDs.
* Uses /findings/latest with check_id and resource_uid__in filters
* to batch-resolve in a single API call.
*/
export const resolveFindingIds = async ({
checkId,
resourceUids,
}: {
checkId: string;
resourceUids: string[];
}): Promise<string[]> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/latest`);
url.searchParams.append("filter[check_id]", checkId);
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
url.searchParams.append("page[size]", resourceUids.length.toString());
try {
const response = await fetch(url.toString(), { headers });
const data = await handleApiResponse(response);
if (!data?.data || !Array.isArray(data.data)) return [];
return data.data.map((item: { id: string }) => item.id);
} catch (error) {
console.error("Error resolving finding IDs:", error);
return [];
}
};
/**
* Resolves check IDs into actual finding UUIDs.
* Used at the group level where each row represents a check_id.
*/
export const resolveFindingIdsByCheckIds = async ({
checkIds,
}: {
checkIds: string[];
}): Promise<string[]> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/findings/latest`);
url.searchParams.append("filter[check_id__in]", checkIds.join(","));
url.searchParams.append("page[size]", "500");
try {
const response = await fetch(url.toString(), { headers });
const data = await handleApiResponse(response);
if (!data?.data || !Array.isArray(data.data)) return [];
return data.data.map((item: { id: string }) => item.id);
} catch (error) {
console.error("Error resolving finding IDs by check IDs:", error);
return [];
}
};
export const getLatestFindingsByResourceUid = async ({
resourceUid,
page = 1,

View File

@@ -48,8 +48,10 @@ function getFailingForLabel(firstSeenAt: string | null): string | null {
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const resource = row.original;
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
const { selectedFindingIds, clearSelection } = useContext(
const { selectedFindingIds, clearSelection, resolveMuteIds } = useContext(
FindingsSelectionContext,
) || {
selectedFindingIds: [],
@@ -59,7 +61,7 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const isCurrentSelected = selectedFindingIds.includes(resource.findingId);
const hasMultipleSelected = selectedFindingIds.length > 1;
const getMuteIds = (): string[] => {
const getDisplayIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
return selectedFindingIds;
}
@@ -68,18 +70,38 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
const getMuteLabel = () => {
if (resource.isMuted) return "Muted";
const ids = getMuteIds();
const ids = getDisplayIds();
if (ids.length > 1) return `Mute ${ids.length}`;
return "Mute";
};
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
if (resolveMuteIds) {
setIsResolving(true);
const ids = await resolveMuteIds(displayIds);
setResolvedIds(ids);
setIsResolving(false);
if (ids.length > 0) setIsMuteModalOpen(true);
} else {
setResolvedIds(displayIds);
setIsMuteModalOpen(true);
}
};
const handleMuteComplete = () => {
clearSelection();
setResolvedIds([]);
};
return (
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={getMuteIds()}
onComplete={clearSelection}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
<div className="flex items-center justify-end">
<ActionDropdown
@@ -101,13 +123,15 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
icon={
resource.isMuted ? (
<VolumeOff className="size-5" />
) : isResolving ? (
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<VolumeX className="size-5" />
)
}
label={getMuteLabel()}
disabled={resource.isMuted}
onSelect={() => setIsMuteModalOpen(true)}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={resource.isMuted || isResolving}
onSelect={handleMuteClick}
/>
</ActionDropdown>
</div>

View File

@@ -68,41 +68,57 @@ export function DataTableRowActions<T extends FindingRowData>({
// Get selection context - if there are other selected rows, include them
const selectionContext = useContext(FindingsSelectionContext);
const { selectedFindingIds, clearSelection } = selectionContext || {
selectedFindingIds: [],
clearSelection: () => {},
};
const { selectedFindingIds, clearSelection, resolveMuteIds } =
selectionContext || {
selectedFindingIds: [],
clearSelection: () => {},
};
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
const [isResolving, setIsResolving] = useState(false);
// If current finding is selected and there are multiple selections, mute all
// Otherwise, just mute this single finding
const isCurrentSelected = selectedFindingIds.includes(finding.id);
const hasMultipleSelected = selectedFindingIds.length > 1;
const getMuteIds = (): string[] => {
const getDisplayIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
// Mute all selected including current
return selectedFindingIds;
}
// Just mute the current finding
return [finding.id];
};
const getMuteLabel = () => {
if (isMuted) return "Muted";
const ids = getMuteIds();
const ids = getDisplayIds();
if (ids.length > 1) {
return `Mute ${ids.length} Findings`;
}
return "Mute Finding";
};
const handleMuteClick = async () => {
const displayIds = getDisplayIds();
if (resolveMuteIds) {
setIsResolving(true);
const ids = await resolveMuteIds(displayIds);
setResolvedIds(ids);
setIsResolving(false);
if (ids.length > 0) setIsMuteModalOpen(true);
} else {
// Regular findings — IDs are already valid finding UUIDs
setResolvedIds(displayIds);
setIsMuteModalOpen(true);
}
};
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();
setResolvedIds([]);
if (onMuteComplete) {
onMuteComplete(getMuteIds());
onMuteComplete(getDisplayIds());
return;
}
@@ -121,7 +137,7 @@ export function DataTableRowActions<T extends FindingRowData>({
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={getMuteIds()}
findingIds={resolvedIds}
onComplete={handleMuteComplete}
/>
@@ -145,15 +161,15 @@ export function DataTableRowActions<T extends FindingRowData>({
icon={
isMuted ? (
<VolumeOff className="size-5" />
) : isResolving ? (
<div className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<VolumeX className="size-5" />
)
}
label={getMuteLabel()}
disabled={isMuted}
onSelect={() => {
setIsMuteModalOpen(true);
}}
label={isResolving ? "Resolving..." : getMuteLabel()}
disabled={isMuted || isResolving}
onSelect={handleMuteClick}
/>
<ActionDropdownItem
icon={<JiraIcon size={20} />}

View File

@@ -7,10 +7,12 @@ import {
RowSelectionState,
useReactTable,
} from "@tanstack/react-table";
import { ChevronLeft } from "lucide-react";
import { ChevronLeft, VolumeX } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
import { Button } from "@/components/shadcn";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import {
Table,
@@ -25,7 +27,7 @@ import { useInfiniteResources } from "@/hooks/use-infinite-resources";
import { cn, hasDateOrScanFilter } from "@/lib";
import { FindingGroupRow, FindingResourceRow } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { MuteFindingsModal } from "../mute-findings-modal";
import { getColumnFindingResources } from "./column-finding-resources";
import { FindingsSelectionContext } from "./findings-selection-context";
import { ImpactedResourcesCell } from "./impacted-resources-cell";
@@ -108,12 +110,42 @@ export function FindingsGroupDrillDown({
router.refresh();
};
// Selection logic for resources
// Selection logic — tracks by findingId (resource_id) for checkbox consistency
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => resources[parseInt(idx)]?.findingId)
.filter(Boolean);
// Mute modal state — resource IDs resolved to finding UUIDs on-click
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
const [isResolvingIds, setIsResolvingIds] = useState(false);
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
const resolveResourceIds = useCallback(
async (ids: string[]) => {
const resourceUids = ids
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
.filter(Boolean) as string[];
if (resourceUids.length === 0) return [];
return resolveFindingIds({
checkId: group.checkId,
resourceUids,
});
},
[resources, group.checkId],
);
const handleMuteClick = async () => {
setIsResolvingIds(true);
const findingIds = await resolveResourceIds(selectedFindingIds);
setResolvedFindingIds(findingIds);
setIsResolvingIds(false);
if (findingIds.length > 0) {
setIsMuteModalOpen(true);
}
};
const selectableRowCount = resources.filter((r) => !r.isMuted).length;
const getRowCanSelect = (row: Row<FindingResourceRow>): boolean => {
@@ -130,6 +162,7 @@ export function FindingsGroupDrillDown({
const handleMuteComplete = () => {
clearSelection();
setResolvedFindingIds([]);
router.refresh();
};
@@ -170,6 +203,7 @@ export function FindingsGroupDrillDown({
selectedFindings: [],
clearSelection,
isSelected,
resolveMuteIds: resolveResourceIds,
}}
>
<div
@@ -282,11 +316,29 @@ export function FindingsGroupDrillDown({
</div>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={resolvedFindingIds}
onComplete={handleMuteComplete}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={handleMuteClick}
disabled={isResolvingIds}
size="lg"
className="shadow-lg"
>
{isResolvingIds ? (
<TreeSpinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedFindingIds.length})
</Button>
</div>
</>
)}
<ResourceDetailDrawer

View File

@@ -1,13 +1,17 @@
"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { VolumeX } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { resolveFindingIdsByCheckIds } from "@/actions/findings/findings-by-resource";
import { Button } from "@/components/shadcn";
import { TreeSpinner } from "@/components/shadcn/tree-view/tree-spinner";
import { DataTable } from "@/components/ui/table";
import { FindingGroupRow, MetaDataProps } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { MuteFindingsModal } from "../mute-findings-modal";
import { getColumnFindingGroups } from "./column-finding-groups";
import { FindingsGroupDrillDown } from "./findings-group-drill-down";
import { FindingsSelectionContext } from "./findings-selection-context";
@@ -79,8 +83,32 @@ export function FindingsGroupTable({
return selectedFindingIds.includes(id);
};
// Mute modal state — check IDs resolved to finding UUIDs on-click
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const [resolvedFindingIds, setResolvedFindingIds] = useState<string[]>([]);
const [isResolvingIds, setIsResolvingIds] = useState(false);
const handleMuteClick = async () => {
setIsResolvingIds(true);
const findingIds = await resolveFindingIdsByCheckIds({
checkIds: selectedFindingIds,
});
setResolvedFindingIds(findingIds);
setIsResolvingIds(false);
if (findingIds.length > 0) {
setIsMuteModalOpen(true);
}
};
/** Shared resolver for row action dropdowns (via context). */
const resolveMuteIds = useCallback(
async (checkIds: string[]) => resolveFindingIdsByCheckIds({ checkIds }),
[],
);
const handleMuteComplete = () => {
clearSelection();
setResolvedFindingIds([]);
router.refresh();
};
@@ -124,6 +152,7 @@ export function FindingsGroupTable({
selectedFindings,
clearSelection,
isSelected,
resolveMuteIds,
}}
>
<DataTable
@@ -140,11 +169,29 @@ export function FindingsGroupTable({
/>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
<>
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={resolvedFindingIds}
onComplete={handleMuteComplete}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={handleMuteClick}
disabled={isResolvingIds}
size="lg"
className="shadow-lg"
>
{isResolvingIds ? (
<TreeSpinner className="size-5" />
) : (
<VolumeX className="size-5" />
)}
Mute ({selectedFindingIds.length})
</Button>
</div>
</>
)}
</FindingsSelectionContext.Provider>
);

View File

@@ -4,10 +4,11 @@ import { createContext, useContext } from "react";
interface FindingsSelectionContextValue {
selectedFindingIds: string[];
selectedFindings: any[];
clearSelection: () => void;
isSelected: (id: string) => boolean;
/** Resolves display IDs (check_ids or resource_ids) into real finding UUIDs for the mute API. */
resolveMuteIds?: (ids: string[]) => Promise<string[]>;
}
export const FindingsSelectionContext =