feat(applications): make bulk selection an explicit mode

This commit is contained in:
cesnimda
2026-11-10 12:26:06 +01:00
parent 99a2b6f4f6
commit 1246994cca
4 changed files with 96 additions and 17 deletions
+34 -14
View File
@@ -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<ContentDensity>("comfortable");
const [selectedIds, setSelectedIds] = useState<number[]>([]);
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<string, string | null>) => {
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
</Box>
)}
{!boardView && selectedIds.length > 0 ? (
{!boardView && mode === "jobs" && (filteredJobs.length > 0 || selectionMode) ? (
<Button size="small" variant={selectionMode ? "contained" : "outlined"} aria-pressed={selectionMode} sx={{ mt: 1.5, minHeight: isMobile ? 44 : undefined }}
onClick={() => {
selectionGeneration.current += 1;
retainedSelectionAfterReloadRef.current = null;
setSelectedIds([]);
setBulkStatusAnchor(null);
setSelectionMode(!selectionMode);
}}>{t("jobTableSelectionMode")}</Button>
) : null}
{!boardView && showSelection && selectedIds.length > 0 ? (
<Paper variant="outlined" sx={{ mt: 1.5, p: 1.5, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", borderColor: "primary.main", borderRadius: 2.5, boxShadow: "none" }}>
<Typography sx={{ fontWeight: 700 }}>{t("jobTableSelected", { count: selectedIds.length })}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", width: { xs: "100%", sm: "auto" }, "& .MuiButton-root": { minHeight: isMobile ? 44 : undefined } }}>
@@ -685,9 +705,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
>
{isMobile ? (
<Stack spacing={0}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
{showSelection ? <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
</Box>
</Box> : null}
{jobsResource.loading ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("loading")}</Typography> : 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
<Stack spacing="var(--app-list-gap)">
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: "1 1 180px" }}>
<Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
{showSelection ? <Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} /> : null}
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
{job.company?.name ?? t("jobTableCompany")}
</Typography>
<Typography sx={{ fontWeight: 700, fontSize: 16, lineHeight: 1.35, overflowWrap: "anywhere" }}>
{job.jobTitle}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
{job.company?.name ?? t("jobTableCompany")}
</Typography>
</Box>
</Box>
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 700, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
@@ -809,9 +829,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Table aria-label={t(mode === "trash" ? "trash" : "jobApplications")} size="small" sx={{ minWidth: 980 }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectAll") }} checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
{showSelection ? <TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectAll") }} checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell> : null}
<TableCell sortDirection={sortBy === "jobTitle" ? sortDir : false}><TableSortLabel active={sortBy === "jobTitle"} direction={sortBy === "jobTitle" ? sortDir : "asc"} onClick={() => requestSort("jobTitle")}>{t("jobTableRole")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "company" ? sortDir : false}><TableSortLabel active={sortBy === "company"} direction={sortBy === "company" ? sortDir : "asc"} onClick={() => requestSort("company")}>{t("jobTableCompany")}</TableSortLabel></TableCell>
<TableCell sortDirection={sortBy === "location" ? sortDir : false}><TableSortLabel active={sortBy === "location"} direction={sortBy === "location" ? sortDir : "asc"} onClick={() => requestSort("location")}>{t("jobTableLocation")}</TableSortLabel></TableCell>
{columns.status ? <TableCell sortDirection={sortBy === "status" ? sortDir : false}><TableSortLabel active={sortBy === "status"} direction={sortBy === "status" ? sortDir : "asc"} onClick={() => requestSort("status")}>{t("jobTableStatus")}</TableSortLabel></TableCell> : null}
{columns.dateApplied ? <TableCell sortDirection={sortBy === "dateApplied" ? sortDir : false}><TableSortLabel active={sortBy === "dateApplied"} direction={sortBy === "dateApplied" ? sortDir : "asc"} onClick={() => requestSort("dateApplied")}>{t("jobTableDateApplied")}</TableSortLabel></TableCell> : null}
@@ -848,8 +868,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: -3 },
}}
>
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
<TableCell sx={{ minWidth: 140, color: "text.secondary", fontWeight: 600 }}>{job.company?.name ?? ""}</TableCell>
{showSelection ? <TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell> : null}
<TableCell sx={{ minWidth: 260 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography component="span" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
@@ -869,6 +888,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
))}
</Box>
</TableCell>
<TableCell sx={{ minWidth: 140, color: "text.secondary", fontWeight: 600 }}>{job.company?.name ?? ""}</TableCell>
<TableCell sx={{ minWidth: 130 }}>{job.location || "—"}</TableCell>
{columns.status ? <TableCell><Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} variant="outlined" sx={{ fontWeight: 600 }} /></TableCell> : null}
{columns.dateApplied ? <TableCell>{appliedDateLabel}</TableCell> : null}
+4 -2
View File
@@ -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",
@@ -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" } });
@@ -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));