Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b23e7b8
feat(stages): add stage schema and migration, Withdrawn status, stage…
Gsync Sep 19, 2026
854d3cf
feat(backup): add the four stage models to MODEL_SPECS, INSERT_ORDER …
Gsync Sep 19, 2026
b574d80
feat(backup): resolve stage-type status by value on import, reseed st…
Gsync Sep 19, 2026
e633e53
feat(stages): add the stage model types, form schema and the shared o…
Gsync Sep 19, 2026
eef7613
feat(stages): add job-stage read, add, edit, delete and promote actio…
Gsync Sep 19, 2026
e0bc248
feat(stages): make updateJobStatus and the Edit Job status field appe…
Gsync Sep 19, 2026
211df3c
feat(stages): write a first current stage from every job creation pat…
Gsync Sep 19, 2026
3e53d1e
feat(stages): add stage-type list, create, rename and guarded delete …
Gsync Sep 19, 2026
39e49be
feat(stages): append a current stage when MCP updates a job's status,…
Gsync Sep 19, 2026
7ebbefa
feat(stages): link and unlink stage interviewers, upserting the job's…
Gsync Sep 19, 2026
83d2f9f
feat(stages): add prep-list link, remove and asked actions, and guard…
Gsync Sep 19, 2026
db49b09
feat(timeline): add stage display helpers and the shared job-stages hook
Gsync Sep 19, 2026
e9683da
feat(timeline): add the horizontal stage stepper and the full stage h…
Gsync Sep 19, 2026
c1a8884
feat(timeline): add the stage detail panel with interviewers, prep li…
Gsync Sep 19, 2026
e17735c
feat(timeline): add the Timeline tab with its stage count badge and s…
Gsync Sep 19, 2026
ddeda47
feat(timeline): add the Update Status header menu, fold the status pi…
Gsync Sep 19, 2026
3eedda3
feat(timeline): add the Add/Edit Stage dialog with custom stage types…
Gsync Sep 19, 2026
b6f0a83
feat(timeline): add the Link Interviewers dialog with inline contact …
Gsync Sep 19, 2026
95dfedb
feat(timeline): add the Add to Prep List dialog and a creatable quest…
Gsync Sep 19, 2026
ef8f94f
feat(library): add the Stages tab listing stage types with reorder, r…
Gsync Sep 20, 2026
695e374
test(timeline): add E2E coverage for stage-to-status and interviewer-…
Gsync Sep 20, 2026
48269d6
feat(timeline): order stages by their Library position, add stage del…
Gsync Sep 20, 2026
9978306
feat(timeline): replace the stage stepper with a vertical Stage Histo…
Gsync Sep 20, 2026
640dc17
feat(timeline): split the stage detail panel into Overview / Intervie…
Gsync Sep 20, 2026
471c1b5
feat(timeline): expand prep-list answers inline, tag new questions wi…
Gsync Sep 20, 2026
982333d
feat(timeline): name the cascading interviewer and prep-question coun…
Gsync Sep 20, 2026
f575a65
fix(timeline): date the stage a status change appends, confine format…
Gsync Sep 20, 2026
cc2c5f7
feat(timeline): rename the interviewer link controls to Add and badge…
Gsync Sep 20, 2026
c9ce514
test(timeline): update specs for the stamped stage date, the prep ans…
Gsync Sep 20, 2026
1a57ac3
fix(timeline): hold the derived-status invariant, make the job and in…
Gsync Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions __tests__/AddPrepQuestionsDialog.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddPrepQuestionsDialog } from "@/components/myjobs/job-details/timeline/AddPrepQuestionsDialog";
import { addStagePrepQuestions } from "@/actions/jobStage.actions";
import { getQuestionsList } from "@/actions/question.actions";

