feat(ui): qualify calendar documents and correspondence workflows
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user