fix(mail): search every owned job
Replace ineffective pageSize=100 selectors with a bounded owner-filtered search endpoint for composing and moving linked threads.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
@@ -43,7 +44,6 @@ import {
|
||||
GmailStatus,
|
||||
GmailThreadRefreshResult,
|
||||
GmailUnlinkResult,
|
||||
JobApplication,
|
||||
} from "../types";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -103,9 +103,7 @@ function formatReasonLabel(label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
interface PagedResult<T> {
|
||||
items: T[];
|
||||
}
|
||||
type JobChoice = { id: number; jobTitle: string; companyName: string };
|
||||
|
||||
export type CorrespondenceJobContext = {
|
||||
companyName?: string | null;
|
||||
@@ -153,7 +151,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
|
||||
const [availableJobs, setAvailableJobs] = useState<JobChoice[]>([]);
|
||||
const [jobChoiceQuery, setJobChoiceQuery] = useState("");
|
||||
const [jobChoicesLoading, setJobChoicesLoading] = useState(false);
|
||||
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
|
||||
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
|
||||
const [manageNote, setManageNote] = useState("");
|
||||
@@ -195,12 +195,15 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
}
|
||||
}, [jobId, toast]);
|
||||
|
||||
const loadAvailableJobs = useCallback(async () => {
|
||||
const loadAvailableJobs = useCallback(async (query?: string) => {
|
||||
try {
|
||||
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
|
||||
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
|
||||
setJobChoicesLoading(true);
|
||||
const res = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: query?.trim() || undefined, limit: 50 } });
|
||||
setAvailableJobs((res.data ?? []).filter((item) => item.id !== jobId));
|
||||
} catch {
|
||||
setAvailableJobs([]);
|
||||
} finally {
|
||||
setJobChoicesLoading(false);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
@@ -257,8 +260,13 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
|
||||
useEffect(() => {
|
||||
void loadGmailStatus();
|
||||
void loadAvailableJobs();
|
||||
}, [loadAvailableJobs, loadGmailStatus]);
|
||||
}, [loadGmailStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageThreadId) return;
|
||||
const timer = window.setTimeout(() => void loadAvailableJobs(jobChoiceQuery), jobChoiceQuery.trim() ? 250 : 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [jobChoiceQuery, loadAvailableJobs, manageThreadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
||||
@@ -405,6 +413,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
const openManageThread = (threadId: string) => {
|
||||
setManageThreadId(threadId);
|
||||
setManageTargetJobId(jobId);
|
||||
setJobChoiceQuery("");
|
||||
setManageNote("");
|
||||
};
|
||||
|
||||
@@ -443,7 +452,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
await loadGmailMatches(gmailQuery);
|
||||
setManageThreadId(null);
|
||||
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
|
||||
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
||||
toast(`Moved thread to ${targetJob?.companyName || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
|
||||
} finally {
|
||||
@@ -561,21 +570,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
|
||||
minRows={2}
|
||||
placeholder="Why this thread should stay in review or move to another job."
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Move to job</InputLabel>
|
||||
<Select
|
||||
value={String(manageTargetJobId)}
|
||||
label="Move to job"
|
||||
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
|
||||
>
|
||||
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
|
||||
{availableJobs.map((item) => (
|
||||
<MenuItem key={item.id} value={String(item.id)}>
|
||||
{item.company?.name || "Unknown company"} • {item.jobTitle}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Autocomplete
|
||||
options={availableJobs}
|
||||
loading={jobChoicesLoading}
|
||||
value={availableJobs.find((item) => item.id === manageTargetJobId) ?? null}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === "input" || reason === "clear") setJobChoiceQuery(value);
|
||||
}}
|
||||
onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
getOptionLabel={(item) => `${item.companyName || "Unknown company"} • ${item.jobTitle}`}
|
||||
renderInput={(params) => <TextField {...params} label="Move to job" placeholder="Search every job" />}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
|
||||
|
||||
@@ -51,28 +51,20 @@ describe("correspondence Gmail import", () => {
|
||||
correspondenceMessages = [];
|
||||
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === "/jobapplications") {
|
||||
if (url === "/jobapplications/choices") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
items: [
|
||||
data: [
|
||||
{
|
||||
id: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
status: "Applied",
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 3,
|
||||
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
||||
companyName: "Acme",
|
||||
},
|
||||
{
|
||||
id: 77,
|
||||
jobTitle: "Platform Engineer",
|
||||
status: "Applied",
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 1,
|
||||
company: { name: "Beta" },
|
||||
companyName: "Beta",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/jobapplications/42") {
|
||||
@@ -377,7 +369,7 @@ describe("correspondence Gmail import", () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
||||
fireEvent.mouseDown((await screen.findAllByRole("combobox")).slice(-1)[0]);
|
||||
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /move to job/i }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('CorrespondenceInboxPage', () => {
|
||||
},
|
||||
] } 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: {
|
||||
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
|
||||
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
|
||||
@@ -328,9 +329,9 @@ describe('CorrespondenceInboxPage', () => {
|
||||
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
||||
{ provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false },
|
||||
] } as any);
|
||||
if (url === '/jobapplications') return Promise.resolve({ data: { items: [
|
||||
{ id: 42, jobTitle: 'Backend Engineer', company: { name: 'Acme Systems' } },
|
||||
] } } as any);
|
||||
if (url === '/jobapplications/choices') return Promise.resolve({ data: [
|
||||
{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' },
|
||||
] } as any);
|
||||
return original!(url, config);
|
||||
});
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
@@ -371,6 +372,34 @@ describe('CorrespondenceInboxPage', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
test('searches the complete server-side job choice set while composing', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true },
|
||||
] } as any);
|
||||
if (url === '/jobapplications/choices') {
|
||||
return Promise.resolve({ data: config?.params?.q === 'Historic'
|
||||
? [{ id: 7, jobTitle: 'Historic target role', companyName: 'Archive Co' }]
|
||||
: [{ id: 42, jobTitle: 'Recent role', companyName: 'Acme Systems' }] } as any);
|
||||
}
|
||||
return original!(url, config);
|
||||
});
|
||||
|
||||
renderPage();
|
||||
const compose = await screen.findByRole('button', { name: /compose new email/i });
|
||||
await waitFor(() => expect(compose).toBeEnabled());
|
||||
fireEvent.click(compose);
|
||||
fireEvent.change(screen.getByRole('combobox', { name: /^job$/i }), { target: { value: 'Historic' } });
|
||||
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', {
|
||||
params: { q: 'Historic', limit: 20 },
|
||||
}));
|
||||
fireEvent.click(await screen.findByRole('option', { name: /Archive Co.*Historic target role/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /start draft/i }));
|
||||
expect(screen.getByText(/Archive Co.*Historic target role/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not offer retry when delivery is uncertain', async () => {
|
||||
const original = mockedApi.get.getMockImplementation();
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
@@ -53,7 +54,7 @@ type EmailProviderStatus = {
|
||||
type JobChoice = {
|
||||
id: number;
|
||||
jobTitle: string;
|
||||
company?: { name?: string | null } | null;
|
||||
companyName: string;
|
||||
};
|
||||
|
||||
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
|
||||
@@ -119,6 +120,8 @@ export default function CorrespondenceInboxPage() {
|
||||
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
||||
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
||||
const [jobsLoaded, setJobsLoaded] = useState(false);
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [jobQuery, setJobQuery] = useState("");
|
||||
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
||||
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
||||
const [composeProvider, setComposeProvider] = useState("");
|
||||
@@ -178,11 +181,18 @@ export default function CorrespondenceInboxPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } })
|
||||
.then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : []))
|
||||
.catch(() => setJobs([]))
|
||||
.finally(() => setJobsLoaded(true));
|
||||
}, []);
|
||||
const timer = window.setTimeout(() => {
|
||||
setJobsLoading(true);
|
||||
api.get<JobChoice[]>("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } })
|
||||
.then((response) => setJobs(Array.isArray(response.data) ? response.data : []))
|
||||
.catch(() => setJobs([]))
|
||||
.finally(() => {
|
||||
setJobsLoaded(true);
|
||||
setJobsLoading(false);
|
||||
});
|
||||
}, jobQuery.trim() ? 250 : 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [jobQuery]);
|
||||
|
||||
const loadStoredDrafts = useCallback(async () => {
|
||||
try {
|
||||
@@ -312,7 +322,7 @@ export default function CorrespondenceInboxPage() {
|
||||
}))) return;
|
||||
setDraft({
|
||||
jobApplicationId: job.id,
|
||||
companyName: job.company?.name || "Unknown company",
|
||||
companyName: job.companyName || "Unknown company",
|
||||
jobTitle: job.jobTitle || "Unknown role",
|
||||
provider: provider.provider,
|
||||
providerName: provider.displayName,
|
||||
@@ -558,12 +568,18 @@ export default function CorrespondenceInboxPage() {
|
||||
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
|
||||
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="compose-job-label">Job</InputLabel>
|
||||
<Select labelId="compose-job-label" value={composeJobId} label="Job" onChange={(event) => setComposeJobId(Number(event.target.value))}>
|
||||
{jobs.map((job) => <MenuItem key={job.id} value={job.id}>{job.company?.name || "Unknown company"} · {job.jobTitle}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Autocomplete
|
||||
options={jobs}
|
||||
loading={jobsLoading}
|
||||
value={jobs.find((job) => job.id === composeJobId) ?? null}
|
||||
onInputChange={(_, value, reason) => {
|
||||
if (reason === "input" || reason === "clear") setJobQuery(value);
|
||||
}}
|
||||
onChange={(_, value) => setComposeJobId(value?.id ?? "")}
|
||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||
getOptionLabel={(job) => `${job.companyName || "Unknown company"} · ${job.jobTitle}`}
|
||||
renderInput={(params) => <TextField {...params} label="Job" placeholder="Search every job" />}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="compose-provider-label">Sending provider</InputLabel>
|
||||
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>
|
||||
|
||||
Reference in New Issue
Block a user