feat(jobs): embed route-backed workspace
CI and Deploy / test (pull_request) Successful in 4m34s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-10 11:28:01 +02:00
parent a54b70960c
commit b67a531af4
9 changed files with 303 additions and 19 deletions
@@ -0,0 +1,147 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { api } from "./api";
import JobTable from "./components/JobTable";
import { ConfirmProvider } from "./confirm";
import { I18nProvider } from "./i18n/I18nProvider";
import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
jest.mock("./components/Attachments", () => () => <div>Documents section</div>);
jest.mock("./components/Correspondence", () => () => <div>Communication section</div>);
jest.mock("./components/AiWorkspacePanel", () => () => <div>AI panel</div>);
jest.mock("./components/ApplicationChecklist", () => () => <div>Checklist section</div>);
jest.mock("./components/ApplicationIntelligence", () => ({
ApplicationAnalysis: () => <div>Analysis section</div>,
ApplicationMatch: () => <div>Match section</div>,
ApplicationTimeline: () => <div>Timeline section</div>,
}));
jest.mock("./components/ApplicationAssets", () => ({
ApplicationCoverLetterSection: () => <div>Cover letter section</div>,
ApplicationCvSection: () => <div>CV section</div>,
}));
jest.mock("./components/InterviewPrep", () => ({ ApplicationInterviewPrep: () => <div>Interview section</div> }));
const mockedApi = api as jest.Mocked<typeof api>;
const job = {
id: 42,
jobTitle: "Backend Developer",
company: { id: 1, name: "Acme" },
companyId: 1,
status: "Waiting",
dateApplied: "2026-08-01T00:00:00Z",
savedAt: "2026-08-01T00:00:00Z",
location: "Oslo",
description: "Build APIs",
daysSince: 9,
isDeleted: false,
needsFollowUp: false,
workflowSignal: null,
};
const overview = {
id: 42,
jobTitle: "Backend Developer",
company: "Acme",
location: "Oslo",
salary: null,
status: "Waiting",
stageGroup: "Applied",
stageOrder: 1,
dateApplied: "2026-08-01T00:00:00Z",
deadline: null,
followUpAt: null,
nextAction: null,
jobUrl: null,
hasJobDescription: true,
cv: { variantId: null, variantName: null, themeId: null, hasTailoredCvText: false, updatedAtUtc: null },
hasCoverLetter: false,
documentCount: 0,
hasPortfolio: false,
aiInteractionCount: 0,
lastAiAtUtc: null,
recentActivity: [],
nextStep: null,
checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 },
};
function LocationControls() {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate(1)}>Browser forward</button>
</>
);
}
function renderTable(path = "/jobs") {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<MemoryRouter initialEntries={[path]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<LocationControls />
<Routes>
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} />} />
</Routes>
</MemoryRouter>
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === "/companies") return Promise.resolve({ data: [{ id: 1, name: "Acme" }] } as any);
if (url === "/jobapplications") return Promise.resolve({ data: { items: [job], total: 1, page: 1, pageSize: 15 } } as any);
if (url === "/jobapplications/42/workspace") return Promise.resolve({ data: overview } as any);
return Promise.resolve({ data: [] } as any);
});
});
afterEach(() => jest.clearAllMocks());
test("opens the workspace in a route-backed overlay and preserves list state through Back/Forward", async () => {
renderTable();
const search = await screen.findByRole("textbox", { name: /search/i });
fireEvent.change(search, { target: { value: "backend" } });
fireEvent.click(await screen.findByRole("button", { name: /open: backend developer/i }));
await screen.findByRole("dialog", { name: /application workspace/i });
expect(screen.getByTestId("location")).toHaveTextContent("/jobs?workspace=42");
expect(screen.getByRole("link", { name: /open full-page workspace/i })).toHaveAttribute("href", "/applications/42?section=overview");
fireEvent.click(screen.getByRole("button", { name: "Match" }));
expect(await screen.findByText("Match section")).toBeInTheDocument();
expect(screen.getByTestId("location")).toHaveTextContent("/jobs?workspace=42&section=match");
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.queryByRole("dialog", { name: /application workspace/i })).not.toBeInTheDocument());
expect(screen.getByTestId("location")).toHaveTextContent("/jobs");
expect(screen.getByRole("textbox", { name: /search/i })).toHaveValue("backend");
fireEvent.click(screen.getByRole("button", { name: /browser forward/i }));
await screen.findByRole("dialog", { name: /application workspace/i });
expect(screen.getByTestId("location")).toHaveTextContent("/jobs?workspace=42&section=match");
});
test("opens a direct workspace URL and closes it without inventing browser history", async () => {
renderTable("/jobs?workspace=42&section=match");
await screen.findByRole("dialog", { name: /application workspace/i });
expect(await screen.findByText("Match section")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /back to applications/i }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/jobs"));
await waitFor(() => expect(screen.queryByRole("dialog", { name: /application workspace/i })).not.toBeInTheDocument());
});
+58 -3
View File
@@ -7,6 +7,8 @@ import {
Checkbox,
Chip,
Collapse,
Dialog,
DialogContent,
FormControl,
FormControlLabel,
IconButton,
@@ -56,6 +58,8 @@ import { useI18n } from "../i18n/I18nProvider";
import { JobApplication } from "../types";
import { useViewResource } from "../hooks/useViewResource";
import { getWorkflowAction, needsInterviewPrep, needsWorkflowWork } from "../jobWorkflowSignals";
import { ApplicationWorkspace } from "../views/ApplicationWorkspacePage";
import { workspaceSection, WorkspaceSectionKey } from "../applicationWorkspace";
interface PagedResult<T> {
items: T[];
@@ -179,6 +183,37 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const [statusJobId, setStatusJobId] = useState<number | null>(null);
const [sortBy, setSortBy] = useState<"dateApplied" | "company" | "jobTitle" | "status" | "daysSince" | "location">("dateApplied");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
const workspaceJobId = Number(searchParams.get("workspace")) || null;
const workspaceSectionKey = workspaceSection(searchParams.get("section"));
const updateWorkspaceRoute = (jobId: number, section: WorkspaceSectionKey = "overview") => {
const next = new URLSearchParams(location.search);
next.set("workspace", String(jobId));
if (section === "overview") next.delete("section");
else next.set("section", section);
navigate({ pathname: location.pathname, search: `?${next.toString()}` }, { state: { workspaceOverlay: true } });
};
const updateWorkspaceSection = (section: WorkspaceSectionKey) => {
if (!workspaceJobId) return;
const next = new URLSearchParams(location.search);
next.set("workspace", String(workspaceJobId));
if (section === "overview") next.delete("section");
else next.set("section", section);
navigate({ pathname: location.pathname, search: `?${next.toString()}` }, { replace: true, state: location.state });
};
const closeWorkspace = () => {
if (location.state?.workspaceOverlay) {
navigate(-1);
return;
}
const next = new URLSearchParams(location.search);
next.delete("workspace");
next.delete("section");
navigate({ pathname: location.pathname, search: next.toString() ? `?${next.toString()}` : "" }, { replace: true });
};
const params = useMemo(() => ({
page: page + 1,
@@ -655,7 +690,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Button variant="outlined" startIcon={<MoreHorizIcon />} onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableQuickStatus")}
</Button>
<Button variant="outlined" startIcon={<LaunchIcon />} onClick={() => setDetailsJobId(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
<Button variant="outlined" startIcon={<LaunchIcon />} onClick={() => updateWorkspaceRoute(job.id)} sx={{ minHeight: 42, fontWeight: 700 }}>
{t("jobTableOpen")}
</Button>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? (
@@ -744,7 +779,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 0.5 }}>
<Tooltip title={t("jobTableEdit")}><IconButton size="small" onClick={() => setEditJobId(job.id)}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableQuickStatus")}><IconButton size="small" onClick={(e) => { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}><MoreHorizIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableOpen")}><IconButton size="small" onClick={() => setDetailsJobId(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title={t("jobTableOpen")}><IconButton size="small" aria-label={`${t("jobTableOpen")}: ${job.jobTitle}`} onClick={() => updateWorkspaceRoute(job.id)}><LaunchIcon fontSize="small" /></IconButton></Tooltip>
{(mode === "trash" || (includeDeleted && job.isDeleted)) ? <Tooltip title={t("jobTableRestore")}><IconButton size="small" onClick={() => void restore(job.id)}><RestoreFromTrashOutlinedIcon fontSize="small" /></IconButton></Tooltip> : <Tooltip title={t("jobTableSoftDelete")}><IconButton size="small" onClick={() => void softDelete(job)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>}
</Box>
</Box>
@@ -776,7 +811,27 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
</Paper>
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); navigate(`/applications/${id}`); }} />
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} initialFollowUpMode={detailsFollowUpMode} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); setDetailsFollowUpMode(undefined); }} onOpenWorkspace={(id) => { setDetailsJobId(null); updateWorkspaceRoute(id); }} />
<Dialog
open={workspaceJobId !== null}
onClose={closeWorkspace}
fullScreen={isMobile}
fullWidth
maxWidth="xl"
slotProps={{ paper: { "aria-label": "Application workspace" } }}
>
<DialogContent sx={{ p: { xs: 1.5, sm: 2.5 } }}>
{workspaceJobId ? (
<ApplicationWorkspace
jobIdOverride={workspaceJobId}
sectionOverride={workspaceSectionKey}
onSectionChange={updateWorkspaceSection}
onClose={closeWorkspace}
fullPageHref={`/applications/${workspaceJobId}?section=${workspaceSectionKey}`}
/>
) : null}
</DialogContent>
</Dialog>
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
{statusOptions.map((status) => <MenuItem key={status} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, status); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status })}</MenuItem>)}
@@ -36,11 +36,29 @@ import {
// reuses the component that already owns that domain (Attachments, Correspondence, AiWorkspacePanel).
// docs/architecture/application-workspace.md.
export default function ApplicationWorkspacePage() {
return <ApplicationWorkspace />;
}
type ApplicationWorkspaceProps = {
jobIdOverride?: number;
sectionOverride?: WorkspaceSectionKey;
onSectionChange?: (section: WorkspaceSectionKey) => void;
onClose?: () => void;
fullPageHref?: string;
};
export function ApplicationWorkspace({
jobIdOverride,
sectionOverride,
onSectionChange,
onClose,
fullPageHref,
}: ApplicationWorkspaceProps) {
const { id } = useParams();
const jobId = Number(id);
const jobId = jobIdOverride ?? Number(id);
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const section = workspaceSection(params.get("section"));
const section = sectionOverride ?? workspaceSection(params.get("section"));
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -57,12 +75,16 @@ export default function ApplicationWorkspacePage() {
load();
}, [load]);
const go = (next: WorkspaceSectionKey) => setParams({ section: next }, { replace: true });
const go = (next: WorkspaceSectionKey) => {
if (onSectionChange) onSectionChange(next);
else setParams({ section: next }, { replace: true });
};
const close = onClose ?? (() => navigate("/jobs"));
if (error) {
return (
<Box sx={{ p: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/jobs")}>Back to applications</Button>
<Button startIcon={<ArrowBackIcon />} onClick={close}>Back to applications</Button>
<Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>
</Box>
);
@@ -73,13 +95,28 @@ export default function ApplicationWorkspacePage() {
<Paper sx={{ p: 1, borderRadius: 3, position: { md: "sticky" }, top: 12 }}>
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1, py: 0.5 }}>
<Tooltip title="Back to applications">
<IconButton size="small" aria-label="Back to applications" onClick={() => navigate("/jobs")}>
<IconButton size="small" aria-label="Back to applications" onClick={close}>
<ArrowBackIcon fontSize="small" />
</IconButton>
</Tooltip>
<Typography variant="caption" sx={{ fontWeight: 800, letterSpacing: ".08em", textTransform: "uppercase", color: "text.secondary" }}>
Workspace
</Typography>
{fullPageHref ? (
<Tooltip title="Open full-page workspace">
<IconButton
component="a"
href={fullPageHref}
target="_blank"
rel="noopener noreferrer"
size="small"
aria-label="Open full-page workspace"
sx={{ ml: "auto" }}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null}
</Stack>
<List dense component="nav" aria-label="Workspace sections">
{WORKSPACE_SECTIONS.map((s) => (