fix(i18n): localize generated labels without changing saved content
This commit is contained in:
@@ -216,7 +216,7 @@ test('[P7-04 PRES-C05 C08 E08] locale-retained create draft locks during held fa
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO');
|
||||
dialog = await openCreate(page, 'nb');
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name);
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cvTemplateMinimalName, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect({ listReads, themeReads }).toEqual(readsBeforeLocale);
|
||||
try {
|
||||
await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).focus(); await page.keyboard.press('Enter');
|
||||
@@ -225,7 +225,7 @@ test('[P7-04 PRES-C05 C08 E08] locale-retained create draft locks during held fa
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cancel, exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cvEditorAtsTemplates, exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cvTemplateMinimalName, exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
await page.keyboard.press('Enter'); await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeVisible(); expect(payloads).toHaveLength(1);
|
||||
const content = dialog.getByRole('region', { name: translations.nb.cvDashboardCreateTitle, exact: true });
|
||||
@@ -244,7 +244,7 @@ test('[P7-04 PRES-C05 C08 E08] locale-retained create draft locks during held fa
|
||||
} finally { release(); }
|
||||
await expect(dialog.getByText('Controlled create unavailable', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByLabel(translations.nb.cvEditorName, { exact: false })).toHaveValue(name);
|
||||
await expect(dialog.getByRole('button', { name: selected.name, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(dialog.getByRole('button', { name: translations.nb.cvTemplateMinimalName, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const createdResponse = page.waitForResponse(response => response.url() === `${apiUrl}/cv/variants` && response.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: translations.nb.cvDashboardCreateCv, exact: true }).click();
|
||||
const created = await json<CvVariant>(await createdResponse);
|
||||
|
||||
@@ -98,6 +98,20 @@ function action(row: JobApplication, language: Sample['language'] = 'en') {
|
||||
: signal.actionKey === 'interview-prep' ? t.jobTableInterviewStage : signal.actionKey === 'follow-up' ? t.jobTableFollowUp : t.jobTableReadiness;
|
||||
return `${label}: ${row.jobTitle}`;
|
||||
}
|
||||
function fixtureReason(row: JobApplication, language: Sample['language']) {
|
||||
if (language === 'en') return row.workflowSignal!.reason;
|
||||
const t = translations[language];
|
||||
switch (row.workflowSignal!.actionKey) {
|
||||
case 'package-work': return t.reminderReasonCvAndAnswers;
|
||||
case 'interview-prep': return t.reminderReasonInterview;
|
||||
case 'review-readiness': return t.reminderReasonNoGaps;
|
||||
default: {
|
||||
const feedback = /^Feedback requested (\d+)d ago$/.exec(row.workflowSignal!.reason);
|
||||
expect(feedback).not.toBeNull();
|
||||
return text(t.reminderReasonFeedbackDays, { days: feedback![1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
function requests(session: Session, method: string, path: string) { return session.requests.filter(request => request.method === method && request.path === path); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function keyboard(page: Page, control: Locator) { await control.focus(); await expect(control).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
@@ -261,7 +275,7 @@ test('[P7-05 Reminders] committed PATCH204 followed by a failed reload retries o
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-05 Reminders] live EN/Bokmal translates status/date controls and preserves raw reasons without a page-triggered refetch', async ({ page, session }, info) => {
|
||||
test('[P7-05 Reminders] live EN/Bokmal translates status/date controls and generated reasons without a page-triggered refetch', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const { rows } = await groups(page.request, session.owner);
|
||||
await enter(page, session.owner); await expect(region(page).getByRole('article')).toHaveCount(4);
|
||||
await expect.poll(() => requests(session, 'GET', '/api/jobapplications/reminders').length).toBeGreaterThanOrEqual(2);
|
||||
@@ -269,7 +283,7 @@ test('[P7-05 Reminders] live EN/Bokmal translates status/date controls and prese
|
||||
const preference = response(page, 'PUT', `${apiUrl}/auth/preferences/language`);
|
||||
await keyboard(page, page.locator('header').getByRole('button', { name: 'Norsk', exact: true })); expect((await preference).status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(region(page, 'nb').getByRole('article')).toHaveCount(4);
|
||||
for (const row of rows) await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
|
||||
for (const row of rows) await expect(article(page, row.jobTitle)).toContainText(fixtureReason(row, 'nb'));
|
||||
await expect(article(page, rows[1].jobTitle).getByText(translations.nb.statusInterview, { exact: true })).toBeVisible();
|
||||
const authoritative = (await read(page.request)).find(row => row.id === rows[3].id)!;
|
||||
const date = await page.evaluate(value => new Date(value).toLocaleDateString('nb-NO'), authoritative.followUpAt!);
|
||||
@@ -297,7 +311,7 @@ test('[P7-05 Reminders] eight finite bilingual theme widths preserve long rows a
|
||||
await enter(page, session.owner, sample); await expect(region(page, sample.language).getByRole('article')).toHaveCount(4);
|
||||
for (const row of rows) {
|
||||
await expect(article(page, row.jobTitle).getByRole('heading', { level: 4 })).toContainText(row.jobTitle);
|
||||
await expect(article(page, row.jobTitle)).toContainText(row.workflowSignal!.reason);
|
||||
await expect(article(page, row.jobTitle)).toContainText(fixtureReason(row, sample.language));
|
||||
const open = article(page, row.jobTitle).getByRole('button', { name: action(row, sample.language), exact: true });
|
||||
await open.scrollIntoViewIfNeeded(); await open.focus(); await expect(open).toBeFocused();
|
||||
for (const days of [3, 7]) { await snooze(page, row, days, sample.language).focus(); await expect(snooze(page, row, days, sample.language)).toBeFocused(); }
|
||||
|
||||
@@ -3,7 +3,7 @@ import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, SubmittedPackagesSection,
|
||||
ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, ApplicationTailoringSection, SubmittedPackagesSection,
|
||||
} from "./components/ApplicationAssets";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
@@ -104,6 +104,23 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test("[P7-11 tailoring] generated metadata and known history enums follow locale while evidence, unknown metadata and dirty prose stay verbatim", async () => {
|
||||
routeGet({ tailoring: { ...tailoring, suggestions: [...tailoring.suggestions, { kind: "custom", title: "My custom title", detail: "My custom detail", items: ["My verified evidence"] }] }, coverLetter: { ...coverLetter, versions: [...coverLetter.versions, { version: 0, source: "custom-source", aiAction: "custom-action", length: 5, createdAtUtc: "2026-07-19T09:00:00Z", isCurrent: false }] } });
|
||||
render(<><ApplicationTailoringSection jobId={7} /><ApplicationCoverLetterSection jobId={7} /></>);
|
||||
await screen.findByText("Skills to highlight");
|
||||
const letter = await screen.findByRole("textbox", { name: "Cover letter" });
|
||||
fireEvent.change(letter, { target: { value: "My unsaved English letter" } });
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
changeLanguage("nb");
|
||||
expect(screen.getByText(copy("assetsTailoringHighlightTitle"))).toBeInTheDocument();
|
||||
expect(screen.getByText(copy("assetsTailoringHighlightDetail"))).toBeInTheDocument();
|
||||
expect(screen.getByText(`${copy("historySourceAi")} · ${copy("coverActionImprove")}`)).toBeInTheDocument();
|
||||
expect(screen.getByText("custom-source · custom-action")).toBeInTheDocument();
|
||||
for (const text of ["My custom title", "My custom detail", "My verified evidence", "C#", "SQL"]) expect(screen.getByText(text)).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: copy("jobDetailsCoverLetter") })).toHaveValue("My unsaved English letter");
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("document package history is read-only and separates captured notes from answers", async () => {
|
||||
const summary = { id: 91, version: 1, cvVariantId: 3, cvVariantName: "Original CV", cvVariantVersion: 2, cvThemeId: "nordic", hasCv: false, hasCoverLetter: true, createdAtUtc: "2026-07-19T10:00:00Z", attachments: [] };
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/91') ? {
|
||||
@@ -197,7 +214,7 @@ test("cover letter loads the current text and its history", async () => {
|
||||
|
||||
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
||||
expect(screen.getByText("v2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ai · improve")).toBeInTheDocument();
|
||||
expect(screen.getByText("AI suggestion · Improve")).toBeInTheDocument();
|
||||
expect(screen.getByText("Current")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -404,6 +404,15 @@ export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId
|
||||
|
||||
// ---------- Tailoring ----------
|
||||
|
||||
const tailoringLabels = new Map<string, [TranslationKey, TranslationKey]>([
|
||||
["highlight-skills", ["assetsTailoringHighlightTitle", "assetsTailoringHighlightDetail"]],
|
||||
["prioritise-experience", ["assetsTailoringExperienceTitle", "assetsTailoringExperienceDetail"]],
|
||||
["emphasise-projects", ["assetsTailoringProjectsTitle", "assetsTailoringProjectsDetail"]],
|
||||
["career-evidence", ["assetsTailoringEvidenceTitle", "assetsTailoringEvidenceDetail"]],
|
||||
["include-keywords", ["assetsTailoringKeywordsTitle", "assetsTailoringKeywordsDetail"]],
|
||||
["gaps", ["assetsTailoringGapsTitle", "assetsTailoringGapsDetail"]],
|
||||
]);
|
||||
|
||||
export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
|
||||
const { t } = useI18n();
|
||||
const { data, error, loading, reload } = useAsset<TailoringPlan>(
|
||||
@@ -437,11 +446,13 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
|
||||
{t("assetsNoSuggestions")}
|
||||
</Typography>
|
||||
) : (
|
||||
(data?.suggestions ?? []).map((s) => (
|
||||
(data?.suggestions ?? []).map((s) => {
|
||||
const labels = tailoringLabels.get(s.kind);
|
||||
return (
|
||||
<Box key={s.kind}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{s.title}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{labels ? t(labels[0]) : s.title}</Typography>
|
||||
{s.detail && (
|
||||
<Typography variant="caption" color="text.secondary">{s.detail}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{labels ? t(labels[1]) : s.detail}</Typography>
|
||||
)}
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.5} sx={{ mt: 0.75 }}>
|
||||
{s.items.map((item) => (
|
||||
@@ -449,7 +460,7 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
); })
|
||||
)}
|
||||
</Stack>
|
||||
</Shell>
|
||||
@@ -458,6 +469,18 @@ export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
|
||||
|
||||
// ---------- Cover letter ----------
|
||||
|
||||
const historySources = new Map<string, TranslationKey>([
|
||||
["manual", "historySourceManual"], ["ai", "historySourceAi"],
|
||||
["template", "historySourceTemplate"], ["restore", "historySourceRestore"],
|
||||
]);
|
||||
const historyAiActions = new Map<string, TranslationKey>([
|
||||
["generate", "coverActionGenerate"], ["regenerate", "coverActionRegenerate"],
|
||||
["improve", "coverActionImprove"], ["shorten", "coverActionShorten"],
|
||||
["expand", "coverActionExpand"], ["professional", "coverActionProfessional"],
|
||||
["natural", "coverActionNatural"], ["grammar", "coverActionGrammar"],
|
||||
["tone", "coverAiTone"], ["tailor", "coverActionTailor"],
|
||||
]);
|
||||
|
||||
const COVER_LETTER_TEMPLATES = {
|
||||
en: `Dear Hiring Manager,
|
||||
|
||||
@@ -615,7 +638,12 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={0.5}>
|
||||
{(data?.versions ?? []).map((v) => (
|
||||
{(data?.versions ?? []).map((v) => {
|
||||
const sourceKey = historySources.get(v.source);
|
||||
const actionKey = historyAiActions.get(v.aiAction ?? "");
|
||||
const source = sourceKey ? t(sourceKey) : v.source;
|
||||
const action = actionKey ? t(actionKey) : v.aiAction;
|
||||
return (
|
||||
<Stack
|
||||
key={v.version}
|
||||
direction="row"
|
||||
@@ -626,7 +654,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>v{v.version}</Typography>
|
||||
<Chip size="small" variant="outlined" label={v.aiAction ? `${v.source} · ${v.aiAction}` : v.source} />
|
||||
<Chip size="small" variant="outlined" label={action ? `${source} · ${action}` : source} />
|
||||
{v.isCurrent && <Chip size="small" color="primary" variant="outlined" label={t("assetsCurrent")} />}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -648,7 +676,7 @@ export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId:
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
); })}
|
||||
</Stack>
|
||||
)}
|
||||
</Shell>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Box, Button, Chip, Paper, Stack, Typography } from "@mui/material";
|
||||
|
||||
import { CvTheme, getVisibleCvThemes } from "../cvBuilder";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import CvTemplateThumbnail from "./CvTemplateThumbnail";
|
||||
|
||||
type Props = {
|
||||
@@ -15,8 +16,20 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const templateLabels = new Map<string, [TranslationKey, TranslationKey, TranslationKey]>([
|
||||
["modern", ["cvTemplateModernName", "cvTemplateCategoryModern", "cvTemplateModernDescription"]],
|
||||
["minimal", ["cvTemplateMinimalName", "cvTemplateCategoryMinimal", "cvTemplateMinimalDescription"]],
|
||||
["executive", ["cvTemplateExecutiveName", "cvTemplateCategoryExecutive", "cvTemplateExecutiveDescription"]],
|
||||
["technical", ["cvTemplateTechnicalName", "cvTemplateCategoryTechnical", "cvTemplateTechnicalDescription"]],
|
||||
["ats-classic", ["cvTemplateAtsClassicName", "cvTemplateCategoryAts", "cvTemplateAtsClassicDescription"]],
|
||||
["nordic", ["cvTemplateNordicName", "cvTemplateCategoryModern", "cvTemplateNordicDescription"]],
|
||||
["elegant", ["cvTemplateElegantName", "cvTemplateCategoryCreative", "cvTemplateElegantDescription"]],
|
||||
["creative", ["cvTemplateCreativeName", "cvTemplateCategoryCreative", "cvTemplateCreativeDescription"]],
|
||||
["code", ["cvTemplateCodeName", "cvTemplateCategoryTechnical", "cvTemplateCodeDescription"]],
|
||||
]);
|
||||
|
||||
export default function CvTemplateGallery({ themes, selectedId, onSelect, previewHeight = 170, showDescriptions = true, disabled = false }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const id = useId();
|
||||
const [atsOnly, setAtsOnly] = useState(true);
|
||||
const visibleThemes = useMemo(() => getVisibleCvThemes(themes, atsOnly, selectedId), [atsOnly, selectedId, themes]);
|
||||
@@ -33,16 +46,18 @@ export default function CvTemplateGallery({ themes, selectedId, onSelect, previe
|
||||
const active = theme.id === selectedId;
|
||||
const locked = theme.available === false;
|
||||
const unavailable = locked || disabled;
|
||||
const labels = language === "nb" ? templateLabels.get(theme.id) : undefined;
|
||||
const name = labels ? t(labels[0]) : theme.name;
|
||||
return (
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={theme.name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={unavailable ? -1 : 0} aria-label={name} aria-describedby={`${id}-${theme.id}-category ${showDescriptions ? `${id}-${theme.id}-description ` : ""}${id}-${theme.id}-features`} aria-disabled={unavailable} aria-pressed={active}
|
||||
onClick={() => { if (!unavailable) onSelect(theme); }}
|
||||
onKeyDown={(event) => { if (!unavailable && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(theme); } }}
|
||||
sx={{ p: 1, cursor: unavailable ? "not-allowed" : "pointer", position: "relative", minWidth: 0, overflowWrap: "anywhere", borderColor: active ? "primary.main" : "divider", borderWidth: active ? 2 : 1, "&:hover": unavailable ? undefined : { borderColor: "primary.main" } }}>
|
||||
{active && <CheckCircleIcon aria-label={t("cvEditorSelectedTemplate")} color="primary" sx={{ position: "absolute", zIndex: 2, top: 6, right: 6, bgcolor: "background.paper", borderRadius: "50%" }} />}
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={previewHeight} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 750 }}>{theme.name}</Typography>
|
||||
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
|
||||
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{theme.description}</Typography>}
|
||||
<Typography variant="body2" sx={{ fontWeight: 750 }}>{name}</Typography>
|
||||
<Typography id={`${id}-${theme.id}-category`} variant="caption" color="text.secondary" sx={{ display: "block" }}>{labels ? t(labels[1]) : theme.category}</Typography>
|
||||
{showDescriptions && <Typography id={`${id}-${theme.id}-description`} variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.35 }}>{labels ? t(labels[2]) : theme.description}</Typography>}
|
||||
<Stack id={`${id}-${theme.id}-features`} direction="row" spacing={0.5} alignItems="center" flexWrap="wrap" useFlexGap sx={{ mt: 0.75 }}>
|
||||
{theme.atsFriendly && <Chip size="small" label={t("cvEditorAtsFriendly")} color="success" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
|
||||
{theme.requiresPro && <Chip size="small" label={locked ? t("cvEditorPro") : t("cvEditorProUnlocked")} color="secondary" variant="outlined" sx={{ height: 20, fontSize: 10 }} />}
|
||||
|
||||
@@ -12,6 +12,33 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useViewResource, type ViewResourceError } from "../hooks/useViewResource";
|
||||
import { PageHeader } from "../layout/PageLayout";
|
||||
import { statusLabel } from "../pipeline";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
|
||||
const generatedReasons = new Map<string, TranslationKey>([
|
||||
["Interview stage reached but prep notes are still missing.", "reminderReasonInterview"],
|
||||
["Tailored CV and saved application answers still need work.", "reminderReasonCvAndAnswers"],
|
||||
["Tailored CV missing for this role.", "reminderReasonCv"],
|
||||
["Saved application answers still need work.", "reminderReasonAnswers"],
|
||||
["No response yet and no follow-up is scheduled.", "reminderReasonUnscheduled"],
|
||||
["Follow-up is due for this role.", "reminderReasonDue"],
|
||||
["No urgent workflow gaps are blocking this job right now.", "reminderReasonNoGaps"],
|
||||
]);
|
||||
const generatedDayReasons: [RegExp, TranslationKey][] = [
|
||||
[/^No reply after (\d+)d$/, "reminderReasonNoReplyDays"],
|
||||
[/^Stalled after (\d+)d$/, "reminderReasonStalledDays"],
|
||||
[/^Feedback requested (\d+)d ago$/, "reminderReasonFeedbackDays"],
|
||||
[/^Waiting (\d+)d$/, "reminderReasonWaitingDays"],
|
||||
];
|
||||
|
||||
function reminderReason(reason: string, t: ReturnType<typeof useI18n>["t"]) {
|
||||
const key = generatedReasons.get(reason);
|
||||
if (key) return t(key);
|
||||
for (const [pattern, dayKey] of generatedDayReasons) {
|
||||
const match = pattern.exec(reason);
|
||||
if (match) return t(dayKey, { days: match[1] });
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
type FollowUpFailure = {
|
||||
job: Pick<JobApplication, "id" | "jobTitle">;
|
||||
@@ -62,7 +89,7 @@ function ReminderSection({ title, items, disabled, onOpen, onSetFollowUp }: { ti
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 0.5, flexWrap: "wrap", minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
|
||||
{j.needsFollowUp ? <Chip size="small" color="warning" label={t("remindersFollowUpLabel")} /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={j.workflowSignal?.reason ?? j.followUpReason} variant="outlined" /> : null}
|
||||
{(j.workflowSignal?.reason ?? j.followUpReason) ? <Chip size="small" label={reminderReason((j.workflowSignal?.reason ?? j.followUpReason)!, t)} variant="outlined" /> : null}
|
||||
{j.followUpAt ? <Chip size="small" label={t("remindersFollowUpDate", { date: new Date(j.followUpAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} variant="outlined" /> : null}
|
||||
<Chip size="small" label={statusLabel(t, j.status)} variant="outlined" />
|
||||
</Box>
|
||||
|
||||
@@ -118,6 +118,24 @@ function renderPage(initialPath = '/') {
|
||||
}
|
||||
|
||||
describe('CorrespondenceInboxPage', () => {
|
||||
test('[P7-11 inbox] resumed draft fallback metadata follows locale without mutating stored prose or refetching', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/email/drafts') return Promise.resolve({ data: [savedComposeDraft({ jobApplicationId: 98 })] } as any);
|
||||
if (url === '/email/providers') return Promise.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: false, address: null, canRead: false, canSend: false }] } as any);
|
||||
return original!(url, config);
|
||||
});
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /resume owned compose draft/i }));
|
||||
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'My unsaved English message' } });
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(screen.getByText(`${copy('emailInboxJobNumber')} #98 · ${copy('emailInboxSavedDraft')}`)).toBeInTheDocument();
|
||||
expect(composeField('emailInboxProvider')).toHaveValue(`Gmail · ${copy('emailInboxReconnectBeforeSending')}`);
|
||||
expect(composeField('emailInboxMessage')).toHaveValue('My unsaved English message');
|
||||
expect(composeField('emailInboxSubject')).toHaveValue('Owned compose draft');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.lang = 'en';
|
||||
@@ -176,11 +194,12 @@ describe('CorrespondenceInboxPage', () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /job email/i })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/1 items/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText("1 item", { exact: true })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 labels/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 attachments/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("1 attachment", { exact: true })).toBeInTheDocument();
|
||||
expect(screen.getByText("1 item", { exact: true })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Gmail: owner@gmail\.test · Read only; reconnect to enable send/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Outlook: Not connected$/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Outlook: Not connected.*Read only/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import CvTemplateGallery from "./components/CvTemplateGallery";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import type { CvTheme } from "./cvBuilder";
|
||||
|
||||
const theme: CvTheme = { id: "modern", name: "Modern", category: "Modern Professional", description: "Clean SaaS-style layout with a confident accent header.", layout: "header-band", accent: "#2563eb", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#2563eb"] };
|
||||
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
test("[P7-11 templates] known metadata follows locale while custom metadata and selection objects stay verbatim", () => {
|
||||
const select = jest.fn();
|
||||
const custom = { ...theme, id: "custom", name: "My Modern", category: "My category", description: "My authored description" };
|
||||
render(<I18nProvider><CvTemplateGallery themes={[theme, custom]} selectedId="modern" onSelect={select} /></I18nProvider>);
|
||||
expect(screen.getByRole("button", { name: "Modern" })).toBeInTheDocument();
|
||||
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
expect(screen.getByRole("button", { name: translations.nb.cvTemplateModernName })).toHaveAccessibleDescription(expect.stringContaining(translations.nb.cvTemplateModernDescription));
|
||||
expect(screen.getByText(translations.nb.cvTemplateCategoryModern)).toBeInTheDocument();
|
||||
expect(screen.getByText(custom.description)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.nb.cvTemplateModernName }));
|
||||
expect(select).toHaveBeenCalledWith(theme);
|
||||
expect(theme.name).toBe("Modern");
|
||||
});
|
||||
@@ -46,6 +46,21 @@ function signal(actionKey: WorkflowSignal["actionKey"], workspaceTab: WorkflowSi
|
||||
};
|
||||
}
|
||||
|
||||
test("[P7-11 reminders] generated fixed and parameterized reasons follow locale while custom wording stays verbatim", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [
|
||||
job(81, { workflowSignal: signal("package-work", "tailored-cv", "Tailored CV missing for this role.") }),
|
||||
job(82, { workflowSignal: null, followUpReason: "No reply after 14d" }),
|
||||
job(83, { workflowSignal: null, followUpReason: "No reply after 14d (my own note)" }),
|
||||
] } as any);
|
||||
renderPage();
|
||||
await screen.findByText("Tailored CV missing for this role.");
|
||||
changeLanguage("nb");
|
||||
expect(screen.getByText(copy("reminderReasonCv"))).toBeInTheDocument();
|
||||
expect(screen.getByText(copy("reminderReasonNoReplyDays", { days: 14 }))).toBeInTheDocument();
|
||||
expect(screen.getByText("No reply after 14d (my own note)")).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(1); assertNoOtherWrites();
|
||||
});
|
||||
|
||||
function job(id: number, overrides: Partial<JobApplication> = {}): JobApplication {
|
||||
return {
|
||||
id, jobTitle: `Reminder role ${id}`, company: { id, name: `Company ${id}` }, status: "Waiting",
|
||||
|
||||
@@ -463,10 +463,10 @@ export default function CorrespondenceInboxPage() {
|
||||
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
||||
openDraft({
|
||||
...stored,
|
||||
companyName: item?.companyName || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}`,
|
||||
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
|
||||
companyName: item?.companyName || "",
|
||||
jobTitle: item?.jobTitle || "",
|
||||
providerName: provider?.displayName || stored.provider,
|
||||
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
||||
fromAddress: provider?.address || "",
|
||||
});
|
||||
setSendResult(null); setSendError(null); setDraftError(null);
|
||||
} finally { finishComposeOperation(); }
|
||||
@@ -676,7 +676,7 @@ export default function CorrespondenceInboxPage() {
|
||||
<PageSection surface title={t("correspondenceInbox")} description={t("emailInboxSubtitle")}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t(inboxTotal === 1 ? "emailInboxItem" : "emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxLinkedOnPage", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxInboundOnPage", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
@@ -782,13 +782,13 @@ export default function CorrespondenceInboxPage() {
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography id="email-draft-title" component="h3" variant="h6">{t("emailInboxDraftTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{draft.companyName || `${t("emailInboxJobNumber")} #${draft.jobApplicationId}`} · {draft.jobTitle || t("emailInboxSavedDraft")}</Typography>
|
||||
{draftDirty ? <Chip size="small" variant="outlined" color="warning" label={t("assetsUnsavedChanges")} sx={{ mt: 1 }} /> : null}
|
||||
</Box>
|
||||
<Button onClick={() => void discardDraft()} disabled={composeBusy}>{t("emailInboxDiscardDraft")}</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
||||
<TextField label={t("emailInboxProvider")} value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxProvider")} value={`${draft.providerName} · ${draft.fromAddress || t("emailInboxReconnectBeforeSending")}`} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxThread")} value={draft.threadId || t("emailInboxNewMessage")} slotProps={{ input: { readOnly: true } }} />
|
||||
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 320 }} />
|
||||
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 998 }} />
|
||||
@@ -839,7 +839,7 @@ export default function CorrespondenceInboxPage() {
|
||||
{item.provider ? <Chip size="small" label={item.provider === "microsoft" ? "Outlook" : item.provider === "gmail" ? "Gmail" : item.provider.toUpperCase()} variant="outlined" /> : null}
|
||||
{item.externalThreadId ? <Chip size="small" label={t("emailInboxThreadId", { id: item.externalThreadId })} color="success" variant="outlined" /> : <Chip size="small" label={t("emailInboxManualInternal")} variant="outlined" />}
|
||||
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={t("emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={t(item.attachmentCount === 1 ? "emailInboxAttachmentCount" : "emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
|
||||
<Button size="small" variant="text" disabled={!!unlinkingThreadId} aria-expanded={selectedMessageId === item.id} aria-controls={selectedMessageId === item.id ? `${rowId}-${item.id}-message` : undefined} onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
||||
<Button size="small" variant="text" disabled={!!unlinkingThreadId} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
|
||||
{item.provider === "gmail" && item.externalThreadId ? (
|
||||
|
||||
Reference in New Issue
Block a user