feat(ui): qualify calendar documents and correspondence workflows
This commit is contained in:
@@ -43,6 +43,7 @@ try {
|
||||
};
|
||||
await page.goto('http://localhost:4338/reminders');
|
||||
const calendar = page.getByRole('region', { name: t('calendarAgendaTitle'), exact: true }); await calendar.getByRole('link', { name: /Electrical engineer/ }).waitFor();
|
||||
await expect(page.getByRole('combobox', { name: new RegExp(`^${t('calendarSelectedMonth')}`) })).toContainText(new Intl.DateTimeFormat(language === 'nb' ? 'nb-NO' : 'en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(`${month}-01T12:00:00Z`)));
|
||||
await expect(page.getByRole('button', { name: t('calendarAgenda'), exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
assert.equal(await calendar.getByRole('link').count(), 3); assert.equal(await calendar.locator('time').first().getAttribute('datetime'), events[0].recordedAt); await scan('agenda');
|
||||
await page.getByRole('button', { name: t('calendarMonth'), exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Built application, synthetic document/mail/provider fixtures; no delivery or real storage.
|
||||
import { chromium, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import ts from 'typescript';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
|
||||
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
||||
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence/documents-inbox/browser'); fs.mkdirSync(out, { recursive: true });
|
||||
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] };
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const width of (process.env.A3_PILOT ? [320] : [320, 390, 768, 1440])) for (const language of (process.env.A3_PILOT ? ['en'] : ['en', 'nb'])) for (const mode of (process.env.A3_PILOT ? ['light'] : ['light', 'dark'])) {
|
||||
console.log(`Checking ${width}/${language}/${mode}`);
|
||||
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
|
||||
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce', acceptDownloads: true }); const page = await context.newPage();
|
||||
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||
page.on('pageerror', error => report.errors.push(error.message));
|
||||
const document = { id: 'attachment:9', resourceId: '9', type: 'attachment', name: 'First aid certificate.txt', state: 'current', visibility: 'private', jobApplicationId: 42, jobTitle: 'Primary teacher', companyName: 'Synthetic school', jobIsDeleted: false, recordedAt: '2026-02-12T09:15:00.1234567', dateKind: 'uploaded-local', version: null, historyCount: null, attachmentCount: null, cvVariantId: null, fileType: 'text/plain', fileSize: 2048, purpose: 'certificate', useForAi: false, sourceHref: '/jobs/42?section=documents', downloadHref: '/api/attachments/download/9', publicHref: null, hasCurrentContent: true };
|
||||
const cv = { ...document, id: 'cv:3', resourceId: '3', type: 'cv', name: 'Teacher CV', dateKind: 'updated-utc', recordedAt: '2026-11-03T10:00:00Z', visibility: 'private', version: 4, sourceHref: '/career/builder/3', downloadHref: null };
|
||||
const mail = { id: 1, jobApplicationId: 42, companyName: 'Synthetic school', jobTitle: 'Primary teacher', from: 'Recruiter', direction: 'inbound', subject: 'Interview invitation', channel: 'Email', date: '2026-11-03T10:00:00Z', contentPreview: 'Recorded invitation', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'recruiter@example.test', externalTo: 'alex@example.test', labelCount: 1, attachmentCount: 0 };
|
||||
let draft = null, sends = 0, documentReads = 0, mailReads = 0;
|
||||
await page.route('**/*', route => {
|
||||
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
||||
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
|
||||
if (!p.startsWith('/api/')) return route.continue();
|
||||
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
if (req.method() === 'POST' && p === '/api/email/drafts') {
|
||||
const body = req.postDataJSON(); assert.equal(body.jobApplicationId, 42); assert.equal(body.bodyText, 'Thank you. Please confirm the available interview times.');
|
||||
draft = { ...body, id: '00000000-0000-4000-8000-000000000101', revision: 4, clientRequestId: '00000000-0000-4000-8000-000000000202', createdAtUtc: '2026-11-03T10:00:00Z', updatedAtUtc: '2026-11-03T10:00:00Z' };
|
||||
report.mockedWrites.push({ width, language, mode, action: 'saveDraft' }); return answer(draft);
|
||||
}
|
||||
if (req.method() === 'POST' && p === '/api/email/send') {
|
||||
const body = req.postDataJSON(); assert.equal(body.confirmed, true); assert.equal(body.clientRequestId, draft.clientRequestId); assert.equal(body.bodyText, draft.bodyText); sends++;
|
||||
report.mockedWrites.push({ width, language, mode, action: 'confirmedSendFixtureUncertain' }); return answer({ attemptId: body.clientRequestId, status: 'uncertain', duplicate: false, failureCategory: 'controlled-transport-interruption' });
|
||||
}
|
||||
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
|
||||
if (p === '/api/attachments/download/9') return route.fulfill({ status: 200, contentType: 'text/plain', body: 'Controlled certificate fixture' });
|
||||
if (p === '/api/documents') { documentReads++; const rows = [document, cv].filter(item => !url.searchParams.get('q') || item.name.toLowerCase().includes(url.searchParams.get('q').toLowerCase())); return answer({ items: rows, page: 1, pageSize: 25, total: rows.length, totalPages: 1 }); }
|
||||
if (p === '/api/correspondence/page') { mailReads++; return answer({ items: [mail], page: 1, pageSize: 50, total: 1, totalPages: 1 }); }
|
||||
const fixtures = {
|
||||
'/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'a3-mail-owner', sessionContext: 'a3-mail-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en', aiEnabled: false }, '/api/companies': [], '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
|
||||
'/api/email/providers': [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'alex@example.test', canRead: true, canSend: true }], '/api/email/drafts': draft ? [draft] : [], '/api/jobapplications/choices': [{ id: 42, jobTitle: 'Primary teacher', companyName: 'Synthetic school' }],
|
||||
'/api/email/message': { id: 'message-1', threadId: 'thread-1', subject: 'Interview invitation', from: 'recruiter@example.test', to: 'alex@example.test', date: mail.date, snippet: 'Recorded invitation', bodyText: 'Please choose an interview time. <script>inert text</script>', labels: ['INBOX'], attachments: [] },
|
||||
};
|
||||
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]); report.unavailableReads.push(p); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' });
|
||||
});
|
||||
const scan = async state => {
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, state);
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: axe.violations }); assert.deepEqual(axe.violations, []);
|
||||
await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
|
||||
};
|
||||
await page.goto('http://localhost:4338/documents');
|
||||
const docs = page.getByRole('region', { name: t('documentsYourDocuments'), exact: true }); await docs.getByText(document.name, { exact: true }).waitFor(); await scan('documents');
|
||||
const reads = documentReads; await page.getByRole('button', { name: t('documentsJumpResults'), exact: true }).press('Enter'); assert.equal(await docs.evaluate(node => node === window.document.activeElement), true);
|
||||
await docs.getByRole('button', { name: t('documentsReturnFilters'), exact: true }).click(); const search = page.getByRole('textbox', { name: t('documentsSearch'), exact: true }); await expect(search).toBeFocused(); assert.equal(documentReads, reads);
|
||||
await search.fill('certificate'); await expect(docs.getByText(cv.name, { exact: true })).toHaveCount(0); await docs.getByText(document.name, { exact: true }).waitFor();
|
||||
const download = page.waitForEvent('download'); await docs.getByRole('button', { name: t('documentsDownloadAttachment'), exact: true }).click(); const file = await download; assert.equal(file.suggestedFilename(), document.name); assert.equal(fs.readFileSync(await file.path(), 'utf8'), 'Controlled certificate fixture');
|
||||
assert.equal(await docs.getByRole('link', { name: t('documentsOpenSource'), exact: true }).getAttribute('href'), document.sourceHref); assert.equal(await docs.locator('time').getAttribute('datetime'), document.recordedAt); await scan('document-filtered');
|
||||
await page.goto('http://localhost:4338/correspondence'); const messages = page.getByRole('region', { name: t('emailInboxLinkedMessages'), exact: true }); await messages.getByText(mail.subject, { exact: true }).waitFor(); await scan('inbox');
|
||||
const initialMailReads = mailReads; await page.getByRole('button', { name: t('emailInboxJumpMessages'), exact: true }).press('Enter'); await expect(messages).toBeFocused(); await messages.getByRole('button', { name: t('emailInboxReturnFilters'), exact: true }).click(); await expect(page.getByRole('textbox', { name: t('correspondenceSearch'), exact: true })).toBeFocused(); assert.equal(mailReads, initialMailReads);
|
||||
await messages.getByRole('button', { name: t('emailInboxViewMessage'), exact: true }).click(); await messages.getByText('Please choose an interview time. <script>inert text</script>', { exact: true }).waitFor(); await scan('message');
|
||||
await messages.getByRole('button', { name: t('emailInboxReply'), exact: true }).click(); const compose = page.getByRole('region', { name: t('emailInboxDraftTitle'), exact: true });
|
||||
await compose.getByRole('textbox', { name: t('emailInboxRecipient'), exact: true }).fill('recruiter@example.test'); await compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true }).fill('Thank you. Please confirm the available interview times.');
|
||||
await compose.getByRole('button', { name: t('emailInboxSaveDraft'), exact: true }).click(); await expect.poll(() => draft?.revision).toBe(4);
|
||||
await compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true }).click(); const dialog = page.getByRole('dialog', { name: t('emailInboxConfirmSendTitle'), exact: true }); await dialog.waitFor(); assert.equal(sends, 0); await scan('send-confirmation');
|
||||
await dialog.getByRole('button', { name: t('emailInboxSendEmail'), exact: true }).click(); await compose.getByText(t('emailInboxDeliveryUncertain'), { exact: true }).waitFor(); await expect(compose.getByRole('button', { name: t('emailInboxReviewSend'), exact: true })).toBeDisabled();
|
||||
assert.equal(sends, 1); await expect(compose.getByRole('textbox', { name: t('emailInboxMessage'), exact: true })).toHaveValue(draft.bodyText); await scan('uncertain-send');
|
||||
report.journeys.push({ width, language, mode, documentFilterFocusDownloadSource: 'PASS', inboxFocusPlainTextReplySaveExplicitConfirmUncertainNoRetry: 'PASS' }); await context.close();
|
||||
}
|
||||
assert.equal(report.screens.length, report.journeys.length * 6); assert.equal(report.mockedWrites.length, report.journeys.length * 2); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
|
||||
console.log(`PASS:${report.journeys.length} document/mail journeys,${report.screens.length} axe scans/captures,${report.mockedWrites.length} fixture-only save/send requests.`);
|
||||
} finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Synthetic metadata/binary/mail/provider fixtures; not live storage, mailbox, OAuth or delivery.', 'Browser covers draft save, explicit confirmation and controlled uncertain outcome only; other recovery/conflict paths remain targeted tests.', 'No real sending, native device, final integrated security or release acceptance.'] }, null, 2)); await browser.close(); }
|
||||
@@ -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