vi.mock("@/actions/jobStage.actions", () => ({
addStagePrepQuestions: vi.fn().mockResolvedValue({ success: true, data: [] }),
}));
vi.mock("@/actions/question.actions", () => ({
getQuestionsList: vi.fn(),
createQuestion: vi.fn().mockResolvedValue({
success: true,
data: { id: "q9", question: "New one", tags: [] },
}),
}));
vi.mock("@/actions/tag.actions", () => ({ getAllTags: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/toast", () => ({
toastActionResult: vi.fn((res, opts) => res.success && opts.onSuccess?.()),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));

const stage = {
id: "st1",
occurredAt: new Date(2026, 8, 24, 10, 0),
StageType: { label: "Final / Onsite Interview", Status: { value: "interview" } },
prepQuestions: [{ id: "p1", questionId: "q1" }],
} as any;

beforeEach(() => {
vi.clearAllMocks();
(getQuestionsList as any).mockResolvedValue({
success: true,
total: 2,
data: [
{ id: "q1", question: "Walk through your caching strategy", tags: [{ id: "tg1", label: "System Design", value: "system design", createdBy: "u1" }] },
{ id: "q2", question: "Tell me about a conflict with a teammate", tags: [] },
],
});
});

describe("AddPrepQuestionsDialog", () => {
it("heads the dialog with the stage and explains the asked workflow", async () => {
render(<AddPrepQuestionsDialog open stage={stage} onOpenChange={() => {}} onAdded={() => {}} />);

expect(await screen.findByText("Final / Onsite Interview · Sep 24, 2026")).toBeInTheDocument();
expect(
screen.getByText(/check them off as "asked" from the Timeline tab/),
).toBeInTheDocument();
});

// The same question cannot be added twice to one stage.
it("shows a question already on the prep list as such and does not allow selecting it", async () => {
render(<AddPrepQuestionsDialog open stage={stage} onOpenChange={() => {}} onAdded={() => {}} />);

const already = await screen.findByRole("checkbox", {
name: /Walk through your caching strategy/,
});
expect(already).toBeDisabled();
expect(already).toBeChecked();
});

it("shows a selection as a removable chip and submits it", async () => {
render(<AddPrepQuestionsDialog open stage={stage} onOpenChange={() => {}} onAdded={() => {}} />);

await userEvent.click(
await screen.findByRole("checkbox", { name: /Tell me about a conflict/ }),
);
expect(
screen.getByRole("button", { name: /Remove Tell me about a conflict/ }),
).toBeInTheDocument();

await userEvent.click(
screen.getByRole("button", { name: "Done — 1 question added to prep list" }),
);

expect(addStagePrepQuestions).toHaveBeenCalledWith("st1", ["q2"]);
});

it("renders the question's tags as category badges", async () => {
render(<AddPrepQuestionsDialog open stage={stage} onOpenChange={() => {}} onAdded={() => {}} />);

expect(await screen.findByText("System Design")).toBeInTheDocument();
});
});
118 changes: 118 additions & 0 deletions __tests__/AddStageDialog.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddStageDialog } from "@/components/myjobs/job-details/timeline/AddStageDialog";
import { addJobStage } from "@/actions/jobStage.actions";

