fix(i18n): unify shared view states

This commit is contained in:
cesnimda
2026-08-29 23:37:07 +02:00
parent 360b992b37
commit 10ce0f2086
9 changed files with 98 additions and 38 deletions
+2
View File
@@ -36,6 +36,7 @@ Updated: 2026-08-29
- Reused the shared EN/NO language control on the unauthenticated landing page, so language can be changed before sign-in and persists through the existing locale mechanism. - Reused the shared EN/NO language control on the unauthenticated landing page, so language can be changed before sign-in and persists through the existing locale mechanism.
- Finished the remaining active CV Builder format labels: long-document page counts, US Letter naming, and localized long/short date examples now follow the selected UI language. - Finished the remaining active CV Builder format labels: long-document page counts, US Letter naming, and localized long/short date examples now follow the selected UI language.
- Localized the Admin System runtime and CV benchmark diagnostics, including probe/email failures, model/Ollama state, parser findings, benchmark summaries, fallback guidance, and locale-aware timestamps while leaving runtime/provider/file values untouched. - Localized the Admin System runtime and CV benchmark diagnostics, including probe/email failures, model/Ollama state, parser findings, benchmark summaries, fallback guidance, and locale-aware timestamps while leaving runtime/provider/file values untouched.
- Consolidated active list/dashboard/Kanban/reminder loading and failure presentation through the shared resource notice. Retry/progress accessibility follows EN/NB, duplicate fallback text is suppressed, and Kanban drag/keyboard announcements are localized.
### In progress ### In progress
@@ -95,6 +96,7 @@ Updated: 2026-08-29
- Final localization regression gate: all 62 frontend suites and 267/267 tests passed after correcting the isolated Pro-notice provider fallback; the optimized Next production build and integrated TypeScript check passed; the full .NET 9 backend suite passed 713/713. - Final localization regression gate: all 62 frontend suites and 267/267 tests passed after correcting the isolated Pro-notice provider fallback; the optimized Next production build and integrated TypeScript check passed; the full .NET 9 backend suite passed 713/713.
- Career Profile atomicity/CV locale verification: focused backend 55/55 and frontend 10/10 passed; the targeted long Code-template Playwright/PDF flow passed 1/1 with no duplicate-key or out-of-range locale warning. Its fresh disposable database contained exactly 9 experiences, 1 education, 8 skills, 1 project, 1 certification and 2 languages, with zero duplicate experience ItemKeys. - Career Profile atomicity/CV locale verification: focused backend 55/55 and frontend 10/10 passed; the targeted long Code-template Playwright/PDF flow passed 1/1 with no duplicate-key or out-of-range locale warning. Its fresh disposable database contained exactly 9 experiences, 1 education, 8 skills, 1 project, 1 certification and 2 languages, with zero duplicate experience ItemKeys.
- Post-fix complete regression: backend 716/716 and frontend 62 suites with 268/268 tests passed; the optimized Next production build and integrated TypeScript check passed. - Post-fix complete regression: backend 716/716 and frontend 62 suites with 268/268 tests passed; the optimized Next production build and integrated TypeScript check passed.
- Shared view-state focused verification: 3 suites and 15/15 tests passed, including Bokmål loading/retry and Kanban interaction coverage; TypeScript passed.
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation. - Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
- Full backend: 712/712 tests passed on .NET 9. - Full backend: 712/712 tests passed on .NET 9.
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch. - Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
@@ -53,7 +53,7 @@ export default function CompaniesTable() {
}, },
{ {
initialData: [], initialData: [],
errorMessage: t("companiesUpdateFailed"), errorMessage: t("companiesLoadFailed"),
deps: [t], deps: [t],
}, },
); );
@@ -120,8 +120,8 @@ export default function CompaniesTable() {
<ViewStateNotice <ViewStateNotice
loading={companiesResource.loading} loading={companiesResource.loading}
error={companiesResource.error} error={companiesResource.error}
title="Unable to load companies" title={t("companiesLoadFailed")}
description="The companies list is unavailable right now. Try again when the API is reachable." description={t("companiesLoadFailedBody")}
onRetry={companiesResource.reload} onRetry={companiesResource.reload}
/> />
@@ -146,8 +146,8 @@ export default function DashboardView() {
loadSummary, loadSummary,
{ {
initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] }, initialData: { stats: null as JobStats | null, overview: null as OverviewAnalytics | null, reminderJobs: [] as ReminderJob[] },
errorMessage: "Unable to load dashboard summary data right now.", errorMessage: t("dashboardSummaryUnavailableTitle"),
deps: [], deps: [t],
}, },
); );
@@ -170,8 +170,8 @@ export default function DashboardView() {
loadTrends, loadTrends,
{ {
initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null }, initialData: { analytics: [] as AnalyticsPoint[], tags: [] as TagPoint[], tagTrends: null as TagTrendResponse | null },
errorMessage: "Unable to load dashboard trends right now.", errorMessage: t("dashboardTrendsUnavailableTitle"),
deps: [months], deps: [months, t],
}, },
); );
+6 -6
View File
@@ -315,8 +315,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
}, },
{ {
initialData: { items: [], total: 0, page: 1, pageSize }, initialData: { items: [], total: 0, page: 1, pageSize },
errorMessage: "Unable to load jobs right now.", errorMessage: t("jobTableJobsLoadFailed"),
deps: [params, refreshToken, reloadToken, pageSize], deps: [params, refreshToken, reloadToken, pageSize, t],
}, },
); );
@@ -614,15 +614,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
<ViewStateNotice <ViewStateNotice
error={jobsResource.error} error={jobsResource.error}
title={mode === "trash" ? "Unable to load trash" : "Unable to load jobs"} title={mode === "trash" ? t("jobTableTrashLoadFailed") : t("jobTableJobsLoadFailed")}
description={mode === "trash" ? "The deleted-jobs view cannot reach the API right now." : "The jobs list cannot reach the API right now."} description={mode === "trash" ? t("jobTableTrashLoadFailedBody") : t("jobTableJobsLoadFailedBody")}
onRetry={jobsResource.reload} onRetry={jobsResource.reload}
/> />
{companiesError ? ( {companiesError ? (
<ViewStateNotice <ViewStateNotice
error={companiesError} error={companiesError}
title="Unable to load company filters" title={t("jobTableCompanyFiltersLoadFailed")}
description="Company filter data is unavailable right now." description={t("jobTableCompanyFiltersLoadFailedBody")}
onRetry={reloadCompanies} onRetry={reloadCompanies}
compact compact
/> />
+16 -16
View File
@@ -102,8 +102,8 @@ export default function KanbanBoard() {
}, },
{ {
initialData: [], initialData: [],
errorMessage: "Unable to load the board right now.", errorMessage: t("kanbanLoadFailed"),
deps: [], deps: [t],
}, },
); );
@@ -143,10 +143,10 @@ export default function KanbanBoard() {
try { try {
await api.patch(`/jobapplications/${movingJobId}/status`, { status }); await api.patch(`/jobapplications/${movingJobId}/status`, { status });
jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j))); jobsResource.setData((prev) => prev.map((j) => (j.id === movingJobId ? { ...j, status } : j)));
setAnnouncement(`Job moved to ${statusLabel(t, status)}.`); setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
} catch (error: any) { } catch (error: any) {
setMoveError(getApiErrorMessage(error, "Unable to move this job right now.")); setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
setAnnouncement("Job move failed."); setAnnouncement(t("kanbanMoveFailedAnnouncement"));
} }
}; };
@@ -155,10 +155,10 @@ export default function KanbanBoard() {
try { try {
await api.patch(`/jobapplications/${id}/status`, { status }); await api.patch(`/jobapplications/${id}/status`, { status });
jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); jobsResource.setData((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j)));
setAnnouncement(`Job moved to ${statusLabel(t, status)}.`); setAnnouncement(t("kanbanMoveSucceeded", { status: statusLabel(t, status) }));
} catch (error: any) { } catch (error: any) {
setMoveError(getApiErrorMessage(error, "Unable to move this job right now.")); setMoveError(getApiErrorMessage(error, t("kanbanMoveFailed")));
setAnnouncement("Job move failed."); setAnnouncement(t("kanbanMoveFailedAnnouncement"));
} }
}; };
@@ -187,8 +187,8 @@ export default function KanbanBoard() {
<ViewStateNotice <ViewStateNotice
loading={jobsResource.loading} loading={jobsResource.loading}
error={jobsResource.error} error={jobsResource.error}
title="Unable to load the kanban board" title={t("kanbanLoadFailed")}
description="The board could not reach the API." description={t("kanbanLoadFailedBody")}
onRetry={jobsResource.reload} onRetry={jobsResource.reload}
/> />
@@ -227,11 +227,11 @@ export default function KanbanBoard() {
onBlur={() => { if (dragOverColumn === key) setDragOverColumn(null); }} onBlur={() => { if (dragOverColumn === key) setDragOverColumn(null); }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Escape" && isDragActive) { if (event.key === "Escape" && isDragActive) {
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled."); event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
} else if ((event.key === "Enter" || event.key === " ") && isDragActive) { } else if ((event.key === "Enter" || event.key === " ") && isDragActive) {
event.preventDefault(); event.preventDefault();
if (droppable) void onDropTo(key as PipelineGroup); if (droppable) void onDropTo(key as PipelineGroup);
else setAnnouncement(`${label} is not a valid drop target.`); else setAnnouncement(t("kanbanInvalidDropTarget", { status: label }));
} }
}} }}
onDragEnter={() => { if (isDragActive) setDragOverColumn(key); }} onDragEnter={() => { if (isDragActive) setDragOverColumn(key); }}
@@ -291,19 +291,19 @@ export default function KanbanBoard() {
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-pressed={dragJobId === j.id} aria-pressed={dragJobId === j.id}
aria-label={`${j.jobTitle}, ${statusLabel(t, j.status)}. ${dragJobId === j.id ? "Picked up; focus a column and press Enter to move." : "Press Space to pick up."}`} aria-label={t(dragJobId === j.id ? "kanbanCardPickedUpLabel" : "kanbanCardPickUpLabel", { title: j.jobTitle, status: statusLabel(t, j.status) })}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Escape" && dragJobId === j.id) { if (event.key === "Escape" && dragJobId === j.id) {
event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement("Keyboard move cancelled."); event.preventDefault(); setDragJobId(null); setDragOverColumn(null); setAnnouncement(t("kanbanKeyboardMoveCancelled"));
} else if (event.key === " ") { } else if (event.key === " ") {
event.preventDefault(); event.preventDefault();
const pickingUp = dragJobId !== j.id; const pickingUp = dragJobId !== j.id;
setDragJobId(pickingUp ? j.id : null); setDragJobId(pickingUp ? j.id : null);
setDragOverColumn(null); setDragOverColumn(null);
setAnnouncement(pickingUp ? `${j.jobTitle} picked up. Focus a column and press Enter to move.` : "Keyboard move cancelled."); setAnnouncement(pickingUp ? t("kanbanPickedUpAnnouncement", { title: j.jobTitle }) : t("kanbanKeyboardMoveCancelled"));
} }
}} }}
onDragStart={() => { setDragJobId(j.id); setAnnouncement(`${j.jobTitle} picked up.`); }} onDragStart={() => { setDragJobId(j.id); setAnnouncement(t("kanbanDragStartedAnnouncement", { title: j.jobTitle })); }}
onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }} onDragEnd={() => { setDragJobId(null); setDragOverColumn(null); }}
sx={{ sx={{
cursor: "grab", cursor: "grab",
@@ -80,8 +80,8 @@ export default function RemindersView() {
}, },
{ {
initialData: [], initialData: [],
errorMessage: "Unable to load reminders right now.", errorMessage: t("remindersLoadFailed"),
deps: [], deps: [t],
}, },
); );
@@ -113,8 +113,8 @@ export default function RemindersView() {
<ViewStateNotice <ViewStateNotice
loading={remindersResource.loading} loading={remindersResource.loading}
error={remindersResource.error} error={remindersResource.error}
title="Unable to load reminders" title={t("remindersLoadFailed")}
description="The reminders view cannot reach the API right now." description={t("remindersLoadFailedBody")}
onRetry={remindersResource.reload} onRetry={remindersResource.reload}
/> />
@@ -3,6 +3,7 @@ import React from "react";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material"; import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import type { ViewResourceError } from "../hooks/useViewResource"; import type { ViewResourceError } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
type Props = { type Props = {
loading?: boolean; loading?: boolean;
@@ -14,11 +15,14 @@ type Props = {
compact?: boolean; compact?: boolean;
}; };
export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel = "Retry", onRetry, compact = false }: Props) { export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel, onRetry, compact = false }: Props) {
const { t } = useI18n();
const resolvedRetryLabel = retryLabel ?? t("retry");
if (loading) { if (loading) {
return ( return (
<Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}> <Box role="status" aria-live="polite" sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
<CircularProgress size={compact ? 24 : 28} /> <CircularProgress size={compact ? 24 : 28} aria-label={t("loading")} />
</Box> </Box>
); );
} }
@@ -26,6 +30,7 @@ export default function ViewStateNotice({ loading = false, error = null, title,
if (!error) return null; if (!error) return null;
const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error"; const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error";
const detail = error.message !== title && error.message !== description ? error.message : null;
return ( return (
<Alert <Alert
@@ -35,11 +40,11 @@ export default function ViewStateNotice({ loading = false, error = null, title,
alignItems: "flex-start", alignItems: "flex-start",
borderRadius: 3, borderRadius: 3,
}} }}
action={error.retryable && onRetry ? <Button color="inherit" size="small" onClick={() => void onRetry()}>{retryLabel}</Button> : undefined} action={error.retryable && onRetry ? <Button color="inherit" size="small" onClick={() => void onRetry()}>{resolvedRetryLabel}</Button> : undefined}
> >
<Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography> <Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography>
{description ? <Typography variant="body2">{description}</Typography> : null} {description ? <Typography variant="body2">{description}</Typography> : null}
{error.message ? <Typography variant="body2" sx={{ mt: 0.5 }}>{error.message}</Typography> : null} {detail ? <Typography variant="body2" sx={{ mt: 0.5 }}>{detail}</Typography> : null}
</Alert> </Alert>
); );
} }
+42
View File
@@ -1339,6 +1339,8 @@ export const translations = {
remindersFollowUpCleared: "Follow-up cleared.", remindersFollowUpCleared: "Follow-up cleared.",
remindersFollowUpSet: "Follow-up set.", remindersFollowUpSet: "Follow-up set.",
remindersFollowUpFailed: "Failed to set follow-up.", remindersFollowUpFailed: "Failed to set follow-up.",
remindersLoadFailed: "Unable to load reminders",
remindersLoadFailedBody: "The reminders view cannot reach the API right now.",
companiesEmpty: "No companies yet.", companiesEmpty: "No companies yet.",
companiesName: "Name", companiesName: "Name",
companiesLocation: "Location", companiesLocation: "Location",
@@ -1355,6 +1357,8 @@ export const translations = {
companiesNextContactField: "Next contact", companiesNextContactField: "Next contact",
companiesUpdated: "Company updated.", companiesUpdated: "Company updated.",
companiesUpdateFailed: "Failed to update company.", companiesUpdateFailed: "Failed to update company.",
companiesLoadFailed: "Unable to load companies",
companiesLoadFailedBody: "The companies list is unavailable right now. Try again when the API is reachable.",
adminUsersTitle: "Users", adminUsersTitle: "Users",
adminUsersSubtitle: "Admin-only user management.", adminUsersSubtitle: "Admin-only user management.",
adminUsersCreateUser: "Create user", adminUsersCreateUser: "Create user",
@@ -1396,6 +1400,17 @@ export const translations = {
kanbanAppliedAgo: "Applied {days}d ago", kanbanAppliedAgo: "Applied {days}d ago",
kanbanFollowUpNow: "Follow up now", kanbanFollowUpNow: "Follow up now",
kanbanReplyDueIn: "Reply due in {days}d", kanbanReplyDueIn: "Reply due in {days}d",
kanbanLoadFailed: "Unable to load the kanban board",
kanbanLoadFailedBody: "The board could not reach the API.",
kanbanMoveSucceeded: "Job moved to {status}.",
kanbanMoveFailed: "Unable to move this job right now.",
kanbanMoveFailedAnnouncement: "Job move failed.",
kanbanKeyboardMoveCancelled: "Keyboard move cancelled.",
kanbanInvalidDropTarget: "{status} is not a valid drop target.",
kanbanCardPickedUpLabel: "{title}, {status}. Picked up; focus a column and press Enter to move.",
kanbanCardPickUpLabel: "{title}, {status}. Press Space to pick up.",
kanbanPickedUpAnnouncement: "{title} picked up. Focus a column and press Enter to move.",
kanbanDragStartedAnnouncement: "{title} picked up.",
adminSystemTitle: "System status", adminSystemTitle: "System status",
adminSystemLoadFailed: "Failed to load system status.", adminSystemLoadFailed: "Failed to load system status.",
adminSystemTestEmailFailed: "Failed to send test email.", adminSystemTestEmailFailed: "Failed to send test email.",
@@ -1948,6 +1963,12 @@ export const translations = {
jobTableOverview: "Overview", jobTableOverview: "Overview",
jobTableNoSummaryYet: "No summary yet.", jobTableNoSummaryYet: "No summary yet.",
jobTableNoJobsFound: "No jobs found.", jobTableNoJobsFound: "No jobs found.",
jobTableJobsLoadFailed: "Unable to load jobs",
jobTableJobsLoadFailedBody: "The jobs list cannot reach the API right now.",
jobTableTrashLoadFailed: "Unable to load trash",
jobTableTrashLoadFailedBody: "The deleted-jobs view cannot reach the API right now.",
jobTableCompanyFiltersLoadFailed: "Unable to load company filters",
jobTableCompanyFiltersLoadFailedBody: "Company filter data is unavailable right now.",
jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.", jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.",
jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.", jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.",
jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet", jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet",
@@ -3617,6 +3638,8 @@ export const translations = {
remindersFollowUpCleared: "Oppfølging fjernet.", remindersFollowUpCleared: "Oppfølging fjernet.",
remindersFollowUpSet: "Oppfølging satt.", remindersFollowUpSet: "Oppfølging satt.",
remindersFollowUpFailed: "Kunne ikke sette oppfølging.", remindersFollowUpFailed: "Kunne ikke sette oppfølging.",
remindersLoadFailed: "Kunne ikke laste påminnelser",
remindersLoadFailedBody: "Påminnelsesvisningen fikk ikke kontakt med API-et akkurat nå.",
companiesEmpty: "Ingen selskaper ennå.", companiesEmpty: "Ingen selskaper ennå.",
companiesName: "Navn", companiesName: "Navn",
companiesLocation: "Sted", companiesLocation: "Sted",
@@ -3633,6 +3656,8 @@ export const translations = {
companiesNextContactField: "Neste kontakt", companiesNextContactField: "Neste kontakt",
companiesUpdated: "Selskap oppdatert.", companiesUpdated: "Selskap oppdatert.",
companiesUpdateFailed: "Kunne ikke oppdatere selskap.", companiesUpdateFailed: "Kunne ikke oppdatere selskap.",
companiesLoadFailed: "Kunne ikke laste selskaper",
companiesLoadFailedBody: "Selskapslisten er utilgjengelig akkurat nå. Prøv igjen når API-et er tilgjengelig.",
adminUsersTitle: "Brukere", adminUsersTitle: "Brukere",
adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.", adminUsersSubtitle: "Brukeradministrasjon kun for administratorer.",
adminUsersCreateUser: "Opprett bruker", adminUsersCreateUser: "Opprett bruker",
@@ -3674,6 +3699,17 @@ export const translations = {
kanbanAppliedAgo: "Søkt for {days}d siden", kanbanAppliedAgo: "Søkt for {days}d siden",
kanbanFollowUpNow: "Følg opp nå", kanbanFollowUpNow: "Følg opp nå",
kanbanReplyDueIn: "Svar forfaller om {days}d", kanbanReplyDueIn: "Svar forfaller om {days}d",
kanbanLoadFailed: "Kunne ikke laste kanban-tavlen",
kanbanLoadFailedBody: "Tavlen fikk ikke kontakt med API-et.",
kanbanMoveSucceeded: "Jobben ble flyttet til {status}.",
kanbanMoveFailed: "Kunne ikke flytte denne jobben akkurat nå.",
kanbanMoveFailedAnnouncement: "Flytting av jobben mislyktes.",
kanbanKeyboardMoveCancelled: "Tastaturflytting avbrutt.",
kanbanInvalidDropTarget: "{status} er ikke et gyldig mål.",
kanbanCardPickedUpLabel: "{title}, {status}. Løftet; fokuser en kolonne og trykk Enter for å flytte.",
kanbanCardPickUpLabel: "{title}, {status}. Trykk mellomrom for å løfte.",
kanbanPickedUpAnnouncement: "{title} er løftet. Fokuser en kolonne og trykk Enter for å flytte.",
kanbanDragStartedAnnouncement: "{title} er løftet.",
adminSystemTitle: "Systemstatus", adminSystemTitle: "Systemstatus",
adminSystemLoadFailed: "Kunne ikke laste systemstatus.", adminSystemLoadFailed: "Kunne ikke laste systemstatus.",
adminSystemTestEmailFailed: "Kunne ikke sende test-e-post.", adminSystemTestEmailFailed: "Kunne ikke sende test-e-post.",
@@ -4226,6 +4262,12 @@ export const translations = {
jobTableOverview: "Oversikt", jobTableOverview: "Oversikt",
jobTableNoSummaryYet: "Ingen oppsummering ennå.", jobTableNoSummaryYet: "Ingen oppsummering ennå.",
jobTableNoJobsFound: "Ingen jobber funnet.", jobTableNoJobsFound: "Ingen jobber funnet.",
jobTableJobsLoadFailed: "Kunne ikke laste jobber",
jobTableJobsLoadFailedBody: "Jobblisten fikk ikke kontakt med API-et akkurat nå.",
jobTableTrashLoadFailed: "Kunne ikke laste papirkurven",
jobTableTrashLoadFailedBody: "Visningen for slettede jobber fikk ikke kontakt med API-et akkurat nå.",
jobTableCompanyFiltersLoadFailed: "Kunne ikke laste selskapsfiltre",
jobTableCompanyFiltersLoadFailedBody: "Selskapsdata for filtrering er utilgjengelige akkurat nå.",
jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.", jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.",
jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.", jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.",
jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket", jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket",
@@ -46,6 +46,7 @@ function renderBoard() {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
window.localStorage.clear();
}); });
test('the board collapses ten stages into the three agreed groups', async () => { test('the board collapses ten stages into the three agreed groups', async () => {
@@ -108,6 +109,16 @@ test('loading and retryable error states replace the board', async () => {
expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeInTheDocument(); expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeInTheDocument();
}); });
test('loading and retry controls follow the selected Bokmål locale', async () => {
window.localStorage.setItem('uiLanguage', 'nb-NO');
mockedApi.get.mockRejectedValueOnce(new Error('offline'));
renderBoard();
expect(await screen.findByText('Kunne ikke laste kanban-tavlen')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Prøv igjen' })).toBeInTheDocument();
});
test('dropping onto a group applies that group entry stage', async () => { test('dropping onto a group applies that group entry stage', async () => {
mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any); mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any);