diff --git a/testplanit/components/UnifiedSearch.test.tsx b/testplanit/components/UnifiedSearch.test.tsx index e9be94d5f..ef976e8e1 100644 --- a/testplanit/components/UnifiedSearch.test.tsx +++ b/testplanit/components/UnifiedSearch.test.tsx @@ -6,11 +6,45 @@ import { UnifiedSearch } from "./UnifiedSearch"; // Mock next-intl vi.mock("next-intl", () => ({ - useTranslations: () => (key: string) => key, + useTranslations: () => (key: string, values?: Record) => + values?.count !== undefined ? `${key} (${values.count})` : key, NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children, })); +// Mock i18n-aware navigation wrapper (next-intl/navigation needs an intl +// context we don't wire up in unit tests). +const mockRouterPush = vi.fn(); +vi.mock("~/lib/navigation", () => ({ + useRouter: () => ({ push: mockRouterPush, replace: vi.fn(), back: vi.fn() }), + usePathname: () => "/", + Link: ({ children }: { children: React.ReactNode }) => children, + redirect: vi.fn(), +})); + +// Mock BulkEditModal so we can assert it's mounted with the right props +// without pulling the entire repository-cases tree into the test. +vi.mock("@/projects/repository/[projectId]/BulkEditModal", () => ({ + BulkEditModal: ({ + isOpen, + selectedCaseIds, + projectId, + onClose, + }: { + isOpen: boolean; + selectedCaseIds: number[]; + projectId: number; + onClose: () => void; + }) => + isOpen ? ( +
+ {projectId} + {selectedCaseIds.join(",")} + +
+ ) : null, +})); + // Mock the hooks vi.mock("~/hooks/useSearchContext", () => ({ useSearchContext: vi.fn(() => ({ @@ -1133,4 +1167,143 @@ describe("UnifiedSearch Component", () => { }); }); }); + + describe("Bulk actions on search results", () => { + function caseHit(id: number, projectId: number, name = `Case ${id}`) { + return { + id, + entityType: SearchableEntityType.REPOSITORY_CASE, + score: 1.0, + source: { id, name, projectId, projectName: `Project ${projectId}` }, + }; + } + function nonCaseHit(id: number, projectId: number) { + return { + id, + entityType: SearchableEntityType.TEST_RUN, + score: 1.0, + source: { id, name: `Run ${id}`, projectId }, + }; + } + + async function renderWithHits(hits: any[]) { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ total: hits.length, hits, took: 1 }), + }); + render(); + const input = screen.getByPlaceholderText(/search/i); + fireEvent.change(input, { target: { value: "anything" } }); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + return input; + } + + it("renders a selection checkbox on RepositoryCase rows only", async () => { + await renderWithHits([caseHit(1, 100), nonCaseHit(2, 100)]); + await waitFor(() => { + expect( + screen.getByTestId("bulk-select-repository_case-1") + ).toBeInTheDocument(); + expect( + screen.queryByTestId("bulk-select-test_run-2") + ).not.toBeInTheDocument(); + }); + }); + + it("shows the bulk toolbar with the selected count after toggling a case", async () => { + await renderWithHits([caseHit(1, 100), caseHit(2, 100)]); + await waitFor(() => { + expect( + screen.getByTestId("bulk-select-repository_case-1") + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-1")); + await waitFor(() => { + expect(screen.getByTestId("bulk-action-toolbar")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-2")); + // Plural-form rendering uses the `#` placeholder for the count value. + await waitFor(() => { + expect(screen.getByTestId("bulk-action-toolbar").textContent).toContain( + "2" + ); + }); + }); + + it("disables both bulk actions when the selection spans multiple projects", async () => { + await renderWithHits([caseHit(1, 100), caseHit(2, 200)]); + await waitFor(() => { + expect( + screen.getByTestId("bulk-select-repository_case-1") + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-1")); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-2")); + await waitFor(() => { + expect(screen.getByTestId("bulk-edit-button")).toBeDisabled(); + expect( + screen.getByTestId("bulk-create-test-run-button") + ).toBeDisabled(); + }); + }); + + it("opens BulkEditModal with the selected case ids + single projectId when selection is same-project", async () => { + await renderWithHits([caseHit(1, 100), caseHit(2, 100)]); + await waitFor(() => { + expect( + screen.getByTestId("bulk-select-repository_case-1") + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-1")); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-2")); + fireEvent.click(screen.getByTestId("bulk-edit-button")); + await waitFor(() => { + expect(screen.getByTestId("bulk-edit-modal-mock")).toBeInTheDocument(); + }); + expect(screen.getByTestId("bulk-edit-project").textContent).toBe("100"); + expect(screen.getByTestId("bulk-edit-ids").textContent).toBe("1,2"); + }); + + it("seeds sessionStorage + navigates to /projects/runs/?openAddRun=true on Create Test Run", async () => { + mockRouterPush.mockReset(); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + await renderWithHits([caseHit(7, 42)]); + await waitFor(() => { + expect( + screen.getByTestId("bulk-select-repository_case-7") + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-7")); + fireEvent.click(screen.getByTestId("bulk-create-test-run-button")); + await waitFor(() => { + expect(setItem).toHaveBeenCalledWith( + "createTestRun_selectedCases", + "[7]" + ); + expect(mockRouterPush).toHaveBeenCalledWith( + "/projects/runs/42?openAddRun=true" + ); + }); + setItem.mockRestore(); + }); + + it("clears the selection via the Clear button (toolbar disappears)", async () => { + await renderWithHits([caseHit(1, 100)]); + await waitFor(() => { + expect( + screen.getByTestId("bulk-select-repository_case-1") + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId("bulk-select-repository_case-1")); + expect(screen.getByTestId("bulk-action-toolbar")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("bulk-clear-button")); + await waitFor(() => { + expect( + screen.queryByTestId("bulk-action-toolbar") + ).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/testplanit/components/UnifiedSearch.tsx b/testplanit/components/UnifiedSearch.tsx index a0a45bd82..b45b04c8b 100644 --- a/testplanit/components/UnifiedSearch.tsx +++ b/testplanit/components/UnifiedSearch.tsx @@ -24,6 +24,7 @@ import { UserDisplay } from "@/components/search/UserDisplay"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; @@ -44,6 +45,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { WorkflowStateDisplay } from "@/components/WorkflowStateDisplay"; +import { BulkEditModal } from "@/projects/repository/[projectId]/BulkEditModal"; import { ChevronLeft, ChevronRight, @@ -51,11 +53,14 @@ import { ChevronsRight, Filter, Folder, + Pencil, + PlayCircle, Search, Settings2, X, } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useRouter } from "~/lib/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getEntityIcon, @@ -149,6 +154,14 @@ export function UnifiedSearch({ Record | undefined >(searchState?.allEntityTypeCounts); + // Bulk-action selection: RepositoryCase hits only. Map so + // we can both look up by id and detect cross-project selections cheaply. + const [selectedCaseRows, setSelectedCaseRows] = useState>( + new Map() + ); + const [bulkEditOpen, setBulkEditOpen] = useState(false); + const router = useRouter(); + // Debounced search query const debouncedQuery = useDebounce(query, 300); @@ -422,8 +435,56 @@ export function UnifiedSearch({ setError(null); setIsFirstSearch(true); setCurrentPage(1); + setSelectedCaseRows(new Map()); }; + // Drop any bulk selection when the query or scope changes — the user is + // re-narrowing, so the prior selection is no longer in the visible result set. + useEffect(() => { + setSelectedCaseRows(new Map()); + }, [debouncedQuery, filters, currentProjectOnly]); + + // Bulk-action derivatives + handlers (RepositoryCase rows only). + const selectedCaseProjectIds = useMemo( + () => new Set(selectedCaseRows.values()), + [selectedCaseRows] + ); + const isCrossProjectSelection = selectedCaseProjectIds.size > 1; + const singleSelectedProjectId = + selectedCaseProjectIds.size === 1 + ? Number([...selectedCaseProjectIds][0]) + : null; + const selectedCaseIdList = useMemo( + () => [...selectedCaseRows.keys()], + [selectedCaseRows] + ); + + const toggleCaseSelection = useCallback((hit: SearchHit) => { + const id = Number(hit.id); + const projectId = Number((hit.source as { projectId?: number })?.projectId); + if (!Number.isFinite(id) || !Number.isFinite(projectId)) return; + setSelectedCaseRows((prev) => { + const next = new Map(prev); + if (next.has(id)) next.delete(id); + else next.set(id, projectId); + return next; + }); + }, []); + + const clearBulkSelection = useCallback(() => { + setSelectedCaseRows(new Map()); + }, []); + + const handleBulkCreateTestRun = useCallback(() => { + if (singleSelectedProjectId == null || selectedCaseIdList.length === 0) + return; + sessionStorage.setItem( + "createTestRun_selectedCases", + JSON.stringify(selectedCaseIdList) + ); + router.push(`/projects/runs/${singleSelectedProjectId}?openAddRun=true`); + }, [router, selectedCaseIdList, singleSelectedProjectId]); + // Handle filter changes from faceted search const handleFiltersChange = (newFilters: UnifiedSearchFilters) => { setFilters(newFilters); @@ -636,6 +697,12 @@ export function UnifiedSearch({ hit={hit} onClick={() => onResultClick?.(hit)} searchQuery={query} + isSelected={selectedCaseRows.has(Number(hit.id))} + onSelectToggle={ + hit.entityType === SearchableEntityType.REPOSITORY_CASE + ? () => toggleCaseSelection(hit) + : undefined + } /> )) )} @@ -652,6 +719,12 @@ export function UnifiedSearch({ hit={hit} onClick={() => onResultClick?.(hit)} searchQuery={query} + isSelected={selectedCaseRows.has(Number(hit.id))} + onSelectToggle={ + hit.entityType === SearchableEntityType.REPOSITORY_CASE + ? () => toggleCaseSelection(hit) + : undefined + } /> ))} @@ -863,7 +936,12 @@ export function UnifiedSearch({ {/* Results */} -
+
0 && "pb-20" + )} + > {loading && (
@@ -910,6 +988,94 @@ export function UnifiedSearch({
)}
+ + {selectedCaseRows.size > 0 && ( +
+
+
+ + {t("repository.duplicates.selected", { + count: selectedCaseRows.size, + })} + + {isCrossProjectSelection && ( + + {t("search.bulk.crossProjectHint")} + + )} +
+
+ + + + + + + {isCrossProjectSelection && ( + + {t("search.bulk.crossProjectDisabled")} + + )} + + + + + + + + {isCrossProjectSelection && ( + + {t("search.bulk.crossProjectDisabled")} + + )} + + +
+
+
+ )} + + {bulkEditOpen && singleSelectedProjectId != null && ( + setBulkEditOpen(false)} + onSaveSuccess={() => { + setBulkEditOpen(false); + clearBulkSelection(); + }} + selectedCaseIds={selectedCaseIdList} + projectId={singleSelectedProjectId} + /> + )}
); } @@ -919,10 +1085,19 @@ function SearchResultCard({ hit, onClick, searchQuery: _searchQuery, + isSelected = false, + onSelectToggle, }: { hit: SearchHit; onClick?: () => void; searchQuery?: string; + isSelected?: boolean; + /** + * When provided, a selection checkbox is rendered before the row icon. + * Currently only wired for `SearchableEntityType.REPOSITORY_CASE` hits by the + * caller, so the column is invisible for other entity types. + */ + onSelectToggle?: () => void; }) { const t = useTranslations(); const Icon = getEntityIcon(hit.entityType); @@ -1402,11 +1577,27 @@ function SearchResultCard({ className={cn( "p-4 cursor-pointer hover:shadow-md transition-all hover:border-primary/50", hit.source.isDeleted && - "bg-destructive/10 border-destructive/20 hover:border-destructive/50" + "bg-destructive/10 border-destructive/20 hover:border-destructive/50", + isSelected && "border-primary/50 bg-primary/5" )} onClick={onClick} >
+ {onSelectToggle && ( +
{ + e.stopPropagation(); + onSelectToggle(); + }} + > + +
+ )}