feat(jobs): embed route-backed workspace
This commit is contained in:
@@ -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§ion=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§ion=match");
|
||||
});
|
||||
|
||||
test("opens a direct workspace URL and closes it without inventing browser history", async () => {
|
||||
renderTable("/jobs?workspace=42§ion=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());
|
||||
});
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user