feat(ui): name controls and preserve modal state

This commit is contained in:
cesnimda
2026-09-11 19:31:32 +02:00
parent 682a3028bb
commit ebd4a3e245
52 changed files with 1366 additions and 42 deletions
@@ -235,8 +235,9 @@ test("AI cover letter suggestions use the linked CV and require explicit apply",
render(<ApplicationCoverLetterSection jobId={7} />);
expect(await screen.findByText(/application's full job advert and analysis/i)).toBeInTheDocument();
const assistantSelects = screen.getAllByRole("combobox");
fireEvent.mouseDown(assistantSelects[assistantSelects.length - 1]);
expect(screen.getByRole("combobox", { name: "Action" })).toBeVisible();
expect(screen.getByRole("combobox", { name: "Tone" })).toBeVisible();
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Document language" }));
fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i }));
fireEvent.click(screen.getByRole("button", { name: "Generate" }));
+2 -2
View File
@@ -73,14 +73,14 @@ test('attachments metadata controls update purpose and ai usage', async () => {
expect(screen.getByRole('button', { name: 'Rename: resume.pdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Delete: resume.pdf' })).toBeInTheDocument();
fireEvent.mouseDown(screen.getByRole('combobox'));
fireEvent.mouseDown(screen.getByRole('combobox', { name: /purpose/i }));
fireEvent.click(await screen.findByRole('option', { name: /portfolio/i }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/attachments/7', expect.objectContaining({ purpose: 'portfolio', useForAi: true }));
});
fireEvent.click(screen.getByRole('switch'));
fireEvent.click(screen.getByRole('switch', { name: 'Use for AI: resume.pdf' }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/attachments/7', expect.objectContaining({ purpose: 'portfolio', useForAi: false }));
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useId, useState } from "react";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
@@ -558,6 +558,7 @@ const COVER_LETTER_ACTIONS = [
] as const;
function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number; currentText: string; onApply: (text: string, action: string) => void }) {
const labelId = useId();
const { canUseAi } = useAccountPlan();
const { language: uiLanguage, t } = useI18n();
const { data: cv, loading: loadingCv } = useAsset<ApplicationCv>(() => applicationAssetsApi.cv(jobId), [jobId]);
@@ -607,14 +608,14 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>{t("coverAiAction")}</InputLabel>
<Select label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
<InputLabel id={`${labelId}-coverAiAction`}>{t("coverAiAction")}</InputLabel>
<Select labelId={`${labelId}-coverAiAction`} label={t("coverAiAction")} value={action} onChange={(event) => setAction(event.target.value)}>
{COVER_LETTER_ACTIONS.map((item) => <MenuItem key={item.key} value={item.key}>{t(item.labelKey)}</MenuItem>)}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel>{t("coverAiTone")}</InputLabel>
<Select label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
<InputLabel id={`${labelId}-coverAiTone`}>{t("coverAiTone")}</InputLabel>
<Select labelId={`${labelId}-coverAiTone`} label={t("coverAiTone")} value={mode} onChange={(event) => setMode(event.target.value)}>
{[
["professional", "coverToneProfessional"], ["friendly", "coverToneFriendly"], ["short", "coverToneShort"],
["detailed", "coverToneDetailed"], ["modern", "coverToneModern"], ["traditional", "coverToneTraditional"],
@@ -622,8 +623,8 @@ function CoverLetterAiAssistant({ jobId, currentText, onApply }: { jobId: number
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 190 }}>
<InputLabel>{t("coverAiDocumentLanguage")}</InputLabel>
<Select label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<InputLabel id={`${labelId}-coverAiDocumentLanguage`}>{t("coverAiDocumentLanguage")}</InputLabel>
<Select labelId={`${labelId}-coverAiDocumentLanguage`} label={t("coverAiDocumentLanguage")} value={language} onChange={(event) => setLanguage(event.target.value as "en" | "nb-NO")}>
<MenuItem value="en">{t("languageEnglish")}</MenuItem>
<MenuItem value="nb-NO">{t("languageNorwegianBokmal")}</MenuItem>
</Select>
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import {
Alert,
@@ -83,6 +83,7 @@ function purposeLabel(purpose: string | null | undefined, t: (key: any) => strin
}
export default function Attachments({ jobId }: { jobId: number }) {
const labelId = useId();
const { toast } = useToast();
const { t } = useI18n();
const { confirmAction, promptForValue } = useDialogActions();
@@ -297,8 +298,8 @@ export default function Attachments({ jobId }: { jobId: number }) {
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
<TableCell>
<FormControl size="small" fullWidth>
<InputLabel>{t("attachmentsPurpose")}</InputLabel>
<Select value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
<InputLabel id={`${labelId}-${a.id}`}>{t("attachmentsPurpose")}</InputLabel>
<Select labelId={`${labelId}-${a.id}`} value={(a.purpose || guessKind(a.fileName)).toLowerCase()} label={t("attachmentsPurpose")} onChange={(e) => void updateMetadata(a, { purpose: e.target.value })}>
<MenuItem value="resume">{t("attachmentsPurposeResume")}</MenuItem>
<MenuItem value="cover-letter">{t("attachmentsPurposeCoverLetter")}</MenuItem>
<MenuItem value="portfolio">{t("attachmentsPurposePortfolio")}</MenuItem>
@@ -310,7 +311,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Switch size="small" checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
<Switch size="small" slotProps={{ input: { role: "switch", "aria-label": `${t("attachmentsAiUse")}: ${a.fileName}` } }} checked={Boolean(a.useForAi)} onChange={(_, checked) => void updateMetadata(a, { useForAi: checked })} />
<Typography variant="caption" sx={{ color: "text.secondary" }}>{a.useForAi ? t("attachmentsAiEnabled") : t("attachmentsAiDisabled")}</Typography>
</Box>
</TableCell>
@@ -114,7 +114,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
];
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<Dialog open={open} onClose={onClose} slotProps={{ paper: { "aria-label": t("quickSearch") } }} fullWidth maxWidth="sm">
<DialogContent sx={{ p: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, pt: 0.5 }}>
<SearchIcon fontSize="small" />
@@ -123,6 +123,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd
variant="standard"
fullWidth
placeholder={t("searchPlaceholder")}
slotProps={{ htmlInput: { "aria-label": t("quickSearch") } }}
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{ disableUnderline: true }}
@@ -1,6 +1,7 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Typography } from "@mui/material";
import { visuallyHidden } from "@mui/utils";
import type { ViewResourceError } from "../hooks/useViewResource";
import { useI18n } from "../i18n/I18nProvider";
@@ -18,22 +19,30 @@ type Props = {
export default function ViewStateNotice({ loading = false, error = null, title, description, retryLabel, onRetry, compact = false }: Props) {
const { t } = useI18n();
const resolvedRetryLabel = retryLabel ?? t("retry");
const [observedLoading, setObservedLoading] = useState(loading);
useEffect(() => { if (loading) setObservedLoading(true); }, [loading]);
// Keep one live region mounted through the read. An idle/error-only consumer
// has not completed a load; unrelated form edits must not announce anything.
const status = <Box component="span" role="status" aria-live="polite" aria-atomic="true" sx={visuallyHidden}>
{loading ? t("loading") : !error && observedLoading ? t("contentLoaded") : ""}
</Box>;
if (loading) {
return (
<Box role="status" aria-live="polite" sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
<>{status}<Box sx={{ py: compact ? 3 : 6, display: "flex", justifyContent: "center" }}>
<CircularProgress size={compact ? 24 : 28} aria-label={t("loading")} />
</Box>
</Box></>
);
}
if (!error) return null;
if (!error) return <>{status}</>;
const severity = error.kind === "unauthorized" ? "warning" : error.kind === "unavailable" ? "error" : "error";
const detail = error.message !== title && error.message !== description ? error.message : null;
return (
<Alert
<>{status}<Alert
severity={severity}
sx={{
mb: compact ? 1.5 : 2,
@@ -45,6 +54,6 @@ export default function ViewStateNotice({ loading = false, error = null, title,
<Typography sx={{ fontWeight: 800, mb: 0.35 }}>{title}</Typography>
{description ? <Typography variant="body2">{description}</Typography> : null}
{detail ? <Typography variant="body2" sx={{ mt: 0.5 }}>{detail}</Typography> : null}
</Alert>
</Alert></>
);
}
@@ -1,4 +1,4 @@
import React from "react";
import React, { useId } from "react";
import {
Box, Button, Divider, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Slider, Stack, Switch, TextField, Typography,
} from "@mui/material";
@@ -27,6 +27,7 @@ type Props = {
export default function CvCustomizeTab({ mode, settings, update, themes, sectionLabel }: Props) {
const { t } = useI18n();
const labelId = useId();
const activeTheme = themes.find((theme) => theme.id === settings.themeId);
const supports = (setting: string) => !activeTheme?.supportedSettings?.length || activeTheme.supportedSettings.includes(setting);
const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
@@ -58,14 +59,14 @@ export default function CvCustomizeTab({ mode, settings, update, themes, section
{supports("typography") && <><Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>{t("cvEditorTypography")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingFont")}</InputLabel><Select label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel>{t("cvEditorBodyFont")}</InputLabel><Select label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingFont`}>{t("cvEditorHeadingFont")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingFont`} label={t("cvEditorHeadingFont")} value={settings.headingFont ?? ""} onChange={(event) => update({ headingFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
<FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorBodyFont`}>{t("cvEditorBodyFont")}</InputLabel><Select labelId={`${labelId}-cvEditorBodyFont`} label={t("cvEditorBodyFont")} value={settings.bodyFont ?? ""} onChange={(event) => update({ bodyFont: event.target.value || null })}><MenuItem value="">{t("cvEditorThemeDefault")}</MenuItem>{FONTS.map((font, index) => <MenuItem key={font} value={font}>{FONT_LABELS[index]}</MenuItem>)}</Select></FormControl>
</Box>
<ControlSlider label={t("cvEditorBodySize")} value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
<ControlSlider label={t("cvEditorHeadingSize")} value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
<ControlSlider label={t("cvEditorLineHeight")} value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} /></>}
{supports("heading") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeadingTreatment")}</InputLabel><Select label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
{supports("header") && <FormControl size="small" fullWidth><InputLabel>{t("cvEditorHeaderTreatment")}</InputLabel><Select label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
{supports("heading") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeadingTreatment`}>{t("cvEditorHeadingTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeadingTreatment`} label={t("cvEditorHeadingTreatment")} value={settings.headingStyle ?? ""} onChange={(event) => update({ headingStyle: (event.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="caps-rule">{t("cvEditorUppercaseDivider")}</MenuItem><MenuItem value="underline">{t("richTextUnderline")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="bar">{t("cvEditorAccentBar")}</MenuItem></Select></FormControl>}
{supports("header") && <FormControl size="small" fullWidth><InputLabel id={`${labelId}-cvEditorHeaderTreatment`}>{t("cvEditorHeaderTreatment")}</InputLabel><Select labelId={`${labelId}-cvEditorHeaderTreatment`} label={t("cvEditorHeaderTreatment")} value={settings.headerStyle ?? ""} onChange={(event) => update({ headerStyle: (event.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">{t("cvEditorTemplateDefault")}</MenuItem><MenuItem value="plain">{t("cvEditorPlain")}</MenuItem><MenuItem value="band">{t("cvEditorColourBand")}</MenuItem><MenuItem value="centered">{t("cvEditorCentred")}</MenuItem><MenuItem value="kicker">{t("cvEditorEditorial")}</MenuItem></Select></FormControl>}
{supports("skills") && <><FormControl size="small" fullWidth><InputLabel>{t("cvEditorSkillsPresentation")}</InputLabel><Select inputProps={{ "aria-label": t("cvEditorSkillsPresentation") }} label={t("cvEditorSkillsPresentation")} value={settings.skillsStyle ?? "tags"} onChange={(event) => update({ skillsStyle: event.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">{t("cvEditorTags")}</MenuItem><MenuItem value="text">{t("cvEditorCompactText")}</MenuItem><MenuItem value="bullets">{t("cvEditorBulletList")}</MenuItem><MenuItem value="grouped">{t("cvEditorGroupedSkills")}</MenuItem></Select></FormControl>
{settings.skillsStyle === "grouped" && <TextField label={t("cvEditorSkillGroups")} helperText={t("cvEditorSkillGroupsHelp")} multiline minRows={3} value={(settings.skillGroups ?? []).map((group) => `${group.name}: ${group.items.join(", ")}`).join("\n")} onChange={(event) => update({ skillGroups: event.target.value.split("\n").filter(Boolean).map((line) => { const [name, ...items] = line.split(":"); return { name: name.trim(), items: items.join(":").split(",").map((item) => item.trim()).filter(Boolean) }; }) })} />}</>}
+2
View File
@@ -29,6 +29,8 @@ test('renders app-owned confirmation dialog', async () => {
expect(await screen.findByText(/are you sure/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Delete job' })).toHaveAccessibleDescription('Are you sure?');
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
test('uses the selected language for shared dialog defaults', async () => {
+5 -4
View File
@@ -1,4 +1,4 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import React, { createContext, useCallback, useContext, useId, useMemo, useRef, useState } from "react";
import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
import { useOptionalI18n } from "./i18n/I18nProvider";
@@ -22,6 +22,7 @@ const ConfirmContext = createContext<ConfirmContextValue | null>(null);
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const messageId = useId();
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const [state, setState] = useState<ConfirmState>({
open: false,
@@ -58,7 +59,7 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
return (
<ConfirmContext.Provider value={value}>
{children}
<Dialog open={state.open} onClose={() => closeWith(false)} fullWidth maxWidth="xs"
<Dialog open={state.open} onClose={() => closeWith(false)} aria-describedby={messageId} fullWidth maxWidth="xs"
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent>
@@ -67,10 +68,10 @@ export function ConfirmProvider({ children }: { children: React.ReactNode }) {
? i18n?.t("confirmDestructiveHint") ?? "This action may be hard to undo."
: i18n?.t("confirmStandardHint") ?? "Please confirm this action."}
</Alert>
<Typography sx={{ color: "text.secondary" }}>{state.message}</Typography>
<Typography id={messageId} sx={{ color: "text.secondary" }}>{state.message}</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button autoFocus onClick={() => closeWith(false)}>{state.cancelLabel}</Button>
<Button color={state.destructive ? "error" : "primary"} variant="contained" onClick={() => closeWith(true)}>
{state.confirmLabel}
</Button>
+2
View File
@@ -179,6 +179,7 @@ export const translations = {
noWord: "No",
createAndAddAnother: "Create & add another",
loading: "Loading...",
contentLoaded: "Content loaded.",
languageEnglish: "English",
languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Discover jobs",
@@ -2617,6 +2618,7 @@ export const translations = {
noWord: "Nei",
createAndAddAnother: "Opprett og legg til en til",
loading: "Laster...",
contentLoaded: "Innhold lastet.",
languageEnglish: "Engelsk",
languageNorwegianBokmal: "Norsk bokmål",
discoverJobs: "Finn jobber",
@@ -30,7 +30,8 @@ describe.each(["light", "dark"] as const)("%s loaded company workflow", mode =>
if (outcome === "error") fail({ response: { status: 503 } });
else finish({ data: outcome === "empty" ? [] : [company] });
});
expect(screen.queryByRole("status")).not.toBeInTheDocument();
const status = screen.getByRole("status");
expect(status).toHaveTextContent(outcome === "error" ? "" : "Content loaded.");
if (outcome === "error") {
const error = screen.getByRole("alert");
expect(error).not.toBeEmptyDOMElement();
@@ -45,7 +46,8 @@ describe.each(["light", "dark"] as const)("%s loaded company workflow", mode =>
fireEvent.click(screen.getByRole("button", { name: `Edit: ${company.name}` }));
expect(screen.getByRole("dialog")).toHaveAccessibleName();
// Reading/editing a loaded form must not repeat the loading announcement or save.
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(screen.getByRole("status", { hidden: true })).toBe(status);
expect(status).toHaveTextContent("Content loaded.");
} else {
expect(screen.getByRole("table")).toHaveTextContent("No companies");
}
+62
View File
@@ -0,0 +1,62 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfirmProvider, useConfirm } from "./confirm";
import { PromptProvider, usePrompt } from "./prompt";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
test.each(["en", "nb"] as const)("[PRES-E08 PRES-S05] %s confirmation traps/returns focus and preserves cancel/Escape/accept results", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const result = jest.fn();
function Caller() {
const { confirm } = useConfirm();
return <button onClick={async () => result(await confirm({ message: "Remove this item?", destructive: true }))}>Open</button>;
}
render(<I18nProvider><ConfirmProvider><Caller /></ConfirmProvider></I18nProvider>);
const trigger = screen.getByRole("button", { name: "Open" });
for (const action of ["escape", "cancel", "confirm"] as const) {
userEvent.click(trigger);
const dialog = await screen.findByRole("dialog", { name: t.confirmAction });
expect(dialog).toHaveAccessibleDescription("Remove this item?");
expect(screen.getByRole("button", { name: t.cancel })).toHaveFocus();
if (action === "escape") fireEvent.keyDown(dialog, { key: "Escape" });
else userEvent.click(screen.getByRole("button", { name: action === "cancel" ? t.cancel : t.confirm }));
await waitFor(() => expect(result).toHaveBeenLastCalledWith(action === "confirm"));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
}
expect(result).toHaveBeenCalledTimes(3);
localStorage.clear();
});
test.each(["en", "nb"] as const)("[PRES-E08 PRES-W08] %s prompt preserves helper link, exact text, cancellation and reopen defaults", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const result = jest.fn();
function Caller() {
const { prompt } = usePrompt();
return <button onClick={async () => result(await prompt({ message: "Name æøå", defaultValue: "CV.pdf" }))}>Open</button>;
}
render(<I18nProvider><PromptProvider><Caller /></PromptProvider></I18nProvider>);
const trigger = screen.getByRole("button", { name: "Open" });
for (const action of ["escape", "cancel", "save"] as const) {
userEvent.click(trigger);
const input = await screen.findByRole("textbox", { name: "Name æøå" });
expect(input).toHaveFocus();
expect(input).toHaveValue("CV.pdf");
expect(input).toHaveAccessibleDescription("6/180");
expect(input).toHaveAttribute("maxlength", "180");
fireEvent.change(input, { target: { value: "Ny CV æøå.pdf" } });
expect(input).toHaveAccessibleDescription("13/180");
if (action === "escape") fireEvent.keyDown(input, { key: "Escape" });
else userEvent.click(screen.getByRole("button", { name: action === "cancel" ? t.cancel : t.save }));
await waitFor(() => expect(result).toHaveBeenLastCalledWith(action === "save" ? "Ny CV æøå.pdf" : null));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
}
expect(result).toHaveBeenCalledTimes(3);
localStorage.clear();
});
@@ -0,0 +1,55 @@
import React, { useState } from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
import CvCustomizeTab from "./components/cv/CvCustomizeTab";
import RichTextField from "./components/RichTextField";
import { emptyCvVariantSettings } from "./cvBuilder";
beforeEach(() => localStorage.clear());
test.each(["en", "nb"] as const)("[PRES-C08 PRES-C09] %s CV selects have visible names and retain exact override patches", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const update = jest.fn();
render(<I18nProvider><CvCustomizeTab mode="customize" settings={emptyCvVariantSettings()} update={update} themes={[]} sectionLabel={(_, fallback) => fallback} /></I18nProvider>);
for (const [name, option, patch] of [
[t.cvEditorHeadingFont, "Arial", { headingFont: "Arial, Helvetica, sans-serif" }],
[t.cvEditorBodyFont, "Georgia (serif)", { bodyFont: "Georgia, 'Times New Roman', serif" }],
[t.cvEditorHeadingTreatment, t.cvEditorPlain, { headingStyle: "plain" }],
[t.cvEditorHeaderTreatment, t.cvEditorColourBand, { headerStyle: "band" }],
] as const) {
fireEvent.mouseDown(screen.getByRole("combobox", { name }));
fireEvent.click(await screen.findByRole("option", { name: option }));
expect(update).toHaveBeenLastCalledWith(patch);
}
expect(update).toHaveBeenCalledTimes(4);
});
test.each(["en", "nb"] as const)("[PRES-C07 PRES-W06] %s rich text retains names, selected Markdown and editor focus", async language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
function Editor() {
const [text, setText] = useState("Norsk æøå");
return <RichTextField label="Career text" value={text} onChange={setText} />;
}
render(<I18nProvider><Editor /></I18nProvider>);
const input = screen.getByRole("textbox", { name: "Career text" }) as HTMLTextAreaElement;
for (const name of [t.richTextBold, t.richTextItalic, t.richTextUnderline, t.richTextLink]) expect(screen.getByRole("button", { name })).toBeEnabled();
act(() => input.focus());
input.setSelectionRange(6, 9);
fireEvent.click(screen.getByRole("button", { name: t.richTextBold }));
expect(input).toHaveValue("Norsk **æøå**");
await waitFor(() => expect(input).toHaveFocus());
await waitFor(() => expect(input.value.slice(input.selectionStart, input.selectionEnd)).toBe("æøå"));
});
test("[PRES-C07 PRES-W06] disabled rich text cannot format or edit and repeated fields keep separate labels", () => {
const change = jest.fn();
render(<I18nProvider><section aria-label="First"><RichTextField label="First draft" value="Saved" onChange={change} disabled /></section><RichTextField label="Second draft" value="Other" onChange={change} /></I18nProvider>);
expect(screen.getByRole("textbox", { name: "First draft" })).toBeDisabled();
expect(screen.getByRole("textbox", { name: "Second draft" })).toBeEnabled();
for (const button of within(screen.getByRole("region", { name: "First" })).getAllByRole("button")) { expect(button).toBeDisabled(); fireEvent.click(button); }
expect(change).not.toHaveBeenCalled();
});
+2
View File
@@ -23,6 +23,8 @@ test('renders app-owned prompt dialog', async () => {
expect(await screen.findByText(/choose a new name/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('resume.pdf')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Choose a new name' })).toHaveAccessibleDescription('10/180');
expect(screen.getByRole('dialog', { name: 'Rename attachment' })).toHaveAccessibleDescription('Choose a new name');
expect(screen.getByRole('button', { name: /rename/i })).toBeInTheDocument();
});
+5 -4
View File
@@ -1,4 +1,4 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import React, { createContext, useCallback, useContext, useId, useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
import { useOptionalI18n } from "./i18n/I18nProvider";
@@ -23,6 +23,7 @@ const PromptContext = createContext<PromptContextValue | null>(null);
export function PromptProvider({ children }: { children: React.ReactNode }) {
const i18n = useOptionalI18n();
const messageId = useId();
const [state, setState] = useState<PromptState>({
open: false,
title: "Enter value",
@@ -60,12 +61,12 @@ export function PromptProvider({ children }: { children: React.ReactNode }) {
return (
<PromptContext.Provider value={contextValue}>
{children}
<Dialog open={state.open} onClose={() => closeWith(null)} fullWidth maxWidth="xs"
<Dialog open={state.open} onClose={() => closeWith(null)} aria-describedby={messageId} fullWidth maxWidth="xs"
slotProps={{ paper: { sx: { borderRadius: 3, border: 1, borderColor: "divider", backgroundImage: "none" } } }}>
<DialogTitle sx={{ pb: 1, fontWeight: 800 }}>{state.title}</DialogTitle>
<DialogContent>
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180 }} helperText={`${value.length}/180`} />
<Typography id={messageId} sx={{ color: "text.secondary", mb: 1.5 }}>{state.message}</Typography>
<TextField autoFocus fullWidth value={value} onChange={(e) => setValue(e.target.value)} inputProps={{ maxLength: 180, "aria-labelledby": messageId }} helperText={`${value.length}/180`} />
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
<Button onClick={() => closeWith(null)}>{state.cancelLabel}</Button>
@@ -22,7 +22,8 @@ test.each([
const onNavigate = jest.fn();
const onClose = jest.fn();
render(<I18nProvider><QuickCommandDialog open onClose={onClose} onNavigate={onNavigate} onOpenAddJob={jest.fn()} /></I18nProvider>);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Nordlys" } });
expect(screen.getByRole("dialog", { name: "Quick Search" })).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "Quick Search" }), { target: { value: "Nordlys" } });
fireEvent.click(await screen.findByText(label, { exact: true }));
expect(onNavigate).toHaveBeenCalledWith(destination);
expect(onClose).toHaveBeenCalledTimes(1);
@@ -0,0 +1,38 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import ViewStateNotice from "./components/ViewStateNotice";
import { I18nProvider } from "./i18n/I18nProvider";
import { translations } from "./i18n/translations";
test.each(["en", "nb"] as const)("[PRES-E08] %s loading/error/recovery updates one persistent polite region", language => {
localStorage.setItem("uiLanguage", language);
const t = translations[language];
const error = { kind: "unavailable" as const, message: "Unavailable", retryable: true };
const view = (loading: boolean, failed = false) => <I18nProvider><ViewStateNotice loading={loading} error={failed ? error : null} title="Companies" /></I18nProvider>;
const { rerender } = render(view(true));
const status = screen.getByRole("status");
expect(status).toHaveTextContent(t.loading);
expect(status).toHaveAttribute("aria-live", "polite");
expect(status).toHaveAttribute("aria-atomic", "true");
rerender(view(false, true));
expect(status).toBeEmptyDOMElement();
expect(screen.getByRole("alert")).toHaveTextContent("Unavailable");
rerender(view(false));
expect(screen.getByRole("status")).toBe(status);
expect(status).toHaveTextContent(language === "en" ? "Content loaded." : "Innhold lastet.");
rerender(view(false));
expect(screen.getByRole("status")).toBe(status);
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
localStorage.clear();
});
test("[PRES-E08] idle/error-only notices do not invent a load completion; later loading still announces", () => {
const view = (loading = false) => <I18nProvider><ViewStateNotice loading={loading} title="Jobs" /></I18nProvider>;
const { rerender } = render(view());
expect(screen.getByRole("status")).toBeEmptyDOMElement();
rerender(view(true));
expect(screen.getByRole("status")).toHaveTextContent("Loading...");
rerender(view());
expect(screen.getByRole("status")).toHaveTextContent("Content loaded.");
});