fix(scale): page mail and batch roles
This commit is contained in:
@@ -44,7 +44,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
|
||||
{ provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false },
|
||||
] } as any);
|
||||
if (url === '/correspondence') return Promise.resolve({ data: [
|
||||
if (url === '/correspondence/page') return Promise.resolve({ data: { items: [
|
||||
{
|
||||
id: 1,
|
||||
jobApplicationId: 42,
|
||||
@@ -64,7 +64,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
labelCount: 2,
|
||||
attachmentCount: 1,
|
||||
},
|
||||
] } as any);
|
||||
], page: 1, pageSize: 50, total: 1, totalPages: 1 } } as any);
|
||||
if (url === '/email/drafts') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/choices') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/email/message') return Promise.resolve({ data: {
|
||||
@@ -101,10 +101,12 @@ describe('CorrespondenceInboxPage', () => {
|
||||
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence/page', expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
q: 'Maria',
|
||||
direction: 'inbound',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
@@ -137,6 +139,44 @@ describe('CorrespondenceInboxPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('navigates beyond the first correspondence page', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/correspondence/page') {
|
||||
const page = config?.params?.page ?? 1;
|
||||
return Promise.resolve({ data: {
|
||||
items: [{
|
||||
id: page,
|
||||
jobApplicationId: 42,
|
||||
companyName: page === 2 ? 'Second page company' : 'First page company',
|
||||
jobTitle: 'Engineer',
|
||||
from: 'Recruiter',
|
||||
direction: 'inbound',
|
||||
subject: `Page ${page}`,
|
||||
channel: 'Email',
|
||||
date: new Date().toISOString(),
|
||||
contentPreview: `Page ${page} message`,
|
||||
labelCount: 0,
|
||||
attachmentCount: 0,
|
||||
}],
|
||||
page,
|
||||
pageSize: 50,
|
||||
total: 51,
|
||||
totalPages: 2,
|
||||
} } as any);
|
||||
}
|
||||
return original!(url, config);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /go to page 2/i }));
|
||||
|
||||
expect(await screen.findByText(/second page company/i)).toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.objectContaining({
|
||||
params: expect.objectContaining({ page: 2, pageSize: 50 }),
|
||||
}));
|
||||
});
|
||||
|
||||
test('unlinks a Gmail thread only after confirmation and returns it to review', async () => {
|
||||
mockedApi.post.mockResolvedValue({ data: { threadId: 'thread-1', jobApplicationId: 42, removedMessages: 1, decision: 'review' } } as any);
|
||||
renderPage();
|
||||
@@ -154,7 +194,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
note: 'Unlinked from Job email hub',
|
||||
nextDecision: 'review',
|
||||
}));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence', expect.anything()));
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/page', expect.anything()));
|
||||
expect(await screen.findByText(/returned to recruitment review/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -452,6 +492,6 @@ describe('CorrespondenceInboxPage', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /recruitment message review/i })).toBeInTheDocument();
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence', expect.anything());
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith('/correspondence/page', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Pagination,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -42,6 +43,14 @@ export type CorrespondenceInboxItem = {
|
||||
attachmentCount: number;
|
||||
};
|
||||
|
||||
type CorrespondenceInboxPage = {
|
||||
items: CorrespondenceInboxItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type EmailProviderStatus = {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
@@ -116,6 +125,9 @@ export default function CorrespondenceInboxPage() {
|
||||
const { toast } = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||
const [inboxPage, setInboxPage] = useState(1);
|
||||
const [inboxTotal, setInboxTotal] = useState(0);
|
||||
const [inboxTotalPages, setInboxTotalPages] = useState(0);
|
||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||
@@ -148,20 +160,27 @@ export default function CorrespondenceInboxPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
||||
const res = await api.get<CorrespondenceInboxPage>("/correspondence/page", {
|
||||
params: {
|
||||
q: query.trim() || undefined,
|
||||
direction: direction === "all" ? undefined : direction,
|
||||
linkState: linkState === "all" ? undefined : linkState,
|
||||
page: inboxPage,
|
||||
pageSize: 50,
|
||||
},
|
||||
});
|
||||
setItems(res.data ?? []);
|
||||
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);
|
||||
setSelectedMessageId(null);
|
||||
setMessageDetail(null);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [direction, linkState, query, toast]);
|
||||
}, [direction, inboxPage, linkState, query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "inbox") void load();
|
||||
@@ -524,7 +543,7 @@ export default function CorrespondenceInboxPage() {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${inboxTotal} items`} variant="outlined" /> : null}
|
||||
{view === "inbox" ? <Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
||||
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
|
||||
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
|
||||
@@ -596,10 +615,10 @@ export default function CorrespondenceInboxPage() {
|
||||
|
||||
{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); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Direction</InputLabel>
|
||||
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
|
||||
<Select value={direction} label="Direction" onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="inbound">Inbound</MenuItem>
|
||||
<MenuItem value="outbound">Outbound</MenuItem>
|
||||
@@ -608,7 +627,7 @@ export default function CorrespondenceInboxPage() {
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Link state</InputLabel>
|
||||
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
|
||||
<Select value={linkState} label="Link state" onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="linked">Linked threads</MenuItem>
|
||||
<MenuItem value="manual">Manual/internal only</MenuItem>
|
||||
@@ -711,6 +730,17 @@ export default function CorrespondenceInboxPage() {
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
{inboxTotalPages > 1 ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||
<Pagination
|
||||
page={inboxPage}
|
||||
count={inboxTotalPages}
|
||||
onChange={(_, value) => setInboxPage(value)}
|
||||
color="primary"
|
||||
aria-label="Correspondence pages"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</>}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user