fix(ui): reconcile shared accessibility and read recovery

This commit is contained in:
cesnimda
2026-10-20 20:26:48 +02:00
parent bba52af4b8
commit d8c0187305
25 changed files with 802 additions and 25 deletions
@@ -220,6 +220,34 @@ async function capture(page: Page, info: TestInfo, sample: Sample, state: string
expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
}
test('[P7-11 E08] Strategy failed reads remain distinct from exact not-generated state and retry only GET in both locales', async ({ page, session }, info) => {
const job = await createJob(page.request, session.owner, 'Recorded Strategy read recovery');
let unavailable = true;
await page.route(`**/api/jobapplications/${job.id}/focus-plan`, route => {
expect(route.request().method()).toBe('GET');
return route.fulfill(unavailable ? { status: 503, json: { title: 'Controlled unavailable Strategy read' } } : { status: 404, json: { code: 'strategy_not_generated' } });
});
for (const sample of [desktop, { language: 'nb', theme: 'dark', width: 320, height: 844 }] as Sample[]) {
unavailable = true;
await enter(page, session.owner, job, 'answers', sample);
await keyboard(page, page.getByRole('tablist', { name: translations[sample.language].workspaceSections, exact: true }).getByRole('tab', { name: translations[sample.language].workspaceMatch, exact: true }));
const t = translations[sample.language];
const error = page.getByRole('alert').filter({ hasText: t.workspaceStrategyLoadFailed });
await expect(error).toBeVisible();
await expect(page.getByText(t.workspaceStrategyEmpty, { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: t.workspaceStrategyProRequired, exact: true })).toBeDisabled();
await capture(page, info, sample, 'strategy-read-unavailable');
const before = count(session, 'GET', `/api/jobapplications/${job.id}/focus-plan`).length;
unavailable = false;
await keyboard(page, error.getByRole('button', { name: t.retry, exact: true }));
await expect(page.getByRole('heading', { name: t.workspaceStrategyTitle, level: 2, exact: true })).toBeFocused();
await expect(page.getByText(t.workspaceStrategyEmpty, { exact: true })).toBeVisible();
expect(count(session, 'GET', `/api/jobapplications/${job.id}/focus-plan`).length).toBe(before + 1);
await capture(page, info, sample, 'strategy-exact-not-generated');
}
expect(mutations(session)).toEqual([]);
});
test('[P7-06 forms] held CV read Retry preserves its request and actual attach/replace/detach remain owner scoped', async ({ page, session, playwright }, info) => {
test.setTimeout(120_000); await source(page.request, session.owner);
const job = await createJob(page.request, session.owner); const first = await variant(page.request, session.owner, 'First reusable CV'); const second = await variant(page.request, session.owner, 'Second reusable CV');
@@ -44,7 +44,8 @@ test("notification bell exposes its unread count and keyboard-accessible action"
expect(open.mock.calls[0][0]).toBeInstanceOf(HTMLElement);
});
test("shows build metadata only when the caller supplies the admin-only badge", () => {
test.each([["en", "Application version 2.4.1, commit abc1234"], ["nb", "Applikasjonsversjon 2.4.1, revisjon abc1234"]])("shows localized %s build metadata only when the caller supplies the admin-only badge", (language, label) => {
localStorage.setItem("uiLanguage", language);
const commonProps = {
pageTitle: "Dashboard",
breadcrumbs: ["Home"],
@@ -67,7 +68,8 @@ test("shows build metadata only when the caller supplies the admin-only badge",
);
expect(screen.getByTestId("admin-version-badge")).toHaveTextContent("v2.4.1");
expect(screen.getByLabelText("Application version 2.4.1, commit abc1234")).toBeInTheDocument();
expect(screen.getByLabelText(label)).toBeInTheDocument();
expect(screen.getByRole("img", { name: label })).toBe(screen.getByTestId("admin-version-badge"));
rerender(
<CssVarsProvider theme={getTheme("light") as any} defaultMode="light">
@@ -122,6 +122,20 @@ test("reordering sends the new id order", async () => {
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/checklist/order", [2, 1, 3]));
});
test("move and remove tooltips describe their wrappers without replacing item-specific button names", async () => {
mockedApi.patch.mockReturnValueOnce(new Promise(() => {}));
renderChecklist();
const down = await screen.findByRole("button", { name: "Move down: Prepare a CV for this role" });
for (const label of ["Move up: Prepare a CV for this role", "Move down: Prepare a CV for this role", "Remove: Prepare a CV for this role"]) {
expect(screen.getByRole("button", { name: label }).parentElement).not.toHaveAttribute("aria-label");
}
fireEvent.click(screen.getByRole("checkbox", { name: "Prepare a CV for this role" }));
expect(down).toBeDisabled();
fireEvent.mouseOver(down.parentElement!);
expect(await screen.findByRole("tooltip")).toHaveTextContent("Move down");
expect(mockedApi.put).not.toHaveBeenCalled();
});
test("removing an item calls delete", async () => {
mockedApi.delete.mockResolvedValue({ data: undefined } as any);
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { api } from './api';
import { AccountPlanProvider } from './accountPlan';
@@ -15,7 +15,7 @@ jest.mock('./api', () => ({
const mockedApi = api as jest.Mocked<typeof api>;
beforeEach(() => jest.clearAllMocks());
beforeEach(() => { jest.clearAllMocks(); localStorage.clear(); });
test('recruiter status suggestions require an explicit apply action', async () => {
const applied = jest.fn();
@@ -50,8 +50,56 @@ test('strategy snapshot shows saved output and queues regeneration only on reque
render(<I18nProvider><ToastProvider><AccountPlanProvider value={{ plan: 'pro', canUseAi: true, canUseProThemes: true }}><ApplicationStrategySnapshot jobId={42} /></AccountPlanProvider></ToastProvider></I18nProvider>);
expect(await screen.findByText('Lead with delivery evidence.')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Strategy snapshot', level: 2 })).toBeVisible();
expect(mockedApi.post).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Regenerate' }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/focus-plan/operations', { attachmentIds: null }));
expect(await screen.findByText('queued')).toBeInTheDocument();
});
function strategy(jobId = 42) {
return <I18nProvider><ToastProvider><AccountPlanProvider value={{ plan: 'free', canUseAi: false, canUseProThemes: false }}><ApplicationStrategySnapshot jobId={jobId} /></AccountPlanProvider></ToastProvider></I18nProvider>;
}
test('failed strategy reads stay distinct from empty, localize without refetching and retry only the read', async () => {
let finishOperation!: (result: any) => void;
mockedApi.get.mockImplementation((url: string) => url.endsWith('/operation') ? new Promise(resolve => { finishOperation = resolve; }) : Promise.reject({ response: { status: 503 } }));
render(strategy());
expect(await screen.findByText('Strategy snapshot could not be loaded.')).toBeVisible();
expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled();
await act(async () => finishOperation({ data: null }));
expect(screen.queryByText('No strategy snapshot yet. Generate one when you want AI-assisted planning.')).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
const calls = mockedApi.get.mock.calls.length;
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(await screen.findByText('Kunne ikke hente strategisammendraget.')).toBeVisible();
expect(mockedApi.get).toHaveBeenCalledTimes(calls);
mockedApi.get.mockResolvedValueOnce({ data: { strategicSummary: 'Saved plan', immediatePriorities: [], proofPointsToLeadWith: [], cvBulletIdeas: [], coverLetterAngles: [], followUpApproach: [] } } as any);
const retry = screen.getByRole('button', { name: 'Prøv igjen' });
act(() => retry.focus()); fireEvent.click(retry);
expect(await screen.findByDisplayValue('Saved plan')).toBeVisible();
expect(mockedApi.get.mock.calls.slice(calls).map(call => call[0])).toEqual(['/jobapplications/42/focus-plan']);
expect(screen.getByRole('heading', { name: 'Strategioversikt', level: 2 })).toHaveFocus();
expect(mockedApi.post).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Krever Pro' })).toBeDisabled();
});
test('only the explicit not-generated response is an intentional empty strategy', async () => {
mockedApi.get.mockRejectedValue({ response: { status: 404, data: { code: 'strategy_not_generated' } } });
render(strategy());
expect(await screen.findByText('No strategy snapshot yet. Generate one when you want AI-assisted planning.')).toBeVisible();
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
test('a late strategy response cannot replace another job and denial never offers read retry', async () => {
let finish!: (result: any) => void;
mockedApi.get.mockImplementation((url: string) => url === '/jobapplications/42/focus-plan' ? new Promise(resolve => { finish = resolve; }) : Promise.reject({ response: { status: 403 } }));
const view = render(strategy());
view.rerender(strategy(43));
expect(await screen.findByText('Strategy snapshot could not be loaded.')).toBeVisible();
await act(async () => finish({ data: { strategicSummary: 'Do not show old owner plan', immediatePriorities: [], proofPointsToLeadWith: [], cvBulletIdeas: [], coverLetterAngles: [], followUpApproach: [] } }));
expect(screen.queryByDisplayValue('Do not show old owner plan')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
});
@@ -445,13 +445,13 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
<Stepper activeStep={activeStep} alternativeLabel sx={{ mb: 3, display: { xs: "none", sm: "flex" } }}>
{steps.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
<Typography variant="subtitle2" sx={{ display: { xs: "block", sm: "none" }, mb: 2 }}>
<Typography component="p" variant="subtitle2" sx={{ display: { xs: "block", sm: "none" }, mb: 2 }}>
{t("addJobModalStepProgress", { current: activeStep + 1, total: steps.length, step: steps[activeStep] })}
</Typography>
{activeStep === 0 ? <Box sx={{ display: "grid", gap: 2 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{t("addJobModalStartWithAdvert")}</Typography>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900 }}>{t("addJobModalStartWithAdvert")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{t("addJobModalStartWithAdvertBody")}
</Typography>
@@ -609,7 +609,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
{activeStep >= 2 ? <Box sx={{ display: "grid", gap: 2 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography>
<Typography component="h3" variant="h6" sx={{ fontWeight: 900 }}>{steps[activeStep]}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
{activeStep === 2 ? t("addJobModalCvStepHelp") : null}
{activeStep === 3 ? t("addJobModalCoverLetterStepHelp") : null}
@@ -150,21 +150,21 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
)}
</Box>
<Stack direction="row" className="checklist-actions" sx={{ opacity: { xs: 1, md: 0 }, transition: "opacity .15s" }}>
<Tooltip title={t("checklistMoveUp")}>
<Tooltip describeChild title={t("checklistMoveUp")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveUpItem", { title: display.title })} onClick={() => move(item, -1)}>
<ArrowUpwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={t("checklistMoveDown")}>
<Tooltip describeChild title={t("checklistMoveDown")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistMoveDownItem", { title: display.title })} onClick={() => move(item, 1)}>
<ArrowDownwardIcon fontSize="inherit" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<Tooltip describeChild title={item.isSystemGenerated ? t("checklistNotRelevant") : t("deleteAction")}>
<span>
<IconButton size="small" disabled={busy} aria-label={t("checklistRemoveItem", { title: display.title })} onClick={() => remove(item)}>
<DeleteOutlineIcon fontSize="inherit" />
@@ -62,19 +62,41 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
const [plan, setPlan] = useState<FocusPlanResponse | null>(null);
const [operation, setOperation] = useState<UserOperation | null>(null);
const [loading, setLoading] = useState(true);
const [readingPlan, setReadingPlan] = useState(false);
const [readError, setReadError] = useState<{ cause: unknown; retryable: boolean } | null>(null);
const announced = useRef<string | null>(null);
const owner = useRef({ mounted: false, jobId, generation: 0 });
const readSequence = useRef(0);
const readPending = useRef(false);
const headingRef = useRef<HTMLHeadingElement>(null);
const loadPlan = useCallback(async () => {
if (!owner.current.mounted || owner.current.jobId !== jobId || readPending.current) return;
const lifetime = owner.current.generation;
const request = ++readSequence.current;
const active = () => owner.current.mounted && owner.current.jobId === jobId && owner.current.generation === lifetime && request === readSequence.current;
readPending.current = true;
setReadingPlan(true);
try {
const { data } = await api.get<FocusPlanResponse>(`/jobapplications/${jobId}/focus-plan`);
if (!active()) return;
setPlan(data);
} catch {
setPlan(null);
setReadError(null);
} catch (cause) {
if (!active()) return;
const response = (cause as { response?: { status?: number; data?: { code?: string } } })?.response;
if (response?.status === 404 && response.data?.code === "strategy_not_generated") { setPlan(null); setReadError(null); }
else setReadError({ cause, retryable: !response?.status || response.status >= 500 });
} finally {
if (active()) { readPending.current = false; setReadingPlan(false); }
}
}, [jobId]);
useEffect(() => {
let active = true;
owner.current = { mounted: true, jobId, generation: owner.current.generation + 1 };
readPending.current = false;
setPlan(null); setReadError(null); setOperation(null);
setLoading(true);
Promise.all([
loadPlan(),
@@ -82,17 +104,18 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
.then(({ data }) => { if (active) setOperation(data); })
.catch(() => { if (active) setOperation(null); }),
]).finally(() => { if (active) setLoading(false); });
return () => { active = false; };
return () => { active = false; owner.current.mounted = false; owner.current.generation += 1; readSequence.current += 1; readPending.current = false; };
}, [jobId, loadPlan]);
useEffect(() => {
if (!operation || terminal(operation.status)) return;
let active = true;
const timer = window.setTimeout(() => {
api.get<UserOperation>(`/operations/${operation.id}`)
.then(({ data }) => setOperation(data))
.then(({ data }) => { if (active) setOperation(data); })
.catch(() => undefined);
}, 1000);
return () => window.clearTimeout(timer);
return () => { active = false; window.clearTimeout(timer); };
}, [operation]);
useEffect(() => {
@@ -133,17 +156,24 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
};
const working = !!operation && !terminal(operation.status);
const reading = loading || readingPlan;
const readErrorTitle = t("workspaceStrategyLoadFailed");
const readErrorDetail = readError ? getApiErrorMessage(readError.cause, readErrorTitle) : "";
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" gap={1} sx={{ mb: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
<Typography ref={headingRef} tabIndex={-1} component="h2" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("workspaceStrategyTitle")}</Typography>
<Typography variant="caption" color="text.secondary">{t("workspaceStrategySubtitle")}</Typography>
</Box>
<Button variant="outlined" size="small" disabled={!canUseAi || loading || working} onClick={() => void generate()}>
<Button variant="outlined" size="small" disabled={!canUseAi || reading || working} onClick={() => void generate()}>
{!canUseAi ? t("workspaceStrategyProRequired") : plan ? t("workspaceStrategyRegenerate") : t("workspaceStrategyGenerate")}
</Button>
</Stack>
{readError ? <Alert severity="error" sx={{ mb: 2, overflowWrap: "anywhere" }} action={readError.retryable ? <Button color="inherit" size="small" disabled={reading} onClick={() => { headingRef.current?.focus(); void loadPlan(); }}>{t("retry")}</Button> : undefined}>
<Typography variant="body2">{readErrorTitle}</Typography>
{readErrorDetail !== readErrorTitle ? <Typography variant="body2">{readErrorDetail}</Typography> : null}
</Alert> : null}
{operation && operation.status !== "succeeded" ? (
<Alert severity={operation.status === "failed" ? "error" : operation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }} action={<>
{operation.canCancel ? <Button size="small" color="inherit" onClick={() => void mutateOperation("cancel")}>{t("cancel")}</Button> : null}
@@ -152,14 +182,14 @@ export function ApplicationStrategySnapshot({ jobId }: { jobId: number }) {
{operation.progressStage || operation.status.replaceAll("_", " ")}{operation.progressPercent != null ? ` · ${operation.progressPercent}%` : ""}
</Alert>
) : null}
{loading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : plan ? (
{reading && !plan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} aria-label={t("loading")} /></Box> : plan ? (
<Stack spacing={2}>
<DraftCard title={t("jobDetailsFocusSummary")} content={plan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={plan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={plan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={plan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={plan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={plan.followUpApproach} />
</Stack>
) : <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography>}
) : !readError ? <Typography color="text.secondary">{t("workspaceStrategyEmpty")}</Typography> : null}
</Paper>
);
}
@@ -381,7 +381,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
</Stack>
)}
</Box>
<Tooltip title={t("interviewPrepDelete")}>
<Tooltip describeChild title={t("interviewPrepDelete")}>
<span>
<IconButton
size="small"
@@ -147,7 +147,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
};
return (
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch"), "aria-labelledby": undefined }, transition: { onEntered: () => searchInputRef.current?.focus() } }} fullWidth maxWidth="sm">
<DialogContent onKeyDown={onKeys} sx={{ p: 1.5, maxHeight: "min(70vh, 620px)" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
<SearchIcon fontSize="small" />
@@ -61,6 +61,8 @@ test.each([0, 1, 2, 3])("Add Job attachment bucket %s retains keyboard selection
await screen.findByDisplayValue("Supported role");
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
for (let step = 0; step < bucket; step += 1) fireEvent.click(screen.getByRole("button", { name: "Skip and continue" }));
const headings = screen.getAllByRole("heading").map(heading => heading.tagName);
expect(headings).toEqual(["H2", "H3"]);
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
expect(input.multiple).toBe(true);
expect(input.accept).toContain(".pdf,.doc,.docx,.txt,.md");
+118
View File
@@ -5,6 +5,7 @@ export const translations = {
appTitle: "Jobbjakt",
appTagline: "Track your hunt",
applicationLanguage: "Application language",
applicationVersionCommit: "Application version {version}, commit {commit}",
dashboard: "Dashboard",
jobApplications: "Job Applications",
reminders: "Reminders",
@@ -130,6 +131,61 @@ export const translations = {
correspondenceInbox: "Job email",
emailInboxSubtitle: "Review linked correspondence and suggested recruitment messages in one place.",
emailInboxItems: "{count} items",
emailInboxItem: "{count} item",
emailInboxAttachmentCount: "{count} attachment",
historySourceManual: "Manual",
historySourceAi: "AI suggestion",
historySourceTemplate: "Template",
historySourceRestore: "Restored version",
historySourceAutosave: "Autosave",
historySourceCreate: "Created",
cvTemplateModernName: "Modern",
cvTemplateModernDescription: "Clean SaaS-style layout with a confident accent header.",
cvTemplateMinimalName: "Minimal",
cvTemplateMinimalDescription: "Quiet, spacious, typography-first. Nothing competes with the content.",
cvTemplateExecutiveName: "Executive",
cvTemplateExecutiveDescription: "High-contrast, serif, leadership-weighted. For senior and client-facing roles.",
cvTemplateTechnicalName: "Technical",
cvTemplateTechnicalDescription: "Dense two-column layout tuned for engineering CVs — skills and projects up front.",
cvTemplateAtsClassicName: "ATS Classic",
cvTemplateAtsClassicDescription: "Single column, no graphics, maximum parser compatibility. The safe default.",
cvTemplateNordicName: "Nordic",
cvTemplateNordicDescription: "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.",
cvTemplateElegantName: "Elegant",
cvTemplateElegantDescription: "Editorial serif headings over sans body, premium spacing and a plum accent.",
cvTemplateCreativeName: "Creative",
cvTemplateCreativeDescription: "Bold accent sidebar and photo-forward header for design and product roles.",
cvTemplateCodeName: "Code",
cvTemplateCodeDescription: "Developer-focused editorial layout with numbered sections and precise mono details.",
cvTemplateCategoryModern: "Modern Professional",
cvTemplateCategoryMinimal: "Minimal",
cvTemplateCategoryExecutive: "Executive",
cvTemplateCategoryTechnical: "Technical",
cvTemplateCategoryAts: "ATS Professional",
cvTemplateCategoryCreative: "Creative",
assetsTailoringHighlightTitle: "Skills to highlight",
assetsTailoringHighlightDetail: "The advert asks for these and your profile already has them — put them where they are seen first.",
assetsTailoringExperienceTitle: "Experience to prioritise",
assetsTailoringExperienceDetail: "Ordered by how much of the advert each role actually covers.",
assetsTailoringProjectsTitle: "Projects to emphasise",
assetsTailoringProjectsDetail: "These projects demonstrate what the advert is asking for.",
assetsTailoringEvidenceTitle: "Verified evidence to reuse",
assetsTailoringEvidenceDetail: "Facts you previously approved. Adapt the wording, but keep the claim accurate.",
assetsTailoringKeywordsTitle: "Keywords to include",
assetsTailoringKeywordsDetail: "Vocabulary from the advert. Use the ones that are honestly true of you — never pad.",
assetsTailoringGapsTitle: "Gaps to address",
assetsTailoringGapsDetail: "Asked for but not found in your profile. Add them if you have them; otherwise be ready to talk about them.",
reminderReasonInterview: "Interview stage reached but prep notes are still missing.",
reminderReasonCvAndAnswers: "Tailored CV and saved application answers still need work.",
reminderReasonCv: "Tailored CV missing for this role.",
reminderReasonAnswers: "Saved application answers still need work.",
reminderReasonUnscheduled: "No response yet and no follow-up is scheduled.",
reminderReasonDue: "Follow-up is due for this role.",
reminderReasonNoGaps: "No urgent workflow gaps are blocking this job right now.",
reminderReasonNoReplyDays: "No reply after {days}d",
reminderReasonStalledDays: "Stalled after {days}d",
reminderReasonFeedbackDays: "Feedback requested {days}d ago",
reminderReasonWaitingDays: "Waiting {days}d",
emailInboxLinked: "{count} linked",
emailInboxLinkedOnPage: "{count} linked on this page",
emailInboxInboundCount: "{count} inbound",
@@ -869,6 +925,8 @@ export const translations = {
cvEditorShowPhoto: "Show profile photo",
cvEditorContactIcons: "Contact icons",
cvEditorOpenFailed: "Could not open this CV.",
cvEditorSourceUnavailable: "This CV is unavailable or you no longer have access to it.",
cvEditorPermissionDenied: "You do not have permission to open this CV.",
cvEditorDiscardMessage: "This CV has unsaved changes. Leave and discard them?",
cvEditorDiscardTitle: "Discard unsaved CV changes?",
cvEditorDiscardLeave: "Discard and leave",
@@ -2759,6 +2817,7 @@ export const translations = {
workspaceStatusSuggestionApplied: "Application status updated from the latest message.",
workspaceStatusSuggestionFailed: "Could not apply the suggested status.",
workspaceStrategyTitle: "Strategy snapshot",
workspaceStrategyLoadFailed: "Strategy snapshot could not be loaded.",
workspaceStrategySubtitle: "An on-demand plan grounded in this advert and your saved career data.",
workspaceStrategyProRequired: "Pro required",
workspaceStrategyGenerate: "Generate",
@@ -2787,6 +2846,7 @@ export const translations = {
appTitle: "Jobbjakt",
appTagline: "Hold oversikt over jobbsøkingen",
applicationLanguage: "Språk i appen",
applicationVersionCommit: "Applikasjonsversjon {version}, revisjon {commit}",
dashboard: "Dashboard",
jobApplications: "Jobbsøknader",
reminders: "Påminnelser",
@@ -2912,6 +2972,61 @@ export const translations = {
correspondenceInbox: "Jobb-e-post",
emailInboxSubtitle: "Se tilknyttet korrespondanse og foreslåtte rekrutteringsmeldinger på ett sted.",
emailInboxItems: "{count} elementer",
emailInboxItem: "{count} element",
emailInboxAttachmentCount: "{count} vedlegg",
historySourceManual: "Manuell",
historySourceAi: "KI-forslag",
historySourceTemplate: "Mal",
historySourceRestore: "Gjenopprettet versjon",
historySourceAutosave: "Automatisk lagring",
historySourceCreate: "Opprettet",
cvTemplateModernName: "Moderne",
cvTemplateModernDescription: "Ren SaaS-inspirert utforming med en tydelig topptekst i aksentfarge.",
cvTemplateMinimalName: "Minimalistisk",
cvTemplateMinimalDescription: "Rolig og luftig, med typografien i sentrum. Ingenting tar oppmerksomheten fra innholdet.",
cvTemplateExecutiveName: "Leder",
cvTemplateExecutiveDescription: "Høy kontrast og serifftype med vekt på ledelse. For seniorroller og kundevendte roller.",
cvTemplateTechnicalName: "Teknisk",
cvTemplateTechnicalDescription: "Kompakt tokolonneutforming for tekniske CV-er, med ferdigheter og prosjekter først.",
cvTemplateAtsClassicName: "ATS klassisk",
cvTemplateAtsClassicDescription: "Én kolonne uten grafikk, med best mulig støtte for CV-tolkere. Et trygt standardvalg.",
cvTemplateNordicName: "Nordisk",
cvTemplateNordicDescription: "Rolig sidekolonne i kjølig blått, god luft og skandinavisk enkelhet.",
cvTemplateElegantName: "Elegant",
cvTemplateElegantDescription: "Redaksjonelle serifoverskrifter over brødtekst uten seriffer, god luft og en plommefarget aksent.",
cvTemplateCreativeName: "Kreativ",
cvTemplateCreativeDescription: "Tydelig sidekolonne i aksentfarge og topptekst med vekt på bilde, for design- og produktroller.",
cvTemplateCodeName: "Kode",
cvTemplateCodeDescription: "Redaksjonell utforming for utviklere med nummererte seksjoner og presise detaljer i fastbreddeskrift.",
cvTemplateCategoryModern: "Moderne og profesjonell",
cvTemplateCategoryMinimal: "Minimalistisk",
cvTemplateCategoryExecutive: "Leder",
cvTemplateCategoryTechnical: "Teknisk",
cvTemplateCategoryAts: "ATS-profesjonell",
cvTemplateCategoryCreative: "Kreativ",
assetsTailoringHighlightTitle: "Ferdigheter å fremheve",
assetsTailoringHighlightDetail: "Annonsen etterspør disse, og de finnes allerede i profilen din. Plasser dem der de blir sett først.",
assetsTailoringExperienceTitle: "Erfaring å prioritere",
assetsTailoringExperienceDetail: "Sortert etter hvor mye av annonsen hver rolle faktisk dekker.",
assetsTailoringProjectsTitle: "Prosjekter å fremheve",
assetsTailoringProjectsDetail: "Disse prosjektene viser det annonsen etterspør.",
assetsTailoringEvidenceTitle: "Bekreftet dokumentasjon å gjenbruke",
assetsTailoringEvidenceDetail: "Fakta du tidligere har godkjent. Tilpass ordlyden, men behold en korrekt påstand.",
assetsTailoringKeywordsTitle: "Nøkkelord å ta med",
assetsTailoringKeywordsDetail: "Ord fra annonsen. Bruk dem som faktisk stemmer for deg, uten å pynte på innholdet.",
assetsTailoringGapsTitle: "Mangler å ta tak i",
assetsTailoringGapsDetail: "Etterspurt, men ikke funnet i profilen din. Legg dem til hvis du har dem, ellers bør du være forberedt på å snakke om dem.",
reminderReasonInterview: "Du har kommet til intervju, men mangler fortsatt forberedelsesnotater.",
reminderReasonCvAndAnswers: "Tilpasset CV og lagrede søknadssvar trenger fortsatt arbeid.",
reminderReasonCv: "Tilpasset CV mangler for denne rollen.",
reminderReasonAnswers: "Lagrede søknadssvar trenger fortsatt arbeid.",
reminderReasonUnscheduled: "Ingen respons ennå, og ingen oppfølging er planlagt.",
reminderReasonDue: "Det er tid for oppfølging av denne rollen.",
reminderReasonNoGaps: "Ingen akutte mangler i arbeidsflyten blokkerer denne jobben nå.",
reminderReasonNoReplyDays: "Ingen svar etter {days} dager",
reminderReasonStalledDays: "Stoppet opp etter {days} dager",
reminderReasonFeedbackDays: "Tilbakemelding ble forespurt for {days} dager siden",
reminderReasonWaitingDays: "Ventet i {days} dager",
emailInboxLinked: "{count} tilknyttet",
emailInboxLinkedOnPage: "{count} tilknyttet på denne siden",
emailInboxInboundCount: "{count} innkommende",
@@ -3651,6 +3766,8 @@ export const translations = {
cvEditorShowPhoto: "Vis profilbilde",
cvEditorContactIcons: "Kontaktikoner",
cvEditorOpenFailed: "Kunne ikke åpne denne CV-en.",
cvEditorSourceUnavailable: "Denne CV-en er utilgjengelig, eller du har ikke lenger tilgang til den.",
cvEditorPermissionDenied: "Du har ikke tilgang til å åpne denne CV-en.",
cvEditorDiscardMessage: "Denne CV-en har ulagrede endringer. Vil du forlate siden og forkaste dem?",
cvEditorDiscardTitle: "Forkaste ulagrede CV-endringer?",
cvEditorDiscardLeave: "Forkast og forlat",
@@ -5541,6 +5658,7 @@ export const translations = {
workspaceStatusSuggestionApplied: "Søknadsstatusen ble oppdatert fra den siste meldingen.",
workspaceStatusSuggestionFailed: "Kunne ikke bruke den foreslåtte statusen.",
workspaceStrategyTitle: "Strategioversikt",
workspaceStrategyLoadFailed: "Kunne ikke hente strategisammendraget.",
workspaceStrategySubtitle: "En behovsstyrt plan basert på denne annonsen og dine lagrede karrieredata.",
workspaceStrategyProRequired: "Krever Pro",
workspaceStrategyGenerate: "Generer",
@@ -86,6 +86,19 @@ test("prep renders grouped items with progress and marks AI-sourced ones", async
expect(screen.getByText("From AI")).toBeInTheDocument();
});
test("delete tooltip describes its wrapper while the disabled button keeps its item name", async () => {
routeGet();
mockedApi.patch.mockReturnValueOnce(new Promise(() => {}));
render(<ApplicationInterviewPrep jobId={7} />);
const remove = await screen.findByRole("button", { name: "Delete: Funding history" });
expect(remove.parentElement).not.toHaveAttribute("aria-label");
fireEvent.click(screen.getByRole("checkbox", { name: "Ready: Funding history" }));
expect(remove).toBeDisabled();
fireEvent.mouseOver(remove.parentElement!);
expect(await screen.findByRole("tooltip")).toHaveTextContent("Delete");
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test("adding a prep item posts the chosen category and title", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any);
+3 -2
View File
@@ -340,10 +340,11 @@ export default function AppShell({
const buildVersion = buildMetadata?.version.trim();
const buildCommit = buildMetadata?.commitSha?.trim();
const buildBadge = buildVersion ? (
<Tooltip title={buildCommit ? `Version ${buildVersion} • commit ${buildCommit}` : `Version ${buildVersion}`}>
<Tooltip title={buildCommit ? t("applicationVersionCommit", { version: buildVersion, commit: buildCommit }) : t("applicationVersion", { version: buildVersion })}>
<Chip
data-testid="admin-version-badge"
aria-label={`Application version ${buildVersion}${buildCommit ? `, commit ${buildCommit}` : ""}`}
role="img"
aria-label={buildCommit ? t("applicationVersionCommit", { version: buildVersion, commit: buildCommit }) : t("applicationVersion", { version: buildVersion })}
label={`v${buildVersion}`}
size="small"
variant="outlined"
@@ -74,6 +74,7 @@ test('localizes the job creation workflow in Norwegian Bokmål', async () => {
renderModal();
expect(await screen.findByText('Start med jobbannonsen')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Start med jobbannonsen', level: 3 })).toBeVisible();
expect(screen.getByRole('button', { name: 'Skriv inn detaljer manuelt' })).toBeInTheDocument();
expect(screen.getByText('Trinn 1 av 6: Legg til jobb')).toBeInTheDocument();
});
@@ -24,6 +24,8 @@ test.each([
const onClose = jest.fn();
render(<I18nProvider><QuickCommandDialog open onClose={onClose} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
expect(screen.getByRole("dialog", { name: "Quick Search" })).toBeVisible();
const labelIds = screen.getByRole("dialog", { name: "Quick Search" }).getAttribute("aria-labelledby");
expect(!labelIds || labelIds.split(/\s+/).every(id => document.getElementById(id))).toBe(true);
fireEvent.change(screen.getByRole("textbox", { name: "Quick Search" }), { target: { value: "Nordlys" } });
fireEvent.click(await screen.findByText(label, { exact: true }));
expect(onNavigate).toHaveBeenCalledWith(destination);