Files
kandev/apps/web/components/task-create-dialog-dependencies.test.tsx
2026-09-04 02:35:15 +00:00

386 lines
13 KiB
TypeScript

import { useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import type { TaskMR } from "@/lib/types/gitlab";
import type { TaskPR } from "@/lib/types/github";
import { TaskCreateDependencies } from "./task-create-dialog-dependencies";
const workspaceHydrationMocks = vi.hoisted(() => ({
useWorkspacePRs: vi.fn(),
useWorkspaceMRs: vi.fn(),
}));
vi.mock("@/hooks/domains/github/use-task-pr", () => workspaceHydrationMocks);
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => workspaceHydrationMocks);
const WORKSPACE_ID = "workspace-1";
type MockStore = {
kanban: { tasks: KanbanState["tasks"] };
kanbanMulti: { snapshots: Record<string, { tasks?: KanbanState["tasks"] }> };
workspaces: { activeId: string | null };
workspaceContextGeneration: number;
taskMRs: { byWorkspaceId: Record<string, Record<string, TaskMR[]>> };
taskPRs: {
byTaskId: Record<string, TaskPR[]>;
workspaceId?: string | null;
workspaceContextGeneration?: number;
};
};
const ALPHA_ID = "task-alpha";
const BETA_ID = "task-beta";
const ALPHA_TITLE = "Alpha task";
const BETA_TITLE = "Beta task";
const NO_DEPENDENCY_LABEL = "No dependency";
const SEARCH_TASKS_LABEL = "Search tasks or #PR/MR number...";
const INFO_LABEL = "About task dependencies";
const TWO_DEPENDENCIES_LABEL = "2 dependencies";
const TRIGGER_TEST_ID = "task-create-dependencies-trigger";
const POPOVER_TEST_ID = "task-create-dependencies-popover";
function emptyStore(): MockStore {
return {
kanban: { tasks: [] },
kanbanMulti: { snapshots: {} },
workspaces: { activeId: WORKSPACE_ID },
workspaceContextGeneration: 0,
taskMRs: { byWorkspaceId: {} },
taskPRs: {
byTaskId: {},
workspaceId: WORKSPACE_ID,
workspaceContextGeneration: 0,
},
};
}
function taskPR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-assoc-1",
workspace_id: WORKSPACE_ID,
task_id: ALPHA_ID,
owner: "kdlbs",
repo: "kandev",
pr_number: 3295,
pr_url: "https://github.com/kdlbs/kandev/pull/3295",
pr_title: "fix flaky test",
head_branch: "fix",
base_branch: "main",
author_login: "nova28",
state: "merged",
review_state: "",
checks_state: "",
mergeable_state: "mergeable",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 0,
...overrides,
} as TaskPR;
}
let mockStore: MockStore = emptyStore();
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: MockStore) => unknown) => selector(mockStore),
}));
function task(
id: string,
title: string,
overrides: Partial<KanbanState["tasks"][number]> = {},
): KanbanState["tasks"][number] {
return {
id,
workflowId: "workflow-1",
workflowStepId: "step-1",
title,
position: 0,
isArchived: false,
...overrides,
};
}
function renderDependencies(value: string[] = [], onChange = vi.fn()) {
return render(
<TooltipProvider>
<TaskCreateDependencies value={value} onChange={onChange} />
</TooltipProvider>,
);
}
function ControlledDependencies({ initialValue = [] }: { initialValue?: string[] }) {
const [value, setValue] = useState(initialValue);
return <TaskCreateDependencies value={value} onChange={setValue} />;
}
function optionTestId(taskId: string): string {
return `task-create-dependency-option-${taskId}`;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStore = emptyStore();
});
describe("TaskCreateDependencies", () => {
it("hydrates change-request associations for the active workspace", () => {
renderDependencies();
expect(workspaceHydrationMocks.useWorkspacePRs).toHaveBeenCalledWith(WORKSPACE_ID);
expect(workspaceHydrationMocks.useWorkspaceMRs).toHaveBeenCalledWith(WORKSPACE_ID);
});
it("starts with a no-dependency selector and its dependency icon", () => {
renderDependencies();
const trigger = screen.getByTestId(TRIGGER_TEST_ID);
expect(trigger.textContent).toContain(NO_DEPENDENCY_LABEL);
expect(trigger.querySelector("svg")).not.toBeNull();
});
it("shows searchable non-archived tasks with task icons and teaching help", async () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [
task(ALPHA_ID, ALPHA_TITLE),
task("task-archived", "Archived task", { isArchived: true }),
],
},
kanbanMulti: {
snapshots: {
other: { tasks: [task(BETA_ID, BETA_TITLE)] },
},
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
expect(within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL)).toBeTruthy();
expect(within(picker).getByTestId("task-create-dependency-info")).toBeTruthy();
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).getByTestId(optionTestId(BETA_ID))).toBeTruthy();
expect(within(picker).queryByText("Archived task")).toBeNull();
expect(
within(within(picker).getByTestId(optionTestId(ALPHA_ID))).getByTestId(
"task-create-dependency-task-icon",
),
).toBeTruthy();
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "Beta" } });
expect(within(picker).getByText(BETA_TITLE)).toBeTruthy();
expect(within(picker).queryByText(ALPHA_TITLE)).toBeNull();
expect(within(picker).getByTestId("task-create-no-dependency")).toBeTruthy();
const info = within(picker).getByTestId("task-create-dependency-info");
expect(info.getAttribute("aria-label")).toBe(INFO_LABEL);
fireEvent.focus(info);
await waitFor(() => {
expect(screen.getByRole("tooltip").textContent).toMatch(/waits until every selected task/i);
});
});
it("toggles multiple predecessors and exposes a localized count", () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)],
},
};
render(
<TooltipProvider>
<ControlledDependencies />
</TooltipProvider>,
);
fireEvent.click(screen.getByTestId("task-create-dependencies-trigger"));
fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID)));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(ALPHA_TITLE);
fireEvent.click(screen.getByTestId(optionTestId(BETA_ID)));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL);
fireEvent.click(screen.getByTestId(optionTestId(ALPHA_ID)));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(BETA_TITLE);
});
it("clears all selected predecessors from the no-dependency entry", () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [task(ALPHA_ID, ALPHA_TITLE), task(BETA_ID, BETA_TITLE)],
},
};
render(
<TooltipProvider>
<ControlledDependencies initialValue={[ALPHA_ID, BETA_ID]} />
</TooltipProvider>,
);
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(TWO_DEPENDENCIES_LABEL);
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
fireEvent.click(screen.getByTestId("task-create-no-dependency"));
expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toContain(NO_DEPENDENCY_LABEL);
});
});
describe("selected dependency preservation", () => {
it("keeps an omitted selected predecessor titled and individually removable", () => {
const selectedID = "task-archived";
const onChange = vi.fn();
render(
<TooltipProvider>
<TaskCreateDependencies
value={[selectedID]}
onChange={onChange}
candidates={[{ id: "task-other", title: "Other task" }]}
selectedTitles={{ [selectedID]: "Archived predecessor" }}
/>
</TooltipProvider>,
);
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const selectedOption = within(
screen.getByTestId("task-create-dependencies-popover"),
).getByTestId(`task-create-dependency-option-${selectedID}`);
expect(selectedOption).toBeTruthy();
expect(selectedOption.textContent).toContain("Archived predecessor");
fireEvent.click(selectedOption);
expect(onChange).toHaveBeenCalledWith([]);
});
});
describe("TaskCreateDependencies change-request search", () => {
it("narrows to the PR-linked task when searching by PR number and shows its badge", () => {
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
statusSummary: { pull_request: { number: 3295, state: "merged" } } as never,
});
mockStore = {
...emptyStore(),
kanban: {
tasks: [prTask, task(BETA_ID, BETA_TITLE)],
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID));
expect(within(alphaOption).getByText("#3295")).toBeTruthy();
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "3295" } });
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
});
it("narrows to the MR-linked task when searching with a leading #", () => {
const mrTask = task(ALPHA_ID, ALPHA_TITLE);
mockStore = {
...emptyStore(),
kanban: { tasks: [mrTask, task(BETA_ID, BETA_TITLE)] },
taskMRs: {
byWorkspaceId: {
[WORKSPACE_ID]: {
[ALPHA_ID]: [
{
id: "mr-1",
task_id: ALPHA_ID,
host: "gitlab.com",
project_path: "kdlbs/kandev",
mr_iid: 42,
mr_url: "https://gitlab.com/kdlbs/kandev/-/merge_requests/42",
mr_title: "Fix",
head_branch: "fix",
base_branch: "main",
author_username: "nova28",
state: "open",
} as TaskMR,
],
},
},
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "#42" } });
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
});
it("lists unfiltered candidates most-recently-updated first", () => {
mockStore = {
...emptyStore(),
kanban: {
tasks: [
task(ALPHA_ID, ALPHA_TITLE, { updatedAt: "2026-01-01T00:00:00Z" }),
task(BETA_ID, BETA_TITLE, { updatedAt: "2026-06-01T00:00:00Z" }),
],
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const options = within(picker)
.getAllByTestId(/task-create-dependency-option-/)
.map((option) => option.getAttribute("data-testid"));
expect(options).toEqual([optionTestId(BETA_ID), optionTestId(ALPHA_ID)]);
});
});
describe("TaskCreateDependencies multi-PR change requests", () => {
it("shows a badge for every GitHub PR linked to a multi-repo task and finds it by any number", () => {
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
statusSummary: { pull_request: { number: 1001, state: "open" } } as never,
});
mockStore = {
...emptyStore(),
kanban: {
tasks: [prTask, task(BETA_ID, BETA_TITLE)],
},
taskPRs: {
byTaskId: {
[ALPHA_ID]: [
taskPR({ id: "pr-assoc-1", pr_number: 1001, repo: "kandev" }),
taskPR({ id: "pr-assoc-2", pr_number: 1002, repo: "kandev-docs" }),
],
},
workspaceId: WORKSPACE_ID,
workspaceContextGeneration: 0,
},
};
renderDependencies();
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
const picker = screen.getByTestId(POPOVER_TEST_ID);
const alphaOption = within(picker).getByTestId(optionTestId(ALPHA_ID));
expect(within(alphaOption).getByText("#1001")).toBeTruthy();
expect(within(alphaOption).getByText("#1002")).toBeTruthy();
const search = within(picker).getByPlaceholderText(SEARCH_TASKS_LABEL);
fireEvent.change(search, { target: { value: "1002" } });
expect(within(picker).getByTestId(optionTestId(ALPHA_ID))).toBeTruthy();
expect(within(picker).queryByTestId(optionTestId(BETA_ID))).toBeNull();
});
});