fix(career): preserve edits during polling
Refresh extraction runs without reloading controlled profile fields. Keep stored-profile AI actions gated until pending edits are saved.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import ProfilePage from './views/ProfilePage';
|
||||
@@ -284,6 +284,55 @@ test('profile page can reprocess from stored artifact history', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('active extraction polling refreshes run status without resetting unsaved career fields', async () => {
|
||||
extractionRunsResponse = [{
|
||||
id: 15,
|
||||
trigger: 'upload',
|
||||
status: 'queued',
|
||||
artifactFileName: 'active-cv.pdf',
|
||||
startedAtUtc: '2026-03-28T12:00:00Z',
|
||||
parserVersion: 'm005-s01',
|
||||
normalizerVersion: 'm005-s01',
|
||||
llmPromptVersion: 'm005-s01',
|
||||
operation: {
|
||||
id: '00000000-0000-0000-0000-000000000015',
|
||||
taskType: 'cv.process',
|
||||
status: 'queued',
|
||||
createdAtUtc: '2026-03-28T12:00:00Z',
|
||||
canCancel: true,
|
||||
canRetry: false,
|
||||
},
|
||||
}];
|
||||
let poll: (() => void) | undefined;
|
||||
const interval = jest.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (timeout === 4000 && typeof handler === 'function') poll = handler as () => void;
|
||||
return 1 as any;
|
||||
});
|
||||
|
||||
renderPage();
|
||||
const nameField = await screen.findByLabelText(/full name/i);
|
||||
await waitFor(() => expect(poll).toBeDefined());
|
||||
fireEvent.change(nameField, { target: { value: 'Unsaved Poll-Safe Name' } });
|
||||
|
||||
extractionRunsResponse = [{ ...extractionRunsResponse[0], status: 'running', operation: { ...extractionRunsResponse[0].operation, status: 'running' } }];
|
||||
await act(async () => {
|
||||
poll?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockedApi.get.mock.calls.filter(([url]) => url === '/profile-cv/runs').length).toBeGreaterThanOrEqual(2));
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/career/profile')).toHaveLength(1);
|
||||
expect(nameField).toHaveValue('Unsaved Poll-Safe Name');
|
||||
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/career/profile', expect.objectContaining({
|
||||
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Unsaved Poll-Safe Name' }) }),
|
||||
})));
|
||||
interval.mockRestore();
|
||||
});
|
||||
|
||||
test('profile page shows durable CV operation state and retries a failed run', async () => {
|
||||
extractionRunsResponse = [{
|
||||
id: 14,
|
||||
|
||||
@@ -163,6 +163,7 @@ export default function CareerProfilePage() {
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
const [reprocessingCv, setReprocessingCv] = useState(false);
|
||||
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
|
||||
const [profileDirty, setProfileDirty] = useState(false);
|
||||
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
|
||||
const [versions, setVersions] = useState<CareerVersion[]>([]);
|
||||
// The raw import/section parser remains available as an advanced recovery tool.
|
||||
@@ -183,6 +184,7 @@ export default function CareerProfilePage() {
|
||||
const r = await api.post<CareerProfileResponse>(`/career/profile/versions/${version}/restore`);
|
||||
setStructuredCv(normalizeStructuredCv(r.data?.profile ?? emptyStructuredCv()));
|
||||
setCompleteness(r.data?.completeness ?? null);
|
||||
setProfileDirty(false);
|
||||
await loadVersions();
|
||||
toast(t("profileUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
@@ -197,26 +199,38 @@ export default function CareerProfilePage() {
|
||||
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
|
||||
const runStatusRef = useRef<Record<number, string>>({});
|
||||
|
||||
const editStructuredCv = useCallback((next: React.SetStateAction<StructuredCvProfile>) => {
|
||||
setStructuredCv(next);
|
||||
setProfileDirty(true);
|
||||
}, []);
|
||||
|
||||
const loadExtractionRuns = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get<ExtractionRun[]>("/profile-cv/runs");
|
||||
setExtractionRuns(response.data ?? []);
|
||||
} catch {
|
||||
// Polling failure must not clear existing run state or touch unsaved profile edits.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// /career reads the structured profile from the relational source of truth (/career/profile);
|
||||
// /auth/me still provides the account row (avatar, provider chips) shown in the header.
|
||||
const [careerResponse, meResponse, runsResponse] = await Promise.all([
|
||||
const [careerResponse, meResponse] = await Promise.all([
|
||||
api.get<CareerProfileResponse>("/career/profile"),
|
||||
api.get<MeResponse>("/auth/me"),
|
||||
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
|
||||
]);
|
||||
setMe(meResponse.data);
|
||||
setProfileCvText(careerResponse.data?.cvText ?? "");
|
||||
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
|
||||
setCompleteness(careerResponse.data?.completeness ?? null);
|
||||
setExtractionRuns(runsResponse.data ?? []);
|
||||
setProfileDirty(false);
|
||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
||||
setLoadError(null);
|
||||
} catch (error: any) {
|
||||
setMe(null);
|
||||
setExtractionRuns([]);
|
||||
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -225,8 +239,9 @@ export default function CareerProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
void loadExtractionRuns();
|
||||
void loadVersions();
|
||||
}, [loadProfile, loadVersions]);
|
||||
}, [loadExtractionRuns, loadProfile, loadVersions]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRuns = extractionRuns.filter((run) => run.operation
|
||||
@@ -235,11 +250,11 @@ export default function CareerProfilePage() {
|
||||
if (activeRuns.length === 0) return;
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void loadProfile();
|
||||
void loadExtractionRuns();
|
||||
}, 4000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [extractionRuns, loadProfile]);
|
||||
}, [extractionRuns, loadExtractionRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = extractionRuns.filter((run) => run.status === "pending_review" && !runDiffs[run.id]);
|
||||
@@ -397,6 +412,7 @@ export default function CareerProfilePage() {
|
||||
|
||||
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
|
||||
{!canUseAi && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.</Alert>}
|
||||
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>You have unsaved career edits. Save them before running actions that use the stored profile.</Alert> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
||||
@@ -419,7 +435,7 @@ export default function CareerProfilePage() {
|
||||
setUploadingCv(true);
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV upload (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
|
||||
@@ -433,12 +449,12 @@ export default function CareerProfilePage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
||||
disabled={!canUseAi || !isLocal || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRebuildingCv(true);
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/rebuild");
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV rebuild (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvRebuildFailed")), "error");
|
||||
@@ -451,12 +467,12 @@ export default function CareerProfilePage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!canUseAi || !isLocal || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
||||
disabled={!canUseAi || !isLocal || profileDirty || !profileCvText.trim() || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setImprovingCv(true);
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/improve");
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV improve run (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
||||
@@ -474,7 +490,7 @@ export default function CareerProfilePage() {
|
||||
setReprocessingCv(true);
|
||||
try {
|
||||
const res = await api.post<QueuedCvRunResponse>("/profile-cv/reprocess");
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast(`Queued CV reprocess run (run ${res.data.extractionRunId}).`, "info");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvReprocessFailed")), "error");
|
||||
@@ -505,7 +521,7 @@ export default function CareerProfilePage() {
|
||||
<TextField
|
||||
label={t("profileCvTextLabel")}
|
||||
value={profileCvText}
|
||||
onChange={(e) => setProfileCvText(e.target.value)}
|
||||
onChange={(e) => { setProfileCvText(e.target.value); setProfileDirty(true); }}
|
||||
helperText={t("profileCvTextHelp")}
|
||||
multiline
|
||||
minRows={12}
|
||||
@@ -551,7 +567,7 @@ export default function CareerProfilePage() {
|
||||
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
|
||||
try {
|
||||
await api.post(`/operations/${run.operation!.id}/cancel`);
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast("CV processing cancellation requested.", "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not cancel CV processing."), "error");
|
||||
@@ -562,7 +578,7 @@ export default function CareerProfilePage() {
|
||||
<Button size="small" color="inherit" sx={{ mt: 0.75 }} onClick={async () => {
|
||||
try {
|
||||
await api.post(`/operations/${run.operation!.id}/retry`);
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast("CV processing queued again.", "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not retry CV processing."), "error");
|
||||
@@ -598,13 +614,18 @@ export default function CareerProfilePage() {
|
||||
label={`Include low-confidence ${change.category}: ${change.label}`}
|
||||
/>
|
||||
))}
|
||||
{profileDirty ? (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Save your current career-profile edits before applying imported changes.
|
||||
</Alert>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
|
||||
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null} onClick={async () => {
|
||||
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null || profileDirty} onClick={async () => {
|
||||
setReviewingRunId(run.id);
|
||||
try {
|
||||
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
|
||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||
await Promise.all([loadProfile(), loadVersions()]);
|
||||
await Promise.all([loadProfile(), loadExtractionRuns(), loadVersions()]);
|
||||
toast("CV changes merged into your career profile.", "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
|
||||
@@ -615,7 +636,7 @@ export default function CareerProfilePage() {
|
||||
try {
|
||||
await api.post(`/profile-cv/runs/${run.id}/discard`);
|
||||
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
|
||||
await loadProfile();
|
||||
await loadExtractionRuns();
|
||||
toast("CV extraction discarded.", "info");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
|
||||
@@ -649,7 +670,7 @@ export default function CareerProfilePage() {
|
||||
setParsingCvSections(true);
|
||||
try {
|
||||
const res = await api.post<{ structuredCv?: StructuredCvProfile }>("/profile-cv/parse", { text: profileCvText });
|
||||
setStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
|
||||
editStructuredCv(normalizeStructuredCv(res.data?.structuredCv));
|
||||
toast(t("profileCvStructureParsed"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvStructureParseFailed")), "error");
|
||||
@@ -689,25 +710,25 @@ export default function CareerProfilePage() {
|
||||
|
||||
<PersonalInformationSection
|
||||
value={structuredCv.contact}
|
||||
onChange={(next) => setStructuredCv((prev) => ({ ...prev, contact: next }))}
|
||||
onChange={(next) => editStructuredCv((prev) => ({ ...prev, contact: next }))}
|
||||
getMetadata={metaFor}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => setStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
|
||||
<SkillsSection value={structuredCv.skills} onChange={(next) => setStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
|
||||
<InterestsSection value={structuredCv.interests} onChange={(next) => setStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
|
||||
<ProfessionalSummarySection value={structuredCv.summary} onChange={(next) => editStructuredCv((prev) => ({ ...prev, summary: next }))} getMetadata={metaFor} />
|
||||
<SkillsSection value={structuredCv.skills} onChange={(next) => editStructuredCv((prev) => ({ ...prev, skills: next }))} getMetadata={metaFor} />
|
||||
<InterestsSection value={structuredCv.interests} onChange={(next) => editStructuredCv((prev) => ({ ...prev, interests: next }))} getMetadata={metaFor} />
|
||||
</Box>
|
||||
|
||||
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => setStructuredCv((prev) => ({ ...prev, [key]: next }))} />
|
||||
<LongTailSections values={{ awards: structuredCv.awards, publications: structuredCv.publications, organisations: structuredCv.organisations, references: structuredCv.references }} onChange={(key, next) => editStructuredCv((prev) => ({ ...prev, [key]: next }))} />
|
||||
|
||||
<LanguagesSection value={structuredCv.languages} onChange={(next) => setStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
|
||||
<LanguagesSection value={structuredCv.languages} onChange={(next) => editStructuredCv((prev) => ({ ...prev, languages: next }))} getMetadata={metaFor} />
|
||||
|
||||
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => setStructuredCv((prev) => ({ ...prev, jobs: next }))} />
|
||||
<WorkExperienceSection value={structuredCv.jobs} onChange={(next) => editStructuredCv((prev) => ({ ...prev, jobs: next }))} />
|
||||
|
||||
<EducationSection value={structuredCv.education} onChange={(next) => setStructuredCv((prev) => ({ ...prev, education: next }))} />
|
||||
<EducationSection value={structuredCv.education} onChange={(next) => editStructuredCv((prev) => ({ ...prev, education: next }))} />
|
||||
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => editStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
@@ -720,6 +741,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{profileDirty ? <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" /> : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || loading}
|
||||
@@ -732,6 +754,7 @@ export default function CareerProfilePage() {
|
||||
const saved = await api.put<CareerProfileResponse>("/career/profile", { profile: structuredCv, cvText: profileCvText });
|
||||
setStructuredCv(normalizeStructuredCv(saved.data?.profile ?? structuredCv));
|
||||
setCompleteness(saved.data?.completeness ?? null);
|
||||
setProfileDirty(false);
|
||||
toast(t("profileUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
||||
|
||||
Reference in New Issue
Block a user