feat(ui): show recorded application context beside opened mail

This commit is contained in:
cesnimda
2026-11-04 08:00:43 +01:00
parent dd284782fd
commit a9208ab4b3
8 changed files with 63 additions and 13 deletions
@@ -53,7 +53,7 @@ function pageData(items: ReturnType<typeof messageItem>[], page = 1, total = ite
}
function messageRow(subject: string) {
return within(screen.getByText(subject, { exact: true }).closest('.MuiPaper-root') as HTMLElement);
return within(screen.getByRole('article', { name: subject }));
}
function LocationProbe() {
@@ -118,11 +118,29 @@ function renderPage(initialPath = '/') {
}
describe('CorrespondenceInboxPage', () => {
test.each(['en', 'nb'] as const)('%s opened message retains one application action in named context', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Interview invite', { exact: true });
const row = messageRow('Interview invite');
fireEvent.click(row.getByRole('button', { name: copy('emailInboxViewMessage', {}, language) }));
const context = await row.findByRole('complementary', { name: copy('emailInboxJob', {}, language) });
expect(context).toHaveTextContent('Acme Systems');
expect(context).toHaveTextContent('Backend Engineer');
expect(row.getAllByRole('button', { name: copy('emailInboxOpenJob', {}, language) })).toHaveLength(1);
expect(within(context).getByRole('button', { name: copy('emailInboxOpenJob', {}, language) })).toBeEnabled();
fireEvent.click(row.getByRole('button', { name: copy('emailInboxHideMessage', {}, language) }));
expect(row.queryByRole('complementary')).not.toBeInTheDocument();
expect(row.getAllByRole('button', { name: copy('emailInboxOpenJob', {}, language) })).toHaveLength(1);
expect(mockedApi.post).not.toHaveBeenCalled(); expect(mockedApi.put).not.toHaveBeenCalled(); expect(mockedApi.delete).not.toHaveBeenCalled();
});
test.each(['en', 'nb'] as const)('%s inbox filters and messages support request-free keyboard navigation', async language => {
localStorage.setItem('uiLanguage', language); renderPage();
await screen.findByText('Interview invite', { exact: true });
const count = mockedApi.get.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxJumpMessages', {}, language) }));
const jump = screen.getByRole('button', { name: copy('emailInboxJumpMessages', {}, language) });
const search = screen.getByRole('textbox', { name: copy('correspondenceSearch', {}, language) });
expect(jump.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(jump);
expect(screen.getByRole('region', { name: copy('emailInboxLinkedMessages', {}, language) })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReturnFilters', {}, language) }));
expect(screen.getByRole('textbox', { name: copy('correspondenceSearch', {}, language) })).toHaveFocus();
@@ -675,7 +675,7 @@ export default function CorrespondenceInboxPage() {
};
return (
<PageSection surface title={t("correspondenceInbox")} description={t("emailInboxSubtitle")}>
<PageSection title={t("correspondenceInbox")} description={t("emailInboxSubtitle")} actions={view === "inbox" ? <Button size="small" onClick={() => messagesPanel.current?.focus()}>{t("emailInboxJumpMessages")}</Button> : undefined}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t(inboxTotal === 1 ? "emailInboxItem" : "emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
@@ -777,7 +777,6 @@ export default function CorrespondenceInboxPage() {
</Select>
</FormControl>
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
<Button size="small" onClick={() => messagesPanel.current?.focus()} sx={{ justifySelf: "start", gridColumn: "1 / -1" }}>{t("emailInboxJumpMessages")}</Button>
</Box>
{draft ? (
@@ -830,10 +829,10 @@ export default function CorrespondenceInboxPage() {
<Stack spacing={1.25}>
{(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 component="article" aria-labelledby={`${rowId}-${item.id}`} key={item.id} sx={{ py: 1.5, borderBottom: 1, borderColor: "divider", 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>
{selectedMessageId !== item.id ? <Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</Typography> : null}
<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")}
@@ -846,7 +845,7 @@ export default function CorrespondenceInboxPage() {
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
{item.attachmentCount > 0 ? <Chip size="small" label={t(item.attachmentCount === 1 ? "emailInboxAttachmentCount" : "emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
<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>
{selectedMessageId !== item.id ? <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> : null}
{item.provider === "gmail" && item.externalThreadId ? (
<Button size="small" color="warning" variant="text" disabled={!!unlinkingThreadId} onClick={() => void unlinkGmailThread(item)}>
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
@@ -855,7 +854,14 @@ export default function CorrespondenceInboxPage() {
</Box>
</Box>
{selectedMessageId === item.id ? (
<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" }}>
<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", display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 2, "@media (min-width:1001px)": { gridTemplateColumns: "minmax(0, 1fr) minmax(14rem, 20rem)" } }}>
<Box component="aside" aria-label={t("emailInboxJob")} sx={{ minWidth: 0, overflowWrap: "anywhere", "@media (min-width:1001px)": { gridColumn: "2", gridRow: "1", borderLeft: 1, borderColor: "divider", pl: 2 } }}>
<Typography component="h4" variant="sectionTitle">{t("emailInboxJob")}</Typography>
<Typography sx={{ fontWeight: 700 }}>{item.jobTitle || t("emailInboxUnknownRole")}</Typography>
<Typography color="text.secondary">{item.companyName || t("emailInboxUnknownCompany")}</Typography>
<Button size="small" variant="text" disabled={!!unlinkingThreadId} sx={{ mt: 1 }} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
</Box>
<Box data-mail-message-content sx={{ minWidth: 0, "@media (min-width:1001px)": { gridColumn: "1", gridRow: "1" } }}>
<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 ? <>
@@ -872,9 +878,10 @@ export default function CorrespondenceInboxPage() {
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
) : null}
</> : null}
</Box>
</Box>
) : null}
</Paper>
</Box>
))}
</Stack>
{hasCurrentInbox && inboxTotalPages > 1 ? (