From 1246994cca9a3ba7cb89eb4605b3dc77b5d7c322 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 12:26:06 +0100 Subject: [PATCH] feat(applications): make bulk selection an explicit mode --- job-tracker-ui/src/components/JobTable.tsx | 48 +++++++++++----- job-tracker-ui/src/i18n/translations.ts | 6 +- job-tracker-ui/src/job-table-density.test.tsx | 55 +++++++++++++++++++ .../src/jobs-view-integration.test.tsx | 4 +- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index 1dd38876..933aa9af 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -164,6 +164,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col // Presentation only: not an account preference, saved-view field or API input. const [density, setDensity] = useState("comfortable"); const [selectedIds, setSelectedIds] = useState([]); + const [selectionMode, setSelectionMode] = useState(false); + const selectionGeneration = useRef(0); + const showSelection = mode === "trash" || selectionMode; const [search, setSearch] = useState(routeQuery.q); const debouncedSearch = useDebouncedValue(search, 250); const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || routeQuery.includeDeleted); @@ -185,10 +188,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col // Selection belongs to the current Table page, never to an invisible view. useEffect(() => { + selectionGeneration.current += 1; + setSelectionMode(false); setSelectedIds([]); + setBulkStatusAnchor(null); setBoardCount(null); retainedSelectionAfterReloadRef.current = null; - }, [boardView]); + }, [boardView, mode]); const viewDestination = (view: "table" | "board") => { const query = new URLSearchParams(location.search); @@ -197,6 +203,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col }; const updateListRoute = useCallback((updates: Record) => { + selectionGeneration.current += 1; + retainedSelectionAfterReloadRef.current = null; setSelectedIds([]); const next = new URLSearchParams(location.search); Object.entries(updates).forEach(([key, value]) => { @@ -409,8 +417,10 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col const runBulkAction = async (action: "delete" | "restore" | "status", value?: string) => { if (selectedIds.length === 0) return; + const selection = selectionGeneration.current; const selectedJobs = jobs.filter((job) => selectedIdSet.has(job.id)); if (action === "delete" && !(await confirmDelete(selectedJobs))) return; + if (selection !== selectionGeneration.current) return; const results = await Promise.allSettled(selectedIds.map((id) => { if (action === "delete") return api.delete(`/jobapplications/${id}`); if (action === "restore") return api.post(`/jobapplications/${id}/restore`); @@ -420,7 +430,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col if (failedIds.length) toast(t("jobTableBulkActionFailed"), "error"); else toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success"); if (failedIds.length !== selectedIds.length) { - retainedSelectionAfterReloadRef.current = failedIds; + if (selection === selectionGeneration.current) retainedSelectionAfterReloadRef.current = failedIds; setReloadToken((token) => token + 1); } }; @@ -456,7 +466,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col }), [buildWorkflowActionSignal, filteredJobs, selectedIdSet, language]); const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; - const visibleDesktopColumns = 6 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl); + const visibleDesktopColumns = 5 + Number(showSelection) + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl); const selectedCompanyIsLoading = companyFilterId !== "All" && !companies.some((company) => company.id === companyFilterId); const matchingCount = boardView ? boardCount : jobsResource.loading || jobsResource.refreshing || jobsResource.error ? null : total; @@ -632,7 +642,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col )} - {!boardView && selectedIds.length > 0 ? ( + {!boardView && mode === "jobs" && (filteredJobs.length > 0 || selectionMode) ? ( + + ) : null} + {!boardView && showSelection && selectedIds.length > 0 ? ( {t("jobTableSelected", { count: selectedIds.length })} @@ -685,9 +705,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col > {isMobile ? ( - + {showSelection ? 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} /> - + : null} {jobsResource.loading ? {t("loading")} : null} {!jobsResource.loading && !jobsResource.error && rowModels.map(({ job, toneName, actionSignals, tags, appliedDateLabel, isSelected }) => { @@ -722,14 +742,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> + {showSelection ? toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> : null} - - {job.company?.name ?? t("jobTableCompany")} - {job.jobTitle} + + {job.company?.name ?? t("jobTableCompany")} + {columns.status ? : null} @@ -809,9 +829,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> - requestSort("company")}>{t("jobTableCompany")} + {showSelection ? 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> : null} requestSort("jobTitle")}>{t("jobTableRole")} + requestSort("company")}>{t("jobTableCompany")} requestSort("location")}>{t("jobTableLocation")} {columns.status ? requestSort("status")}>{t("jobTableStatus")} : null} {columns.dateApplied ? requestSort("dateApplied")}>{t("jobTableDateApplied")} : null} @@ -848,8 +868,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: -3 }, }} > - toggleSelected(job.id, e.target.checked)} /> - {job.company?.name ?? ""} + {showSelection ? toggleSelected(job.id, e.target.checked)} /> : null} {job.jobTitle} @@ -869,6 +888,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ))} + {job.company?.name ?? ""} {job.location || "—"} {columns.status ? : null} {columns.dateApplied ? {appliedDateLabel} : null} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 1c8c9c3f..2a3f8df8 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -2826,6 +2826,7 @@ export const translations = { jobTableInterviewStage: "Interview stage", jobTableShowDeleted: "Show deleted", jobTableColumns: "Columns", + jobTableSelectionMode: "Select applications", jobTableSelectAll: "Select all", jobTableSelected: "{count} selected", jobTableRestoreSelected: "Restore selected", @@ -2856,7 +2857,7 @@ export const translations = { jobTableFollowUp: "Follow up", jobTableCvMissing: "CV missing", jobTableCvReady: "CV ready", - jobTablePackageWork: "Build package", + jobTablePackageWork: "Prepare application", jobTablePackageMissingCvAndNotes: "Tailored CV and job notes still need a working draft.", jobTablePackageMissingNotes: "Job notes still need a working draft.", jobTableOpenListing: "Open listing", @@ -6050,6 +6051,7 @@ export const translations = { jobTableInterviewStage: "Intervjustadium", jobTableShowDeleted: "Vis slettede", jobTableColumns: "Kolonner", + jobTableSelectionMode: "Velg søknader", jobTableSelectAll: "Velg alle", jobTableSelected: "{count} valgt", jobTableRestoreSelected: "Gjenopprett valgte", @@ -6080,7 +6082,7 @@ export const translations = { jobTableFollowUp: "Følg opp", jobTableCvMissing: "CV mangler", jobTableCvReady: "CV klar", - jobTablePackageWork: "Bygg pakke", + jobTablePackageWork: "Klargjør søknaden", jobTablePackageMissingCvAndNotes: "Tilpasset CV og jobbnotater trenger fortsatt et arbeidsutkast.", jobTablePackageMissingNotes: "Jobbnotater trenger fortsatt et arbeidsutkast.", jobTableOpenListing: "Åpne stilling", diff --git a/job-tracker-ui/src/job-table-density.test.tsx b/job-tracker-ui/src/job-table-density.test.tsx index cac6df5f..f2688d28 100644 --- a/job-tracker-ui/src/job-table-density.test.tsx +++ b/job-tracker-ui/src/job-table-density.test.tsx @@ -34,9 +34,34 @@ beforeEach(() => { mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job], total: 31, page: 1, pageSize: 15 } : [] } as any)); }); +test("ordinary applications hide checkboxes until explicit selection mode and discard selection on exit", async () => { + show("jobs", "?q=platform"); + const row = await screen.findByRole("row", { name: /platform engineer/i }); + const readsBefore = calls().length; + expect(within(row).queryByRole("checkbox")).not.toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: t.jobTableSelectAll })).not.toBeInTheDocument(); + const selection = screen.getByRole("button", { name: t.jobTableSelectionMode }); + expect(within(row).getAllByRole("cell")[0]).toHaveTextContent(job.jobTitle); + expect(within(row).getAllByRole("cell")[1]).toHaveTextContent(job.company.name); + fireEvent.click(selection); + expect(selection).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(row).getByRole("checkbox")); + expect(screen.getByRole("button", { name: t.jobTableDeleteSelected })).toBeVisible(); + fireEvent.click(selection); + expect(selection).toHaveAttribute("aria-pressed", "false"); + expect(within(row).queryByRole("checkbox")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: t.jobTableDeleteSelected })).not.toBeInTheDocument(); + fireEvent.click(selection); + expect(within(row).getByRole("checkbox")).not.toBeChecked(); + expect(calls()).toHaveLength(readsBefore); + expect(mockedApi.delete).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Current route")).toHaveTextContent("/jobs?q=platform"); +}); + test("row status/edit/delete controls retain selection and do not open the workspace", async () => { show(); const row = await screen.findByRole("row", { name: /platform engineer/i }); + fireEvent.click(screen.getByRole("button", { name: t.jobTableSelectionMode })); fireEvent.click(within(row).getByRole("checkbox")); fireEvent.click(within(row).getByRole("button", { name: `${t.jobTableEdit}: ${job.jobTitle}` })); expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible(); @@ -62,6 +87,11 @@ test.each(["en", "nb"] as const)("%s mobile row actions remain direct, named and const copy = translations[language]; show("jobs", "?q=platform"); const row = await screen.findByRole("link", { name: copy.jobTableOpenJob.replace("{title}", job.jobTitle).replace("{company}", job.company.name) }); + expect(within(row).queryByRole("checkbox")).not.toBeInTheDocument(); + const roleTitle = within(row).getByText(job.jobTitle); + const employer = within(row).getByText(job.company.name); + expect(roleTitle.compareDocumentPosition(employer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: copy.jobTableSelectionMode })); fireEvent.click(within(row).getByRole("checkbox")); fireEvent.click(within(row).getByRole("button", { name: copy.jobTableEdit })); expect(screen.getByRole("dialog", { name: "Edit job" })).toBeVisible(); @@ -118,6 +148,7 @@ async function compact() { test("density preserves selected rows, query, page, custom status, columns and listing link without refetch", async () => { show("jobs", "?q=platform&page=2&sortBy=company&sortDir=asc"); const row = await screen.findByRole("row", { name: /open platform engineer/i }); + fireEvent.click(screen.getByRole("button", { name: t.jobTableSelectionMode })); const columns = within(row).getAllByRole("cell").length; fireEvent.click(within(row).getByRole("checkbox")); const before = calls().length; @@ -185,6 +216,7 @@ test("partial bulk failure refreshes successful rows and retains only failed vis show(); const first = await screen.findByRole("row", { name: /open platform engineer/i }); const second = screen.getByRole("row", { name: /open second role/i }); + fireEvent.click(screen.getByRole("button", { name: t.jobTableSelectionMode })); fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll })); fireEvent.click(screen.getByRole("button", { name: "Offer" })); await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledTimes(2)); @@ -193,6 +225,28 @@ test("partial bulk failure refreshes successful rows and retains only failed vis expect(calls().length).toBeGreaterThan(1); }); +test("a completed bulk request cannot restore a discarded selection", async () => { + const secondJob = { ...job, id: 43, jobTitle: "Second role" }; + mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/companies" ? [job.company] : url === "/jobapplications" ? { items: [job, secondJob], total: 2, page: 1, pageSize: 15 } : [] } as any)); + let completeFirst!: () => void; + mockedApi.patch.mockImplementation((url: string) => url.includes("/43/") + ? Promise.reject(new Error("Synthetic failure")) + : new Promise(resolve => { completeFirst = () => resolve({ status: 204 } as any); })); + show(); + await screen.findByRole("row", { name: /open second role/i }); + const selection = screen.getByRole("button", { name: t.jobTableSelectionMode }); + fireEvent.click(selection); + fireEvent.click(screen.getByRole("checkbox", { name: t.jobTableSelectAll })); + fireEvent.click(screen.getByRole("button", { name: "Offer" })); + fireEvent.click(selection); + fireEvent.click(selection); + await act(async () => { completeFirst(); }); + await waitFor(() => expect(calls().length).toBeGreaterThan(1)); + const second = await screen.findByRole("row", { name: /open second role/i }); + expect(within(second).getByRole("checkbox")).not.toBeChecked(); + expect(screen.queryByRole("button", { name: t.jobTableDeleteSelected })).not.toBeInTheDocument(); +}); + test("saved view create/apply/delete stays scoped to the signed-in account", async () => { setAuthUserKey("account-a"); show("jobs", "?q=platform&status=Interview"); @@ -217,6 +271,7 @@ test("saved view create/apply/delete stays scoped to the signed-in account", asy test("changing the result set clears selection before an old row can be bulk-updated", async () => { show(); const row = await screen.findByRole("row", { name: /platform engineer/i }); + fireEvent.click(screen.getByRole("button", { name: t.jobTableSelectionMode })); fireEvent.click(within(row).getByRole("checkbox")); expect(screen.getByText(t.jobTableSelected.replace("{count}", "1"))).toBeVisible(); fireEvent.change(screen.getByRole("textbox", { name: t.jobTableSearch }), { target: { value: "different" } }); diff --git a/job-tracker-ui/src/jobs-view-integration.test.tsx b/job-tracker-ui/src/jobs-view-integration.test.tsx index 6f4dc8e2..6caf85d0 100644 --- a/job-tracker-ui/src/jobs-view-integration.test.tsx +++ b/job-tracker-ui/src/jobs-view-integration.test.tsx @@ -59,6 +59,7 @@ test("Table/Board switches preserve query, complete membership, page, density an await screen.findByText("16–26 of 26"); expect(reads("/jobapplications/board")).toHaveLength(0); const pageIds = ids("[data-job-row-id]", "data-job-row-id"); + fireEvent.click(screen.getByRole("button", { name: t.jobTableSelectionMode })); fireEvent.click(screen.getAllByRole("checkbox", { name: /Select Engineer/ })[0]); fireEvent.mouseDown(screen.getByRole("combobox", { name: t.listDensity })); fireEvent.click(await screen.findByRole("option", { name: "Compact" })); @@ -75,7 +76,8 @@ test("Table/Board switches preserve query, complete membership, page, density an await screen.findByText("16–26 of 26"); expect(ids("[data-job-row-id]", "data-job-row-id")).toEqual(pageIds); expect(screen.getByRole("combobox", { name: t.listDensity })).toHaveTextContent("Compact"); - expect(screen.getAllByRole("checkbox", { name: /Select Engineer/ }).every(el => !(el as HTMLInputElement).checked)).toBe(true); + expect(screen.queryByRole("checkbox", { name: /Select Engineer/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: t.jobTableSelectionMode })).toHaveAttribute("aria-pressed", "false"); expect(reads("/jobapplications/board")).toHaveLength(1); fireEvent.click(screen.getByText("History back")); await waitFor(() => expect(document.querySelectorAll("[data-kanban-card-id]")).toHaveLength(26));