fix(ui): preserve inbox context and review recovery
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, type Response, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { CorrespondenceInboxItem } from '../src/views/CorrespondenceInboxPage';
|
||||
import type { GmailReviewQueueResponse, GmailReviewThread, GmailSuggestedJobsResponse } from '../src/types';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; headers: Record<string, string> };
|
||||
type Job = { id: number; companyId: number; jobTitle: string };
|
||||
type Message = { id: number; jobApplicationId: number; subject: string; content: string; provider: string; externalMessageId: string | null; externalThreadId: string | null };
|
||||
type Inbox = { items: CorrespondenceInboxItem[]; page: number; pageSize: number; total: number; totalPages: number };
|
||||
type Detail = { id: string; threadId: string; subject: string; from: string; to: string; snippet: string; bodyText: string; labels: string[]; attachments: { fileName: string }[] };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
type RequestRecord = { method: string; path: string; query: Record<string, string>; body: unknown };
|
||||
type Session = { owner: Owner; administrator: Owner; admin: APIRequestContext; requests: RequestRecord[] };
|
||||
const desktop: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const boundary = 'Actual owner correspondence POST/page/message/job reads and local nonlinked review-decision POST use disposable isolated SQLite. Provider status/live-copy and paired Gmail queue reads are explicitly controlled local HTTP fixtures; no Google/Outlook/IMAP connection, provider import, generation/model, sync, create-suggested-job, unlink, compose/draft/save/send or delivery request is executed. Manual correspondence DTOs remain provider=manual on the real server. One provider-copy case changes only the browser projection discriminator for one actual-owned row to exercise live-copy fallback; stored body/detail/readback remain actual owner HTTP. Review queue projections are not provider qualification or independent persistence readback. Free accounts and explicit runtime worker disables prevent model/delivery. No raw SQL/schema/production writes; request receipts exclude credentials/CSRF/session headers. Finite all-enabled axe/target-size/keyboard images do not replace human reader, device, real zoom, font/photo or production evidence.';
|
||||
function interpolate(template: string, params: Record<string, string | number>) { return template.replace(/\{(\w+)\}/g, (_, key: string) => String(params[key] ?? `{${key}}`)); }
|
||||
function hold() { let release!: () => void; const pending = new Promise<void>(resolve => { release = resolve; }); return { pending, release }; }
|
||||
async function json<T>(response: APIResponse | Response, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
||||
function waitResponse(page: Page, method: string, path: string) { return page.waitForResponse(response => response.request().method() === method && new URL(response.url()).pathname === path); }
|
||||
function count(session: Session, method: string, path: string) { return session.requests.filter(request => request.method === method && request.path === path); }
|
||||
function readRequests(session: Session) { return session.requests.filter(request => request.method === 'GET' && /^\/api\/(?:correspondence|email\/providers|email\/message|gmail\/review-candidates|gmail\/suggested-jobs)(?:\/|$)/.test(request.path)); }
|
||||
function noDelivery(session: Session) { expect(session.requests.filter(request => request.method !== 'GET' && /^\/api\/(?:email\/|gmail\/(?:manual-sync|create-suggested-job|unlink-thread)|ai(?:\/|$))/.test(request.path))).toEqual([]); }
|
||||
async function keyboard(page: Page, locator: Locator) { await locator.focus(); await expect(locator).toBeFocused(); await page.keyboard.press('Enter'); }
|
||||
function inbox(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].correspondenceInbox, exact: true }); }
|
||||
function row(page: Page, subject: string) { return page.getByRole('article', { name: subject, exact: true }); }
|
||||
function review(page: Page, language: Sample['language'] = 'en') { return page.getByRole('region', { name: translations[language].gmailReviewEmbeddedTitle, exact: true }); }
|
||||
function detail(page: Page, subject: string, language: Sample['language'] = 'en') { return row(page, subject).getByRole('group', { name: translations[language].emailInboxMessage, exact: true }); }
|
||||
function labelPrefix(label: string) { return new RegExp(`^${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\s|$)`); }
|
||||
async function select(page: Page, label: string, option: string) { await keyboard(page, page.getByRole('combobox', { name: labelPrefix(label) })); await keyboard(page, page.getByRole('option', { name: option, exact: true })); }
|
||||
async function pageOwnedFocus(section: Locator) {
|
||||
await expect.poll(() => section.evaluate(node => node.contains(document.activeElement) && document.activeElement !== document.body && document.activeElement?.isConnected === true)).toBe(true);
|
||||
}
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await json<{ id: string; sessionContext: string }>(await api.get(`${apiUrl}/auth/me`));
|
||||
const token = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
|
||||
return { id: me.id, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
async function account(admin: APIRequestContext, owner: Owner) {
|
||||
const email = `p706-read-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: owner.headers, data: { email, password, roles: [] } })).status()).toBe(200); return email;
|
||||
}
|
||||
const test = base.extend<{ session: Session }>({
|
||||
session: async ({ page, context, playwright }, provide, info) => {
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
|
||||
const admin = await playwright.request.newContext(); const requests: RequestRecord[] = []; const blocked: string[] = [];
|
||||
page.on('request', request => {
|
||||
const url = new URL(request.url());
|
||||
if (!/^\/api\/(?:correspondence|email|gmail|ai|jobapplications)(?:\/|$)/.test(url.pathname)) return;
|
||||
requests.push({ method: request.method(), path: url.pathname, query: Object.fromEntries(url.searchParams), body: request.postData() ? request.postDataJSON() : null });
|
||||
});
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test'); const owner = await login(page.request, await account(admin, administrator));
|
||||
expect((await json<{ entitlements: { ai: boolean } }>(await page.request.get(`${apiUrl}/auth/me`))).entitlements.ai).toBe(false);
|
||||
await context.route('**/*', route => { const url = new URL(route.request().url());
|
||||
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['blob:', 'data:', 'about:'].includes(url.protocol)) return route.continue();
|
||||
blocked.push(url.origin + url.pathname); return route.abort();
|
||||
});
|
||||
await provide({ owner, administrator, admin, requests });
|
||||
} finally { await info.attach('correspondence-read-boundary', { body: JSON.stringify({ boundary, requests, blockedExternalRequests: blocked }), contentType: 'application/json' }); await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
async function job(api: APIRequestContext, owner: Owner, title = 'Recorded correspondence job — æøå', ownedCompanyId?: number) {
|
||||
const companyId = ownedCompanyId ?? (await json<{ id: number }>(await api.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Recorded correspondence company — æøå' } }), 201)).id;
|
||||
return json<Job>(await api.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: title, status: 'Saved' } }), 201);
|
||||
}
|
||||
async function message(api: APIRequestContext, owner: Owner, jobId: number, subject: string, index = 0) {
|
||||
const content = `Saved full plain-text body for ${subject} — æøå\n` + ('Recorded owner text; preserve exact saved copy and line breaks.\n').repeat(8) + '<script>fixture marker stays plain text</script>';
|
||||
return json<Message>(await api.post(`${apiUrl}/correspondence`, { headers: owner.headers, data: {
|
||||
jobApplicationId: jobId, from: index % 3 === 0 ? 'Company' : 'Me', subject, content, channel: 'Email',
|
||||
date: '2026-10-13T08:00:00Z', direction: ['inbound', 'outbound', 'internal'][index % 3],
|
||||
externalMessageId: index % 2 === 0 ? `recorded-message-${jobId}-${index}` : null, externalThreadId: index % 2 === 0 ? `recorded-thread-${jobId}-${index}` : null,
|
||||
externalFrom: 'Recorded recruiter <recruiter@example.test>', externalTo: 'recorded-owner@example.test',
|
||||
externalLabelsJson: JSON.stringify(['RECORDED', 'Owner label']), attachmentMetadataJson: JSON.stringify([{ FileName: 'Recorded metadata — æøå.txt', MimeType: 'text/plain', SizeBytes: 64, Inline: false }]),
|
||||
} }), 201);
|
||||
}
|
||||
async function seed(page: Page, owner: Owner, number = 3, prefix = 'Recorded message') {
|
||||
const parent = await job(page.request, owner); const messages: Message[] = [];
|
||||
for (let index = 0; index < number; index++) messages.push(await message(page.request, owner, parent.id, `${prefix} ${String(index).padStart(2, '0')} — æøå`, index));
|
||||
return { job: parent, messages };
|
||||
}
|
||||
async function pageRead(api: APIRequestContext, params: Record<string, string | number> = {}) { return json<Inbox>(await api.get(`${apiUrl}/correspondence/page`, { params: { page: 1, pageSize: 50, ...params } })); }
|
||||
async function enter(page: Page, owner: Owner, sample: Sample = desktop, path = '/correspondence?source=phase7-read&marker=preserved#inbox') {
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(({ language, theme }) => { if (localStorage.getItem('uiLanguage') === null) localStorage.setItem('uiLanguage', language);
|
||||
if (localStorage.getItem('jobtracker.themeMode') === null) localStorage.setItem('jobtracker.themeMode', theme); }, sample);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path);
|
||||
const stored = await page.evaluate(() => ({ language: localStorage.getItem('uiLanguage'), theme: localStorage.getItem('jobtracker.themeMode') }));
|
||||
if (stored.language !== sample.language || stored.theme !== sample.theme) { await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.reload(); }
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); await expect(inbox(page, sample.language).getByRole('heading', { level: 2, name: translations[sample.language].correspondenceInbox, exact: true })).toBeVisible();
|
||||
}
|
||||
async function show(page: Page, value: Message, language: Sample['language'] = 'en') { await keyboard(page, row(page, value.subject).getByRole('button', { name: translations[language].emailInboxViewMessage, exact: true })); }
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight, rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
focused: { tag: document.activeElement?.tagName, role: document.activeElement?.getAttribute('role'), name: document.activeElement?.getAttribute('aria-label') } }));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p706-read-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry, boundary, ariaSnapshot: await page.locator('body').ariaSnapshot(),
|
||||
axe: { scope: 'All default-enabled rules plus target-size, no exclusions', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
qualifications: ['Finite isolated Chromium states, not all language/theme/width/state combinations.', 'All incomplete vectors retained; no human AT, physical touch/software keyboard, real zoom or precise screenshot contrast acceptance.',
|
||||
'Full-page images retain context but may be auto-resized and do not prove real viewport focus visibility.', 'Provider/review fixtures are expressly local projections; no live provider/model/send qualification.'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow).toBe(0); expect(axe.violations).toEqual([]);
|
||||
}
|
||||
function reviewFixture(parent: Job, long = false) {
|
||||
const threads: GmailReviewThread[] = ['review', 'unmatched'].map((routing, index) => ({
|
||||
threadId: `recorded-review-${parent.id}-${index}`, subject: long ? `Lang kontrolltråd om samfunnskritiske tjenester og inkluderende arbeidsprosesser Æ Ø Å ${index}` : `Recorded review thread ${index} — æøå`,
|
||||
latestDate: '2026-10-13T08:00:00Z', messageCount: 2, routing, hasImportedMessages: false, decisionNote: `Previously recorded review note ${index}`,
|
||||
matchedQueries: ['Recorded source query; no provider search'], jobCandidates: [{ jobApplicationId: parent.id, jobTitle: parent.jobTitle, companyName: 'Recorded correspondence company — æøå', score: 24, confidence: 'medium', reasons: [] }], messages: [],
|
||||
}));
|
||||
const queue: GmailReviewQueueResponse = { queries: ['Recorded source query; no provider search'], candidateThreadCount: 2, autoLinkThreadCount: 0, reviewThreadCount: 1, unmatchedThreadCount: 1, threads };
|
||||
const suggestions: GmailSuggestedJobsResponse = { count: 0, items: [] };
|
||||
return { queue, suggestions, threads };
|
||||
}
|
||||
async function controlledReview(page: Page, fixture: ReturnType<typeof reviewFixture>) {
|
||||
await page.route(`${apiUrl}/gmail/review-candidates`, route => route.fulfill({ status: 200, json: fixture.queue }));
|
||||
await page.route(`${apiUrl}/gmail/suggested-jobs`, route => route.fulfill({ status: 200, json: fixture.suggestions }));
|
||||
}
|
||||
|
||||
test('[P7-06 read] actual owner page50, filters, ordering and job Back preserve saved-message context and ownership', async ({ page, session, playwright }, info) => {
|
||||
test.setTimeout(180_000); const seeded = await seed(page, session.owner, 55);
|
||||
const removedJob = await job(page.request, session.owner, 'Deleted-parent messages stay absent', seeded.job.companyId); const removed = await message(page.request, session.owner, removedJob.id, 'Deleted-parent private body');
|
||||
expect((await page.request.delete(`${apiUrl}/jobapplications/${removedJob.id}`, { headers: session.owner.headers })).status()).toBe(204);
|
||||
const foreign = await playwright.request.newContext(); const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const other = await login(foreign, await account(session.admin, session.administrator)); const otherJob = await job(foreign, other, 'Other owner job'); const otherMessage = await message(foreign, other, otherJob.id, 'Other owner private body');
|
||||
expect((await page.request.get(`${apiUrl}/correspondence/message/${otherMessage.id}`)).status()).toBe(404); expect((await foreign.get(`${apiUrl}/correspondence/message/${seeded.messages[0].id}`)).status()).toBe(404); expect((await anonymous.get(`${apiUrl}/correspondence/page`)).status()).toBe(401);
|
||||
// Trashing an owned job preserves its saved correspondence; the existing
|
||||
// owner-only message/page contract does not filter the parent's IsDeleted.
|
||||
const ownedIds = [...seeded.messages.map(value => value.id), removed.id].sort((a, b) => b - a);
|
||||
expect(await json<Detail>(await page.request.get(`${apiUrl}/correspondence/message/${removed.id}`))).toMatchObject({ bodyText: removed.content, subject: removed.subject });
|
||||
const first = await pageRead(page.request); expect(first.total).toBe(56); expect(first.pageSize).toBe(50); expect(first.items.map(value => value.id)).toEqual(ownedIds.slice(0, 50));
|
||||
expect(first.items.every(value => value.provider === 'manual')).toBe(true);
|
||||
await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(50); await expect(page.getByText(interpolate(translations.en.emailInboxItems, { count: 56 }), { exact: true })).toBeVisible();
|
||||
await expect(row(page, removed.subject)).toBeVisible(); await expect(row(page, otherMessage.subject)).toHaveCount(0);
|
||||
const pageTwo = waitResponse(page, 'GET', '/api/correspondence/page'); await keyboard(page, page.getByRole('navigation', { name: translations.en.emailInboxPages, exact: true }).getByRole('button', { name: 'Go to page 2', exact: true }));
|
||||
const second = await json<Inbox>(await pageTwo); expect(second.page).toBe(2); expect(second.total).toBe(56); expect(second.items.map(value => value.id)).toEqual(ownedIds.slice(50)); expect(second.items).toHaveLength(6); await expect(inbox(page).getByRole('article')).toHaveCount(6);
|
||||
await expect(page.getByText(interpolate(translations.en.emailInboxLinkedOnPage, { count: second.items.filter(value => value.externalThreadId).length }), { exact: true })).toBeVisible();
|
||||
const selected = second.items[0]; const loaded = waitResponse(page, 'GET', `/api/jobapplications/${selected.jobApplicationId}/workspace`);
|
||||
await keyboard(page, row(page, selected.subject!).getByRole('button', { name: translations.en.emailInboxOpenJob, exact: true }));
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs/${selected.jobApplicationId}\\?section=communication`)); expect(await json<{ id: number }>(await loaded)).toMatchObject({ id: selected.jobApplicationId });
|
||||
await page.goBack(); await expect(inbox(page).getByRole('article')).toHaveCount(6);
|
||||
expect(new URL(page.url()).searchParams.get('source')).toBe('phase7-read'); expect(new URL(page.url()).searchParams.get('marker')).toBe('preserved'); expect(new URL(page.url()).hash).toBe('#inbox');
|
||||
await expect(row(page, selected.subject!)).toBeVisible();
|
||||
await page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }).fill('Recorded message');
|
||||
await select(page, translations.en.emailInboxDirection, translations.en.emailInboxOutbound); await select(page, translations.en.emailInboxLinkState, translations.en.emailInboxLinkedThreads);
|
||||
const filtered = await pageRead(page.request, { q: 'Recorded message', direction: 'outbound', linkState: 'linked' });
|
||||
await expect(inbox(page).getByRole('article')).toHaveCount(filtered.items.length);
|
||||
expect(count(session, 'GET', '/api/correspondence/page').at(-1)?.query).toMatchObject({ q: 'Recorded message', direction: 'outbound', linkState: 'linked', page: '1', pageSize: '50' });
|
||||
for (const value of filtered.items) await expect(row(page, value.subject!)).toBeVisible();
|
||||
await capture(page, info, desktop, 'real-filtered-owner-page'); noDelivery(session);
|
||||
await info.attach('correspondence-real-page', { body: JSON.stringify({ boundary, first: { total: first.total, pageSize: first.pageSize, ids: first.items.map(value => value.id) }, second: { page: second.page, ids: second.items.map(value => value.id) },
|
||||
filtered: { total: filtered.total, ids: filtered.items.map(value => value.id) }, qualification: 'Actual saved manual metadata and SQL page/filter/order boundaries. Linked/inbound chips describe the currently shown page; provider=manual is not a live mailbox.' }), contentType: 'application/json' });
|
||||
} finally { await foreign.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
test('[P7-06 read] initial503 Retry and held latest query remain distinct from true empty results', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 2, 'Latest query'); const firstGate = hold(); const olderGate = hold(); const newerGate = hold(); let initialReads = 0;
|
||||
await page.route(`${apiUrl}/correspondence/page?**`, async route => {
|
||||
const q = new URL(route.request().url()).searchParams.get('q');
|
||||
if (q === 'Latest query 00') { await olderGate.pending; return route.continue(); }
|
||||
if (q === 'Latest query 01') { await newerGate.pending; return route.continue(); }
|
||||
initialReads += 1; if (initialReads === 1) return route.fulfill({ status: 503, json: {} });
|
||||
await firstGate.pending; return route.continue();
|
||||
});
|
||||
try {
|
||||
await enter(page, session.owner); await expect(inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxLoadFailed })).toBeVisible();
|
||||
await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'initial-list503-not-empty');
|
||||
await keyboard(page, inbox(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => initialReads).toBe(2); await pageOwnedFocus(inbox(page));
|
||||
await expect(inbox(page).getByRole('group', { name: translations.en.emailInboxLinkedMessages, exact: true }).getByRole('status').filter({ hasText: translations.en.loading })).toContainText(translations.en.loading); firstGate.release(); await expect(inbox(page).getByRole('article')).toHaveCount(2);
|
||||
const search = page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true });
|
||||
await search.fill('Latest query 00'); await expect.poll(() => count(session, 'GET', '/api/correspondence/page').filter(value => value.query.q === 'Latest query 00').length).toBe(1);
|
||||
await search.fill('Latest query 01'); await expect.poll(() => count(session, 'GET', '/api/correspondence/page').filter(value => value.query.q === 'Latest query 01').length).toBe(1);
|
||||
await expect(row(page, seeded.messages[0].subject)).toHaveCount(0); await expect(row(page, seeded.messages[1].subject)).toHaveCount(0);
|
||||
await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toHaveCount(0); await capture(page, info, desktop, 'held-current-query-no-stale-rows');
|
||||
newerGate.release(); await expect(row(page, seeded.messages[1].subject)).toBeVisible();
|
||||
const older = page.waitForResponse(response => new URL(response.url()).pathname === '/api/correspondence/page' && new URL(response.url()).searchParams.get('q') === 'Latest query 00'); olderGate.release(); expect((await older).status()).toBe(200);
|
||||
await expect(row(page, seeded.messages[0].subject)).toHaveCount(0); await expect(row(page, seeded.messages[1].subject)).toBeVisible(); await expect(search).toHaveValue('Latest query 01');
|
||||
await search.fill('No matching saved fixture'); await expect(inbox(page).getByText(translations.en.emailInboxNoMatches, { exact: true })).toBeVisible(); noDelivery(session);
|
||||
} finally { firstGate.release(); olderGate.release(); newerGate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 read] actual saved-detail Retry preserves its ID and Hide/another row cancel stale completion', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 2, 'Saved detail'); await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(2);
|
||||
const first = seeded.messages[0]; const second = seeded.messages[1]; const gate = hold(); let attempts = 0;
|
||||
await page.route(`${apiUrl}/correspondence/message/${first.id}`, async route => { attempts += 1; if (attempts === 1) return route.fulfill({ status: 503, json: {} }); await gate.pending; return route.continue(); });
|
||||
try {
|
||||
await show(page, first); await expect(row(page, first.subject).getByRole('alert')).toContainText(translations.en.emailInboxMessageLoadFailed);
|
||||
const listReads = count(session, 'GET', '/api/correspondence/page').length; await keyboard(page, row(page, first.subject).getByRole('button', { name: translations.en.retry, exact: true }));
|
||||
await expect.poll(() => attempts).toBe(2); await capture(page, info, desktop, 'held-original-saved-detail-retry');
|
||||
await keyboard(page, row(page, first.subject).getByRole('button', { name: translations.en.emailInboxHideMessage, exact: true }));
|
||||
await show(page, second); await expect(detail(page, second.subject)).toContainText(second.content);
|
||||
const late = waitResponse(page, 'GET', `/api/correspondence/message/${first.id}`); gate.release(); expect((await late).status()).toBe(200);
|
||||
await expect(detail(page, first.subject)).toHaveCount(0); await expect(detail(page, second.subject)).toContainText(second.content);
|
||||
expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads);
|
||||
await show(page, first); await expect(detail(page, first.subject)).toContainText(first.content); await expect(detail(page, first.subject)).toContainText('Owner label'); await expect(detail(page, first.subject)).toContainText('Recorded metadata — æøå.txt');
|
||||
await expect(row(page, first.subject).locator('script')).toHaveCount(0); expect(await json<Detail>(await page.request.get(`${apiUrl}/correspondence/message/${first.id}`))).toMatchObject({ bodyText: first.content });
|
||||
await capture(page, info, desktop, 'actual-saved-body-and-metadata'); noDelivery(session);
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 read] qualified provider live-copy failure retries exact captured source before real saved fallback', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 1, 'Provider branch'); const saved = seeded.messages[0];
|
||||
await page.route(`${apiUrl}/email/providers`, route => route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'controlled-readonly@example.test', canRead: true, canSend: false }] }));
|
||||
await page.route(`${apiUrl}/correspondence/page?**`, async route => { const actual = await route.fetch(); const data: Inbox = await actual.json(); return route.fulfill({ response: actual, json: { ...data, items: data.items.map(value => value.id === saved.id ? { ...value, provider: 'gmail' } : value) } }); });
|
||||
let providerReads = 0; let savedReads = 0;
|
||||
await page.route(`${apiUrl}/email/message?**`, route => { providerReads += 1; expect(new URL(route.request().url()).searchParams.get('provider')).toBe('gmail'); expect(new URL(route.request().url()).searchParams.get('messageId')).toBe(saved.externalMessageId); return route.fulfill({ status: 503, json: {} }); });
|
||||
await page.route(`${apiUrl}/correspondence/message/${saved.id}`, route => { savedReads += 1; return savedReads === 1 ? route.fulfill({ status: 503, json: {} }) : route.continue(); });
|
||||
await enter(page, session.owner); await show(page, saved); await expect(row(page, saved.subject).getByRole('alert').filter({ hasText: translations.en.emailInboxMessageLoadFailed })).toBeVisible();
|
||||
const listReads = count(session, 'GET', '/api/correspondence/page').length;
|
||||
await keyboard(page, row(page, saved.subject).getByRole('button', { name: translations.en.retry, exact: true }));
|
||||
await expect(detail(page, saved.subject)).toContainText(saved.content); await expect(row(page, saved.subject)).toContainText(translations.en.emailInboxProviderCopyUnavailable);
|
||||
expect(providerReads).toBe(2); expect(savedReads).toBe(2); expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads);
|
||||
expect((await pageRead(page.request)).items[0].provider).toBe('manual'); await expect(row(page, saved.subject).getByRole('button', { name: translations.en.emailInboxReply, exact: true })).toHaveCount(0);
|
||||
await capture(page, info, desktop, 'qualified-provider-unavailable-real-saved-copy'); noDelivery(session);
|
||||
await info.attach('provider-copy-qualification', { body: JSON.stringify({ boundary, actualOwnedMessageId: saved.id, underlyingProvider: 'manual', browserProjectionProvider: 'gmail', providerReads, actualSavedReadAttempts: savedReads,
|
||||
qualification: 'Only browser discriminator/live-copy/status are controlled fixtures. Actual owned saved body/metadata and tenant admission remain real HTTP. No live mailbox or actual provider grant/read qualification.' }), contentType: 'application/json' });
|
||||
});
|
||||
|
||||
test('[P7-06 read] strict real UI-language204 preserves inbox context, selected saved body and dirty review notes with zero read deltas', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await seed(page, session.owner, 3, 'Locale preserved'); const queue = reviewFixture(seeded.job); await controlledReview(page, queue);
|
||||
await enter(page, session.owner); await page.getByRole('textbox', { name: translations.en.correspondenceSearch, exact: true }).fill('Locale preserved'); await expect(inbox(page).getByRole('article')).toHaveCount(3); await show(page, seeded.messages[0]); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content);
|
||||
let reads = readRequests(session).length;
|
||||
const locale = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'Norsk', exact: true }));
|
||||
const real = await locale; await info.attach('inbox-locale-first-http', { body: JSON.stringify({ status: real.status(), body: await real.text() }), contentType: 'application/json' }); expect(real.status()).toBe(204);
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'nb-NO'); await expect(page.getByRole('textbox', { name: translations.nb.correspondenceSearch, exact: true })).toHaveValue('Locale preserved'); await expect(detail(page, seeded.messages[0].subject, 'nb')).toContainText(seeded.messages[0].content);
|
||||
expect(readRequests(session)).toHaveLength(reads); await capture(page, info, { ...desktop, language: 'nb' }, 'strict-locale-preserved-saved-detail');
|
||||
await keyboard(page, inbox(page, 'nb').getByRole('button', { name: translations.nb.emailInboxReviewSuggestions, exact: true })); await expect(review(page, 'nb').getByRole('article')).toHaveCount(2);
|
||||
const subject = queue.threads[0].subject; await row(page, subject).getByRole('textbox', { name: translations.nb.gmailReviewNotes, exact: true }).fill('Ulagret kontrollnotat — æøå'); reads = readRequests(session).length;
|
||||
const second = waitResponse(page, 'PUT', '/api/auth/preferences/language'); await keyboard(page, page.locator('header').first().getByRole('button', { name: 'English', exact: true }));
|
||||
const secondReal = await second; await info.attach('inbox-locale-second-http', { body: JSON.stringify({ status: secondReal.status(), body: await secondReal.text() }), contentType: 'application/json' }); expect(secondReal.status()).toBe(204);
|
||||
await expect(row(page, subject).getByRole('textbox', { name: translations.en.gmailReviewNotes, exact: true })).toHaveValue('Ulagret kontrollnotat — æøå'); expect(readRequests(session)).toHaveLength(reads);
|
||||
await capture(page, info, desktop, 'strict-locale-dirty-review-notes'); noDelivery(session);
|
||||
expect(session.requests.filter(request => request.method !== 'GET' && !request.path.startsWith('/api/jobapplications/'))).toEqual([]);
|
||||
});
|
||||
|
||||
test('[P7-06 read] independent provider-status503 Retry only rereads status while saved owner messages remain usable', async ({ page, session }, info) => {
|
||||
test.setTimeout(90_000); const seeded = await seed(page, session.owner, 2, 'Provider status independent'); let providerReads = 0; const gate = hold();
|
||||
await page.route(`${apiUrl}/email/providers`, async route => { providerReads += 1; if (providerReads === 1) return route.fulfill({ status: 503, json: {} }); await gate.pending; return route.fulfill({ status: 200, json: [{ provider: 'gmail', displayName: 'Gmail', connected: false, address: null, canRead: false, canSend: false }] }); });
|
||||
try {
|
||||
await enter(page, session.owner); await expect(inbox(page).getByRole('article')).toHaveCount(2); await expect(inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxProviderStatusUnavailable })).toBeVisible();
|
||||
await show(page, seeded.messages[0]); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content);
|
||||
const listReads = count(session, 'GET', '/api/correspondence/page').length; const savedReads = count(session, 'GET', `/api/correspondence/message/${seeded.messages[0].id}`).length;
|
||||
const providerAlert = inbox(page).getByRole('alert').filter({ hasText: translations.en.emailInboxProviderStatusUnavailable }); await keyboard(page, providerAlert.getByRole('button', { name: translations.en.retry, exact: true }));
|
||||
await expect.poll(() => providerReads).toBe(2); await expect(detail(page, seeded.messages[0].subject)).toContainText(seeded.messages[0].content); await capture(page, info, desktop, 'held-status-retry-saved-body-retained'); gate.release();
|
||||
await expect(page.getByRole('group', { name: translations.en.emailInboxProviderStatus, exact: true })).toContainText(interpolate(translations.en.emailInboxProviderNotConnected, { provider: 'Gmail' }));
|
||||
expect(count(session, 'GET', '/api/correspondence/page')).toHaveLength(listReads); expect(count(session, 'GET', `/api/correspondence/message/${seeded.messages[0].id}`)).toHaveLength(savedReads);
|
||||
await expect(inbox(page).getByRole('button', { name: translations.en.emailInboxCompose, exact: true })).toBeDisabled(); noDelivery(session);
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 read] qualified paired review reads retain notes/filter and actual nonlinked decision recovery never replays committed POST', async ({ page, session }, info) => {
|
||||
test.setTimeout(120_000); const seeded = await seed(page, session.owner); const fixture = reviewFixture(seeded.job); let failRead = false; let queueReads = 0; let suggestionsReads = 0; let decisionWrites = 0; const gate = hold();
|
||||
await page.route(`${apiUrl}/gmail/review-candidates`, route => { queueReads += 1; return failRead ? route.fulfill({ status: 503, json: {} }) : route.fulfill({ status: 200, json: fixture.queue }); });
|
||||
await page.route(`${apiUrl}/gmail/suggested-jobs`, route => { suggestionsReads += 1; return route.fulfill({ status: 200, json: fixture.suggestions }); });
|
||||
await enter(page, session.owner, desktop, '/correspondence/review?source=phase7-review&marker=alias&view=inbox#queued');
|
||||
await expect.poll(() => { const url = new URL(page.url()); return url.pathname + url.search + url.hash; }).toBe('/correspondence?source=phase7-review&marker=alias&view=review#queued');
|
||||
await expect(review(page).getByRole('article')).toHaveCount(2);
|
||||
const thread = fixture.threads[0]; const second = fixture.threads[1]; const notes = row(page, thread.subject).getByRole('textbox', { name: translations.en.gmailReviewNotes, exact: true }); await expect(notes).toHaveValue(thread.decisionNote!); await notes.fill(' Owner-reviewed decision note — æøå ');
|
||||
await keyboard(page, review(page).getByRole('button', { name: translations.en.gmailReviewRouteReview, exact: true })); await expect(row(page, second.subject)).toHaveCount(0);
|
||||
failRead = true; await keyboard(page, review(page).getByRole('button', { name: translations.en.correspondenceRefresh, exact: true }));
|
||||
await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewLoadFailed); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå '); await expect(review(page).getByText(translations.en.gmailReviewNoCandidates, { exact: true })).toHaveCount(0);
|
||||
failRead = false; await keyboard(page, review(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå ');
|
||||
await expect(review(page).getByRole('button', { name: translations.en.gmailReviewRouteReview, exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await capture(page, info, desktop, 'review-read-retry-retains-note-filter');
|
||||
await page.route(`${apiUrl}/gmail/review-decision`, async route => {
|
||||
decisionWrites += 1;
|
||||
if (decisionWrites === 1) { await gate.pending; return route.fulfill({ status: 503, json: {} }); }
|
||||
// Nonlinked decisions use the actual owner-local endpoint; never provider imports.
|
||||
const actual = await route.fetch(); const data = await actual.json(); expect(actual.status()).toBe(200); expect(data.decision).toBe('rejected'); expect(data.note).toBe('Owner-reviewed decision note — æøå');
|
||||
fixture.threads[0].decisionNote = data.note; fixture.threads[0].routing = data.decision; failRead = true;
|
||||
return route.fulfill({ response: actual, json: data });
|
||||
});
|
||||
try {
|
||||
await keyboard(page, row(page, thread.subject).getByRole('button', { name: translations.en.gmailReviewReject, exact: true })); await expect.poll(() => decisionWrites).toBe(1);
|
||||
await expect(notes).toBeDisabled(); await expect(review(page).getByRole('button', { name: translations.en.correspondenceRefresh, exact: true })).toBeDisabled();
|
||||
expect(count(session, 'POST', '/api/gmail/review-decision').map(request => request.body)).toEqual([{ threadId: thread.threadId, decision: 'rejected', jobApplicationId: null, note: 'Owner-reviewed decision note — æøå' }]);
|
||||
await capture(page, info, desktop, 'held-local-review-decision'); gate.release();
|
||||
await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewSaveFailed); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå ');
|
||||
const committed = waitResponse(page, 'POST', '/api/gmail/review-decision'); await keyboard(page, row(page, thread.subject).getByRole('button', { name: translations.en.gmailReviewReject, exact: true }));
|
||||
expect(await json<{ note: string; decision: string }>(await committed)).toMatchObject({ note: 'Owner-reviewed decision note — æøå', decision: 'rejected' });
|
||||
await expect(review(page).getByRole('alert')).toContainText(translations.en.gmailReviewLoadFailed); const writes = decisionWrites; const q = queueReads; const s = suggestionsReads; failRead = false;
|
||||
await keyboard(page, review(page).getByRole('button', { name: translations.en.retry, exact: true })); await expect.poll(() => queueReads).toBe(q + 1); await expect.poll(() => suggestionsReads).toBe(s + 1); expect(decisionWrites).toBe(writes);
|
||||
await keyboard(page, review(page).getByRole('button', { name: translations.en.gmailReviewRouteRejected, exact: true })); await expect(row(page, thread.subject)).toBeVisible(); await expect(notes).toHaveValue(' Owner-reviewed decision note — æøå ');
|
||||
await capture(page, info, desktop, 'committed-decision-get-only-recovery'); noDelivery(session);
|
||||
await info.attach('review-provider-and-persistence-boundary', { body: JSON.stringify({ boundary, queueReads, suggestionsReads, actualNonlinkedWriteResponse: { threadId: thread.threadId, decision: 'rejected', note: 'Owner-reviewed decision note — æøå' }, decisionWrites,
|
||||
qualification: 'Paired queue read fixture echoes actual200 local decision response. No supported pure decision GET was available: refreshed fixture notes are not an independent DB/provider persistence readback. Linked decisions/manual-sync/import/create/send are unexecuted.' }), contentType: 'application/json' });
|
||||
} finally { gate.release(); }
|
||||
});
|
||||
|
||||
test('[P7-06 read] eight finite bilingual theme widths preserve long saved bodies and qualified review-note keyboard access', async ({ page, session }, info) => {
|
||||
test.setTimeout(240_000); const seeded = await seed(page, session.owner, 3, 'Langt emne om samfunnskritisk plattformutvikling med mange samarbeidspartnere Æ Ø Å'); const fixture = reviewFixture(seeded.job, true); await controlledReview(page, fixture);
|
||||
const samples: (Sample & { view: 'inbox' | 'detail' | 'review' })[] = [
|
||||
{ language: 'en', theme: 'light', width: 320, height: 740, view: 'inbox' }, { language: 'nb', theme: 'dark', width: 360, height: 844, view: 'review' },
|
||||
{ language: 'en', theme: 'dark', width: 390, height: 844, view: 'detail' }, { language: 'nb', theme: 'light', width: 768, height: 1024, view: 'inbox' },
|
||||
{ language: 'en', theme: 'dark', width: 1024, height: 768, view: 'review' }, { language: 'nb', theme: 'light', width: 1280, height: 320, view: 'detail' },
|
||||
{ language: 'en', theme: 'light', width: 1440, height: 900, view: 'review' }, { language: 'nb', theme: 'dark', width: 1920, height: 1080, view: 'inbox' },
|
||||
];
|
||||
for (const sample of samples) {
|
||||
await enter(page, session.owner, sample, `/correspondence?source=phase7-width${sample.view === 'review' ? '&view=review' : ''}#long`); const t = translations[sample.language];
|
||||
if (sample.view === 'review') {
|
||||
await expect(review(page, sample.language).getByRole('article')).toHaveCount(2);
|
||||
const control = row(page, fixture.threads[0].subject).getByRole('textbox', { name: t.gmailReviewNotes, exact: true }); await control.focus(); await expect(control).toBeFocused();
|
||||
} else {
|
||||
await expect(inbox(page, sample.language).getByRole('article')).toHaveCount(3);
|
||||
if (sample.view === 'detail') { await show(page, seeded.messages[0], sample.language); await expect(detail(page, seeded.messages[0].subject, sample.language)).toContainText(seeded.messages[0].content); }
|
||||
const action = row(page, seeded.messages[0].subject).getByRole('button', { name: sample.view === 'detail' ? t.emailInboxHideMessage : t.emailInboxViewMessage, exact: true }); await action.focus(); await expect(action).toBeFocused();
|
||||
}
|
||||
await page.evaluate(() => scrollTo(0, 0)); await capture(page, info, sample, `long-${sample.view}`);
|
||||
}
|
||||
noDelivery(session); expect(count(session, 'POST', '/api/gmail/review-decision')).toHaveLength(0);
|
||||
});
|
||||
@@ -76,6 +76,13 @@ function LegacyBoardRedirect() {
|
||||
query.set("view", "board");
|
||||
return <Navigate replace to={{ pathname: "/jobs", search: `?${query}`, hash: location.hash }} state={location.state} />;
|
||||
}
|
||||
|
||||
function LegacyCorrespondenceReviewRedirect() {
|
||||
const location = useLocation();
|
||||
const query = new URLSearchParams(location.search);
|
||||
query.set("view", "review");
|
||||
return <Navigate replace to={{ pathname: "/correspondence", search: `?${query}`, hash: location.hash }} state={location.state} />;
|
||||
}
|
||||
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
|
||||
const ThemeControlsContext = createContext<ThemeControls | null>(null);
|
||||
type MeResponse = {
|
||||
@@ -403,7 +410,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<Route path="/companies" element={<CompaniesTable />} />
|
||||
<Route path="/offers" element={<OfferComparisonPage />} />
|
||||
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
||||
<Route path="/correspondence/review" element={<LegacyCorrespondenceReviewRedirect />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/applications/:id" element={<LegacyApplicationRedirect />} />
|
||||
<Route path="/career" element={<CareerWorkspacePage />} />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
|
||||
import { api } from './api';
|
||||
import { translations, type UiLanguage } from './i18n/translations';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -22,12 +23,56 @@ jest.mock('./api', () => ({
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') {
|
||||
return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function changeLanguage(language: UiLanguage) {
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
||||
}
|
||||
|
||||
function retryNotice(key: string) {
|
||||
const alert = screen.getAllByRole('alert').find((node) => node.textContent?.includes(copy(key)) && within(node).queryByRole('button', { name: copy('retry') }));
|
||||
expect(alert).toBeDefined();
|
||||
return within(alert!);
|
||||
}
|
||||
|
||||
function messageItem(overrides: Record<string, unknown> = {}) {
|
||||
return { id: 1, jobApplicationId: 42, companyName: 'Acme Systems', jobTitle: 'Backend Engineer', from: 'Recruiter', direction: 'inbound', subject: 'Interview invite', channel: 'Email', date: '2026-02-13T11:30:00Z', contentPreview: 'Saved preview', externalThreadId: 'thread-1', externalMessageId: 'message-1', provider: 'gmail', externalFrom: 'Maria <maria@acme.test>', externalTo: 'owner@gmail.test', labelCount: 0, attachmentCount: 0, ...overrides };
|
||||
}
|
||||
|
||||
function pageData(items: ReturnType<typeof messageItem>[], page = 1, total = items.length, totalPages = total > 50 ? 2 : 1) {
|
||||
return { items, page, pageSize: 50, total, totalPages };
|
||||
}
|
||||
|
||||
function messageRow(subject: string) {
|
||||
return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement);
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="inbox-location">{location.pathname}{location.search}{location.hash}</output>;
|
||||
}
|
||||
|
||||
function HistoryBackControl() {
|
||||
const navigate = useNavigate();
|
||||
return <button onClick={() => navigate(-1)}>History Back</button>;
|
||||
}
|
||||
|
||||
function renderPage(initialPath = '/') {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<LocationProbe />
|
||||
<CorrespondenceInboxPage />
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
@@ -39,6 +84,13 @@ function renderPage() {
|
||||
describe('CorrespondenceInboxPage', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.lang = 'en';
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.post.mockReset();
|
||||
mockedApi.put.mockReset();
|
||||
mockedApi.patch.mockReset();
|
||||
mockedApi.delete.mockReset();
|
||||
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: jest.fn(() => '00000000-0000-4000-8000-000000000123') });
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||
@@ -238,7 +290,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
expect(screen.getByLabelText(/recipient/i)).toHaveValue('Maria Recruiter <maria@acme.test>');
|
||||
expect(screen.getByLabelText(/subject/i)).toHaveValue('Re: Interview invite');
|
||||
expect(screen.getByLabelText(/thread/i)).toHaveValue('thread-1');
|
||||
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Thank you. Tuesday works well.' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Thank you. Tuesday works well.' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /confirm email send/i })).toBeInTheDocument();
|
||||
@@ -246,7 +298,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(screen.getByLabelText(/message/i)).toHaveValue('Thank you. Tuesday works well.');
|
||||
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Thank you. Tuesday works well.');
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^send email$/i }));
|
||||
@@ -315,8 +367,8 @@ describe('CorrespondenceInboxPage', () => {
|
||||
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i }));
|
||||
expect(screen.getByLabelText(/message/i)).toHaveValue('Saved private draft.');
|
||||
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Conflicting local edit.' } });
|
||||
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Saved private draft.');
|
||||
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Conflicting local edit.' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save changes$/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/draft-1', {
|
||||
@@ -326,7 +378,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
bodyText: 'Conflicting local edit.',
|
||||
}));
|
||||
expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/message/i)).toHaveValue('Conflicting local edit.');
|
||||
expect(screen.getByRole('textbox', { name: copy('emailInboxMessage') })).toHaveValue('Conflicting local edit.');
|
||||
});
|
||||
|
||||
test('persists a new delivery identity only after a definitive failed attempt', async () => {
|
||||
@@ -400,7 +452,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
expect(screen.getByLabelText(/thread/i)).toHaveValue('New message');
|
||||
fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: 'recruiter@acme.test' } });
|
||||
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Application question' } });
|
||||
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hello from a synthetic draft.' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Hello from a synthetic draft.' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save draft$/i }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', {
|
||||
@@ -456,7 +508,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
|
||||
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Synthetic reply.' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: copy('emailInboxMessage') }), { target: { value: 'Synthetic reply.' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^send email$/i }));
|
||||
|
||||
@@ -506,4 +558,280 @@ describe('CorrespondenceInboxPage', () => {
|
||||
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything());
|
||||
});
|
||||
|
||||
test('failed inbox reads retain a current-language error and recover with one explicit GET-only Retry', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const retry = deferred<any>();
|
||||
let listReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/correspondence/page') return ++listReads === 1 ? Promise.reject({ response: { status: 503 } }) : retry.promise;
|
||||
return original!(url, config);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText(copy('emailInboxLoadFailed'));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
|
||||
expect(screen.queryByText(copy('emailInboxNoMatches'))).not.toBeInTheDocument();
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
const button = retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(listReads).toBe(2);
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: undefined, direction: undefined, linkState: undefined, page: 1, pageSize: 50 } });
|
||||
expect(screen.getByRole('progressbar', { name: copy('loading') })).toBeInTheDocument();
|
||||
await act(async () => { retry.resolve({ data: pageData([messageItem()]) }); await retry.promise; });
|
||||
expect(await screen.findByText('Acme Systems', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.queryByText(copy('emailInboxNoMatches'))).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a late old page cannot overwrite the latest query and page-one inbox snapshot', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const oldPage = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/correspondence/page') {
|
||||
const { q, page } = config.params;
|
||||
if (q === 'A' && page === 2) return oldPage.promise;
|
||||
return Promise.resolve({ data: pageData([messageItem({ subject: q === 'B' ? 'Current B query' : 'A query first page' })], 1, q === 'B' ? 1 : 51, q === 'B' ? 1 : 2) } as any);
|
||||
}
|
||||
return original!(url, config);
|
||||
});
|
||||
renderPage();
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'A' } });
|
||||
await screen.findByText('A query first page');
|
||||
fireEvent.click(screen.getByRole('button', { name: /go to page 2/i }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'A', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'B' } });
|
||||
await screen.findByText('Current B query');
|
||||
await act(async () => { oldPage.resolve({ data: pageData([messageItem({ subject: 'Obsolete A page two' })], 2, 51, 2) }); await oldPage.promise; });
|
||||
expect(screen.getByText('Current B query')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Obsolete A page two')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('B');
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'B', direction: undefined, linkState: undefined, page: 1, pageSize: 50 } });
|
||||
expect(screen.queryByRole('button', { name: /go to page 2/i })).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('message Retry replays the captured owned detail read without hiding it and localizes saved-copy fallback', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
let savedReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/email/message') return Promise.reject({ response: { status: 503 } });
|
||||
if (url === '/correspondence/message/1') return ++savedReads === 1 ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: { id: 'message-1', threadId: 'thread-1', subject: 'Saved subject', from: 'Maria', to: 'Owner', bodyText: 'Owned saved body', snippet: '', labels: ['INBOX'], attachments: [{ fileName: 'owned.pdf' }] } } as any);
|
||||
return original!(url, config);
|
||||
});
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxViewMessage') }));
|
||||
await screen.findByText(copy('emailInboxMessageLoadFailed'));
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
fireEvent.click(retryNotice('emailInboxMessageLoadFailed').getByRole('button', { name: copy('retry') }));
|
||||
expect(await screen.findByText('Owned saved body')).toBeInTheDocument();
|
||||
expect(screen.getByText(copy('emailInboxProviderCopyUnavailable'))).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxHideMessage') })).toBeInTheDocument();
|
||||
expect(screen.getByText('owned.pdf')).toBeInTheDocument();
|
||||
const details = mockedApi.get.mock.calls.filter(([url]) => url === '/email/message' || url === '/correspondence/message/1');
|
||||
expect(details).toEqual([
|
||||
['/email/message', { params: { provider: 'gmail', messageId: 'message-1' } }], ['/correspondence/message/1'],
|
||||
['/email/message', { params: { provider: 'gmail', messageId: 'message-1' } }], ['/correspondence/message/1'],
|
||||
]);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hidden or inactive details never resume a saved-copy fallback and hub return preserves inbox context', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const detailA = deferred<any>();
|
||||
const detailB = deferred<any>();
|
||||
const items = [messageItem({ subject: 'Detail A' }), messageItem({ id: 2, jobApplicationId: 43, subject: 'Detail B', externalThreadId: 'thread-2', externalMessageId: 'message-2' })];
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/correspondence/page') return Promise.resolve({ data: pageData(items, config.params.page, 51, 2) } as any);
|
||||
if (url === '/email/message') return config.params.messageId === 'message-1' ? detailA.promise : detailB.promise;
|
||||
return original!(url, config);
|
||||
});
|
||||
renderPage();
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'Recruiter' } });
|
||||
await screen.findByText('Detail B');
|
||||
fireEvent.click(screen.getByRole('button', { name: /go to page 2/i }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'Recruiter', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } }));
|
||||
fireEvent.click(messageRow('Detail A').getByRole('button', { name: copy('emailInboxViewMessage') }));
|
||||
fireEvent.click(messageRow('Detail A').getByRole('button', { name: copy('emailInboxHideMessage') }));
|
||||
await act(async () => { detailA.reject({ response: { status: 503 } }); await detailA.promise.catch(() => undefined); });
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/message/1');
|
||||
fireEvent.click(messageRow('Detail B').getByRole('button', { name: copy('emailInboxViewMessage') }));
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSuggestions') }));
|
||||
await screen.findByRole('heading', { name: copy('gmailReviewEmbeddedTitle') });
|
||||
await act(async () => { detailB.reject({ response: { status: 503 } }); await detailB.promise.catch(() => undefined); });
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/message/2');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxLinkedMessages') }));
|
||||
expect(await screen.findByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('Recruiter');
|
||||
expect(await screen.findByText('Detail B')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: copy('emailInboxHideMessage') })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'Recruiter', direction: undefined, linkState: undefined, page: 2, pageSize: 50 } });
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('provider status Retry is isolated from saved messages and drafts and live locale changes cause no reads', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const providerRetry = deferred<any>();
|
||||
let providerReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/email/providers') return ++providerReads === 1 ? Promise.reject({ response: { status: 503 } }) : providerRetry.promise;
|
||||
if (url === '/correspondence/page') return Promise.resolve({ data: pageData([messageItem()]) } as any);
|
||||
if (url === '/email/drafts') return Promise.resolve({ data: [{ id: 'owned-draft', revision: 3, jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Owned saved draft', bodyText: 'Private draft', threadId: 'thread-1', clientRequestId: 'owned-request', createdAtUtc: '2026-02-13T11:30:00Z', updatedAtUtc: '2026-02-13T11:30:00Z' }] } as any);
|
||||
return original!(url, config);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText(copy('emailInboxProviderStatusUnavailable'));
|
||||
await screen.findByRole('button', { name: /resume owned saved draft/i });
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(screen.getByText(new Date('2026-02-13T11:30:00Z').toLocaleString('nb-NO'), { exact: false })).toBeInTheDocument();
|
||||
const button = retryNotice('emailInboxProviderStatusUnavailable').getByRole('button', { name: copy('retry') });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads + 1);
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/email/providers');
|
||||
await act(async () => { providerRetry.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false }] }); await providerRetry.promise; });
|
||||
expect(await screen.findByText(/Gmail: owner@gmail\.test.*Kun lesing/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned saved draft' }) })).toBeInTheDocument();
|
||||
expect(screen.getByText('Interview invite')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('confirmed unlink owns pending actions and a failed successful-write refresh recovers with GET only', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const unlink = deferred<any>();
|
||||
let refreshFails = false;
|
||||
const items = [messageItem({ subject: 'Unlink A' }), messageItem({ id: 2, jobApplicationId: 43, subject: 'Unlink B', externalThreadId: 'thread-2', externalMessageId: 'message-2' })];
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/correspondence/page'
|
||||
? refreshFails ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: pageData(items) } as any)
|
||||
: original!(url, config));
|
||||
mockedApi.post.mockReturnValue(unlink.promise);
|
||||
renderPage();
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: copy('emailInboxUnlinkThread') }))[0]);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: copy('emailInboxUnlinkThread') }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/unlink-thread', { jobApplicationId: 42, threadId: 'thread-1', note: 'Unlinked from Job email hub', nextDecision: 'review' });
|
||||
await waitFor(() => expect(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })).toBeInTheDocument());
|
||||
expect(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') })).toBeDisabled();
|
||||
fireEvent.click(messageRow('Unlink B').getByRole('button', { name: copy('emailInboxUnlinkThread') }));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
refreshFails = true;
|
||||
await act(async () => { unlink.resolve({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } }); await unlink.promise; });
|
||||
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/correspondence/page')).toHaveLength(2));
|
||||
expect(screen.getByText('Unlink B')).toBeInTheDocument();
|
||||
refreshFails = false;
|
||||
fireEvent.click(retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') }));
|
||||
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/correspondence/page')).toHaveLength(3));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('literal all search preserves source and hash and hides another query snapshot while retaining same-query refresh data', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
const refreshRecruiter = deferred<any>();
|
||||
const allQuery = deferred<any>();
|
||||
let recruiterReads = 0;
|
||||
let allReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/correspondence/page') {
|
||||
if (config.params.q === 'Recruiter') return ++recruiterReads === 1
|
||||
? Promise.resolve({ data: pageData([messageItem({ subject: 'Old Recruiter snapshot' })], 1, 51, 2) } as any)
|
||||
: refreshRecruiter.promise;
|
||||
if (config.params.q === 'all') {
|
||||
if (++allReads === 1) return allQuery.promise;
|
||||
return Promise.resolve({ data: pageData([messageItem({ subject: config.params.page === 2 ? 'All query page two' : 'Loaded literal all snapshot' })], config.params.page, 75, 2) } as any);
|
||||
}
|
||||
return Promise.resolve({ data: pageData([messageItem({ subject: 'Unexpected missing search' })]) } as any);
|
||||
}
|
||||
return original!(url, config);
|
||||
});
|
||||
const location = () => new URL(screen.getByTestId('inbox-location').textContent!, 'http://localhost');
|
||||
renderPage('/correspondence?q=Recruiter&source=workflow#inbox');
|
||||
await screen.findByText('Old Recruiter snapshot');
|
||||
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('Recruiter');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('refresh') }));
|
||||
expect(recruiterReads).toBe(2);
|
||||
expect(screen.getByText('Old Recruiter snapshot')).toBeInTheDocument();
|
||||
expect(screen.getByText(copy('emailInboxItems', { count: 51 }))).toBeInTheDocument();
|
||||
await act(async () => { refreshRecruiter.resolve({ data: pageData([messageItem({ subject: 'Old Recruiter snapshot' })], 1, 51, 2) }); await refreshRecruiter.promise; });
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: copy('correspondenceSearch') }), { target: { value: 'all' } });
|
||||
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch') })).toHaveValue('all');
|
||||
expect(location().searchParams.get('q')).toBe('all');
|
||||
expect(location().searchParams.get('source')).toBe('workflow');
|
||||
expect(location().hash).toBe('#inbox');
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', { params: { q: 'all', direction: undefined, linkState: undefined, page: 1, pageSize: 50 } });
|
||||
expect(screen.queryByText('Old Recruiter snapshot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(copy('emailInboxItems', { count: 51 }))).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: copy('emailInboxViewMessage') })).not.toBeInTheDocument();
|
||||
await act(async () => { allQuery.reject({ response: { status: 503 } }); await allQuery.promise.catch(() => undefined); });
|
||||
expect(screen.queryByText('Old Recruiter snapshot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(copy('emailInboxItems', { count: 51 }))).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: copy('emailInboxUnlinkThread') })).not.toBeInTheDocument();
|
||||
fireEvent.click(retryNotice('emailInboxLoadFailed').getByRole('button', { name: copy('retry') }));
|
||||
await screen.findByText('Loaded literal all snapshot');
|
||||
expect(screen.getByText(copy('emailInboxItems', { count: 75 }))).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSuggestions') }));
|
||||
expect(location().searchParams.get('q')).toBe('all');
|
||||
expect(location().searchParams.get('source')).toBe('workflow');
|
||||
expect(location().hash).toBe('#inbox');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxLinkedMessages') }));
|
||||
await screen.findByText('Loaded literal all snapshot');
|
||||
expect(location().hash).toBe('#inbox');
|
||||
fireEvent.click(screen.getByRole('button', { name: /go to page 2/i }));
|
||||
await screen.findByText('All query page two');
|
||||
expect(location().searchParams.get('page')).toBe('2');
|
||||
expect(location().searchParams.get('q')).toBe('all');
|
||||
expect(location().searchParams.get('source')).toBe('workflow');
|
||||
expect(location().hash).toBe('#inbox');
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('typing an inbox search keeps one natural Back step to the prior screen and preserves filter context', async () => {
|
||||
render(
|
||||
<ToastProvider><ConfirmProvider><I18nProvider>
|
||||
<MemoryRouter initialEntries={['/previous-screen', '/correspondence?source=workflow&direction=inbound&linkState=linked#inbox']} initialIndex={1}>
|
||||
<LocationProbe />
|
||||
<HistoryBackControl />
|
||||
<Routes>
|
||||
<Route path="/previous-screen" element={<h2>Previous screen</h2>} />
|
||||
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</I18nProvider></ConfirmProvider></ToastProvider>,
|
||||
);
|
||||
const search = await screen.findByRole('textbox', { name: copy('correspondenceSearch') });
|
||||
await screen.findByText('Interview invite');
|
||||
for (const text of ['A', 'Ac', 'Acm', 'Acme']) fireEvent.change(search, { target: { value: text } });
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', { params: { q: 'Acme', direction: 'inbound', linkState: 'linked', page: 1, pageSize: 50 } }));
|
||||
const current = new URL(screen.getByTestId('inbox-location').textContent!, 'http://localhost');
|
||||
expect(current.searchParams.get('q')).toBe('Acme');
|
||||
expect(current.searchParams.get('source')).toBe('workflow');
|
||||
expect(current.searchParams.get('direction')).toBe('inbound');
|
||||
expect(current.searchParams.get('linkState')).toBe('linked');
|
||||
expect(current.searchParams.get('page')).toBeNull();
|
||||
expect(current.hash).toBe('#inbox');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'History Back' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Previous screen' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('inbox-location')).toHaveTextContent('/previous-screen');
|
||||
expect(screen.queryByRole('textbox', { name: copy('correspondenceSearch') })).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import GmailReviewPage from './views/GmailReviewPage';
|
||||
import { api } from './api';
|
||||
import { translations, type UiLanguage } from './i18n/translations';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
@@ -21,6 +22,39 @@ jest.mock('./api', () => ({
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function copy(key: string, params: Record<string, string | number> = {}, language: UiLanguage = document.documentElement.lang === 'nb-NO' ? 'nb' : 'en') {
|
||||
return ((translations[language] as Record<string, string>)[key] ?? key).replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function changeLanguage(language: UiLanguage) {
|
||||
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
||||
}
|
||||
|
||||
function retryNotice(key: string) {
|
||||
const alert = screen.getAllByRole('alert').find((node) => node.textContent?.includes(copy(key)) && within(node).queryByRole('button', { name: copy('retry') }));
|
||||
expect(alert).toBeDefined();
|
||||
return within(alert!);
|
||||
}
|
||||
|
||||
function reviewThread(overrides: Record<string, unknown> = {}) {
|
||||
return { threadId: 'thread-1', subject: 'Owned review thread', latestDate: '2026-02-13T11:30:00Z', messageCount: 2, routing: 'review', hasImportedMessages: false, decisionNote: 'Recorded review note', matchedQueries: ['"Acme" "Backend Developer"'], jobCandidates: [{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] }], messages: [], ...overrides };
|
||||
}
|
||||
|
||||
function reviewQueue(threads = [reviewThread()]) {
|
||||
return { queries: [], candidateThreadCount: threads.length, autoLinkThreadCount: 0, reviewThreadCount: threads.length, unmatchedThreadCount: 0, threads };
|
||||
}
|
||||
|
||||
function threadRow(subject: string) {
|
||||
return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement);
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
@@ -36,6 +70,13 @@ function renderPage() {
|
||||
describe('GmailReviewPage', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.lang = 'en';
|
||||
jest.clearAllMocks();
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.post.mockReset();
|
||||
mockedApi.put.mockReset();
|
||||
mockedApi.patch.mockReset();
|
||||
mockedApi.delete.mockReset();
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/gmail/review-candidates') {
|
||||
return Promise.resolve({
|
||||
@@ -112,4 +153,134 @@ describe('GmailReviewPage', () => {
|
||||
expect(screen.getByRole('button', { name: 'Knytt til beste jobbtreff' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Kontrollnotater')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('failed paired review reads have durable current-language recovery and one explicit GET-only Retry cycle', async () => {
|
||||
const queueRetry = deferred<any>();
|
||||
const suggestedRetry = deferred<any>();
|
||||
let queueReads = 0;
|
||||
let suggestedReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/gmail/review-candidates') return ++queueReads === 1 ? Promise.reject({ response: { status: 503 } }) : queueRetry.promise;
|
||||
if (url === '/gmail/suggested-jobs') return ++suggestedReads === 1 ? Promise.resolve({ data: { count: 0, items: [] } } as any) : suggestedRetry.promise;
|
||||
return Promise.reject(new Error(`Unexpected GET ${url}`));
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText(copy('gmailReviewLoadFailed'));
|
||||
expect(screen.queryByText(copy('gmailReviewNoCandidates'))).not.toBeInTheDocument();
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
const button = retryNotice('gmailReviewLoadFailed').getByRole('button', { name: copy('retry') });
|
||||
act(() => { fireEvent.click(button); fireEvent.click(button); });
|
||||
expect(queueReads).toBe(2);
|
||||
expect(suggestedReads).toBe(2);
|
||||
await act(async () => { queueRetry.resolve({ data: reviewQueue() }); await queueRetry.promise; });
|
||||
expect(screen.getByRole('progressbar', { name: copy('loading') })).toBeInTheDocument();
|
||||
await act(async () => { suggestedRetry.resolve({ data: { count: 0, items: [] } }); await suggestedRetry.promise; });
|
||||
expect(await screen.findByText('Owned review thread')).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('review notes round-trip without losing edits to filtering, refresh, failed explicit saving or locale changes', async () => {
|
||||
let serverNote = 'Recorded review note';
|
||||
let writeFails = true;
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/gmail/review-candidates'
|
||||
? reviewQueue([reviewThread({ decisionNote: serverNote })]) : { count: 0, items: [] } } as any));
|
||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||
expect(url).toBe('/gmail/review-decision');
|
||||
if (writeFails) return Promise.reject({ response: { status: 503 } });
|
||||
serverNote = body.note;
|
||||
return Promise.resolve({ data: { threadId: body.threadId, decision: body.decision, jobApplicationId: body.jobApplicationId, note: body.note } } as any);
|
||||
});
|
||||
const mounted = renderPage();
|
||||
const note = await screen.findByRole('textbox', { name: copy('gmailReviewNotes') });
|
||||
expect(note).toHaveValue('Recorded review note');
|
||||
fireEvent.change(note, { target: { value: ' Unsaved owned review note ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewRouteRejected') }));
|
||||
expect(screen.queryByRole('textbox', { name: copy('gmailReviewNotes') })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewRouteAll') }));
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
serverNote = 'Changed saved server note';
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('correspondenceRefresh') }));
|
||||
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/gmail/review-candidates')).toHaveLength(2));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('correspondenceRefresh') })).toBeEnabled());
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') }));
|
||||
await screen.findByText(copy('gmailReviewSaveFailed'));
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
await changeLanguage('nb');
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Unsaved owned review note ');
|
||||
writeFails = false;
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(1, '/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Unsaved owned review note' });
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(2, '/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Unsaved owned review note' });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') })).toBeEnabled());
|
||||
mounted.unmount();
|
||||
renderPage();
|
||||
expect(await screen.findByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue('Unsaved owned review note');
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('decision, manual sync and suggested creation each own their captured request against duplicate and competing actions', async () => {
|
||||
const second = reviewThread({ threadId: 'thread-2', subject: 'Other owned thread' });
|
||||
const suggestion = { threadId: 'thread-1', companyName: 'Acme', suggestedJobTitle: 'Backend Developer', subject: 'Suggestion subject', recruiterName: 'Maria', recruiterEmail: 'maria@acme.test', preview: 'Recorded preview' };
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/gmail/review-candidates' ? reviewQueue([reviewThread(), second]) : { count: 1, items: [suggestion] } } as any));
|
||||
renderPage();
|
||||
await screen.findByText('Owned review thread');
|
||||
const operations = [
|
||||
{ key: 'decision', url: '/gmail/review-decision', body: { threadId: 'thread-1', decision: 'linked', jobApplicationId: 42, note: 'Captured note' } },
|
||||
{ key: 'sync', url: '/gmail/manual-sync', body: { lookbackDays: 365, maxResultsPerQuery: 8, autoImportHighConfidence: true, includeSpamTrash: false } },
|
||||
{ key: 'create', url: '/gmail/create-suggested-job', body: { threadId: 'thread-1', companyName: 'Acme', jobTitle: 'Backend Developer', recruiterName: 'Maria', recruiterEmail: 'maria@acme.test', notes: 'Captured note', status: 'Applied' } },
|
||||
];
|
||||
for (const operation of operations) {
|
||||
mockedApi.post.mockReset();
|
||||
const held = deferred<any>();
|
||||
mockedApi.post.mockReturnValue(held.promise);
|
||||
fireEvent.change(threadRow('Owned review thread').getByRole('textbox', { name: copy('gmailReviewNotes') }), { target: { value: ' Captured note ' } });
|
||||
const primary = operation.key === 'decision' ? threadRow('Owned review thread').getByRole('button', { name: copy('gmailReviewLinkTopJob') })
|
||||
: operation.key === 'sync' ? screen.getByRole('button', { name: copy('gmailReviewRunSync') })
|
||||
: threadRow('Owned review thread').getByRole('button', { name: copy('createJob') });
|
||||
const otherDecision = threadRow('Other owned thread').getByRole('button', { name: copy('gmailReviewKeepInReview') });
|
||||
const sync = screen.getByRole('button', { name: copy('gmailReviewRunSync') });
|
||||
const create = threadRow('Owned review thread').getByRole('button', { name: copy('createJob') });
|
||||
act(() => { fireEvent.click(primary); fireEvent.click(primary); fireEvent.click(otherDecision); fireEvent.click(sync); fireEvent.click(create); });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith(operation.url, operation.body);
|
||||
expect(otherDecision).toBeDisabled();
|
||||
expect(sync).toBeDisabled();
|
||||
expect(create).toBeDisabled();
|
||||
await act(async () => { held.reject({ response: { status: 503 } }); await held.promise.catch(() => undefined); });
|
||||
await waitFor(() => expect(threadRow('Other owned thread').getByRole('button', { name: copy('gmailReviewKeepInReview') })).toBeEnabled());
|
||||
expect(threadRow('Owned review thread').getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue(' Captured note ');
|
||||
}
|
||||
expect(mockedApi.post.mock.calls.some(([url]) => url === '/email/send')).toBe(false);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('an already-saved decision with failed paired refresh recovers reads without duplicating the mutation', async () => {
|
||||
let readFails = false;
|
||||
mockedApi.get.mockImplementation((url: string) => readFails ? Promise.reject({ response: { status: 503 } }) : Promise.resolve({ data: url === '/gmail/review-candidates' ? reviewQueue() : { count: 0, items: [] } } as any));
|
||||
mockedApi.post.mockImplementation(() => { readFails = true; return Promise.resolve({ data: { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Edited review note' } } as any); });
|
||||
renderPage();
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: copy('gmailReviewNotes') }), { target: { value: 'Edited review note' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: copy('gmailReviewKeepInReview') }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(4));
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('textbox', { name: copy('gmailReviewNotes') })).toHaveValue('Edited review note');
|
||||
expect(screen.queryByText(copy('gmailReviewSaveFailed'))).not.toBeInTheDocument();
|
||||
readFails = false;
|
||||
fireEvent.click(retryNotice('gmailReviewLoadFailed').getByRole('button', { name: copy('retry') }));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(6));
|
||||
expect(mockedApi.get.mock.calls.slice(-2)).toEqual([['/gmail/review-candidates'], ['/gmail/suggested-jobs']]);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', { threadId: 'thread-1', decision: 'review', jobApplicationId: null, note: 'Edited review note' });
|
||||
expect(screen.getByText('Owned review thread')).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,9 @@ export const translations = {
|
||||
emailInboxSubtitle: "Review linked correspondence and suggested recruitment messages in one place.",
|
||||
emailInboxItems: "{count} items",
|
||||
emailInboxLinked: "{count} linked",
|
||||
emailInboxLinkedOnPage: "{count} linked on this page",
|
||||
emailInboxInboundCount: "{count} inbound",
|
||||
emailInboxInboundOnPage: "{count} inbound on this page",
|
||||
emailInboxLinkedMessages: "Linked messages",
|
||||
emailInboxReviewSuggestions: "Review suggestions",
|
||||
emailInboxCompose: "Compose new email",
|
||||
@@ -1949,6 +1951,7 @@ export const translations = {
|
||||
correspondenceMessagesCount: "{count} messages",
|
||||
correspondenceAlreadyLinked: "Already linked",
|
||||
gmailReviewLoadFailed: "Failed to load Gmail review candidates.",
|
||||
gmailReviewNotConnected: "Connect Gmail in Connected accounts to review your messages.",
|
||||
gmailReviewLinked: "Thread linked and imported.",
|
||||
gmailReviewRejected: "Thread rejected from review.",
|
||||
gmailReviewKeptSuggested: "Thread kept as suggested job material.",
|
||||
@@ -2703,7 +2706,9 @@ export const translations = {
|
||||
emailInboxSubtitle: "Se tilknyttet korrespondanse og foreslåtte rekrutteringsmeldinger på ett sted.",
|
||||
emailInboxItems: "{count} elementer",
|
||||
emailInboxLinked: "{count} tilknyttet",
|
||||
emailInboxLinkedOnPage: "{count} tilknyttet på denne siden",
|
||||
emailInboxInboundCount: "{count} innkommende",
|
||||
emailInboxInboundOnPage: "{count} innkommende på denne siden",
|
||||
emailInboxLinkedMessages: "Tilknyttede meldinger",
|
||||
emailInboxReviewSuggestions: "Gjennomgå forslag",
|
||||
emailInboxCompose: "Skriv ny e-post",
|
||||
@@ -4603,6 +4608,7 @@ export const translations = {
|
||||
correspondenceMessagesCount: "{count} meldinger",
|
||||
correspondenceAlreadyLinked: "Allerede tilknyttet",
|
||||
gmailReviewLoadFailed: "Kunne ikke laste kandidater til Gmail-kontroll.",
|
||||
gmailReviewNotConnected: "Koble til Gmail under Tilkoblede kontoer for å gjennomgå meldingene dine.",
|
||||
gmailReviewLinked: "Tråden ble koblet til og importert.",
|
||||
gmailReviewRejected: "Tråden ble avvist fra kontrollen.",
|
||||
gmailReviewKeptSuggested: "Tråden ble beholdt som materiale til en foreslått jobb.",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
@@ -23,6 +22,9 @@ import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import GmailReviewPage from "./GmailReviewPage";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
|
||||
export type CorrespondenceInboxItem = {
|
||||
id: number;
|
||||
@@ -112,15 +114,23 @@ type EmailSendResult = {
|
||||
failureCategory?: string | null;
|
||||
};
|
||||
|
||||
function readError(error: any): ViewResourceError {
|
||||
const status = error?.response?.status as number | undefined;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
return { kind: unauthorized ? "unauthorized" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(error, "") };
|
||||
}
|
||||
|
||||
export default function CorrespondenceInboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
||||
const { toast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const { language, t } = useI18n();
|
||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||
const [inboxPage, setInboxPage] = useState(1);
|
||||
const page = Number(searchParams.get("page"));
|
||||
const inboxPage = Number.isInteger(page) && page > 0 && page <= 2147483647 ? page : 1;
|
||||
const [inboxTotal, setInboxTotal] = useState(0);
|
||||
const [inboxTotalPages, setInboxTotalPages] = useState(0);
|
||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||
@@ -133,16 +143,35 @@ export default function CorrespondenceInboxPage() {
|
||||
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||
const [composeProvider, setComposeProvider] = useState("");
|
||||
const [providerStatusError, setProviderStatusError] = useState(false);
|
||||
const [providerError, setProviderError] = useState<ViewResourceError | null>(null);
|
||||
const [providersLoading, setProvidersLoading] = useState(false);
|
||||
const providerPending = useRef(false);
|
||||
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
||||
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [detailNotice, setDetailNotice] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<ViewResourceError | null>(null);
|
||||
const [detailNotice, setDetailNotice] = useState(false);
|
||||
const detailRequest = useRef(0);
|
||||
const detailItem = useRef<CorrespondenceInboxItem | null>(null);
|
||||
const detailPending = useRef(false);
|
||||
const detailPanel = useRef<HTMLDivElement>(null);
|
||||
const inboxPanel = useRef<HTMLDivElement>(null);
|
||||
const providerPanel = useRef<HTMLDivElement>(null);
|
||||
const rowId = useId();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string>("all");
|
||||
const [linkState, setLinkState] = useState<string>("all");
|
||||
const [loadError, setLoadError] = useState<ViewResourceError | null>(null);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const listRequest = useRef(0);
|
||||
const pendingList = useRef<string | null>(null);
|
||||
const [loadedListKey, setLoadedListKey] = useState<string | null>(null);
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const direction = ["inbound", "outbound", "internal"].includes(searchParams.get("direction") ?? "") ? searchParams.get("direction")! : "all";
|
||||
const linkState = ["linked", "manual"].includes(searchParams.get("linkState") ?? "") ? searchParams.get("linkState")! : "all";
|
||||
const currentListKey = JSON.stringify([query.trim(), direction, linkState, inboxPage]);
|
||||
const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized";
|
||||
const [reviewVisited, setReviewVisited] = useState(view === "review");
|
||||
const mounted = useRef(true);
|
||||
const [draftsReadError, setDraftsReadError] = useState(false);
|
||||
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
||||
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
@@ -151,9 +180,33 @@ export default function CorrespondenceInboxPage() {
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(null);
|
||||
const unlinkPending = useRef(false);
|
||||
const [unlinkError, setUnlinkError] = useState<ViewResourceError | null>(null);
|
||||
|
||||
const updateFilters = useCallback((changes: Record<string, string>) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (!value || (value === "all" && (key === "direction" || key === "linkState")) || (key === "page" && value === "1") || (key === "view" && value === "inbox")) next.delete(key);
|
||||
else next.set(key, value);
|
||||
}
|
||||
navigate({ pathname: location.pathname, search: `?${next}`, hash: location.hash }, { state: location.state, replace: Object.hasOwn(changes, "q") });
|
||||
}, [location.hash, location.pathname, location.state, navigate, searchParams]);
|
||||
const latestFilters = useRef(updateFilters);
|
||||
latestFilters.current = updateFilters;
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; };
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (view !== "inbox") return;
|
||||
const key = JSON.stringify([query.trim(), direction, linkState, inboxPage]);
|
||||
if (pendingList.current === key) return;
|
||||
const request = ++listRequest.current;
|
||||
pendingList.current = key;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await api.get<CorrespondenceInboxPage>("/correspondence/page", {
|
||||
params: {
|
||||
@@ -164,35 +217,63 @@ export default function CorrespondenceInboxPage() {
|
||||
pageSize: 50,
|
||||
},
|
||||
});
|
||||
if (!mounted.current || request !== listRequest.current) return;
|
||||
setItems(res.data?.items ?? []);
|
||||
setInboxTotal(res.data?.total ?? 0);
|
||||
setInboxTotalPages(res.data?.totalPages ?? 0);
|
||||
if (res.data?.page && res.data.page !== inboxPage) setInboxPage(res.data.page);
|
||||
setLoadedListKey(key);
|
||||
if (res.data?.page && res.data.page !== inboxPage) latestFilters.current({ page: String(res.data.page) });
|
||||
setHasLoaded(true);
|
||||
detailRequest.current += 1;
|
||||
detailItem.current = null;
|
||||
detailPending.current = false;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailInboxLoadFailed")), "error");
|
||||
if (mounted.current && request === listRequest.current) setLoadError(readError(error));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mounted.current && request === listRequest.current) { pendingList.current = null; setLoading(false); }
|
||||
}
|
||||
}, [direction, inboxPage, linkState, query, t, toast]);
|
||||
}, [direction, inboxPage, linkState, query, view]);
|
||||
const latestLoad = useRef(load);
|
||||
latestLoad.current = load;
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "inbox") void load();
|
||||
else setReviewVisited(true);
|
||||
return () => {
|
||||
listRequest.current += 1;
|
||||
pendingList.current = null;
|
||||
detailRequest.current += 1;
|
||||
detailItem.current = null;
|
||||
detailPending.current = false;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
setDetailLoading(false);
|
||||
};
|
||||
}, [load, view]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<EmailProviderStatus[]>("/email/providers")
|
||||
.then((response) => {
|
||||
setProviders(response.data ?? []);
|
||||
setProviderStatusError(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setProviders([]);
|
||||
setProviderStatusError(true);
|
||||
})
|
||||
.finally(() => setProviderStatusLoaded(true));
|
||||
const loadProviders = useCallback(async () => {
|
||||
if (providerPending.current) return;
|
||||
providerPending.current = true;
|
||||
setProvidersLoading(true);
|
||||
try {
|
||||
const response = await api.get<EmailProviderStatus[]>("/email/providers");
|
||||
if (!mounted.current) return;
|
||||
setProviders(response.data ?? []);
|
||||
setProviderStatusError(false);
|
||||
setProviderError(null);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
setProviders([]);
|
||||
setProviderStatusError(true);
|
||||
setProviderError(readError(error));
|
||||
} finally {
|
||||
providerPending.current = false;
|
||||
if (mounted.current) { setProviderStatusLoaded(true); setProvidersLoading(false); }
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void loadProviders(); }, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -211,36 +292,33 @@ export default function CorrespondenceInboxPage() {
|
||||
const loadStoredDrafts = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
||||
setStoredDrafts(Array.isArray(response.data) ? response.data : []);
|
||||
if (mounted.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(false); }
|
||||
} catch {
|
||||
setDraftError(t("emailInboxDraftsLoadFailed"));
|
||||
if (mounted.current) setDraftsReadError(true);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStoredDrafts();
|
||||
}, [loadStoredDrafts]);
|
||||
|
||||
const filteredSummary = useMemo(() => {
|
||||
if (!hasCurrentInbox) return { linked: 0, inbound: 0 };
|
||||
const linked = items.filter((item) => item.externalThreadId).length;
|
||||
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||
return { linked, inbound };
|
||||
}, [items]);
|
||||
}, [hasCurrentInbox, items]);
|
||||
const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]);
|
||||
|
||||
const showMessage = async (item: CorrespondenceInboxItem) => {
|
||||
if (selectedMessageId === item.id) {
|
||||
detailRequest.current += 1;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadMessage = async (item: CorrespondenceInboxItem) => {
|
||||
if (detailPending.current && detailItem.current?.id === item.id) return;
|
||||
const request = ++detailRequest.current;
|
||||
detailPending.current = true;
|
||||
detailItem.current = item;
|
||||
setSelectedMessageId(item.id);
|
||||
setMessageDetail(null);
|
||||
setDetailError(null);
|
||||
setDetailNotice(null);
|
||||
setDetailNotice(false);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
if (item.provider && item.provider !== "manual" && item.externalMessageId) {
|
||||
@@ -253,7 +331,7 @@ export default function CorrespondenceInboxPage() {
|
||||
return;
|
||||
} catch {
|
||||
if (request !== detailRequest.current) return;
|
||||
setDetailNotice(t("emailInboxProviderCopyUnavailable"));
|
||||
setDetailNotice(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,12 +340,22 @@ export default function CorrespondenceInboxPage() {
|
||||
setMessageDetail(saved.data);
|
||||
} catch (error) {
|
||||
if (request !== detailRequest.current) return;
|
||||
setDetailError(getApiErrorMessage(error, t("emailInboxMessageLoadFailed")));
|
||||
setDetailError(readError(error));
|
||||
} finally {
|
||||
if (request === detailRequest.current) setDetailLoading(false);
|
||||
if (request === detailRequest.current) { detailPending.current = false; setDetailLoading(false); }
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (item: CorrespondenceInboxItem) => {
|
||||
if (selectedMessageId !== item.id) return loadMessage(item);
|
||||
detailRequest.current += 1;
|
||||
detailItem.current = null;
|
||||
detailPending.current = false;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
setDetailLoading(false);
|
||||
};
|
||||
|
||||
const startReply = async (item: CorrespondenceInboxItem) => {
|
||||
if (!messageDetail || !item.provider) return;
|
||||
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
||||
@@ -490,33 +578,37 @@ export default function CorrespondenceInboxPage() {
|
||||
};
|
||||
|
||||
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
|
||||
if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return;
|
||||
if (!(await confirm({
|
||||
title: t("emailInboxUnlinkTitle"),
|
||||
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
|
||||
confirmLabel: t("emailInboxUnlinkThread"),
|
||||
destructive: true,
|
||||
}))) return;
|
||||
|
||||
setUnlinkingThreadId(item.externalThreadId);
|
||||
if (item.provider !== "gmail" || !item.externalThreadId || unlinkPending.current) return;
|
||||
unlinkPending.current = true;
|
||||
try {
|
||||
if (!(await confirm({
|
||||
title: t("emailInboxUnlinkTitle"),
|
||||
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
|
||||
confirmLabel: t("emailInboxUnlinkThread"),
|
||||
destructive: true,
|
||||
})) || !mounted.current) return;
|
||||
setUnlinkingThreadId(item.externalThreadId);
|
||||
inboxPanel.current?.focus({ preventScroll: true });
|
||||
setUnlinkError(null);
|
||||
await api.post("/gmail/unlink-thread", {
|
||||
jobApplicationId: item.jobApplicationId,
|
||||
threadId: item.externalThreadId,
|
||||
note: "Unlinked from Job email hub",
|
||||
nextDecision: "review",
|
||||
});
|
||||
if (!mounted.current) return;
|
||||
if (selectedMessageId === item.id) {
|
||||
detailRequest.current += 1;
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
}
|
||||
await load();
|
||||
await latestLoad.current();
|
||||
toast(t("emailInboxUnlinked"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("emailInboxUnlinkFailed")), "error");
|
||||
if (mounted.current) setUnlinkError({ ...readError(error), retryable: false });
|
||||
} finally {
|
||||
setUnlinkingThreadId(null);
|
||||
unlinkPending.current = false;
|
||||
if (mounted.current) setUnlinkingThreadId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -528,33 +620,20 @@ export default function CorrespondenceInboxPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 0,
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
}}
|
||||
>
|
||||
<PageSection surface title={t("correspondenceInbox")} description={t("emailInboxSubtitle")}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("correspondenceInbox")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("emailInboxSubtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip label={t("emailInboxLinked", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" ? <Chip label={t("emailInboxInboundCount", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxLinkedOnPage", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxInboundOnPage", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
||||
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
||||
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label={t("emailInboxProviderStatus")}>
|
||||
<Box ref={providerPanel} tabIndex={-1} role="group" aria-label={t("emailInboxProviderStatus")} sx={{ minWidth: 0, mb: 2 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{providers.map((provider) => (
|
||||
<Chip
|
||||
key={provider.provider}
|
||||
@@ -565,12 +644,12 @@ export default function CorrespondenceInboxPage() {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{providerStatusError ? (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxProviderStatusUnavailable")}</Alert>
|
||||
) : null}
|
||||
<ViewStateNotice compact loading={providersLoading} title={t("emailInboxProviderStatusUnavailable")} error={providerError ? { ...providerError, message: providerError.message || t("emailInboxProviderStatusUnavailable") } : null} onRetry={() => { providerPanel.current?.focus({ preventScroll: true }); return loadProviders(); }} />
|
||||
</Box>
|
||||
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
||||
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxReconnectToCompose")}</Alert> : null}
|
||||
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
|
||||
{draftsReadError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxDraftsLoadFailed")}</Alert> : null}
|
||||
|
||||
{storedDrafts.length > 0 ? (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
@@ -615,12 +694,13 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{view === "review" ? <GmailReviewPage embedded /> : <>
|
||||
{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} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder={t("emailInboxSearchPlaceholder")} />
|
||||
<TextField 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} label={t("emailInboxDirection")} onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
|
||||
<Select labelId="email-direction-label" value={direction} disabled={!!unlinkingThreadId} label={t("emailInboxDirection")} onChange={(e) => updateFilters({ direction: String(e.target.value), page: "1" })}>
|
||||
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
||||
<MenuItem value="inbound">{t("emailInboxInbound")}</MenuItem>
|
||||
<MenuItem value="outbound">{t("emailInboxOutbound")}</MenuItem>
|
||||
@@ -629,13 +709,13 @@ export default function CorrespondenceInboxPage() {
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="email-link-state-label">{t("emailInboxLinkState")}</InputLabel>
|
||||
<Select labelId="email-link-state-label" value={linkState} label={t("emailInboxLinkState")} onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
|
||||
<Select labelId="email-link-state-label" value={linkState} disabled={!!unlinkingThreadId} label={t("emailInboxLinkState")} onChange={(e) => updateFilters({ linkState: String(e.target.value), page: "1" })}>
|
||||
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
||||
<MenuItem value="linked">{t("emailInboxLinkedThreads")}</MenuItem>
|
||||
<MenuItem value="manual">{t("emailInboxManualOnly")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? t("loading") : t("refresh")}</Button>
|
||||
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
|
||||
</Box>
|
||||
|
||||
{draft ? (
|
||||
@@ -676,19 +756,20 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
<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} />
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
{hasLoaded && hasCurrentInbox && !loadError && !loading && items.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>{t("emailInboxNoMatches")}</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
{(hasCurrentInbox ? items : []).map((item) => (
|
||||
<Paper component="article" aria-labelledby={`${rowId}-${item.id}`} key={item.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
|
||||
<Typography id={`${rowId}-${item.id}`} component="h3" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{item.subject || item.contentPreview || t("emailInboxNoSubject")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}
|
||||
</Typography>
|
||||
@@ -699,20 +780,19 @@ export default function CorrespondenceInboxPage() {
|
||||
{item.externalThreadId ? <Chip size="small" label={t("emailInboxThreadId", { id: item.externalThreadId })} color="success" variant="outlined" /> : <Chip size="small" label={t("emailInboxManualInternal")} variant="outlined" />}
|
||||
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={t("emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
|
||||
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>{t("emailInboxOpenJob")}</Button>
|
||||
<Button size="small" variant="text" disabled={!!unlinkingThreadId} aria-expanded={selectedMessageId === item.id} aria-controls={selectedMessageId === item.id ? `${rowId}-${item.id}-message` : undefined} onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
||||
<Button size="small" variant="text" disabled={!!unlinkingThreadId} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
|
||||
{item.provider === "gmail" && item.externalThreadId ? (
|
||||
<Button size="small" color="warning" variant="text" disabled={unlinkingThreadId === item.externalThreadId} onClick={() => void unlinkGmailThread(item)}>
|
||||
<Button size="small" color="warning" variant="text" disabled={!!unlinkingThreadId} onClick={() => void unlinkGmailThread(item)}>
|
||||
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
{selectedMessageId === item.id ? (
|
||||
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
||||
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">{t("emailInboxLoadingMessage")}</Typography></Box> : null}
|
||||
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{detailNotice}</Alert> : null}
|
||||
{detailError ? <Alert severity="error">{detailError}</Alert> : null}
|
||||
<Box ref={detailPanel} tabIndex={-1} role="group" id={`${rowId}-${item.id}-message`} aria-label={t("emailInboxMessage")} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
||||
<ViewStateNotice compact loading={detailLoading} title={t("emailInboxMessageLoadFailed")} error={detailError ? { ...detailError, message: detailError.message || t("emailInboxMessageLoadFailed") } : null} onRetry={() => { detailPanel.current?.focus({ preventScroll: true }); return detailItem.current ? loadMessage(detailItem.current) : undefined; }} />
|
||||
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{t("emailInboxProviderCopyUnavailable")}</Alert> : null}
|
||||
{messageDetail ? <>
|
||||
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || t("emailInboxNoSubject")}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""}</Typography>
|
||||
@@ -732,18 +812,19 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
{inboxTotalPages > 1 ? (
|
||||
{hasCurrentInbox && inboxTotalPages > 1 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<Pagination
|
||||
page={inboxPage}
|
||||
count={inboxTotalPages}
|
||||
onChange={(_, value) => setInboxPage(value)}
|
||||
onChange={(_, value) => updateFilters({ page: String(value) })}
|
||||
disabled={!!unlinkingThreadId}
|
||||
color="primary"
|
||||
aria-label={t("emailInboxPages")}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>}
|
||||
</Paper>
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, Chip, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import type { TranslationKey } from "../i18n/translations";
|
||||
import type { ViewResourceError } from "../hooks/useViewResource";
|
||||
import ViewStateNotice from "../components/ViewStateNotice";
|
||||
import { PageHeader, PageSection } from "../layout/PageLayout";
|
||||
|
||||
export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) {
|
||||
type ReviewRequestError = { response?: { status?: number; data?: { code?: string } } };
|
||||
|
||||
function noticeError(cause: unknown, fallback: string): ViewResourceError {
|
||||
const status = (cause as ReviewRequestError)?.response?.status;
|
||||
const unauthorized = status === 401 || status === 403;
|
||||
return { kind: unauthorized ? "unauthorized" : !status || status >= 500 ? "unavailable" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(cause, fallback) };
|
||||
}
|
||||
|
||||
export default function GmailReviewPage({ embedded = false, active = true }: { embedded?: boolean; active?: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
|
||||
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
|
||||
const [readError, setReadError] = useState<unknown | null>(null);
|
||||
const [writeError, setWriteError] = useState<{ cause: unknown; key: TranslationKey } | null>(null);
|
||||
const [operation, setOperation] = useState<{ kind: "decision" | "sync" | "create"; threadId?: string } | null>(null);
|
||||
const [createdJobs, setCreatedJobs] = useState<Record<string, number>>({});
|
||||
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const sectionId = useId();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const activeRef = useRef(active);
|
||||
const viewSequence = useRef(0);
|
||||
const readSequence = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const mutationRef = useRef(false);
|
||||
const tRef = useRef(t);
|
||||
activeRef.current = active;
|
||||
tRef.current = t;
|
||||
const isCurrentView = useCallback((view: number) => mountedRef.current && activeRef.current && view === viewSequence.current, []);
|
||||
const routeLabel = (value: string) => {
|
||||
if (value === "auto-link") return t("gmailReviewRouteAutoLink");
|
||||
if (value === "review") return t("gmailReviewRouteReview");
|
||||
@@ -29,14 +54,20 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!mountedRef.current || !activeRef.current || loadingRef.current) return false;
|
||||
const view = viewSequence.current;
|
||||
const request = ++readSequence.current;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [reviewRes, suggestedRes] = await Promise.all([
|
||||
api.get<GmailReviewQueueResponse>("/gmail/review-candidates"),
|
||||
api.get<GmailSuggestedJobsResponse>("/gmail/suggested-jobs"),
|
||||
]);
|
||||
if (!isCurrentView(view) || request !== readSequence.current) return false;
|
||||
setData(reviewRes.data);
|
||||
setSuggestions(suggestedRes.data);
|
||||
setReadError(null);
|
||||
setNotes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const thread of reviewRes.data.threads) {
|
||||
@@ -44,19 +75,54 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewLoadFailed")), "error");
|
||||
if (isCurrentView(view) && request === readSequence.current) setReadError(error);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (mountedRef.current && request === readSequence.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [t, toast]);
|
||||
}, [isCurrentView]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
readSequence.current += 1;
|
||||
viewSequence.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
viewSequence.current += 1;
|
||||
readSequence.current += 1;
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
if (active && !mutationRef.current) void load();
|
||||
}, [active, load]);
|
||||
|
||||
const beginOperation = useCallback((kind: "decision" | "sync" | "create", threadId?: string) => {
|
||||
if (!mountedRef.current || !activeRef.current || mutationRef.current || loadingRef.current || readError) return null;
|
||||
bodyRef.current?.focus();
|
||||
mutationRef.current = true;
|
||||
setOperation({ kind, threadId });
|
||||
setWriteError(null);
|
||||
return viewSequence.current;
|
||||
}, [readError]);
|
||||
|
||||
const finishOperation = useCallback((view: number) => {
|
||||
mutationRef.current = false;
|
||||
if (!mountedRef.current) return;
|
||||
setOperation(null);
|
||||
if (activeRef.current && view !== viewSequence.current) void load();
|
||||
}, [load]);
|
||||
|
||||
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => {
|
||||
setSavingThreadId(threadId);
|
||||
const view = beginOperation("decision", threadId);
|
||||
if (view === null) return;
|
||||
try {
|
||||
await api.post("/gmail/review-decision", {
|
||||
threadId,
|
||||
@@ -64,26 +130,29 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
|
||||
note: notes[threadId]?.trim() || null,
|
||||
});
|
||||
if (!isCurrentView(view)) return;
|
||||
await load();
|
||||
if (!isCurrentView(view)) return;
|
||||
toast(
|
||||
decision === "linked"
|
||||
? t("gmailReviewLinked")
|
||||
? tRef.current("gmailReviewLinked")
|
||||
: decision === "rejected"
|
||||
? t("gmailReviewRejected")
|
||||
? tRef.current("gmailReviewRejected")
|
||||
: decision === "suggested"
|
||||
? t("gmailReviewKeptSuggested")
|
||||
: t("gmailReviewReturned"),
|
||||
? tRef.current("gmailReviewKeptSuggested")
|
||||
: tRef.current("gmailReviewReturned"),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewSaveFailed")), "error");
|
||||
if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSaveFailed" });
|
||||
} finally {
|
||||
setSavingThreadId(null);
|
||||
finishOperation(view);
|
||||
}
|
||||
}, [load, notes, t, toast]);
|
||||
}, [beginOperation, finishOperation, isCurrentView, load, notes, toast]);
|
||||
|
||||
const runManualSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
const view = beginOperation("sync");
|
||||
if (view === null) return;
|
||||
try {
|
||||
const res = await api.post<GmailManualSyncResult>("/gmail/manual-sync", {
|
||||
lookbackDays: 365,
|
||||
@@ -91,23 +160,26 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
autoImportHighConfidence: true,
|
||||
includeSpamTrash: false,
|
||||
});
|
||||
if (!isCurrentView(view)) return;
|
||||
await load();
|
||||
if (!isCurrentView(view)) return;
|
||||
toast(
|
||||
t("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }),
|
||||
tRef.current("gmailReviewSyncFinished", { linked: res.data.importedThreads, review: res.data.reviewThreadCount, unmatched: res.data.unmatchedThreadCount }),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewSyncFailed")), "error");
|
||||
if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewSyncFailed" });
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
finishOperation(view);
|
||||
}
|
||||
}, [load, t, toast]);
|
||||
}, [beginOperation, finishOperation, isCurrentView, load, toast]);
|
||||
|
||||
const createSuggestedJob = useCallback(async (threadId: string) => {
|
||||
const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
|
||||
if (!suggestion) return;
|
||||
if (!suggestion || createdJobs[threadId]) return;
|
||||
|
||||
setCreatingThreadId(threadId);
|
||||
const view = beginOperation("create", threadId);
|
||||
if (view === null) return;
|
||||
try {
|
||||
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
|
||||
threadId,
|
||||
@@ -118,92 +190,93 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
notes: notes[threadId]?.trim() || suggestion.preview || null,
|
||||
status: "Applied",
|
||||
});
|
||||
await load();
|
||||
toast(t(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success");
|
||||
navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
|
||||
if (!mountedRef.current) return;
|
||||
// Keep a successful creation identity even if the owner changes views before it settles.
|
||||
setCreatedJobs((prev) => ({ ...prev, [threadId]: res.data.jobApplicationId }));
|
||||
if (!isCurrentView(view)) return;
|
||||
const refreshed = await load();
|
||||
if (!isCurrentView(view)) return;
|
||||
toast(tRef.current(res.data.imported === 1 ? "gmailReviewCreatedImportedMessage" : "gmailReviewCreatedImportedMessages", { count: res.data.imported }), "success");
|
||||
if (refreshed) navigate(`/jobs/${res.data.jobApplicationId}?section=communication`);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("gmailReviewCreateFailed")), "error");
|
||||
if (isCurrentView(view)) setWriteError({ cause: error, key: "gmailReviewCreateFailed" });
|
||||
} finally {
|
||||
setCreatingThreadId(null);
|
||||
finishOperation(view);
|
||||
}
|
||||
}, [load, navigate, notes, suggestions?.items, t, toast]);
|
||||
}, [beginOperation, createdJobs, finishOperation, isCurrentView, load, navigate, notes, suggestions?.items, t, toast]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const threads = data?.threads ?? [];
|
||||
return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter);
|
||||
}, [data?.threads, routingFilter]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
mt: 0,
|
||||
p: 2,
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography component={embedded ? "h2" : "h1"} variant="h5" sx={{ fontWeight: 900 }}>{embedded ? t("gmailReviewEmbeddedTitle") : t("gmailReviewTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("gmailReviewSubtitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
|
||||
{syncing ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||
{loading ? t("loading") : t("correspondenceRefresh")}
|
||||
</Button>
|
||||
{!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>{t("gmailReviewBackToInbox")}</Button> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
const disconnected = (readError as ReviewRequestError)?.response?.data?.code === "gmail_not_connected";
|
||||
const readTitle = t(disconnected ? "gmailReviewNotConnected" : "gmailReviewLoadFailed");
|
||||
const readNotice = readError ? noticeError(readError, readTitle) : null;
|
||||
if (disconnected && readNotice) readNotice.message = readTitle;
|
||||
const unavailable = disconnected || readNotice?.kind === "unauthorized";
|
||||
const controlsDisabled = !active || loading || operation !== null || readError !== null;
|
||||
const chipSx = { maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } };
|
||||
const actions = <>
|
||||
<Button variant="contained" onClick={() => void runManualSync()} disabled={controlsDisabled}>
|
||||
{operation?.kind === "sync" ? t("gmailReviewSyncing") : t("gmailReviewRunSync")}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => { bodyRef.current?.focus(); void load(); }} disabled={!active || loading || operation !== null}>
|
||||
{loading ? t("loading") : t("correspondenceRefresh")}
|
||||
</Button>
|
||||
{!embedded ? <Button variant="text" disabled={!active || operation !== null} onClick={() => navigate("/correspondence")}>{t("gmailReviewBackToInbox")}</Button> : null}
|
||||
</>;
|
||||
const body = <Box ref={bodyRef} role="group" aria-label={t(embedded ? "gmailReviewEmbeddedTitle" : "gmailReviewTitle")} tabIndex={-1} sx={{ minWidth: 0 }}>
|
||||
<ViewStateNotice compact loading={loading || operation !== null} error={readNotice} title={readTitle} onRetry={() => { bodyRef.current?.focus(); void load(); }} />
|
||||
{writeError ? <ViewStateNotice compact title={t(writeError.key)} error={{ ...noticeError(writeError.cause, t(writeError.key)), retryable: false }} /> : null}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={t("gmailReviewFilter", { value: routeLabel(routingFilter) })} variant="outlined" />
|
||||
<Chip label={t("gmailReviewFilter", { value: routeLabel(routingFilter) })} variant="outlined" sx={chipSx} />
|
||||
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
|
||||
<Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
|
||||
<Button key={value} size="small" aria-pressed={routingFilter === value} disabled={!active} variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
|
||||
{routeLabel(value)}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{data ? (
|
||||
{!unavailable && data ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={t("gmailReviewCandidateThreads", { count: data.candidateThreadCount })} variant="outlined" />
|
||||
<Chip label={t("gmailReviewAutoLinkCount", { count: data.autoLinkThreadCount })} color="success" variant="outlined" />
|
||||
<Chip label={t("gmailReviewReviewCount", { count: data.reviewThreadCount })} color="warning" variant="outlined" />
|
||||
<Chip label={t("gmailReviewUnmatchedCount", { count: data.unmatchedThreadCount })} variant="outlined" />
|
||||
{suggestions?.count ? <Chip label={t("gmailReviewSuggestedJobsCount", { count: suggestions.count })} color="secondary" variant="outlined" /> : null}
|
||||
<Chip label={t("gmailReviewCandidateThreads", { count: data.candidateThreadCount })} variant="outlined" sx={chipSx} />
|
||||
<Chip label={t("gmailReviewAutoLinkCount", { count: data.autoLinkThreadCount })} color="success" variant="outlined" sx={chipSx} />
|
||||
<Chip label={t("gmailReviewReviewCount", { count: data.reviewThreadCount })} color="warning" variant="outlined" sx={chipSx} />
|
||||
<Chip label={t("gmailReviewUnmatchedCount", { count: data.unmatchedThreadCount })} variant="outlined" sx={chipSx} />
|
||||
{suggestions?.count ? <Chip label={t("gmailReviewSuggestedJobsCount", { count: suggestions.count })} color="secondary" variant="outlined" sx={chipSx} /> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>{t("gmailReviewNoCandidates")}</Typography> : null}
|
||||
{!loading && !readError && data && filteredThreads.length === 0 ? <Typography color="text.secondary">{t("gmailReviewNoCandidates")}</Typography> : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{filteredThreads.map((thread) => {
|
||||
{!unavailable && filteredThreads.map((thread) => {
|
||||
const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId);
|
||||
const headingId = `${sectionId}-${encodeURIComponent(thread.threadId)}`;
|
||||
return (
|
||||
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Paper key={thread.threadId} component="article" aria-labelledby={headingId} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
|
||||
<Typography id={headingId} component={embedded ? "h3" : "h2"} variant="subtitle1" sx={{ fontWeight: 700, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("gmailReviewThreadSummary", { count: thread.messageCount, route: routeLabel(thread.routing) })}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
|
||||
{thread.matchedQueries.slice(0, 3).map((query) => (
|
||||
<Chip key={query} size="small" label={query} variant="outlined" />
|
||||
<Chip key={query} size="small" label={query} variant="outlined" sx={chipSx} />
|
||||
))}
|
||||
{thread.hasImportedMessages ? <Chip size="small" label={t("gmailReviewHasImported")} color="success" variant="outlined" /> : null}
|
||||
{thread.hasImportedMessages ? <Chip size="small" label={t("gmailReviewHasImported")} color="success" variant="outlined" sx={chipSx} /> : null}
|
||||
</Box>
|
||||
<TextField
|
||||
label={t("gmailReviewNotes")}
|
||||
value={notes[thread.threadId] ?? ""}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
|
||||
onChange={(event) => {
|
||||
if (!activeRef.current || mutationRef.current) return;
|
||||
setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }));
|
||||
}}
|
||||
disabled={!active || operation !== null}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
@@ -217,17 +290,18 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end", minWidth: 0, maxWidth: "100%", "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{thread.jobCandidates.slice(0, 2).map((candidate) => (
|
||||
<Chip
|
||||
key={candidate.jobApplicationId}
|
||||
label={`${candidate.companyName} • ${candidate.jobTitle} (${candidate.score})`}
|
||||
variant="outlined"
|
||||
sx={chipSx}
|
||||
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
|
||||
/>
|
||||
))}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
|
||||
<Button size="small" variant="text" disabled={!active || operation !== null} onClick={() => navigate(`/jobs/${thread.jobCandidates[0].jobApplicationId}?section=communication`)}>
|
||||
{t("gmailReviewOpenTopJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -235,7 +309,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
|
||||
>
|
||||
{t("gmailReviewLinkTopJob")}
|
||||
@@ -245,7 +319,7 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "review")}
|
||||
>
|
||||
{t("gmailReviewKeepInReview")}
|
||||
@@ -254,26 +328,26 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "suggested")}
|
||||
>
|
||||
{t("gmailReviewSuggestedJobAction")}
|
||||
</Button>
|
||||
{suggestion ? (
|
||||
{createdJobs[thread.threadId] ? <Button size="small" variant="outlined" disabled={!active || operation !== null} onClick={() => navigate(`/jobs/${createdJobs[thread.threadId]}?section=communication`)}>{t("emailInboxOpenJob")}</Button> : suggestion ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={creatingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void createSuggestedJob(thread.threadId)}
|
||||
>
|
||||
{creatingThreadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
|
||||
{operation?.kind === "create" && operation.threadId === thread.threadId ? t("gmailReviewCreating") : t("createJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => void saveDecision(thread.threadId, "rejected")}
|
||||
>
|
||||
{t("gmailReviewReject")}
|
||||
@@ -284,6 +358,11 @@ export default function GmailReviewPage({ embedded = false }: { embedded?: boole
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
</Box>;
|
||||
|
||||
return embedded ? <PageSection title={t("gmailReviewEmbeddedTitle")} description={t("gmailReviewSubtitle")} actions={actions}>{body}</PageSection> :
|
||||
<Box component="section" aria-labelledby={sectionId} sx={{ minWidth: 0, display: "grid", gap: 2 }}>
|
||||
<PageHeader title={t("gmailReviewTitle")} titleId={sectionId} description={t("gmailReviewSubtitle")} actions={actions} />
|
||||
{body}
|
||||
</Box>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user