fix(jobs): align table and board query contracts
This commit is contained in:
@@ -52,6 +52,7 @@ import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import type { ContentDensity } from "../theme";
|
||||
import { jobsQueryFromSearch, JobSortKey, ReadinessFilter } from "../jobsQuery";
|
||||
|
||||
interface PagedResult<T> {
|
||||
items: T[];
|
||||
@@ -93,10 +94,6 @@ interface Props {
|
||||
mode?: "jobs" | "trash";
|
||||
}
|
||||
|
||||
type JobSortKey = "dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location";
|
||||
type ReadinessFilter = "all" | "needs-work" | "interview";
|
||||
|
||||
const JOB_SORT_KEYS: JobSortKey[] = ["dateApplied", "company", "jobTitle", "status", "daysSince", "location"];
|
||||
const JOB_STATUS_FILTERS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"];
|
||||
|
||||
function queryPage(search: string): number {
|
||||
@@ -104,26 +101,6 @@ function queryPage(search: string): number {
|
||||
return Number.isInteger(value) && value > 1 ? value - 1 : 0;
|
||||
}
|
||||
|
||||
function queryCompany(search: string): number | "All" {
|
||||
const value = Number(new URLSearchParams(search).get("companyId"));
|
||||
return Number.isInteger(value) && value > 0 ? value : "All";
|
||||
}
|
||||
|
||||
function queryStatus(search: string): string {
|
||||
const value = new URLSearchParams(search).get("status");
|
||||
return value || "All";
|
||||
}
|
||||
|
||||
function queryReadiness(search: string): ReadinessFilter {
|
||||
const value = new URLSearchParams(search).get("readiness");
|
||||
return value === "needs-work" || value === "interview" ? value : "all";
|
||||
}
|
||||
|
||||
function querySort(search: string): JobSortKey {
|
||||
const value = new URLSearchParams(search).get("sortBy") as JobSortKey | null;
|
||||
return value && JOB_SORT_KEYS.includes(value) ? value : "dateApplied";
|
||||
}
|
||||
|
||||
function parseTags(raw?: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
@@ -173,6 +150,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const { t, language } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
const location = useLocation();
|
||||
const routeQuery = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
|
||||
const navigate = useNavigate();
|
||||
const listRouteRef = useRef(`${location.pathname}${location.search}`);
|
||||
const restoredFocusJobIdRef = useRef<number | null>(null);
|
||||
@@ -183,24 +161,24 @@ 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 [search, setSearch] = useState(() => new URLSearchParams(location.search).get("q") ?? "");
|
||||
const [search, setSearch] = useState(routeQuery.q);
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || new URLSearchParams(location.search).get("includeDeleted") === "1");
|
||||
const [includeDeleted, setIncludeDeleted] = useState(() => mode === "trash" || routeQuery.includeDeleted);
|
||||
const [columnsAnchor, setColumnsAnchor] = useState<null | HTMLElement>(null);
|
||||
const [statusFilter, setStatusFilter] = useState(() => queryStatus(location.search));
|
||||
const [locationFilter, setLocationFilter] = useState(() => new URLSearchParams(location.search).get("location") ?? "");
|
||||
const [statusFilter, setStatusFilter] = useState(routeQuery.status);
|
||||
const [locationFilter, setLocationFilter] = useState(routeQuery.location);
|
||||
const debouncedLocation = useDebouncedValue(locationFilter, 250);
|
||||
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(() => new URLSearchParams(location.search).get("needsFollowUp") === "1");
|
||||
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>(() => queryReadiness(location.search));
|
||||
const [needsFollowUpOnly, setNeedsFollowUpOnly] = useState(routeQuery.needsFollowUp);
|
||||
const [readinessFilter, setReadinessFilter] = useState<ReadinessFilter>(routeQuery.readiness ?? "all");
|
||||
const { companies, error: companiesError, reload: reloadCompanies } = useCompanies();
|
||||
const [companyFilterId, setCompanyFilterId] = useState<number | "All">(() => queryCompany(location.search));
|
||||
const [companyFilterId, setCompanyFilterId] = useState<number | "All">(routeQuery.companyId ?? "All");
|
||||
const [editJobId, setEditJobId] = useState<number | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
|
||||
const [bulkStatusAnchor, setBulkStatusAnchor] = useState<null | HTMLElement>(null);
|
||||
const [statusJobId, setStatusJobId] = useState<number | null>(null);
|
||||
const [sortBy, setSortBy] = useState<JobSortKey>(() => querySort(location.search));
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => new URLSearchParams(location.search).get("sortDir") === "asc" ? "asc" : "desc");
|
||||
const [sortBy, setSortBy] = useState<JobSortKey>(routeQuery.sortBy);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(routeQuery.sortDir);
|
||||
|
||||
const updateListRoute = useCallback((updates: Record<string, string | null>) => {
|
||||
setSelectedIds([]);
|
||||
@@ -219,18 +197,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams(location.search);
|
||||
setSearch(next.get("q") ?? "");
|
||||
setStatusFilter(queryStatus(location.search));
|
||||
setCompanyFilterId(queryCompany(location.search));
|
||||
setLocationFilter(next.get("location") ?? "");
|
||||
setNeedsFollowUpOnly(next.get("needsFollowUp") === "1");
|
||||
setReadinessFilter(queryReadiness(location.search));
|
||||
setIncludeDeleted(mode === "trash" || next.get("includeDeleted") === "1");
|
||||
setSortBy(querySort(location.search));
|
||||
setSortDir(next.get("sortDir") === "asc" ? "asc" : "desc");
|
||||
setSearch(routeQuery.q);
|
||||
setStatusFilter(routeQuery.status);
|
||||
setCompanyFilterId(routeQuery.companyId ?? "All");
|
||||
setLocationFilter(routeQuery.location);
|
||||
setNeedsFollowUpOnly(routeQuery.needsFollowUp);
|
||||
setReadinessFilter(routeQuery.readiness ?? "all");
|
||||
setIncludeDeleted(mode === "trash" || routeQuery.includeDeleted);
|
||||
setSortBy(routeQuery.sortBy);
|
||||
setSortDir(routeQuery.sortDir);
|
||||
setPage(queryPage(location.search));
|
||||
}, [location.search, mode]);
|
||||
}, [location.search, routeQuery, mode]);
|
||||
|
||||
const changeSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
|
||||
@@ -23,6 +23,7 @@ import ViewStateNotice from "./ViewStateNotice";
|
||||
import { JobApplication } from "../types";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { jobsQueryFromSearch } from "../jobsQuery";
|
||||
import {
|
||||
GROUP_ENTRY_STATUS,
|
||||
PIPELINE_GROUPS,
|
||||
@@ -77,8 +78,10 @@ export default function KanbanBoard() {
|
||||
const palette = theme.vars?.palette ?? theme.palette;
|
||||
const { t, language } = useI18n();
|
||||
const location = useLocation();
|
||||
const params = useMemo(() => jobsQueryFromSearch(location.search), [location.search]);
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const focusAfterMoveRef = useRef<number | null>(null);
|
||||
const [focusAfterMoveId, setFocusAfterMoveId] = useState<number | null>(null);
|
||||
const restoredFocusRef = useRef<number | null>(null);
|
||||
const [dragJobId, setDragJobId] = useState<number | null>(null);
|
||||
const [dragOverColumn, setDragOverColumn] = useState<ColumnKey | null>(null);
|
||||
@@ -89,29 +92,31 @@ export default function KanbanBoard() {
|
||||
|
||||
const jobsResource = useViewResource(
|
||||
async () => {
|
||||
const response = await api.get<JobApplication[]>("/jobapplications/board");
|
||||
const response = await api.get<JobApplication[]>("/jobapplications/board", { params });
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
initialData: [],
|
||||
errorMessage: t("kanbanLoadFailed"),
|
||||
deps: [t],
|
||||
deps: [t, params],
|
||||
},
|
||||
);
|
||||
|
||||
const jobs = jobsResource.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (jobsResource.loading || jobsResource.error) return;
|
||||
if (jobsResource.loading || jobsResource.refreshing || jobsResource.error) return;
|
||||
const returnId = window.history.state?.jobBoardFocusId ?? (location.state as { focusJobId?: unknown } | null)?.focusJobId;
|
||||
const targetId = focusAfterMoveRef.current ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
|
||||
const targetId = focusAfterMoveId ?? (typeof returnId === "number" && restoredFocusRef.current !== returnId ? returnId : null);
|
||||
if (targetId == null) return;
|
||||
const card = document.querySelector<HTMLElement>(`[data-kanban-card-id="${targetId}"] [role="button"]`);
|
||||
if (!card) return;
|
||||
card.focus();
|
||||
focusAfterMoveRef.current = null;
|
||||
// A successful move may leave the filtered population. Keep keyboard context in the board.
|
||||
const target = card ?? (focusAfterMoveId != null ? boardRef.current?.querySelector<HTMLElement>('[role="group"]') : null);
|
||||
if (!target) return;
|
||||
target.focus();
|
||||
setFocusAfterMoveId(null);
|
||||
restoredFocusRef.current = targetId;
|
||||
}, [jobs, jobsResource.loading, jobsResource.error, location.state]);
|
||||
}, [jobs, jobsResource.loading, jobsResource.refreshing, jobsResource.error, location.state, focusAfterMoveId]);
|
||||
|
||||
const openJob = (id: number) => {
|
||||
window.history.replaceState({ ...window.history.state, jobBoardFocusId: id }, "");
|
||||
@@ -151,8 +156,8 @@ export default function KanbanBoard() {
|
||||
setMoveError("");
|
||||
try {
|
||||
await api.patch(`/jobapplications/${movingJobId}/status`, { status });
|
||||
focusAfterMoveRef.current = movingJobId;
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
|
||||
await jobsResource.reload();
|
||||
setFocusAfterMoveId(movingJobId);
|
||||
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
||||
} catch (error: any) {
|
||||
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
||||
@@ -165,8 +170,8 @@ export default function KanbanBoard() {
|
||||
setMoveError("");
|
||||
try {
|
||||
await api.patch(`/jobapplications/${id}/status`, { status });
|
||||
focusAfterMoveRef.current = id;
|
||||
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
|
||||
await jobsResource.reload();
|
||||
setFocusAfterMoveId(id);
|
||||
setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
|
||||
} catch (error: any) {
|
||||
setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
|
||||
@@ -192,27 +197,27 @@ export default function KanbanBoard() {
|
||||
}, [groups, t]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
|
||||
<Box ref={boardRef} sx={{ mt: 2, width: "100%", minWidth: 0, maxWidth: "100%", overflowX: "clip", contain: "inline-size" }}>
|
||||
<Box sx={{ display: "flex", alignItems: { xs: "stretch", sm: "center" }, justifyContent: "space-between", gap: 1.5, flexDirection: { xs: "column", sm: "row" }, mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("kanbanHint")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("kanbanSeparateViews")}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexShrink: 0 }}>
|
||||
<Button component={RouterLink} to="/jobs" variant="outlined" size="small">{t("jobApplications")}</Button>
|
||||
<Button component={RouterLink} to={{ pathname: "/jobs", search: location.search }} variant="outlined" size="small">{t("jobApplications")}</Button>
|
||||
<Button variant="contained" size="small" aria-current="page" disabled>{t("kanbanBoard")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ViewStateNotice
|
||||
loading={jobsResource.loading}
|
||||
loading={jobsResource.loading || jobsResource.refreshing}
|
||||
error={jobsResource.error}
|
||||
title={t("kanbanLoadFailed")}
|
||||
description={t("kanbanLoadFailedBody")}
|
||||
onRetry={jobsResource.reload}
|
||||
/>
|
||||
|
||||
{!jobsResource.loading && !jobsResource.error ? (
|
||||
{!jobsResource.loading && !jobsResource.refreshing && !jobsResource.error ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", lg: "grid" },
|
||||
|
||||
@@ -1541,7 +1541,7 @@ export const translations = {
|
||||
adminUsersSafetyNotice: "The final administrator is protected. Removing your own administrator role requires explicit confirmation.",
|
||||
adminUsersPassword: "Password",
|
||||
kanbanHint: "Drag cards between columns to move a job forward. Use the card menu to set an exact stage.",
|
||||
kanbanSeparateViews: "The board shows all tracked jobs. Table filters do not apply here yet.",
|
||||
kanbanSeparateViews: "The board shows all jobs matching this page’s query. Table and Board remain separate views.",
|
||||
kanbanDropHere: "Drop here",
|
||||
kanbanEmptyColumn: "No jobs in this group yet.",
|
||||
kanbanColumnLabel: "{label} column",
|
||||
@@ -4007,7 +4007,7 @@ export const translations = {
|
||||
adminUsersSafetyNotice: "Den siste administratoren er beskyttet. Fjerning av din egen administratorrolle krever uttrykkelig bekreftelse.",
|
||||
adminUsersPassword: "Passord",
|
||||
kanbanHint: "Dra kort mellom kolonnene for å flytte en jobb videre. Bruk kortmenyen for å sette et eksakt trinn.",
|
||||
kanbanSeparateViews: "Tavlen viser alle lagrede jobber. Tabellfiltre gjelder ikke her ennå.",
|
||||
kanbanSeparateViews: "Tavlen viser alle jobber som samsvarer med sidens søk og filtre. Tabell og tavle er fortsatt separate visninger.",
|
||||
kanbanDropHere: "Slipp her",
|
||||
kanbanEmptyColumn: "Ingen jobber i denne gruppen ennå.",
|
||||
kanbanColumnLabel: "Kolonne: {label}",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// The existing Jobs URL vocabulary, shared by the separate Table and Board routes.
|
||||
// Pagination is Table-only; neither view may reinterpret the logical filters.
|
||||
export type JobSortKey = "dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location";
|
||||
export type ReadinessFilter = "all" | "needs-work" | "interview";
|
||||
const sortKeys: JobSortKey[] = ["dateApplied", "company", "jobTitle", "status", "daysSince", "location"];
|
||||
|
||||
export function jobsQueryFromSearch(search: string) {
|
||||
const query = new URLSearchParams(search);
|
||||
const company = Number(query.get("companyId"));
|
||||
const readiness = query.get("readiness");
|
||||
const sort = query.get("sortBy") as JobSortKey | null;
|
||||
return {
|
||||
q: query.get("q") ?? "",
|
||||
status: query.get("status") || "All",
|
||||
companyId: Number.isInteger(company) && company > 0 ? company : undefined,
|
||||
location: query.get("location") ?? "",
|
||||
needsFollowUp: query.get("needsFollowUp") === "1",
|
||||
readiness: readiness === "needs-work" || readiness === "interview" ? readiness as Exclude<ReadinessFilter, "all"> : undefined,
|
||||
includeDeleted: query.get("includeDeleted") === "1",
|
||||
sortBy: sort && sortKeys.includes(sort) ? sort : "dateApplied" as JobSortKey,
|
||||
sortDir: query.get("sortDir") === "asc" ? "asc" as const : "desc" as const,
|
||||
};
|
||||
}
|
||||
@@ -34,11 +34,11 @@ function job(id: number, jobTitle: string, status: string, overrides: Partial<Jo
|
||||
} as JobApplication;
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
function renderBoard(entry = '/kanban?source=board') {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter initialEntries={['/kanban?source=board']}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/kanban" element={<KanbanBoard />} />
|
||||
<Route path="/jobs/:id" element={<div role="main">Job workspace <RouteState /></div>} />
|
||||
@@ -201,8 +201,8 @@ test('full-board population keeps jobs beyond the table page and offers a separa
|
||||
renderBoard();
|
||||
expect(await screen.findByText('Role 26')).toBeVisible();
|
||||
expect(screen.getAllByRole('button', { name: /Press Space to pick up/ })).toHaveLength(27);
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/board');
|
||||
expect(screen.getByRole('link', { name: /Job Applications/ })).toHaveAttribute('href', '/jobs');
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/board', expect.objectContaining({ params: expect.objectContaining({ includeDeleted: false }) }));
|
||||
expect(screen.getByRole('link', { name: /Job Applications/ })).toHaveAttribute('href', '/jobs?source=board');
|
||||
});
|
||||
|
||||
test('keyboard Escape cancels movement without a write and successful movement restores card focus', async () => {
|
||||
@@ -238,3 +238,39 @@ test('keyboard activation of the exact-status menu does not open the job', async
|
||||
expect(await screen.findByRole('menu')).toBeVisible();
|
||||
expect(screen.queryByText('Job workspace')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Board sends the shared logical URL query, excluding Table paging and client ownership fields', async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||
renderBoard('/kanban?q=Board&status=Custom%20review&companyId=7&location=Troms%C3%B8&needsFollowUp=1&readiness=interview&includeDeleted=1&sortBy=jobTitle&sortDir=asc&page=3&pageSize=999&userId=other');
|
||||
await screen.findByRole('group', { name: 'Not Applied column' });
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/board', { params: {
|
||||
q: 'Board', status: 'Custom review', companyId: 7, location: 'Tromsø', needsFollowUp: true,
|
||||
readiness: 'interview', includeDeleted: true, sortBy: 'jobTitle', sortDir: 'asc',
|
||||
} });
|
||||
expect(screen.getByRole('link', { name: /Job Applications/ })).toHaveAttribute('href', expect.stringContaining('q=Board&status=Custom%20review'));
|
||||
});
|
||||
|
||||
test('a successful filtered move reloads membership and focuses the board when the job leaves it', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [job(11, 'Filtered Role', 'Saved')] } as any)
|
||||
.mockResolvedValueOnce({ data: [] } as any);
|
||||
renderBoard('/kanban?status=Saved');
|
||||
fireEvent.keyDown(await screen.findByRole('button', { name: /^Filtered Role/ }), { key: ' ' });
|
||||
fireEvent.keyDown(screen.getByRole('group', { name: 'Active column' }), { key: 'Enter' });
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.queryByText('Filtered Role')).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('group', { name: 'Not Applied column' })).toHaveFocus());
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/jobapplications/board', { params: expect.objectContaining({ status: 'Saved' }) });
|
||||
});
|
||||
|
||||
test('a committed move followed by a failed query hides stale membership until retry succeeds', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({ data: [job(11, 'Filtered Role', 'Saved')] } as any)
|
||||
.mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ data: [] } as any);
|
||||
renderBoard('/kanban?status=Saved');
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Quick status: Filtered Role' }));
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Set Applied' }));
|
||||
expect(await screen.findByText('Unable to load the kanban board')).toBeVisible();
|
||||
expect(screen.queryByText('Filtered Role')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeVisible();
|
||||
expect(mockedApi.patch).toHaveBeenCalledTimes(1); // retry only the read, never replay the write
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user