fix(workspace): clear native package and navigation readiness gates

This commit is contained in:
cesnimda
2026-10-07 00:30:10 +02:00
parent fadf99a3f5
commit acd1d94684
36 changed files with 1551 additions and 40 deletions
@@ -268,6 +268,8 @@ test("[PRES-W06] rejecting an AI cover letter preserves the saved draft without
const onDirtyChange = jest.fn();
render(<ApplicationCoverLetterSection jobId={7} onDirtyChange={onDirtyChange} />);
await screen.findByText(/application's full job advert and analysis/i);
expect(screen.getByText(/Using Backend CV and this application's full job advert and analysis/i)).toBeInTheDocument();
expect(screen.queryByText(/\{name\}/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Generate" }));
await screen.findByText("Unaccepted letter");
expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team");
@@ -319,3 +319,41 @@ test("uses recorded dates in the summary and keeps absent dates unknown", async
expect(within(summary).getAllByText("—")).toHaveLength(2);
expect(screen.queryByRole("link", { name: "Open original advert" })).not.toBeInTheDocument();
});
test("section changes retain follow-up query context and expose the selected labelled panel", async () => {
renderTable("/jobs/42?section=communication&followMode=waiting-update");
await screen.findByRole("heading", { name: "Backend Developer" });
fireEvent.click(screen.getByRole("tab", { name: "Analysis" }));
expect(await screen.findByRole("tabpanel", { name: "Analysis" })).toHaveTextContent("Match section");
expect(screen.getByTestId("location")).toHaveTextContent("/jobs/42?section=analysis&followMode=waiting-update");
const tab = screen.getByRole("tab", { name: "Analysis" });
expect(document.getElementById(tab.getAttribute("aria-controls")!)).toBe(screen.getByRole("tabpanel", { name: "Analysis" }));
});
test("accordion controls identify exactly one labelled content region", async () => {
renderTable("/jobs/42");
await screen.findByRole("heading", { name: "Backend Developer" });
const trigger = screen.getByRole("button", { name: "Documents" });
fireEvent.click(trigger);
const target = trigger.getAttribute("aria-controls")!;
expect(document.querySelectorAll(`[id='${target}']`)).toHaveLength(1);
expect(document.getElementById(target)).toHaveAttribute("role", "region");
expect(document.getElementById(target)).toHaveAttribute("aria-labelledby", trigger.id);
});
test("legacy document links reveal their existing content and focus the disclosure", async () => {
renderTable("/jobs/42?section=documents");
const trigger = await screen.findByRole("button", { name: "Documents" });
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
expect(trigger).toHaveFocus();
expect(screen.getByRole("region", { name: "Documents" })).toHaveTextContent("Documents section");
});
test("the missing-profile next task opens the existing canonical profile entry", async () => {
mockedApi.get.mockResolvedValue({ data: { ...overview, nextStep: { key: "complete-career-profile", label: "Complete your career profile", reason: "Canonical data", section: "checklist" } } } as any);
renderTable("/jobs/42");
fireEvent.click(await screen.findByRole("button", { name: "Complete your career profile" }));
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/career?section=profile"));
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
});
@@ -7,4 +7,9 @@ test("workspace navigation exposes five purposes and preserves legacy links", ()
expect(workspaceSection("match")).toBe("analysis");
expect(workspaceSection("timeline")).toBe("overview");
expect(workspaceSection("portfolio")).toBe("overview");
expect(workspaceSection("notes")).toBe("cover-letter");
for (const alias of ["job-details", "checklist", "documents", "communication"]) expect(workspaceSection(alias)).toBe("overview");
expect(workspaceSection("unknown-section")).toBe("overview");
expect(workspaceSection("constructor")).toBe("overview");
expect(workspaceSection("__proto__")).toBe("overview");
});
+3 -1
View File
@@ -101,11 +101,13 @@ const LEGACY_WORKSPACE_SECTIONS: Record<string, WorkspaceSectionKey> = {
documents: "overview",
timeline: "overview",
communication: "overview",
portfolio: "overview",
notes: "cover-letter",
};
/** Preserve old shared/bookmarked workspace links while keeping one clear primary navigation. */
export const workspaceSection = (value: string | null): WorkspaceSectionKey => {
if (value && value in LEGACY_WORKSPACE_SECTIONS) return LEGACY_WORKSPACE_SECTIONS[value];
if (value && Object.prototype.hasOwnProperty.call(LEGACY_WORKSPACE_SECTIONS, value)) return LEGACY_WORKSPACE_SECTIONS[value];
return WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
};
@@ -603,7 +603,7 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
<Alert severity="info">{t("coverAiSelectCv")}</Alert>
) : (
<Alert severity="success" variant="outlined" sx={{ py: 0.5 }}>
{t("coverAiUsing")} <strong>{cv?.attachedVariantName}</strong>
{t("coverAiUsing", { name: cv?.attachedVariantName ?? "" })}
</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
@@ -126,7 +126,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
direction="row"
alignItems="flex-start"
spacing={1}
sx={{ py: 0.75, borderRadius: 2, "&:hover .checklist-actions": { opacity: 1 } }}
sx={{ py: 0.75, borderRadius: 2, "&:hover .checklist-actions, &:focus-within .checklist-actions": { opacity: 1 } }}
>
<Checkbox
size="small"
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import {
BlockerFunction, useBeforeUnload, useBlocker, useLocation, useNavigate, useParams, useSearchParams,
} from "react-router-dom";
@@ -75,6 +75,7 @@ export function ApplicationWorkspace({
const { confirm } = useConfirm();
const [params, setParams] = useSearchParams();
const section = sectionOverride ?? workspaceSection(params.get("section"));
const sectionId = useId();
const [overview, setOverview] = useState<WorkspaceOverview | null>(null);
const [error, setError] = useState<ViewResourceError | null>(null);
@@ -139,9 +140,17 @@ export function ApplicationWorkspace({
load();
}, [load]);
const go = (next: WorkspaceSectionKey) => {
if (onSectionChange) onSectionChange(next);
else setParams({ section: next }, { replace: true, state: location.state });
const go = (next: string) => {
if (next === "career-profile") {
navigate("/career?section=profile");
return;
}
if (onSectionChange) onSectionChange(workspaceSection(next));
else setParams((current) => {
const updated = new URLSearchParams(current);
updated.set("section", next);
return updated;
}, { state: location.state });
};
const close = onClose ?? (() => {
const state = location.state as { from?: unknown; focusJobId?: unknown } | null;
@@ -200,11 +209,11 @@ export function ApplicationWorkspace({
aria-label={t("workspaceSections")}
sx={{ px: { xs: 0.5, sm: 1.5 }, borderTop: 1, borderColor: "divider", minHeight: 46 }}
>
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
{WORKSPACE_SECTIONS.map((s) => <Tab key={s.key} id={`${sectionId}-${s.key}`} aria-controls={`${sectionId}-panel`} value={s.key} label={workspaceSectionLabel(t, s.key)} sx={{ minHeight: 46, fontWeight: 700 }} />)}
</Tabs>
</Paper>
<Box sx={{ display: "grid", gap: 2, minWidth: 0 }}>
<Box role="tabpanel" id={`${sectionId}-panel`} aria-labelledby={`${sectionId}-${section}`} tabIndex={0} sx={{ display: "grid", gap: 2, minWidth: 0 }}>
{section === "overview" && jobId > 0 && <ApplicationStatusSuggestion jobId={jobId} onApplied={load} />}
{section === "overview" && <OverviewSection overview={overview} onGo={go} onReload={load} onEdit={() => setEditOpen(true)} />}
{section === "analysis" && jobId > 0 && (
@@ -296,7 +305,7 @@ function ApplicationProgress({ status }: { status: string }) {
function OverviewSection({ overview, onGo, onReload, onEdit }: {
overview: WorkspaceOverview | null;
onGo: (s: WorkspaceSectionKey) => void;
onGo: (s: string) => void;
onReload: () => void;
onEdit: () => void;
}) {
@@ -304,9 +313,9 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
const stats = useMemo(() => overview ? [
{ icon: <DescriptionOutlinedIcon fontSize="small" />, label: t("workspaceCv"), value: overview.cv.variantName ?? (overview.cv.hasTailoredCvText ? t("workspaceTailoredText") : t("workspaceNotPrepared")), ok: !!overview.cv.variantId || overview.cv.hasTailoredCvText, go: "cv" as const },
{ icon: <MailOutlineIcon fontSize="small" />, label: t("jobDetailsCoverLetter"), value: overview.hasCoverLetter ? t("workspaceReady") : t("workspaceNotWritten"), ok: overview.hasCoverLetter, go: "cover-letter" as const },
{ icon: <FolderOutlinedIcon fontSize="small" />, label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "overview" as const },
{ icon: <FolderOutlinedIcon fontSize="small" />, label: t("workspaceDocuments"), value: overview.documentCount ? t("workspaceAttachedCount", { count: overview.documentCount }) : t("assetsNone"), ok: overview.documentCount > 0, go: "documents" as const },
{ icon: <AutoFixHighIcon fontSize="small" />, label: t("workspaceAiSuggestions"), value: overview.aiInteractionCount ? t("workspaceSavedCount", { count: overview.aiInteractionCount }) : t("workspaceNoneYet"), ok: overview.aiInteractionCount > 0, go: "analysis" as const },
{ icon: <ChecklistIcon fontSize="small" />, label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "overview" as const },
{ icon: <ChecklistIcon fontSize="small" />, label: t("workspaceChecklist"), value: overview.checklistProgress ? t("workspaceDoneCount", { completed: overview.checklistProgress.completed, total: overview.checklistProgress.total }) : "—", ok: (overview.checklistProgress?.percent ?? 0) === 100, go: "checklist" as const },
] : [], [overview, t]);
if (!overview) {
@@ -341,7 +350,7 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 700 }}>{nextStepLabel}</Typography>
<Typography color="text.secondary" sx={{ mb: 1.5 }}>{nextStepReason}</Typography>
<Button variant="contained" endIcon={<ArrowForwardIcon />}
onClick={() => overview.nextStep?.section && onGo(overview.nextStep.section as WorkspaceSectionKey)}>
onClick={() => onGo(overview.nextStep?.key === "complete-career-profile" ? "career-profile" : overview.nextStep?.section ?? "checklist")}>
{nextStepLabel}
</Button>
</PageSection>
@@ -389,6 +398,18 @@ function OverviewSection({ overview, onGo, onReload, onEdit }: {
function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number; overview: WorkspaceOverview; onReload: () => void; onEdit: () => void }) {
const { t } = useI18n();
const [params] = useSearchParams();
const prefix = useId();
const triggers = useRef<Record<string, HTMLDivElement | null>>({});
const targets: Record<string, string> = { "job-details": "details", checklist: "tasks", documents: "documents", portfolio: "documents", timeline: "timeline", communication: "communication" };
const key = params.get("section") ?? "";
const target = Object.prototype.hasOwnProperty.call(targets, key) ? targets[key] : undefined;
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!target) return;
setExpanded((current) => ({ ...current, [target]: true }));
triggers.current[target]?.focus();
}, [target]);
const panels = [
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
@@ -400,11 +421,11 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
return (
<Box>
{panels.map((panel) => (
<Accordion key={panel.id} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${panel.id}-content`} id={`${panel.id}-header`}>
<Accordion key={panel.id} expanded={!!expanded[panel.id]} onChange={(_, open) => setExpanded((current) => ({ ...current, [panel.id]: open }))} disableGutters elevation={0} sx={{ border: 1, borderColor: "divider", "&:not(:last-child)": { borderBottom: 0 }, "&:before": { display: "none" } }}>
<AccordionSummary ref={(node: HTMLDivElement | null) => { triggers.current[panel.id] = node; }} expandIcon={<ExpandMoreIcon />} aria-controls={`${prefix}-${panel.id}-content`} id={`${prefix}-${panel.id}-header`}>
<Typography sx={{ fontWeight: 750 }}>{panel.title}</Typography>
</AccordionSummary>
<AccordionDetails id={`${panel.id}-content`} sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
<AccordionDetails sx={{ px: { xs: 1, sm: 2 }, pb: 2 }}>{panel.content}</AccordionDetails>
</Accordion>
))}
</Box>