From 318ffae8101280dd56daf34034a3314c1540dd55 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Wed, 3 Jun 2026 22:47:10 -0500 Subject: [PATCH] enhancement(search): bulk actions on RepositoryCase search results Adds per-row selection on RepositoryCase hits in UnifiedSearch and a viewport- pinned bulk-action toolbar that surfaces two existing actions against the selection: Bulk Edit (reuses the BulkEditModal from the repository view) and Create Test Run (seeds sessionStorage["createTestRun_selectedCases"] and navigates to /projects/runs/?openAddRun=true, the same handoff the repository view uses). Selection is gated to the same project: when the selection spans projects both action buttons are disabled with a tooltip explaining why, so a cross-project selection never resolves to an ambiguous target. Selection clears automatically when the query, filters, or current-project-only scope change. Toolbar is fixed bottom-right with sm:max-w-3xl to match the GlobalSearchSheet column on sm+ screens; spans full width on xs, same as the Sheet does at that breakpoint. Search results get bottom padding while the toolbar is visible so the last row isn't covered. Checkbox is only rendered on REPOSITORY_CASE hits; other entity types (TestRuns, Sessions, etc.) render unchanged, so multi-entity bulk actions can be added later without rewiring this code. i18n: 5 existing strings reused (repository.cases.bulkEdit, .createTestRun, common.actions.clear, repository.duplicates.selectRow, .selected). Two new keys added under search.bulk for the cross-project guard and synced to the 13 non-English locales via crowdin. Tests: 6 new co-located cases in UnifiedSearch.test.tsx covering checkbox visibility per entity type, toolbar appearance + count rendering, cross- project disable + tooltip, BulkEditModal mount with the right (ids, projectId) shape, the Create-Test-Run sessionStorage + navigation handoff, and the clear-selection control. Existing 26 tests still pass; mocked next-intl now substitutes the `count` value so plural-key assertions work, and the i18n-aware navigation wrapper is mocked. --- testplanit/components/UnifiedSearch.test.tsx | 175 ++++++++++++++++- testplanit/components/UnifiedSearch.tsx | 195 ++++++++++++++++++- testplanit/messages/de-DE.json | 4 + testplanit/messages/en-US.json | 4 + testplanit/messages/es-ES.json | 4 + testplanit/messages/fr-FR.json | 4 + testplanit/messages/it-IT.json | 4 + testplanit/messages/ja-JP.json | 4 + testplanit/messages/ko-KR.json | 4 + testplanit/messages/nl-NL.json | 4 + testplanit/messages/pl-PL.json | 4 + testplanit/messages/pt-BR.json | 4 + testplanit/messages/ru-RU.json | 4 + testplanit/messages/tr-TR.json | 4 + testplanit/messages/vi-VN.json | 4 + testplanit/messages/zh-CN.json | 4 + testplanit/messages/zh-TW.json | 4 + 17 files changed, 427 insertions(+), 3 deletions(-) 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(); + }} + > + +
+ )}