feat(email): consolidate correspondence routes
CI and Deploy / test (pull_request) Failing after 1m35s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-09 21:19:27 +02:00
parent efacc3a06b
commit 6008b4afef
4 changed files with 59 additions and 22 deletions
+1 -2
View File
@@ -60,7 +60,6 @@ const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage")); const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage")); const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage")); const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./views/GmailReviewPage"));
const OperationsPage = lazy(() => import("./views/OperationsPage")); const OperationsPage = lazy(() => import("./views/OperationsPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage")); const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
@@ -359,7 +358,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/kanban" element={<KanbanBoard />} /> <Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} /> <Route path="/companies" element={<CompaniesTable />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} /> <Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/correspondence/review" element={<GmailReviewPage />} /> <Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/applications/:id" element={<ApplicationWorkspacePage />} /> <Route path="/applications/:id" element={<ApplicationWorkspacePage />} />
<Route path="/career" element={<CareerWorkspacePage />} /> <Route path="/career" element={<CareerWorkspacePage />} />
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage'; import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
@@ -35,8 +35,8 @@ function renderPage() {
describe('CorrespondenceInboxPage', () => { describe('CorrespondenceInboxPage', () => {
beforeEach(() => { beforeEach(() => {
mockedApi.get.mockResolvedValue({ mockedApi.get.mockImplementation((url: string) => {
data: [ if (url === '/correspondence') return Promise.resolve({ data: [
{ {
id: 1, id: 1,
jobApplicationId: 42, jobApplicationId: 42,
@@ -54,8 +54,13 @@ describe('CorrespondenceInboxPage', () => {
labelCount: 2, labelCount: 2,
attachmentCount: 1, attachmentCount: 1,
}, },
], ] } as any);
} as any); if (url === '/gmail/review-candidates') return Promise.resolve({ data: {
queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [],
} } as any);
if (url === '/gmail/suggested-jobs') return Promise.resolve({ data: { count: 0, items: [] } } as any);
return Promise.resolve({ data: {} } as any);
});
}); });
afterEach(() => { afterEach(() => {
@@ -65,7 +70,7 @@ describe('CorrespondenceInboxPage', () => {
test('renders correspondence inbox items and reloads with filters', async () => { test('renders correspondence inbox items and reloads with filters', async () => {
renderPage(); renderPage();
expect(await screen.findByText(/correspondence inbox/i)).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: /job email/i })).toBeInTheDocument();
expect(await screen.findByText(/1 items/i)).toBeInTheDocument(); expect(await screen.findByText(/1 items/i)).toBeInTheDocument();
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument(); expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument(); expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
@@ -85,4 +90,31 @@ describe('CorrespondenceInboxPage', () => {
})); }));
}); });
}); });
test('uses one hub for linked messages and recruitment suggestions', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /review suggestions/i }));
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
expect(screen.getByRole('button', { name: /linked messages/i })).toBeInTheDocument();
});
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
render(
<ToastProvider>
<I18nProvider>
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
</Routes>
</MemoryRouter>
</I18nProvider>
</ToastProvider>,
);
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence', expect.anything());
});
}); });
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { import {
Box, Box,
Chip, Chip,
@@ -17,6 +17,7 @@ import {
import MailOutlineIcon from "@mui/icons-material/MailOutline"; import MailOutlineIcon from "@mui/icons-material/MailOutline";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast"; import { useToast } from "../toast";
import GmailReviewPage from "./GmailReviewPage";
export type CorrespondenceInboxItem = { export type CorrespondenceInboxItem = {
id: number; id: number;
@@ -38,6 +39,8 @@ export type CorrespondenceInboxItem = {
export default function CorrespondenceInboxPage() { export default function CorrespondenceInboxPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const view = searchParams.get("view") === "review" ? "review" : "inbox";
const { toast } = useToast(); const { toast } = useToast();
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]); const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -64,8 +67,8 @@ export default function CorrespondenceInboxPage() {
}, [direction, linkState, query, toast]); }, [direction, linkState, query, toast]);
useEffect(() => { useEffect(() => {
void load(); if (view === "inbox") void load();
}, [load]); }, [load, view]);
const filteredSummary = useMemo(() => { const filteredSummary = useMemo(() => {
const linked = items.filter((item) => item.externalThreadId).length; const linked = items.filter((item) => item.externalThreadId).length;
@@ -85,20 +88,22 @@ export default function CorrespondenceInboxPage() {
> >
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Correspondence inbox</Typography> <Typography component="h1" variant="h5" sx={{ fontWeight: 900 }}>Job email</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Cross-job view of imported correspondence and Gmail-linked history. Review linked correspondence and suggested recruitment messages in one place.
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" /> {view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" /> : null}
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> {view === "inbox" ? <Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> {view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button> <Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>Review suggestions</Button>
</Box> </Box>
</Box> </Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}> {view === "review" ? <GmailReviewPage embedded /> : <>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" /> <TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Direction</InputLabel> <InputLabel>Direction</InputLabel>
@@ -118,7 +123,7 @@ export default function CorrespondenceInboxPage() {
</Select> </Select>
</FormControl> </FormControl>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button> <Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
</Box> </Box>
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null} {loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
@@ -148,6 +153,7 @@ export default function CorrespondenceInboxPage() {
</Paper> </Paper>
))} ))}
</Stack> </Stack>
</>}
</Paper> </Paper>
); );
} }
+3 -3
View File
@@ -5,7 +5,7 @@ import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueue
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export default function GmailReviewPage() { export default function GmailReviewPage({ embedded = false }: { embedded?: boolean }) {
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null); const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
@@ -134,7 +134,7 @@ export default function GmailReviewPage() {
> >
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Gmail review queue</Typography> <Typography component={embedded ? "h2" : "h1"} variant="h5" sx={{ fontWeight: 900 }}>{embedded ? "Recruitment message review" : "Gmail review queue"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads. Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
</Typography> </Typography>
@@ -146,7 +146,7 @@ export default function GmailReviewPage() {
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}> <Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"} {loading ? "Loading..." : "Refresh"}
</Button> </Button>
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button> {!embedded ? <Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button> : null}
</Box> </Box>
</Box> </Box>