From 16dd17ebeac22c9906d36ed86c514a0dde3aa17d Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:23:33 +0000 Subject: [PATCH] Fix Tailor CV adding new skills and restore original skills on revert (#190) * Initial commit * refactor slightly * refactor and fix bugs --- .../components/TailoringEditor.test.tsx | 120 ++++ .../discovered-panel/TailorMode.test.tsx | 73 +++ .../client/components/tailoring-utils.test.ts | 48 +- .../src/client/components/tailoring-utils.ts | 41 ++ .../tailoring/TailoringSections.tsx | 571 +++++++++++------- .../tailoring/TailoringWorkspace.tsx | 211 +++++-- .../components/tailoring/useTailoringDraft.ts | 1 + 7 files changed, 806 insertions(+), 259 deletions(-) diff --git a/orchestrator/src/client/components/TailoringEditor.test.tsx b/orchestrator/src/client/components/TailoringEditor.test.tsx index 10aa670..631b6c9 100644 --- a/orchestrator/src/client/components/TailoringEditor.test.tsx +++ b/orchestrator/src/client/components/TailoringEditor.test.tsx @@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../api"; +import { useProfile } from "../hooks/useProfile"; import { _resetTracerReadinessCache } from "../hooks/useTracerReadiness"; import { TailoringEditor } from "./TailoringEditor"; @@ -14,6 +15,10 @@ vi.mock("../api", () => ({ getTracerReadiness: vi.fn(), })); +vi.mock("../hooks/useProfile", () => ({ + useProfile: vi.fn(), +})); + vi.mock("sonner", () => ({ toast: { success: vi.fn(), @@ -54,6 +59,32 @@ describe("TailoringEditor", () => { lastSuccessAt: Date.now(), reason: null, }); + vi.mocked(useProfile).mockReturnValue({ + profile: { + basics: { + summary: "Original base summary", + label: "Original base headline", + }, + sections: { + skills: { + items: [ + { + id: "s1", + name: "Backend", + description: "", + level: 0, + keywords: ["Node.js", "TypeScript"], + visible: true, + }, + ], + }, + }, + }, + error: null, + isLoading: false, + personName: "Resume", + refreshProfile: vi.fn(), + }); }); it("does not rehydrate local edits from same-job prop updates", async () => { @@ -277,4 +308,93 @@ describe("TailoringEditor", () => { ), ); }); + + it("supports undo to template and redo to AI draft", async () => { + render(); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Original base summary", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Saved summary", + ); + + ensureAccordionOpen("Headline"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Original base headline", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Saved headline", + ); + + ensureAccordionOpen("Tailored Skills"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[2]); + ensureAccordionOpen("Backend"); + expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument(); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]); + ensureAccordionOpen("Core"); + expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument(); + }); + + it("resets redo baseline when switching jobs", async () => { + const { rerender } = render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Original base summary", + ); + + rerender( + , + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Second job summary", + ); + }); + + it("keeps undo disabled until profile template is loaded", async () => { + vi.mocked(useProfile).mockReturnValue({ + profile: null, + error: null, + isLoading: true, + personName: "Resume", + refreshProfile: vi.fn(), + }); + + render(); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + ensureAccordionOpen("Summary"); + ensureAccordionOpen("Headline"); + ensureAccordionOpen("Tailored Skills"); + + for (const button of screen.getAllByLabelText("Undo to template")) { + expect(button).toBeDisabled(); + } + }); }); diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx index 06474e1..cf21ae0 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.test.tsx @@ -3,6 +3,7 @@ import type { Job } from "@shared/types.js"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "../../api"; +import { useProfile } from "../../hooks/useProfile"; import { _resetTracerReadinessCache } from "../../hooks/useTracerReadiness"; import { TailorMode } from "./TailorMode"; @@ -13,6 +14,10 @@ vi.mock("../../api", () => ({ getTracerReadiness: vi.fn(), })); +vi.mock("../../hooks/useProfile", () => ({ + useProfile: vi.fn(), +})); + vi.mock("sonner", () => ({ toast: { success: vi.fn(), @@ -53,6 +58,32 @@ describe("TailorMode", () => { lastSuccessAt: Date.now(), reason: null, }); + vi.mocked(useProfile).mockReturnValue({ + profile: { + basics: { + summary: "Original base summary", + label: "Original base headline", + }, + sections: { + skills: { + items: [ + { + id: "s1", + name: "Backend", + description: "", + level: 0, + keywords: ["Node.js", "TypeScript"], + visible: true, + }, + ], + }, + }, + }, + error: null, + isLoading: false, + personName: "Resume", + refreshProfile: vi.fn(), + }); }); it("does not rehydrate local edits from same-job prop updates", async () => { @@ -270,4 +301,46 @@ describe("TailorMode", () => { expect(screen.getByDisplayValue("Backend")).toBeInTheDocument(); expect(screen.getByDisplayValue("Node.js, Kafka")).toBeInTheDocument(); }); + + it("supports undo to template and redo to AI draft", async () => { + render( + , + ); + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + ensureAccordionOpen("Summary"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Original base summary", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[0]); + expect(screen.getByLabelText("Tailored Summary")).toHaveValue( + "Saved summary", + ); + + ensureAccordionOpen("Headline"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Original base headline", + ); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[1]); + expect(screen.getByLabelText("Tailored Headline")).toHaveValue( + "Saved headline", + ); + + ensureAccordionOpen("Tailored Skills"); + fireEvent.click(screen.getAllByLabelText("Undo to template")[2]); + ensureAccordionOpen("Backend"); + expect(screen.getByDisplayValue("Node.js, TypeScript")).toBeInTheDocument(); + fireEvent.click(screen.getAllByLabelText("Redo to AI draft")[2]); + ensureAccordionOpen("Core"); + expect(screen.getByDisplayValue("React, TypeScript")).toBeInTheDocument(); + }); }); diff --git a/orchestrator/src/client/components/tailoring-utils.test.ts b/orchestrator/src/client/components/tailoring-utils.test.ts index ed56915..7ccdff2 100644 --- a/orchestrator/src/client/components/tailoring-utils.test.ts +++ b/orchestrator/src/client/components/tailoring-utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseTailoredSkills } from "./tailoring-utils"; +import { + getOriginalHeadline, + getOriginalSkills, + getOriginalSummary, + parseTailoredSkills, +} from "./tailoring-utils"; describe("parseTailoredSkills", () => { it("parses object-based tailored skills payload", () => { @@ -44,4 +49,45 @@ describe("parseTailoredSkills", () => { [], ); }); + + it("extracts original summary and headline from profile basics", () => { + const profile = { + basics: { + summary: " Base summary ", + label: " Base headline ", + }, + }; + + expect(getOriginalSummary(profile)).toBe("Base summary"); + expect(getOriginalHeadline(profile)).toBe("Base headline"); + }); + + it("extracts original skills from profile skills items", () => { + const profile = { + sections: { + skills: { + items: [ + { + id: "1", + name: "Backend", + description: "", + level: 0, + keywords: [" Node.js ", "TypeScript"], + visible: true, + }, + ], + }, + }, + }; + + expect(getOriginalSkills(profile)).toEqual([ + { name: "Backend", keywords: ["Node.js", "TypeScript"] }, + ]); + }); + + it("returns defaults when profile sections are missing", () => { + expect(getOriginalSummary(null)).toBe(""); + expect(getOriginalHeadline(null)).toBe(""); + expect(getOriginalSkills(null)).toEqual([]); + }); }); diff --git a/orchestrator/src/client/components/tailoring-utils.ts b/orchestrator/src/client/components/tailoring-utils.ts index 4e64692..895dc18 100644 --- a/orchestrator/src/client/components/tailoring-utils.ts +++ b/orchestrator/src/client/components/tailoring-utils.ts @@ -1,3 +1,5 @@ +import type { ResumeProfile } from "@shared/types"; + export interface TailoredSkillGroup { name: string; keywords: string[]; @@ -93,3 +95,42 @@ export function fromEditableSkillGroups( return normalized; } + +export function getOriginalSummary(profile: ResumeProfile | null): string { + if (!profile) return ""; + return profile.basics?.summary?.trim() ?? ""; +} + +export function getOriginalHeadline(profile: ResumeProfile | null): string { + if (!profile) return ""; + return profile.basics?.label?.trim() ?? ""; +} + +export function getOriginalSkills( + profile: ResumeProfile | null, +): TailoredSkillGroup[] { + if (!profile) return []; + + const items = profile.sections?.skills?.items; + if (!Array.isArray(items)) return []; + + const groups: TailoredSkillGroup[] = []; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const name = + typeof item.name === "string" + ? item.name.trim() + : typeof item.description === "string" + ? item.description.trim() + : ""; + const keywordsRaw = Array.isArray(item.keywords) ? item.keywords : []; + const keywords = keywordsRaw + .filter((value: unknown): value is string => typeof value === "string") + .map((value: string) => value.trim()) + .filter(Boolean); + if (!name && keywords.length === 0) continue; + groups.push({ name, keywords }); + } + + return groups; +} diff --git a/orchestrator/src/client/components/tailoring/TailoringSections.tsx b/orchestrator/src/client/components/tailoring/TailoringSections.tsx index c494121..bad5f05 100644 --- a/orchestrator/src/client/components/tailoring/TailoringSections.tsx +++ b/orchestrator/src/client/components/tailoring/TailoringSections.tsx @@ -1,5 +1,5 @@ import type { ResumeProjectCatalogItem } from "@shared/types.js"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Redo2, Trash2, Undo2 } from "lucide-react"; import type React from "react"; import { Accordion, @@ -9,6 +9,12 @@ import { } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { ProjectSelector } from "../discovered-panel/ProjectSelector"; import type { EditableSkillGroup } from "../tailoring-utils"; @@ -28,6 +34,19 @@ interface TailoringSectionsProps { disableInputs: boolean; onSummaryChange: (value: string) => void; onHeadlineChange: (value: string) => void; + onUndoSummary: () => void; + onUndoHeadline: () => void; + onUndoSkills: () => void; + onRedoSummary: () => void; + onRedoHeadline: () => void; + onRedoSkills: () => void; + canUndoSummary: boolean; + canUndoHeadline: boolean; + canUndoSkills: boolean; + canRedoSummary: boolean; + canRedoHeadline: boolean; + canRedoSkills: boolean; + undoDisabledReason?: string | null; onDescriptionChange: (value: string) => void; onSkillGroupOpenChange: (value: string) => void; onAddSkillGroup: () => void; @@ -63,6 +82,19 @@ export const TailoringSections: React.FC = ({ disableInputs, onSummaryChange, onHeadlineChange, + onUndoSummary, + onUndoHeadline, + onUndoSkills, + onRedoSummary, + onRedoHeadline, + onRedoSkills, + canUndoSummary, + canUndoHeadline, + canUndoSkills, + canRedoSummary, + canRedoHeadline, + canRedoSkills, + undoDisabledReason = null, onDescriptionChange, onSkillGroupOpenChange, onAddSkillGroup, @@ -73,226 +105,345 @@ export const TailoringSections: React.FC = ({ }) => { const tracerToggleDisabled = disableInputs || (!tracerLinksEnabled && tracerEnableBlocked); + const undoTooltip = "Undo to template"; + const redoTooltip = "Redo to AI draft"; return ( - - - - Job Description - - - -