vi.mock("@/actions/jobStage.actions", () => ({
addJobStage: vi.fn().mockResolvedValue({ success: true, data: { id: "st1" } }),
updateJobStage: vi.fn().mockResolvedValue({ success: true, data: { id: "st1" } }),
}));
vi.mock("@/lib/toast", () => ({
toastActionResult: vi.fn((res, opts) => res.success && opts.onSuccess?.()),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));

// jsdom lacks scrollIntoView, which cmdk calls when selecting an item
Element.prototype.scrollIntoView = vi.fn();

const stageTypes = [
{ id: "t-off", label: "Offer", value: "offer", statusId: "s-off", sortOrder: 7, Status: { id: "s-off", label: "Offer", value: "offer" } },
{ id: "t-int", label: "Final / Onsite Interview", value: "final / onsite interview", statusId: "s-int", sortOrder: 6, Status: { id: "s-int", label: "Interview", value: "interview" } },
] as any;

const jobStatuses = [
{ id: "s-off", label: "Offer", value: "offer" },
{ id: "s-int", label: "Interview", value: "interview" },
] as any;

const props = {
open: true,
jobId: "j1",
jobLabel: "Senior Frontend Engineer · Acme Corp",
stage: null,
stageTypes,
jobStatuses,
onOpenChange: () => {},
onSaved: vi.fn(),
};

// The shared Combobox takes no data-testid, so the picker is wrapped in one
// and the trigger reached through it (Playwright's centre click lands on the
// button; a jsdom click on the wrapper would not).
const openStageTypePicker = async () =>
userEvent.click(
within(screen.getByTestId("stage-type-select")).getByRole("combobox"),
);

describe("AddStageDialog", () => {
beforeEach(() => vi.clearAllMocks());

it("heads the dialog with the job it belongs to", () => {
render(<AddStageDialog {...props} />);
expect(screen.getByText("Senior Frontend Engineer · Acme Corp")).toBeInTheDocument();
});

it("ticks Set as current stage by default", () => {
render(<AddStageDialog {...props} />);
expect(screen.getByRole("checkbox", { name: /Set as current stage/ })).toBeChecked();
});

// Nothing demotes a stage, so a live checkbox here would silently do nothing.
it("locks the checkbox when editing the stage that is already current", () => {
render(
<AddStageDialog
{...props}
stage={{ id: "st1", isCurrent: true, stageTypeId: "t-int", occurredAt: null, notes: null, outcome: null, durationMins: null, format: null, location: null, StageType: stageTypes[1] } as any}
/>,
);

const box = screen.getByRole("checkbox", { name: /Set as current stage/ });
expect(box).toBeChecked();
expect(box).toBeDisabled();
expect(screen.getByText(/tick the box on another stage to move it/)).toBeInTheDocument();
});

it("hides the parent-status picker until a custom name is typed", async () => {
render(<AddStageDialog {...props} />);
expect(screen.queryByText(/PARENT STATUS/i)).toBeNull();

await userEvent.type(
screen.getByPlaceholderText("e.g. Panel Interview, Reference Check…"),
"Panel Interview",
);

expect(screen.getByText(/PARENT STATUS/i)).toBeInTheDocument();
});

// Adding a stage records something that just happened, so the date and time
// open on now rather than empty.
it("saves with the picked type, today's date and time, and set-as-current", async () => {
render(<AddStageDialog {...props} />);

await openStageTypePicker();
await userEvent.click(screen.getByRole("option", { name: "Offer" }));
await userEvent.click(screen.getByRole("button", { name: "Add Stage" }));

expect(addJobStage).toHaveBeenCalledWith(
expect.objectContaining({
jobId: "j1",
stageTypeId: "t-off",
setAsCurrent: true,
date: expect.any(Date),
time: expect.stringMatching(/^(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)$/),
}),
);
});

it("refuses to save with neither a type nor a custom name", async () => {
render(<AddStageDialog {...props} />);

await userEvent.click(screen.getByRole("button", { name: "Add Stage" }));

expect(addJobStage).not.toHaveBeenCalled();
expect(
await screen.findByText(/Pick a stage or enter a custom stage name/),
).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions __tests__/AdminTabsContainer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ vi.mock("@/actions/contactRole.actions", () => ({
deleteContactRoleById: vi.fn(),
}));

vi.mock("@/actions/jobStageType.actions", () => ({
getJobStageTypeList: vi.fn().mockResolvedValue({ data: [], total: 0 }),
createJobStageType: vi.fn(),
updateJobStageType: vi.fn(),
deleteJobStageTypeById: vi.fn(),
}));

describe("AdminTabsContainer", () => {
const user = userEvent.setup({ skipHover: true });

Expand All @@ -70,6 +77,12 @@ describe("AdminTabsContainer", () => {
expect(screen.getByRole("tab", { name: "Roles" })).toBeInTheDocument();
});

it("offers a Stages tab", () => {
render(<AdminTabsContainer />);

expect(screen.getByRole("tab", { name: "Stages" })).toBeInTheDocument();
});

it("should default to companies tab", () => {
render(<AdminTabsContainer />);

Expand Down
4 changes: 4 additions & 0 deletions __tests__/ComboBox.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ vi.mock("@/actions/contactRole.actions", () => ({
createContactRole: vi.fn(),
}));

vi.mock("@/actions/tag.actions", () => ({
createTag: vi.fn(),
}));

vi.mock("@/lib/toast", () => ({
toastError: vi.fn(),
}));
Expand Down
Loading
Loading