feat(jobs): complete workspace draft parity
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user