6ff413078b
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
558 lines
19 KiB
TypeScript
558 lines
19 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { StateProvider } from "@/components/state-provider";
|
|
import { t } from "@/lib/i18n";
|
|
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
|
import type { TaskPR } from "@/lib/types/github";
|
|
import type { TaskMR } from "@/lib/types/gitlab";
|
|
import { TaskTitleHoverCard } from "./task-title-hover-card";
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
const LONG_TITLE = "A very long parent task title that should render in full";
|
|
const FIRST_SUBTASK_TITLE = "First subtask";
|
|
const HOVER_CARD_TEST_ID = "task-title-hover-card";
|
|
const PREVIEW_TRIGGER_TEST_ID = "task-title-preview-trigger";
|
|
const PARENT_SECTION_TEST_ID = "task-title-hover-parent";
|
|
|
|
function makeTask(overrides: Partial<KanbanState["tasks"][number]>): KanbanState["tasks"][number] {
|
|
return {
|
|
id: "id",
|
|
workflowId: "wf-1",
|
|
workflowStepId: "step-1",
|
|
title: "Task",
|
|
position: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makePR(overrides: Pick<TaskPR, "id" | "task_id">): TaskPR {
|
|
return {
|
|
workspace_id: "workspace-1",
|
|
owner: "o",
|
|
repo: "r",
|
|
pr_number: 1,
|
|
pr_url: "",
|
|
pr_title: "Fix",
|
|
head_branch: "feat",
|
|
base_branch: "main",
|
|
author_login: "alice",
|
|
state: "open" as const,
|
|
review_state: "",
|
|
checks_state: "",
|
|
mergeable_state: "",
|
|
review_count: 0,
|
|
pending_review_count: 0,
|
|
comment_count: 0,
|
|
unresolved_review_threads: 0,
|
|
checks_total: 0,
|
|
checks_passing: 0,
|
|
additions: 0,
|
|
deletions: 0,
|
|
created_at: "",
|
|
merged_at: null,
|
|
closed_at: null,
|
|
last_synced_at: null,
|
|
updated_at: "",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeMR(): TaskMR {
|
|
return {
|
|
id: "mr-1",
|
|
task_id: "child-1",
|
|
host: "https://gitlab.com",
|
|
project_path: "group/project",
|
|
mr_iid: 5,
|
|
mr_url: "",
|
|
mr_title: "Linked MR",
|
|
head_branch: "feature",
|
|
base_branch: "main",
|
|
author_username: "alice",
|
|
state: "open",
|
|
approval_state: "",
|
|
pipeline_state: "",
|
|
merge_status: "",
|
|
draft: false,
|
|
approval_count: 0,
|
|
required_approvals: 0,
|
|
pipeline_jobs_total: 0,
|
|
pipeline_jobs_pass: 0,
|
|
reviewer_count: 0,
|
|
unapproved_reviewers: 0,
|
|
unresolved_discussions: 0,
|
|
created_at: "",
|
|
updated_at: "",
|
|
};
|
|
}
|
|
|
|
function renderCard(tasks: KanbanState["tasks"], title = LONG_TITLE) {
|
|
render(
|
|
<StateProvider initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks } }}>
|
|
<TaskTitleHoverCard taskId="parent-1" title={title}>
|
|
<span data-testid="trigger">{title}</span>
|
|
</TaskTitleHoverCard>
|
|
</StateProvider>,
|
|
);
|
|
}
|
|
|
|
// Keyboard activation opens the interactive preview and moves focus into it.
|
|
async function openCard() {
|
|
const trigger = screen.getByTestId(PREVIEW_TRIGGER_TEST_ID);
|
|
trigger.focus();
|
|
fireEvent.click(trigger, { detail: 0 });
|
|
await screen.findAllByTestId(HOVER_CARD_TEST_ID);
|
|
}
|
|
|
|
/** First (portal) match for a subtask row; Radix also force-mounts a copy. */
|
|
function subtaskRow(childId: string): HTMLElement {
|
|
return screen.getAllByTestId(`task-subtask-row-${childId}`)[0];
|
|
}
|
|
|
|
describe("TaskTitleHoverCard", () => {
|
|
it("AC12: shows the full title in bold (font-semibold) when opened", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
|
|
]);
|
|
await openCard();
|
|
|
|
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
|
|
expect(card.textContent).toContain(LONG_TITLE);
|
|
const titleEl = card.querySelector(".font-semibold");
|
|
expect(titleEl?.textContent).toBe(LONG_TITLE);
|
|
});
|
|
|
|
it("AC13: renders one row per direct subtask with its full title", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({
|
|
id: "child-1",
|
|
title: FIRST_SUBTASK_TITLE,
|
|
parentTaskId: "parent-1",
|
|
position: 1,
|
|
}),
|
|
makeTask({ id: "child-2", title: "Second subtask", parentTaskId: "parent-1", position: 2 }),
|
|
]);
|
|
await openCard();
|
|
|
|
expect(subtaskRow("child-1").textContent).toContain(FIRST_SUBTASK_TITLE);
|
|
expect(subtaskRow("child-2").textContent).toContain("Second subtask");
|
|
});
|
|
|
|
it("names the subtask link by its title, with the state label on the icon", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({
|
|
id: "child-1",
|
|
title: FIRST_SUBTASK_TITLE,
|
|
parentTaskId: "parent-1",
|
|
position: 1,
|
|
state: "IN_PROGRESS",
|
|
}),
|
|
]);
|
|
await openCard();
|
|
|
|
const row = subtaskRow("child-1");
|
|
// An aria-label on the anchor would override its accessible name, so every
|
|
// row would announce as "In progress" with the title never read out.
|
|
expect(row.getAttribute("aria-label")).toBeNull();
|
|
expect(row.textContent).toContain(FIRST_SUBTASK_TITLE);
|
|
expect(row.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("In progress");
|
|
});
|
|
|
|
it("AC13: shows a CI glyph only for a subtask with a linked PR", async () => {
|
|
render(
|
|
<StateProvider
|
|
initialState={{
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({
|
|
id: "child-1",
|
|
title: "Has a PR",
|
|
parentTaskId: "parent-1",
|
|
position: 1,
|
|
}),
|
|
makeTask({
|
|
id: "child-2",
|
|
title: "No PR",
|
|
parentTaskId: "parent-1",
|
|
position: 2,
|
|
}),
|
|
],
|
|
},
|
|
taskPRs: {
|
|
byTaskId: {
|
|
"child-1": [makePR({ id: "pr-1", task_id: "child-1" })],
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<TaskTitleHoverCard taskId="parent-1" title="Parent">
|
|
<span tabIndex={0} data-testid="trigger">
|
|
Parent
|
|
</span>
|
|
</TaskTitleHoverCard>
|
|
</StateProvider>,
|
|
);
|
|
await openCard();
|
|
|
|
const withPR = subtaskRow("child-1");
|
|
const withoutPR = subtaskRow("child-2");
|
|
expect(withPR.querySelector(".tabler-icon-git-pull-request")).not.toBeNull();
|
|
expect(withoutPR.querySelector(".tabler-icon-git-pull-request")).toBeNull();
|
|
expect(withoutPR.querySelector(".tabler-icon-git-merge")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("TaskTitleHoverCard — GitLab MR subtasks", () => {
|
|
it("AC13: shows the merge glyph for a subtask whose only link is a GitLab MR", async () => {
|
|
render(
|
|
<StateProvider
|
|
initialState={{
|
|
workspaces: { items: [], activeId: "ws-1" },
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({
|
|
id: "child-1",
|
|
title: "Has an MR",
|
|
parentTaskId: "parent-1",
|
|
position: 1,
|
|
}),
|
|
makeTask({ id: "child-2", title: "No link", parentTaskId: "parent-1", position: 2 }),
|
|
],
|
|
},
|
|
taskMRs: { byWorkspaceId: { "ws-1": { "child-1": [makeMR()] } } },
|
|
}}
|
|
>
|
|
<TaskTitleHoverCard taskId="parent-1" title="Parent">
|
|
<span tabIndex={0} data-testid="trigger">
|
|
Parent
|
|
</span>
|
|
</TaskTitleHoverCard>
|
|
</StateProvider>,
|
|
);
|
|
await openCard();
|
|
|
|
const withMR = subtaskRow("child-1");
|
|
const withoutMR = subtaskRow("child-2");
|
|
expect(withMR.querySelector(".tabler-icon-git-merge")).not.toBeNull();
|
|
expect(withMR.querySelector(".tabler-icon-git-pull-request")).toBeNull();
|
|
expect(withoutMR.querySelector(".tabler-icon-git-merge")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("TaskTitleHoverCard — subtask cap and dismissal", () => {
|
|
it("AC15: does not mount a trigger when there are no subtasks", () => {
|
|
renderCard([makeTask({ id: "parent-1" })]);
|
|
|
|
expect(screen.queryByTestId(PREVIEW_TRIGGER_TEST_ID)).toBeNull();
|
|
expect(screen.queryByTestId(HOVER_CARD_TEST_ID)).toBeNull();
|
|
});
|
|
|
|
it("AC17: caps subtask rows at 12 and shows a +N more line", async () => {
|
|
const children = Array.from({ length: 15 }, (_, i) =>
|
|
makeTask({
|
|
id: `child-${i}`,
|
|
title: `Subtask ${i}`,
|
|
parentTaskId: "parent-1",
|
|
position: i,
|
|
}),
|
|
);
|
|
renderCard([makeTask({ id: "parent-1" }), ...children]);
|
|
await openCard();
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
expect(screen.getAllByTestId(`task-subtask-row-child-${i}`).length).toBeGreaterThan(0);
|
|
}
|
|
for (let i = 12; i < 15; i++) {
|
|
expect(screen.queryAllByTestId(`task-subtask-row-child-${i}`)).toHaveLength(0);
|
|
}
|
|
const subtasksSection = screen.getAllByTestId("task-title-hover-subtasks")[0];
|
|
expect(subtasksSection.textContent).toContain("3 more");
|
|
});
|
|
|
|
it("AC19: never renders a TooltipContent inside the card", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1", position: 1 }),
|
|
]);
|
|
await openCard();
|
|
|
|
expect(document.querySelector('[data-slot="tooltip-content"]')).toBeNull();
|
|
});
|
|
|
|
it("does not bubble a click on the title (non-link) area to the parent row's onClick", async () => {
|
|
const onParentClick = vi.fn();
|
|
render(
|
|
<StateProvider
|
|
initialState={{
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
|
|
],
|
|
},
|
|
}}
|
|
>
|
|
<div onClick={onParentClick} data-testid="parent-row">
|
|
<TaskTitleHoverCard taskId="parent-1" title={LONG_TITLE}>
|
|
<span tabIndex={0} data-testid="trigger">
|
|
{LONG_TITLE}
|
|
</span>
|
|
</TaskTitleHoverCard>
|
|
</div>
|
|
</StateProvider>,
|
|
);
|
|
await openCard();
|
|
|
|
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
|
|
const titleEl = card.querySelector(".font-semibold");
|
|
expect(titleEl).not.toBeNull();
|
|
fireEvent.click(titleEl!);
|
|
|
|
expect(onParentClick).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
type TitleCardProps = Partial<{
|
|
description: string;
|
|
parentTaskId: string | null;
|
|
isTitleTruncated: boolean;
|
|
}>;
|
|
|
|
function renderTitleCard(tasks: KanbanState["tasks"], props: TitleCardProps = {}) {
|
|
render(
|
|
<StateProvider initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks } }}>
|
|
<TaskTitleHoverCard taskId="parent-1" title="Parent" {...props}>
|
|
<span data-testid="trigger">Parent</span>
|
|
</TaskTitleHoverCard>
|
|
</StateProvider>,
|
|
);
|
|
}
|
|
|
|
describe("TaskTitleHoverCard — description and parent content (AC-UI-PIPELINE-ROW-004.2/004.3)", () => {
|
|
it("mounts a trigger and shows the description when the task has a description but no subtasks or parent", async () => {
|
|
renderTitleCard([makeTask({ id: "parent-1" })], {
|
|
description: "Some longer description text",
|
|
});
|
|
|
|
expect(screen.getByTestId(PREVIEW_TRIGGER_TEST_ID)).not.toBeNull();
|
|
await openCard();
|
|
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
|
|
expect(card.textContent).toContain("Some longer description text");
|
|
});
|
|
|
|
it("mounts a trigger and shows the parent task's title when parentTaskId is set", async () => {
|
|
renderTitleCard(
|
|
[makeTask({ id: "root-1", title: "Root task" }), makeTask({ id: "parent-1" })],
|
|
{ parentTaskId: "root-1" },
|
|
);
|
|
|
|
expect(screen.getByTestId(PREVIEW_TRIGGER_TEST_ID)).not.toBeNull();
|
|
await openCard();
|
|
const parentSection = screen.getAllByTestId(PARENT_SECTION_TEST_ID)[0];
|
|
expect(parentSection.textContent).toContain("Root task");
|
|
});
|
|
|
|
it("falls back to a generic label when the parent task's title cannot be resolved from the store, matching KanbanCardRelationship", async () => {
|
|
renderTitleCard([makeTask({ id: "parent-1" })], { parentTaskId: "missing-root" });
|
|
|
|
await openCard();
|
|
const parentSection = screen.getAllByTestId(PARENT_SECTION_TEST_ID)[0];
|
|
expect(parentSection.textContent).toContain(t("task:subtask"));
|
|
});
|
|
|
|
it("resolves the parent title from a loaded cross-workflow snapshot when the parent isn't in the active board (Pipeline view)", async () => {
|
|
render(
|
|
<StateProvider
|
|
initialState={{
|
|
kanban: { workflowId: "wf-1", steps: [], tasks: [makeTask({ id: "parent-1" })] },
|
|
kanbanMulti: {
|
|
snapshots: {
|
|
"wf-2": {
|
|
workflowId: "wf-2",
|
|
workflowName: "Other workflow",
|
|
steps: [],
|
|
tasks: [makeTask({ id: "root-1", workflowId: "wf-2", title: "Root task" })],
|
|
},
|
|
},
|
|
isLoading: false,
|
|
orderRevisionByStepId: {},
|
|
pendingReorderBandKeys: {},
|
|
withheldReorderByBandKey: {},
|
|
},
|
|
}}
|
|
>
|
|
<TaskTitleHoverCard taskId="parent-1" title="Parent" parentTaskId="root-1">
|
|
<span data-testid="trigger">Parent</span>
|
|
</TaskTitleHoverCard>
|
|
</StateProvider>,
|
|
);
|
|
|
|
await openCard();
|
|
const parentSection = screen.getAllByTestId(PARENT_SECTION_TEST_ID)[0];
|
|
expect(parentSection.textContent).toContain("Root task");
|
|
});
|
|
|
|
it("mounts a trigger when the title is visually truncated, even with no description, parent or subtasks", () => {
|
|
renderTitleCard([makeTask({ id: "parent-1" })], { isTitleTruncated: true });
|
|
|
|
expect(screen.getByTestId(PREVIEW_TRIGGER_TEST_ID)).not.toBeNull();
|
|
});
|
|
|
|
it("still does not mount a trigger with no content and no truncation (AC15 regression)", () => {
|
|
renderTitleCard([makeTask({ id: "parent-1" })], { isTitleTruncated: false });
|
|
|
|
expect(screen.queryByTestId(PREVIEW_TRIGGER_TEST_ID)).toBeNull();
|
|
});
|
|
|
|
it("orders description before parent before subtasks", async () => {
|
|
renderTitleCard(
|
|
[
|
|
makeTask({ id: "root-1", title: "Root task" }),
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1", position: 1 }),
|
|
],
|
|
{ description: "Desc text", parentTaskId: "root-1" },
|
|
);
|
|
await openCard();
|
|
|
|
const card = screen.getAllByTestId(HOVER_CARD_TEST_ID)[0];
|
|
const descIndex = card.innerHTML.indexOf("Desc text");
|
|
const parentIndex = card.innerHTML.indexOf(PARENT_SECTION_TEST_ID);
|
|
const subtasksIndex = card.innerHTML.indexOf("task-title-hover-subtasks");
|
|
expect(descIndex).toBeGreaterThan(-1);
|
|
expect(parentIndex).toBeGreaterThan(descIndex);
|
|
expect(subtasksIndex).toBeGreaterThan(parentIndex);
|
|
});
|
|
});
|
|
|
|
describe("TaskTitleHoverCard — keyboard propagation", () => {
|
|
it("lets Escape bubble from the trigger to a closed surface (e.g. a preview panel) when the preview itself is not open", () => {
|
|
// Regression: a plain click focuses the trigger button without opening
|
|
// the preview (handleTriggerClick early-returns for real clicks). If
|
|
// Escape were unconditionally swallowed here, closing an ancestor
|
|
// surface (like the kanban preview panel) via Escape would silently stop
|
|
// working the moment its title happened to be under focus.
|
|
const onParentKeyDown = vi.fn();
|
|
render(
|
|
<StateProvider
|
|
initialState={{
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
|
|
],
|
|
},
|
|
}}
|
|
>
|
|
<div onKeyDown={onParentKeyDown}>
|
|
<TaskTitleHoverCard taskId="parent-1" title={LONG_TITLE}>
|
|
<span data-testid="trigger">{LONG_TITLE}</span>
|
|
</TaskTitleHoverCard>
|
|
</div>
|
|
</StateProvider>,
|
|
);
|
|
const trigger = screen.getByTestId(PREVIEW_TRIGGER_TEST_ID);
|
|
trigger.focus();
|
|
fireEvent.keyDown(trigger, { key: "Escape" });
|
|
|
|
expect(onParentKeyDown).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByTestId(HOVER_CARD_TEST_ID)).toBeNull();
|
|
});
|
|
|
|
it("does not bubble keyboard activation from a subtask link to the parent row", async () => {
|
|
const onParentKeyDown = vi.fn();
|
|
render(
|
|
<StateProvider
|
|
initialState={{
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1" }),
|
|
],
|
|
},
|
|
}}
|
|
>
|
|
<div onKeyDown={onParentKeyDown}>
|
|
<TaskTitleHoverCard taskId="parent-1" title="Parent">
|
|
<span>Parent</span>
|
|
</TaskTitleHoverCard>
|
|
</div>
|
|
</StateProvider>,
|
|
);
|
|
await openCard();
|
|
|
|
const row = subtaskRow("child-1");
|
|
expect(document.activeElement).toBe(row);
|
|
row.focus();
|
|
fireEvent.keyDown(row, { key: "Enter" });
|
|
|
|
expect(onParentKeyDown).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("TaskTitleHoverCard — keyboard-focusing the trigger opens the disclosure (AC-UI-PIPELINE-ROW-004.1)", () => {
|
|
it("opens the preview when the trigger receives focus, without a click", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
|
|
]);
|
|
const trigger = screen.getByTestId(PREVIEW_TRIGGER_TEST_ID);
|
|
expect(screen.queryByTestId(HOVER_CARD_TEST_ID)).toBeNull();
|
|
|
|
// A real .focus() call, not fireEvent.focus (which only dispatches the
|
|
// event without moving document.activeElement), so the "focus stays on
|
|
// the trigger" assertion below is checking real focus state.
|
|
trigger.focus();
|
|
|
|
await screen.findAllByTestId(HOVER_CARD_TEST_ID);
|
|
// A plain Tab-focus open must not steal focus into the popover — only an
|
|
// explicit keyboard activation (Enter/Space) does that.
|
|
expect(document.activeElement).toBe(trigger);
|
|
});
|
|
|
|
it("closes the preview when focus leaves the trigger for something other than the popover content", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
|
|
]);
|
|
const trigger = screen.getByTestId(PREVIEW_TRIGGER_TEST_ID);
|
|
trigger.focus();
|
|
await screen.findAllByTestId(HOVER_CARD_TEST_ID);
|
|
|
|
trigger.blur();
|
|
|
|
await waitFor(() => expect(screen.queryByTestId(HOVER_CARD_TEST_ID)).toBeNull());
|
|
});
|
|
|
|
it("moves focus into the preview when Enter activates an already focus-opened disclosure", async () => {
|
|
renderCard([
|
|
makeTask({ id: "parent-1" }),
|
|
makeTask({ id: "child-1", parentTaskId: "parent-1" }),
|
|
]);
|
|
const trigger = screen.getByTestId(PREVIEW_TRIGGER_TEST_ID);
|
|
trigger.focus();
|
|
await screen.findAllByTestId(HOVER_CARD_TEST_ID);
|
|
|
|
expect(document.activeElement).toBe(trigger);
|
|
fireEvent.click(trigger, { detail: 0 });
|
|
|
|
await waitFor(() => expect(document.activeElement).toBe(subtaskRow("child-1")));
|
|
});
|
|
});
|