feat(ui): qualify calendar documents and correspondence workflows

This commit is contained in:
cesnimda
2026-11-03 15:11:53 +01:00
parent 194e01ed7f
commit fba80ef346
158 changed files with 1961 additions and 14 deletions
@@ -49,6 +49,7 @@ test('locale changes translate controls without repeating the read or translatin
mount(); await screen.findByRole('link', { name: /Recorded teacher role/ });
act(() => window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: 'nb' })));
expect(screen.getByRole('button', { name: translations.nb.calendarMonth })).toBeVisible();
expect(screen.getByRole('combobox', { name: translations.nb.calendarSelectedMonth })).toHaveTextContent(new Intl.DateTimeFormat('nb-NO', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${month}-01T12:00:00Z`)));
expect(screen.getByRole('link', { name: /Recorded teacher role/ })).toBeVisible();
expect(get).toHaveBeenCalledTimes(1);
});
@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Box, Button, Chip, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
import { Box, Button, Chip, MenuItem, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
import { api } from '../api';
import { getUserScopedStorageKey } from '../auth';
import { useI18n } from '../i18n/I18nProvider';
@@ -61,7 +61,9 @@ export default function CalendarAgenda() {
<ToggleButton value="agenda">{t('calendarAgenda')}</ToggleButton><ToggleButton value="month">{t('calendarMonth')}</ToggleButton>
</ToggleButtonGroup>
<Button size="small" disabled={month === '1000-01'} onClick={() => offsetMonth(-1)} aria-label={t('calendarPreviousMonth')}>‹</Button>
<TextField type="month" size="small" label={t('calendarSelectedMonth')} value={month} onChange={event => changeMonth(event.target.value)} slotProps={{ inputLabel: { shrink: true }, htmlInput: { min: '1000-01', max: '9998-12' } }} sx={{ minWidth: 150, maxWidth: '100%' }} />
<TextField select size="small" label={t('calendarSelectedMonth')} value={month} onChange={event => changeMonth(event.target.value)} sx={{ minWidth: 170, maxWidth: '100%' }}>
{Array.from({ length: 25 }, (_, index) => monthValue(new Date(year, monthNumber - 1 + index - 12, 1))).filter(value => Number(value.slice(0, 4)) >= 1000 && Number(value.slice(0, 4)) <= 9998).map(value => <MenuItem key={value} value={value}>{new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(calendarDate(`${value}-01`)!)}</MenuItem>)}
</TextField>
<Button size="small" disabled={month === '9998-12'} onClick={() => offsetMonth(1)} aria-label={t('calendarNextMonth')}>›</Button>
</Stack>
<Box role="region" aria-label={t('calendarAgendaTitle')} aria-busy={resource.loading || resource.refreshing} sx={{ minWidth: 0 }}>
@@ -118,6 +118,17 @@ function renderPage(initialPath = '/') {
}
describe('CorrespondenceInboxPage', () => {
test.each(['en', 'nb'] as const)('%s inbox filters and messages support request-free keyboard navigation', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Interview invite', { exact: true });
const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxJumpMessages', {}, language) }));
expect(screen.getByRole('region', { name: copy('emailInboxLinkedMessages', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch', {}, language) })).toHaveFocus();
expect(mockedApi.get).toHaveBeenCalledTimes(count);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
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) => {
@@ -38,6 +38,27 @@ function deferred<T>() {
return { promise, resolve, reject };
}
describe.each(['en', 'nb'] as const)('%s uploaded wall-clock dates', locale => {
test.each(['2026-02-30T09:15:00', '2026-02-12T24:15:00', '2026-02-12T09:61:00', '2026-02-12T09:15:00junk'])('does not invent a timestamp from %s', async recordedAt => {
localStorage.setItem('uiLanguage', locale);
mockedApi.get.mockResolvedValue({ data: page([item({ recordedAt, dateKind: 'uploaded-local' })]) } as never);
renderPage();
expect(await screen.findByText(copy('documentsDateUnknown', {}, locale))).toBeVisible();
expect(document.querySelector('time')).not.toBeInTheDocument();
});
test('preserves fractional-second wall-clock metadata without timezone conversion', async () => {
localStorage.setItem('uiLanguage', locale);
const recordedAt = '2026-02-12T09:15:00.1234567';
mockedApi.get.mockResolvedValue({ data: page([item({ recordedAt, dateKind: 'uploaded-local' })]) } as never);
renderPage();
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
const time = document.querySelector('time');
expect(time).toHaveAttribute('datetime', recordedAt);
expect(time).toHaveTextContent(new Intl.DateTimeFormat(locale === 'nb' ? 'nb-NO' : 'en-GB', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(new Date('2026-02-12T09:15:00Z')));
});
});
function Probe() {
const location = useLocation();
const navigate = useNavigate();
@@ -75,6 +96,17 @@ afterEach(() => {
jest.restoreAllMocks();
});
test.each(['en', 'nb'] as const)('%s document filters and results support request-free keyboard navigation', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Metadata CV ÆØÅ', { exact: true });
const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('documentsJumpResults', {}, language) }));
expect(screen.getByRole('region', { name: copy('documentsYourDocuments', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('documentsReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('documentsSearch', {}, language) })).toHaveFocus();
expect(mockedApi.get).toHaveBeenCalledTimes(count);
});
test('[P7-07 documents] validates URL filters and reads one metadata page with literal Nordic search', async () => {
renderPage('/documents?q=100%25_%21+%C3%86%C3%98%C3%85&page=bad&type=unknown&state=unknown&visibility=unknown&sort=unknown&jobApplicationId=-2&keep=1#context');
expect(await screen.findByText('Metadata CV ÆØÅ', { exact: true })).toBeVisible();
+12
View File
@@ -2,6 +2,12 @@ export type UiLanguage = "en" | "nb";
export const translations = {
en: {
documentsFilters: "Document filters",
documentsJumpResults: "Jump to documents",
documentsReturnFilters: "Return to document filters",
emailInboxFilters: "Message filters",
emailInboxJumpMessages: "Jump to messages",
emailInboxReturnFilters: "Return to message filters",
calendarAgendaTitle: "Calendar",
calendarDateScope: "Recorded application deadlines and follow-up dates, grouped by their stored calendar date. Times and original timezones are not inferred. Interview appointments are not stored here; undated workflow tasks remain in Reminders below.",
calendarView: "Calendar view",
@@ -3191,6 +3197,12 @@ export const translations = {
coverAiReject: "Reject",
},
nb: {
documentsFilters: "Dokumentfiltre",
documentsJumpResults: "Gå til dokumenter",
documentsReturnFilters: "Tilbake til dokumentfiltre",
emailInboxFilters: "Meldingsfiltre",
emailInboxJumpMessages: "Gå til meldinger",
emailInboxReturnFilters: "Tilbake til meldingsfiltre",
calendarAgendaTitle: "Kalender",
calendarDateScope: "Registrerte søknadsfrister og oppfølgingsdatoer, gruppert etter lagret kalenderdato. Klokkeslett og opprinnelige tidssoner utledes ikke. Intervjuavtaler lagres ikke her; oppgaver uten dato vises i Påminnelser nedenfor.",
calendarView: "Kalendervisning",
@@ -154,6 +154,8 @@ export default function CorrespondenceInboxPage() {
const [providersLoading, setProvidersLoading] = useState(false);
const providerPending = useRef(false);
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
const messagesPanel = useRef<HTMLDivElement>(null);
const searchField = useRef<HTMLInputElement>(null);
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<ViewResourceError | null>(null);
@@ -755,8 +757,8 @@ export default function CorrespondenceInboxPage() {
{reviewVisited ? <Box hidden={view !== "review"}><GmailReviewPage embedded active={view === "review"} /></Box> : null}
<Box ref={inboxPanel} role="group" tabIndex={-1} aria-label={t("emailInboxLinkedMessages")} hidden={view !== "inbox"}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
<Box component="aside" aria-label={t("emailInboxFilters")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField inputRef={searchField} label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
<FormControl fullWidth>
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
<Select labelId="email-direction-label" value={direction} disabled={!!unlinkingThreadId} label={t("emailInboxDirection")} onChange={(e) => updateFilters({ direction: String(e.target.value), page: "1" })}>
@@ -775,6 +777,7 @@ export default function CorrespondenceInboxPage() {
</Select>
</FormControl>
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
<Button size="small" onClick={() => messagesPanel.current?.focus()} sx={{ justifySelf: "start", gridColumn: "1 / -1" }}>{t("emailInboxJumpMessages")}</Button>
</Box>
{draft ? (
@@ -816,6 +819,8 @@ export default function CorrespondenceInboxPage() {
</Paper>
) : null}
<Box ref={messagesPanel} role="region" aria-label={t("emailInboxLinkedMessages")} tabIndex={-1} aria-busy={loading} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} disabled={!!unlinkingThreadId} onClick={() => searchField.current?.focus()}>{t("emailInboxReturnFilters")}</Button>
<ViewStateNotice compact loading={loading} title={t("emailInboxLoadFailed")} error={loadError ? { ...loadError, message: loadError.message || t("emailInboxLoadFailed") } : null} onRetry={() => { inboxPanel.current?.focus({ preventScroll: true }); return load(); }} />
<ViewStateNotice compact loading={!!unlinkingThreadId} title={t("emailInboxUnlinkFailed")} error={unlinkError ? { ...unlinkError, message: unlinkError.message || t("emailInboxUnlinkFailed") } : null} />
@@ -885,6 +890,7 @@ export default function CorrespondenceInboxPage() {
</Box>
) : null}
</Box>
</Box>
</PageSection>
);
}
+14 -4
View File
@@ -82,6 +82,7 @@ export default function DocumentsPage() {
const listController = useRef<AbortController | null>(null);
const mounted = useRef(true);
const resultsRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [downloadState, setDownloadState] = useState<DownloadState | null>(null);
const downloadController = useRef<AbortController | null>(null);
const downloadFocus = useRef<{ button: HTMLButtonElement; key: string } | null>(null);
@@ -214,9 +215,16 @@ export default function DocumentsPage() {
const label: TranslationKey = item.dateKind === "uploaded-local" ? "documentsUploadedLocal" : item.dateKind === "captured-utc" ? "documentsCaptured" : item.dateKind === "history-recorded-utc" ? "documentsHistoryRecorded" : "documentsUpdated";
let value: Date | null = null;
if (item.recordedAt && item.dateKind === "uploaded-local") {
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)/.exec(item.recordedAt);
const local = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(?:\.\d{1,7})?$/.exec(item.recordedAt);
// UTC is only a formatting carrier for the unzoned wall-clock components.
if (local) value = new Date(Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +local[6]));
if (local) {
const parts = local.slice(1).map(Number);
const candidate = new Date(0);
candidate.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
candidate.setUTCHours(parts[3], parts[4], parts[5], 0);
// Date normalises impossible fields; show unknown rather than inventing a date.
if ([candidate.getUTCFullYear(), candidate.getUTCMonth() + 1, candidate.getUTCDate(), candidate.getUTCHours(), candidate.getUTCMinutes(), candidate.getUTCSeconds()].every((part, index) => part === parts[index])) value = candidate;
}
} else if (item.recordedAt && ["updated-utc", "captured-utc", "history-recorded-utc"].includes(item.dateKind)) value = new Date(item.recordedAt);
if (!value || Number.isNaN(value.valueOf())) return <Typography variant="body2" color="text.secondary">{t("documentsDateUnknown")}</Typography>;
return <Box><Typography variant="caption" color="text.secondary">{t(label)}</Typography><Typography variant="body2" component="time" dateTime={item.recordedAt!} sx={{ display: "block" }}>{value.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short", ...(item.dateKind === "uploaded-local" ? { timeZone: "UTC" } : {}) })}</Typography></Box>;
@@ -267,8 +275,8 @@ export default function DocumentsPage() {
return <Stack spacing={2} data-content-density="comfortable" sx={{ minWidth: 0 }}>
<PageHeader level="h2" title={t("documentsYourDocuments")} description={t("documentsIndexHelp")} actions={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh} disabled={loading} sx={buttonSx}>{t("documentsRefresh")}</Button>} />
<Stack spacing={2}>
<TextField fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Stack component="aside" aria-label={t("documentsFilters")} spacing={2}>
<TextField inputRef={searchRef} fullWidth size="small" label={t("documentsSearch")} value={q} onChange={(event) => updateQuery({ q: event.target.value, page: "1" }, true)} slotProps={{ htmlInput: { maxLength: 200 } }} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2, minWidth: 0 }}>
{filter("type", "documentsType", type, types)}
{filter("state", "documentsState", state, states)}
@@ -278,8 +286,10 @@ export default function DocumentsPage() {
</Box>
{jobsError ? <ViewStateNotice compact title={t("documentsJobsLoadFailed")} error={{ ...jobsError, message: t("documentsJobsLoadFailed") }} onRetry={() => { resultsRef.current?.focus(); return loadJobs(); }} /> : null}
<Typography variant="caption" color="text.secondary">{t("documentsDateOrderHelp")}</Typography>
<Button size="small" sx={{ alignSelf: "flex-start" }} onClick={() => resultsRef.current?.focus()}>{t("documentsJumpResults")}</Button>
</Stack>
<Box ref={resultsRef} role="region" aria-label={t("documentsYourDocuments")} tabIndex={-1} aria-busy={loading || !current && !error} sx={{ minWidth: 0 }}>
<Button size="small" sx={{ mb: 1 }} onClick={() => searchRef.current?.focus()}>{t("documentsReturnFilters")}</Button>
<ViewStateNotice compact loading={loading} title={t("documentsLoadFailed")} error={error ? { ...error, message: t("documentsLoadFailed") } : null} onRetry={refresh} />
{current && result ? <>
<Typography role="status" variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("documentsResultCount", { shown: items.length, total: result.total })}</Typography>