feat(jobs): complete workspace draft parity
CI and Deploy / test (pull_request) Successful in 5m4s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 17:34:32 +02:00
parent 3b86ea2da0
commit deed948183
28 changed files with 676 additions and 131 deletions
@@ -243,7 +243,7 @@ I would welcome the chance to talk it through.
Kind regards,
[Your name]`;
export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
export function ApplicationCoverLetterSection({ jobId, onDirtyChange }: { jobId: number; onDirtyChange?: (dirty: boolean) => void }) {
const { data, error, loading, setData, setError } = useAsset<CoverLetter>(
() => applicationAssetsApi.coverLetter(jobId),
[jobId],
@@ -256,6 +256,11 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
const text = draft ?? data?.text ?? "";
const dirty = draft !== null && draft !== (data?.text ?? "");
useEffect(() => {
onDirtyChange?.(dirty);
return () => onDirtyChange?.(false);
}, [dirty, onDirtyChange]);
const save = async (value: string, source = "manual") => {
setBusy(true);
try {
@@ -367,3 +372,97 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
</Stack>
);
}
// ---------- Application answer and recruiter message ----------
type PackageDrafts = { applicationAnswer: string; recruiterMessage: string };
export function ApplicationPackageDraftsSection({
jobId,
initialApplicationAnswer,
initialRecruiterMessage,
onSaved,
onDirtyChange,
}: {
jobId: number;
initialApplicationAnswer: string;
initialRecruiterMessage: string;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const initial = { applicationAnswer: initialApplicationAnswer, recruiterMessage: initialRecruiterMessage };
const [saved, setSaved] = useState<PackageDrafts>(initial);
const [draft, setDraft] = useState<PackageDrafts | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (draft === null) setSaved(initial);
// `draft` is deliberately excluded: a parent refresh must never overwrite in-progress edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobId, initialApplicationAnswer, initialRecruiterMessage]);
const value = draft ?? saved;
const dirty = draft !== null && (
draft.applicationAnswer !== saved.applicationAnswer ||
draft.recruiterMessage !== saved.recruiterMessage
);
useEffect(() => {
onDirtyChange?.(dirty);
return () => onDirtyChange?.(false);
}, [dirty, onDirtyChange]);
const update = (patch: Partial<PackageDrafts>) => setDraft({ ...value, ...patch });
const save = async () => {
setBusy(true);
try {
await applicationAssetsApi.saveApplicationDrafts(jobId, value.applicationAnswer, value.recruiterMessage);
setSaved(value);
setDraft(null);
setError(null);
onSaved?.();
} catch (err) {
setError(getApiErrorMessage(err, "Could not save the application drafts."));
} finally {
setBusy(false);
}
};
return (
<Shell
title="Application answers and recruiter message"
subtitle="Keep reusable application-form answers and a recruiter note with this application. Ordinary job notes stay separate."
loading={false}
error={error}
>
<Stack spacing={2}>
<RichTextField
minRows={6}
label="Application answer"
value={value.applicationAnswer}
disabled={busy}
onChange={(applicationAnswer) => update({ applicationAnswer })}
placeholder="Draft an answer for motivation, suitability, or another application-form question."
/>
<RichTextField
minRows={4}
label="Recruiter message"
value={value.recruiterMessage}
disabled={busy}
onChange={(recruiterMessage) => update({ recruiterMessage })}
placeholder="Draft a concise message to the recruiter or hiring manager."
/>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center">
<Button variant="contained" disabled={busy || !dirty} onClick={save}>
Save application drafts
</Button>
<Button disabled={busy || !dirty} onClick={() => setDraft(null)}>
Discard changes
</Button>
{dirty && <Chip size="small" color="warning" variant="outlined" label="Unsaved changes" />}
</Stack>
</Stack>
</Shell>
);
}
@@ -25,6 +25,7 @@ import { useCompanies } from "../hooks/useCompanies";
import TagsInput from "./TagsInput";
import { useI18n } from "../i18n/I18nProvider";
import { PIPELINE_STATUSES, statusLabel } from "../pipeline";
import { removeApplicationAnswerDraft } from "../applicationDrafts";
interface Props {
open: boolean;
@@ -121,7 +122,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
setNextAction((j as any).nextAction ?? "");
setFollowUpAt((j as any).followUpAt ? toDateInputValue((j as any).followUpAt) : "");
setJobUrl(j.jobUrl ?? "");
setNotes(j.notes ?? "");
setNotes(removeApplicationAnswerDraft(j.notes));
setDescription((j as any).description ?? "");
setTranslatedDescription((j as any).translatedDescription ?? "");
setDescriptionLanguage((j as any).descriptionLanguage ?? "");
@@ -38,6 +38,7 @@ import { useI18n } from "../i18n/I18nProvider";
import { useJobWorkspaceBaseData } from "./job-workspace/useJobWorkspaceBaseData";
import { useWorkspaceTabCache } from "./job-workspace/useWorkspaceTabCache";
import { useAccountPlan } from "../accountPlan";
import { upsertApplicationAnswerDraft } from "../applicationDrafts";
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
type CoverLetterStyle = "balanced" | "concise" | "formal" | "bold";
@@ -85,31 +86,6 @@ function copyLines(items: string[]) {
return navigator.clipboard.writeText(items.map((item) => `${item}`).join("\n"));
}
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
function upsertApplicationAnswerDraft(notes: string | null | undefined, draft: string) {
const trimmedNotes = (notes ?? "").trim();
const trimmedDraft = draft.trim();
const block = trimmedDraft
? `${APPLICATION_ANSWER_START}\n${trimmedDraft}\n${APPLICATION_ANSWER_END}`
: "";
if (!trimmedNotes) return block;
const markerPattern = new RegExp(`${APPLICATION_ANSWER_START}[\\s\\S]*?${APPLICATION_ANSWER_END}`, "g");
if (markerPattern.test(trimmedNotes)) {
return block ? trimmedNotes.replace(markerPattern, block).trim() : trimmedNotes.replace(markerPattern, "").trim();
}
const legacyPattern = /(?:\n\n)?Application answer draft:\s*\n[\s\S]*$/i;
if (legacyPattern.test(trimmedNotes)) {
return block ? trimmedNotes.replace(legacyPattern, `\n\n${block}`).trim() : trimmedNotes.replace(legacyPattern, "").trim();
}
return block ? `${trimmedNotes}\n\n${block}` : trimmedNotes;
}
function getWorkspaceStatus(currentValue: string, savedValue: string) {
const current = currentValue.trim();
const saved = savedValue.trim();
+13 -1
View File
@@ -175,6 +175,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
const location = useLocation();
const navigate = useNavigate();
const listRouteRef = useRef(`${location.pathname}${location.search}`);
const restoredFocusJobIdRef = useRef<number | null>(null);
const [jobs, setJobs] = useState<JobApplication[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => queryPage(location.search));
@@ -287,7 +288,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
};
const openJob = useCallback((jobId: number, path?: string) => {
navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current } });
navigate(path ?? `/jobs/${jobId}`, { state: { from: listRouteRef.current, focusJobId: jobId } });
}, [navigate]);
const params = useMemo(() => ({
@@ -338,6 +339,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return jobs.filter((job) => needsWorkflowWork(job));
}, [jobs, readinessFilter]);
useEffect(() => {
const focusJobId = (location.state as { focusJobId?: unknown } | null)?.focusJobId;
if (typeof focusJobId !== "number" || restoredFocusJobIdRef.current === focusJobId || jobsResource.loading) return;
const row = document.querySelector<HTMLElement>(`[data-job-row-id="${focusJobId}"]`);
if (!row) return;
restoredFocusJobIdRef.current = focusJobId;
row.focus();
}, [filteredJobs, jobsResource.loading, location.state]);
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
// empty state can actually help a first-time user instead of just saying "nothing here".
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
@@ -640,6 +650,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return (
<Paper
key={job.id}
data-job-row-id={job.id}
role={mode === "jobs" && !job.isDeleted ? "link" : undefined}
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
@@ -780,6 +791,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
return (
<TableRow
key={job.id}
data-job-row-id={job.id}
hover={mode === "jobs" && !job.isDeleted}
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../../api";
import { extractApplicationAnswerDraft } from "../../applicationDrafts";
import { AttachmentItem, JobApplication } from "../../types";
type PackageWorkspaceState = {
@@ -9,23 +10,6 @@ type PackageWorkspaceState = {
recruiterMessage: string;
};
const APPLICATION_ANSWER_START = "<<<APPLICATION_ANSWER_DRAFT>>>";
const APPLICATION_ANSWER_END = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
function extractApplicationAnswerDraft(notes?: string | null) {
const value = (notes ?? "").trim();
if (!value) return "";
const startIndex = value.indexOf(APPLICATION_ANSWER_START);
const endIndex = value.indexOf(APPLICATION_ANSWER_END);
if (startIndex >= 0 && endIndex > startIndex) {
return value.slice(startIndex + APPLICATION_ANSWER_START.length, endIndex).trim();
}
const legacyMatch = value.match(/Application answer draft:\s*\n([\s\S]*)$/i);
return legacyMatch?.[1]?.trim() ?? "";
}
export function useJobWorkspaceBaseData({
open,
jobId,
@@ -124,5 +108,4 @@ export function useJobWorkspaceBaseData({
};
}
export { extractApplicationAnswerDraft };
export type { PackageWorkspaceState };