feat(web): add rich task and GitLab MR hover previews (#2610)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { Badge } from "@kandev/ui/badge";
|
||||
import { PRTaskIcon } from "@/components/github/pr-task-icon";
|
||||
import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon";
|
||||
import { MRTaskIcon } from "@/components/gitlab/mr-task-icon";
|
||||
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
|
||||
import { useTaskPendingInput, type PendingInput } from "@/hooks/use-task-pending-input";
|
||||
import { getTaskStateIcon } from "@/lib/ui/state-icons";
|
||||
import type { Repository, Task } from "@/lib/types/http";
|
||||
@@ -43,9 +44,11 @@ function PrimaryTaskLine({
|
||||
hasPendingPermission: pendingInput.permission,
|
||||
interrupted: task.interrupted,
|
||||
})}
|
||||
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
|
||||
{task.title}
|
||||
</span>
|
||||
<TaskTitleHoverCard taskId={task.id} title={task.title}>
|
||||
<span className="min-w-0 truncate font-medium" data-testid="tasks-list-row-title">
|
||||
{task.title}
|
||||
</span>
|
||||
</TaskTitleHoverCard>
|
||||
{showContributions && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1"
|
||||
|
||||
@@ -3,9 +3,9 @@ import { cleanup, render } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice";
|
||||
|
||||
// AC12: TasksPageClient must call useWorkspaceMRs unconditionally, never
|
||||
// gated on tasksListShowDetails. Every direct import of TasksPageClient is
|
||||
// mocked so this test asserts only the wiring under AC12, not the behaviour
|
||||
// AC12: TasksPageClient must hydrate both change-request providers
|
||||
// unconditionally, never gated on tasksListShowDetails. Every direct import
|
||||
// of TasksPageClient is mocked so this test asserts only the wiring, not the behaviour
|
||||
// of the many unrelated hooks/components the page also renders (per the
|
||||
// plan's U9 guidance: mock rather than exercise the whole page).
|
||||
const useWorkspaceMRsMock = vi.fn();
|
||||
@@ -109,16 +109,12 @@ function renderPage(tasksListShowDetails: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("TasksPageClient MR hydration (AC12)", () => {
|
||||
it("calls useWorkspaceMRs with the workspace id when task details are off", () => {
|
||||
describe("TasksPageClient change-request hydration (AC12)", () => {
|
||||
it("hydrates PRs and MRs with the workspace id when task details are off", () => {
|
||||
renderPage(false);
|
||||
|
||||
expect(useWorkspaceMRsMock).toHaveBeenCalledWith("ws1");
|
||||
// The gated GitHub call is unaffected: contrast case proving the two
|
||||
// hooks are wired differently on purpose. Exact-call-record equality
|
||||
// (not toHaveBeenCalledWith, a presence check) so the assertion fails
|
||||
// if a regression also calls the hook ungated.
|
||||
expect(useWorkspacePRsMock.mock.calls).toEqual([[null]]);
|
||||
expect(useWorkspacePRsMock).toHaveBeenCalledWith("ws1");
|
||||
});
|
||||
|
||||
it("calls useWorkspaceMRs with the workspace id when task details are on", () => {
|
||||
|
||||
@@ -527,10 +527,9 @@ export function TasksPageClient(props: TasksPageClientProps) {
|
||||
state.kanban.workflowId === s.activeWorkflowId ? state.kanban.steps : EMPTY_WORKFLOW_STEPS,
|
||||
);
|
||||
useWorkflowSnapshot(s.activeWorkflowId);
|
||||
useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null);
|
||||
// Unconditional, unlike useWorkspacePRs above: useWorkspaceMRs(null) clears
|
||||
// every workspace's cached MRs, not just this page's, and the sidebar (also
|
||||
// mounted here) reads that same cache — see spec "Hydration ownership".
|
||||
// Task-title previews render PR/MR glyphs independently of the list-details
|
||||
// preference, so both workspace caches must be hydrated here.
|
||||
useWorkspacePRs(s.activeWorkspaceId);
|
||||
useWorkspaceMRs(s.activeWorkspaceId);
|
||||
useForegroundRefresh(() => s.fetchTasks(true), Boolean(s.activeWorkspaceId), s.activeWorkspaceId);
|
||||
const { handleSortChange, handleGroupChange } = useTasksListPreferenceSync({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { StateProvider, useAppStore } from "@/components/state-provider";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { MRTaskIcon } from "./mr-task-icon";
|
||||
|
||||
@@ -39,19 +39,18 @@ function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
};
|
||||
}
|
||||
|
||||
// Seeds the store and renders MRTaskIcon inside the same provider tree in one
|
||||
// pass — each StateProvider mount owns an independent store instance, so a
|
||||
// separate seed-then-render step would seed a store the render never reads.
|
||||
// Seeds the store via StateProvider's initialState rather than a render-time
|
||||
// setTaskMRs call, matching the pattern task-title-hover-card.test.tsx uses.
|
||||
function renderMRTaskIcon(mrs: TaskMR[]) {
|
||||
function Fixture() {
|
||||
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
|
||||
setTaskMRs("ws-1", { "task-1": mrs });
|
||||
return <MRTaskIcon taskId="task-1" />;
|
||||
}
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<StateProvider initialState={{ workspaces: { items: [], activeId: "ws-1" } }}>
|
||||
<Fixture />
|
||||
<StateProvider
|
||||
initialState={{
|
||||
workspaces: { items: [], activeId: "ws-1" },
|
||||
taskMRs: { byWorkspaceId: { "ws-1": { "task-1": mrs } } },
|
||||
}}
|
||||
>
|
||||
<MRTaskIcon taskId="task-1" />
|
||||
</StateProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -80,8 +79,98 @@ describe("MRTaskIcon", () => {
|
||||
renderMRTaskIcon([makeMR({ state: "merged" })]);
|
||||
const badge = screen.getByTestId(BADGE_TEST_ID);
|
||||
expect(badge.getAttribute("data-mr-state")).toBe("merged");
|
||||
// The tooltip text itself (getMRTooltip's "State: merged" output) is
|
||||
// covered directly in mr-task-icon.test.ts — jsdom does not reliably
|
||||
// open Radix Tooltip content on a non-focusable trigger span.
|
||||
});
|
||||
|
||||
// AC1, AC7, AC24: the trigger is keyboard-focusable and accessible, and the
|
||||
// rendered summary uses MR !{iid} plus labelled rows — no pipe-delimited
|
||||
// string anywhere.
|
||||
it("AC1/AC7: trigger has role=img, tabIndex=0, and a localized aria-label", () => {
|
||||
renderMRTaskIcon([makeMR({ mr_iid: 42 })]);
|
||||
const badge = screen.getByTestId(BADGE_TEST_ID);
|
||||
expect(badge.getAttribute("role")).toBe("img");
|
||||
expect(badge.getAttribute("tabIndex")).toBe("0");
|
||||
expect(badge.getAttribute("aria-label")).toBe("Merge request !42 status");
|
||||
});
|
||||
|
||||
it("AC1/AC24: hovering the trigger opens the structured summary with MR !iid and the title", () => {
|
||||
renderMRTaskIcon([
|
||||
makeMR({
|
||||
mr_iid: 7,
|
||||
mr_title: "Fix the login flow",
|
||||
approval_state: "approved",
|
||||
pipeline_state: "success",
|
||||
detailed_merge_status: "mergeable",
|
||||
}),
|
||||
]);
|
||||
const badge = screen.getByTestId(BADGE_TEST_ID);
|
||||
fireEvent.pointerEnter(badge, { pointerType: "mouse" });
|
||||
|
||||
// Radix mounts the tooltip content into both a measurement copy and the
|
||||
// visible portal, so scope to the first (portal) match rather than
|
||||
// asserting a single element.
|
||||
expect(screen.getAllByTestId("mr-task-status-summary").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("mr-task-status-iid")[0].textContent).toBe("MR !7");
|
||||
expect(screen.getAllByTestId("mr-task-status-title")[0].textContent).toBe("Fix the login flow");
|
||||
expect(screen.getAllByTestId("mr-task-status-review")[0].textContent).toContain("Review");
|
||||
expect(screen.getAllByTestId("mr-task-status-review")[0].textContent).toContain("Approved");
|
||||
expect(screen.getAllByTestId("mr-task-status-ci")[0].textContent).toContain("CI");
|
||||
expect(screen.getAllByTestId("mr-task-status-merge")[0].textContent).toContain("Merge");
|
||||
expect(document.body.textContent).not.toContain("|");
|
||||
});
|
||||
|
||||
it("AC1/AC24: the multi-MR badge renders one summary entry per MR, separated", () => {
|
||||
renderMRTaskIcon([
|
||||
makeMR({ id: "a", mr_iid: 11, mr_title: "First linked MR", pipeline_state: "success" }),
|
||||
makeMR({ id: "b", mr_iid: 12, mr_title: "Second linked MR", pipeline_state: "failure" }),
|
||||
]);
|
||||
fireEvent.pointerEnter(screen.getByTestId(BADGE_TEST_ID), { pointerType: "mouse" });
|
||||
|
||||
// Radix force-mounts a measurement copy alongside the visible portal, so
|
||||
// scope to one summary rather than counting matches across the document.
|
||||
const summary = screen.getAllByTestId("mr-task-status-summary")[0];
|
||||
const entries = summary.querySelectorAll('[data-testid="mr-task-status-entry"]');
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].textContent).toContain("MR !11");
|
||||
expect(entries[0].textContent).toContain("First linked MR");
|
||||
expect(entries[0].textContent).toContain("Passed");
|
||||
expect(entries[1].textContent).toContain("MR !12");
|
||||
expect(entries[1].textContent).toContain("Second linked MR");
|
||||
expect(entries[1].textContent).toContain("Failed");
|
||||
// Only the trailing entries carry the divider.
|
||||
expect(entries[0].className).not.toContain("border-t");
|
||||
expect(entries[1].className).toContain("border-t");
|
||||
});
|
||||
|
||||
it("AC7: the multi-MR badge reports ready-to-merge only when every open MR is ready", () => {
|
||||
const ready = {
|
||||
pipeline_state: "success" as const,
|
||||
detailed_merge_status: "mergeable",
|
||||
};
|
||||
renderMRTaskIcon([makeMR({ id: "a", ...ready }), makeMR({ id: "b", mr_iid: 2, ...ready })]);
|
||||
expect(screen.getByTestId(BADGE_TEST_ID).getAttribute("data-mr-ready-to-merge")).toBe("true");
|
||||
|
||||
cleanup();
|
||||
renderMRTaskIcon([
|
||||
makeMR({ id: "a", ...ready }),
|
||||
makeMR({ id: "b", mr_iid: 2, pipeline_state: "failure" }),
|
||||
]);
|
||||
expect(screen.getByTestId(BADGE_TEST_ID).getAttribute("data-mr-ready-to-merge")).toBe("false");
|
||||
});
|
||||
|
||||
it("malformed store value still bails to null instead of throwing", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<StateProvider
|
||||
initialState={{
|
||||
workspaces: { items: [], activeId: "ws-1" },
|
||||
// @ts-expect-error simulating a partial hydration payload
|
||||
taskMRs: { byWorkspaceId: { "ws-1": { "task-1": { not: "an array" } } } },
|
||||
}}
|
||||
>
|
||||
<MRTaskIcon taskId="task-1" />
|
||||
</StateProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.queryByTestId(BADGE_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aggregateMRChipStatus,
|
||||
aggregateMRStatusColor,
|
||||
areAllOpenMRsReadyToMerge,
|
||||
compareChipMR,
|
||||
getMRStatusColor,
|
||||
getMRTooltip,
|
||||
isMRReadyToMerge,
|
||||
mrChipStatus,
|
||||
MR_CHIP_STATUS_RANK,
|
||||
@@ -95,37 +95,6 @@ describe("getMRStatusColor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// AC37: colour is never the only carrier of MR state — the tooltip text
|
||||
// states it explicitly for any non-open MR.
|
||||
describe("getMRTooltip", () => {
|
||||
it("states the MR state for a non-open MR", () => {
|
||||
expect(getMRTooltip(makeMR({ state: "merged" }))).toContain("State: merged");
|
||||
expect(getMRTooltip(makeMR({ state: "closed" }))).toContain("State: closed");
|
||||
});
|
||||
|
||||
it("omits the state line for an open MR", () => {
|
||||
expect(getMRTooltip(makeMR({ state: "open" }))).not.toContain("State:");
|
||||
});
|
||||
|
||||
it("flags a ready-to-merge MR", () => {
|
||||
expect(
|
||||
getMRTooltip(
|
||||
makeMR({
|
||||
approval_state: "approved",
|
||||
pipeline_state: "success",
|
||||
merge_status: "can_be_merged",
|
||||
}),
|
||||
),
|
||||
).toContain("Ready to merge");
|
||||
});
|
||||
|
||||
it("flags a draft MR without claiming it's ready to merge", () => {
|
||||
const tooltip = getMRTooltip(makeMR({ draft: true }));
|
||||
expect(tooltip).toContain("Draft");
|
||||
expect(tooltip).not.toContain("Ready to merge");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMRReadyToMerge", () => {
|
||||
it("is true only for open, non-draft MRs with a passing pipeline and a mergeable status", () => {
|
||||
expect(
|
||||
@@ -471,3 +440,31 @@ describe("compareChipMR", () => {
|
||||
expect(compareChipMR(a, a)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// AC7: data-mr-ready-to-merge on the multi-MR badge, mirroring github's
|
||||
// areAllOpenPRsReadyToMerge.
|
||||
describe("areAllOpenMRsReadyToMerge", () => {
|
||||
const readyMR = makeMR({
|
||||
approval_state: "approved",
|
||||
pipeline_state: "success",
|
||||
merge_status: "can_be_merged",
|
||||
});
|
||||
|
||||
it("is false when there are no open MRs", () => {
|
||||
expect(areAllOpenMRsReadyToMerge([makeMR({ state: "merged" })])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when every open MR is ready to merge", () => {
|
||||
expect(areAllOpenMRsReadyToMerge([readyMR, { ...readyMR, id: "b", mr_iid: 2 }])).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when any open MR is not ready to merge", () => {
|
||||
const notReady = makeMR({ id: "b", mr_iid: 2, pipeline_state: "pending" });
|
||||
expect(areAllOpenMRsReadyToMerge([readyMR, notReady])).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores terminal siblings when deciding readiness", () => {
|
||||
const merged = makeMR({ id: "c", mr_iid: 3, state: "merged" });
|
||||
expect(areAllOpenMRsReadyToMerge([readyMR, merged])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useChangeRequestTaskTooltipState } from "@/components/integrations/use-change-request-task-tooltip-state";
|
||||
import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { deriveMRTaskStatusSummary, MRTaskStatusSummary } from "./mr-task-status-summary";
|
||||
|
||||
const MUTED_FOREGROUND = "text-muted-foreground";
|
||||
const PURPLE_500 = "text-purple-500";
|
||||
@@ -170,14 +172,14 @@ function mrMergeStatusReady(mr: TaskMR): boolean {
|
||||
return mr.merge_status === "can_be_merged";
|
||||
}
|
||||
|
||||
export function getMRTooltip(mr: TaskMR): string {
|
||||
const parts = [`!${mr.mr_iid}: ${mr.mr_title}`];
|
||||
if (mr.state !== "open") parts.push(t("gitlab:mrTooltipState", { state: mr.state }));
|
||||
if (mr.approval_state) parts.push(t("gitlab:mrTooltipReview", { state: mr.approval_state }));
|
||||
if (mr.pipeline_state) parts.push(t("gitlab:mrTooltipPipeline", { state: mr.pipeline_state }));
|
||||
if (mr.draft) parts.push(t("gitlab:draft"));
|
||||
else if (isMRReadyToMerge(mr)) parts.push(t("gitlab:mrTooltipReadyToMerge"));
|
||||
return parts.join(" | ");
|
||||
/**
|
||||
* True when at least one MR is open AND every open MR is ready to merge.
|
||||
* Terminal (merged/closed/locked) siblings are ignored so they can't drag the
|
||||
* result to false. Mirrors github's areAllOpenPRsReadyToMerge.
|
||||
*/
|
||||
export function areAllOpenMRsReadyToMerge(mrs: TaskMR[]): boolean {
|
||||
const openMRs = mrs.filter((mr) => mr.state === "open");
|
||||
return openMRs.length > 0 && openMRs.every(isMRReadyToMerge);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,51 +228,73 @@ export function MRTaskIcon({ taskId }: { taskId: string }) {
|
||||
}
|
||||
|
||||
function SingleMRIcon({ taskId, mr }: { taskId: string; mr: TaskMR }) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = useChangeRequestTaskTooltipState();
|
||||
const readyToMerge = isMRReadyToMerge(mr);
|
||||
const summary = deriveMRTaskStatusSummary(mr, readyToMerge);
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip open={tooltip.open}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`mr-task-icon-${taskId}`}
|
||||
data-mr-state={mr.state}
|
||||
data-mr-count="1"
|
||||
data-mr-ready-to-merge={isMRReadyToMerge(mr) ? "true" : "false"}
|
||||
data-mr-ready-to-merge={readyToMerge ? "true" : "false"}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={t("gitlab:mergeRequestStatus", { iid: mr.mr_iid })}
|
||||
onPointerEnter={tooltip.onPointerEnter}
|
||||
onPointerLeave={tooltip.onPointerLeave}
|
||||
onFocus={tooltip.onFocus}
|
||||
onBlur={tooltip.onBlur}
|
||||
className={cn("inline-flex items-center shrink-0", getMRStatusColor(mr))}
|
||||
>
|
||||
<IconGitMerge className="h-3.5 w-3.5" />
|
||||
<IconGitMerge aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{/* MR title/state come from the linked GitLab MR itself, not
|
||||
translatable UI copy — mirrors github's untranslated getPRTooltip. */}
|
||||
<TooltipContent>{getMRTooltip(mr)}</TooltipContent>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
||||
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
||||
>
|
||||
<MRTaskStatusSummary summaries={[summary]} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiMRIcon({ taskId, mrs }: { taskId: string; mrs: TaskMR[] }) {
|
||||
const { t } = useTranslation();
|
||||
const tooltip = useChangeRequestTaskTooltipState();
|
||||
const aggregateColor = aggregateMRStatusColor(mrs);
|
||||
const allReady = areAllOpenMRsReadyToMerge(mrs);
|
||||
const summaries = mrs.map((mr) => deriveMRTaskStatusSummary(mr, isMRReadyToMerge(mr)));
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip open={tooltip.open}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-testid={`mr-task-icon-${taskId}`}
|
||||
data-mr-count={mrs.length}
|
||||
data-mr-ready-to-merge={allReady ? "true" : "false"}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={t("gitlab:mergeRequestStatuses", { count: mrs.length })}
|
||||
onPointerEnter={tooltip.onPointerEnter}
|
||||
onPointerLeave={tooltip.onPointerLeave}
|
||||
onFocus={tooltip.onFocus}
|
||||
onBlur={tooltip.onBlur}
|
||||
className={cn("inline-flex items-center gap-0.5 shrink-0", aggregateColor)}
|
||||
>
|
||||
<IconGitMerge className="h-3.5 w-3.5" />
|
||||
<span className="text-[9px] font-semibold leading-none">{mrs.length}</span>
|
||||
<IconGitMerge aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<span className="text-[9px] font-semibold leading-none tabular-nums">{mrs.length}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
{mrs.map((mr) => (
|
||||
<div key={mr.id} className="flex items-center gap-2">
|
||||
<span className={cn("inline-flex shrink-0", getMRStatusColor(mr))}>
|
||||
<IconGitMerge className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{getMRTooltip(mr)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
||||
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
||||
>
|
||||
<MRTaskStatusSummary summaries={summaries} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { deriveMRTaskStatusSummary } from "./mr-task-status-summary";
|
||||
|
||||
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
|
||||
return {
|
||||
id: "id",
|
||||
task_id: "task-1",
|
||||
host: "https://gitlab.com",
|
||||
project_path: "group/project",
|
||||
mr_iid: 12,
|
||||
mr_url: "",
|
||||
mr_title: "Fix the thing",
|
||||
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: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveMRTaskStatusSummary", () => {
|
||||
it("derives review, CI, and merge rows for an open, approved, mergeable MR", () => {
|
||||
const mr = makeMR({
|
||||
approval_state: "approved",
|
||||
pipeline_state: "success",
|
||||
detailed_merge_status: "mergeable",
|
||||
});
|
||||
|
||||
const summary = deriveMRTaskStatusSummary(mr, true);
|
||||
|
||||
expect(summary).toEqual({
|
||||
iid: 12,
|
||||
title: "Fix the thing",
|
||||
rows: [
|
||||
{ kind: "review", status: "approved", tone: "success" },
|
||||
{ kind: "ci", status: "passed", tone: "success" },
|
||||
{ kind: "merge", status: "ready", tone: "success" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders CI failed in the danger tone", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ pipeline_state: "failure" }), false);
|
||||
expect(summary.rows).toContainEqual({ kind: "ci", status: "failed", tone: "danger" });
|
||||
});
|
||||
|
||||
it("renders merge conflicts in the danger tone from detailed_merge_status", () => {
|
||||
const summary = deriveMRTaskStatusSummary(
|
||||
makeMR({ detailed_merge_status: "cannot_be_merged" }),
|
||||
false,
|
||||
);
|
||||
expect(summary.rows).toContainEqual({ kind: "merge", status: "conflicts", tone: "danger" });
|
||||
});
|
||||
|
||||
it("renders merge conflicts from the legacy merge_status when detailed_merge_status is absent", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ merge_status: "cannot_be_merged" }), false);
|
||||
expect(summary.rows).toContainEqual({ kind: "merge", status: "conflicts", tone: "danger" });
|
||||
});
|
||||
|
||||
it("renders a draft MR with the draft merge row", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ draft: true }), false);
|
||||
expect(summary.rows).toEqual([{ kind: "merge", status: "draft", tone: "muted" }]);
|
||||
});
|
||||
|
||||
it("renders a State row and omits the Merge row for a merged MR", () => {
|
||||
const summary = deriveMRTaskStatusSummary(
|
||||
makeMR({
|
||||
state: "merged",
|
||||
pipeline_state: "success",
|
||||
detailed_merge_status: "mergeable",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
expect(summary.rows).toEqual([
|
||||
{ kind: "state", status: "merged", tone: "merged" },
|
||||
{ kind: "ci", status: "passed", tone: "success" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a Closed state row for a closed MR", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ state: "closed" }), false);
|
||||
expect(summary.rows).toEqual([{ kind: "state", status: "closed", tone: "danger" }]);
|
||||
});
|
||||
|
||||
it("renders a Closed state row for a locked MR", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ state: "locked" }), false);
|
||||
expect(summary.rows).toEqual([{ kind: "state", status: "closed", tone: "danger" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveMRTaskStatusSummary — empty and unrecognized values", () => {
|
||||
it("renders no rows for an MR with every optional field empty", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR(), false);
|
||||
expect(summary.rows).toEqual([]);
|
||||
expect(summary.iid).toBe(12);
|
||||
expect(summary.title).toBe("Fix the thing");
|
||||
});
|
||||
|
||||
it("preserves an unrecognized pipeline_state verbatim in a raw row", () => {
|
||||
const summary = deriveMRTaskStatusSummary(
|
||||
makeMR({ pipeline_state: "canceled" as unknown as TaskMR["pipeline_state"] }),
|
||||
false,
|
||||
);
|
||||
expect(summary.rows).toContainEqual({
|
||||
kind: "ci",
|
||||
status: "raw",
|
||||
tone: "muted",
|
||||
rawValue: "canceled",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an unrecognized detailed_merge_status verbatim in a raw row", () => {
|
||||
const summary = deriveMRTaskStatusSummary(
|
||||
makeMR({ detailed_merge_status: "policies_denied" }),
|
||||
false,
|
||||
);
|
||||
expect(summary.rows).toContainEqual({
|
||||
kind: "merge",
|
||||
status: "raw",
|
||||
tone: "muted",
|
||||
rawValue: "policies_denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the merge row entirely for checking/unchecked/ci_still_running (the CI row already says it)", () => {
|
||||
for (const status of ["checking", "unchecked", "ci_still_running"]) {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ detailed_merge_status: status }), false);
|
||||
expect(summary.rows.find((row) => row.kind === "merge")).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders an awaiting-approval review row in the info tone", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ approval_state: "pending" }), false);
|
||||
expect(summary.rows).toContainEqual({
|
||||
kind: "review",
|
||||
status: "awaiting_approval",
|
||||
tone: "info",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a merge row for a non-open MR even when readyToMerge is true", () => {
|
||||
const summary = deriveMRTaskStatusSummary(makeMR({ state: "closed" }), true);
|
||||
expect(summary.rows.find((row) => row.kind === "merge")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { IconGitMerge } from "@tabler/icons-react";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import {
|
||||
ChangeRequestTaskStatusSummary,
|
||||
type ChangeRequestTaskStatusPresentation,
|
||||
type ChangeRequestTaskSummaryRow,
|
||||
type ChangeRequestTaskSummaryRowKind,
|
||||
type ChangeRequestTaskSummaryStatus,
|
||||
type ChangeRequestTaskSummaryTone,
|
||||
} from "@/components/integrations/change-request-task-status-summary";
|
||||
|
||||
export type MRTaskSummaryRowKind = ChangeRequestTaskSummaryRowKind;
|
||||
export type MRTaskSummaryTone = ChangeRequestTaskSummaryTone;
|
||||
export type MRTaskSummaryStatus = ChangeRequestTaskSummaryStatus;
|
||||
export type MRTaskSummaryRow = ChangeRequestTaskSummaryRow;
|
||||
|
||||
export type MRTaskStatusSummaryData = {
|
||||
iid: number;
|
||||
title: string;
|
||||
rows: MRTaskSummaryRow[];
|
||||
};
|
||||
|
||||
function rawRow(kind: MRTaskSummaryRowKind, rawValue: string): MRTaskSummaryRow {
|
||||
return { kind, status: "raw", tone: "muted", rawValue };
|
||||
}
|
||||
|
||||
function deriveStateRow(state: string): MRTaskSummaryRow | null {
|
||||
if (!state || state === "open") return null;
|
||||
if (state === "merged") return { kind: "state", status: "merged", tone: "merged" };
|
||||
if (state === "closed" || state === "locked") {
|
||||
return { kind: "state", status: "closed", tone: "danger" };
|
||||
}
|
||||
return rawRow("state", state);
|
||||
}
|
||||
|
||||
function deriveReviewRow(approvalState: string): MRTaskSummaryRow | null {
|
||||
if (!approvalState) return null;
|
||||
if (approvalState === "approved") {
|
||||
return { kind: "review", status: "approved", tone: "success" };
|
||||
}
|
||||
if (approvalState === "pending") {
|
||||
return { kind: "review", status: "awaiting_approval", tone: "info" };
|
||||
}
|
||||
return rawRow("review", approvalState);
|
||||
}
|
||||
|
||||
function deriveCIRow(pipelineState: string): MRTaskSummaryRow | null {
|
||||
if (!pipelineState) return null;
|
||||
if (pipelineState === "success") return { kind: "ci", status: "passed", tone: "success" };
|
||||
if (pipelineState === "failure") return { kind: "ci", status: "failed", tone: "danger" };
|
||||
if (pipelineState === "pending") return { kind: "ci", status: "in_progress", tone: "warning" };
|
||||
return rawRow("ci", pipelineState);
|
||||
}
|
||||
|
||||
// checking/unchecked/ci_still_running produce no merge row: the CI row above
|
||||
// already communicates the wait, so a second "still checking" line would be
|
||||
// redundant noise.
|
||||
const MERGE_STATUS_NO_ROW = new Set(["checking", "unchecked", "ci_still_running"]);
|
||||
|
||||
function deriveMergeStatusRow(mergeStatus: string): MRTaskSummaryRow | null {
|
||||
if (!mergeStatus || MERGE_STATUS_NO_ROW.has(mergeStatus)) return null;
|
||||
if (mergeStatus === "mergeable" || mergeStatus === "can_be_merged") {
|
||||
return { kind: "merge", status: "mergeable", tone: "muted" };
|
||||
}
|
||||
if (mergeStatus === "broken_status" || mergeStatus === "cannot_be_merged") {
|
||||
return { kind: "merge", status: "conflicts", tone: "danger" };
|
||||
}
|
||||
if (mergeStatus === "discussions_not_resolved") {
|
||||
return { kind: "merge", status: "unresolved_discussions", tone: "warning" };
|
||||
}
|
||||
if (mergeStatus === "not_approved") {
|
||||
return { kind: "merge", status: "awaiting_approval", tone: "muted" };
|
||||
}
|
||||
return rawRow("merge", mergeStatus);
|
||||
}
|
||||
|
||||
function deriveMergeRow(mr: TaskMR, readyToMerge: boolean): MRTaskSummaryRow | null {
|
||||
if (mr.state !== "open") return null;
|
||||
if (mr.draft) return { kind: "merge", status: "draft", tone: "muted" };
|
||||
if (readyToMerge) return { kind: "merge", status: "ready", tone: "success" };
|
||||
return deriveMergeStatusRow(mr.detailed_merge_status || mr.merge_status);
|
||||
}
|
||||
|
||||
export function deriveMRTaskStatusSummary(
|
||||
mr: TaskMR,
|
||||
readyToMerge: boolean,
|
||||
): MRTaskStatusSummaryData {
|
||||
const rows = [
|
||||
deriveStateRow(mr.state),
|
||||
deriveReviewRow(mr.approval_state),
|
||||
deriveCIRow(mr.pipeline_state),
|
||||
deriveMergeRow(mr, readyToMerge),
|
||||
].filter((row): row is MRTaskSummaryRow => row !== null);
|
||||
|
||||
return { iid: mr.mr_iid, title: mr.mr_title, rows };
|
||||
}
|
||||
|
||||
const MR_PRESENTATION: ChangeRequestTaskStatusPresentation = {
|
||||
summaryTestId: "mr-task-status-summary",
|
||||
entryTestId: "mr-task-status-entry",
|
||||
identifierTestId: "mr-task-status-iid",
|
||||
titleTestId: "mr-task-status-title",
|
||||
rowTestIdPrefix: "mr-task-status",
|
||||
icon: IconGitMerge,
|
||||
entryAriaLabelKey: "gitlab:mergeRequestStatus",
|
||||
identifierLabelKey: "gitlab:mrTaskStatusIid",
|
||||
rowLabelKeys: {
|
||||
state: "gitlab:mrTaskStatusState",
|
||||
review: "gitlab:mrTaskStatusReview",
|
||||
ci: "gitlab:mrTaskStatusCi",
|
||||
merge: "gitlab:mrTaskStatusMerge",
|
||||
},
|
||||
statusLabelKeys: {
|
||||
merged: "gitlab:merged",
|
||||
closed: "gitlab:closed",
|
||||
approved: "gitlab:approved",
|
||||
awaiting_approval: "gitlab:awaitingApproval",
|
||||
passed: "gitlab:passed",
|
||||
failed: "gitlab:failed",
|
||||
in_progress: "gitlab:inProgress",
|
||||
draft: "gitlab:draft",
|
||||
ready: "gitlab:readyToMerge",
|
||||
mergeable: "gitlab:mergeable",
|
||||
conflicts: "gitlab:conflicts",
|
||||
unresolved_discussions: "gitlab:unresolvedDiscussions",
|
||||
},
|
||||
};
|
||||
|
||||
export function MRTaskStatusSummary({ summaries }: { summaries: MRTaskStatusSummaryData[] }) {
|
||||
return (
|
||||
<ChangeRequestTaskStatusSummary
|
||||
summaries={summaries.map((summary) => ({
|
||||
number: summary.iid,
|
||||
title: summary.title,
|
||||
rows: summary.rows,
|
||||
}))}
|
||||
presentation={MR_PRESENTATION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export type ChangeRequestTaskSummaryStatus =
|
||||
| "approved"
|
||||
| "changes_requested"
|
||||
| "pending_review"
|
||||
| "awaiting_approval"
|
||||
| "passed"
|
||||
| "failed"
|
||||
| "in_progress"
|
||||
@@ -37,6 +38,7 @@ export type ChangeRequestTaskSummaryStatus =
|
||||
| "conflicts"
|
||||
| "behind"
|
||||
| "blocked"
|
||||
| "unresolved_discussions"
|
||||
| "mergeable"
|
||||
| "raw";
|
||||
|
||||
@@ -53,6 +55,19 @@ export type ChangeRequestTaskStatusSummaryData = {
|
||||
rows: ChangeRequestTaskSummaryRow[];
|
||||
};
|
||||
|
||||
export type ChangeRequestTaskStatusPresentation = {
|
||||
summaryTestId: string;
|
||||
entryTestId: string;
|
||||
identifierTestId: string;
|
||||
titleTestId: string;
|
||||
rowTestIdPrefix: string;
|
||||
icon: TablerIcon;
|
||||
entryAriaLabelKey: string;
|
||||
identifierLabelKey: string;
|
||||
rowLabelKeys: Record<ChangeRequestTaskSummaryRowKind, string>;
|
||||
statusLabelKeys?: Partial<Record<Exclude<ChangeRequestTaskSummaryStatus, "raw">, string>>;
|
||||
};
|
||||
|
||||
const ROW_LABEL_KEYS: Record<ChangeRequestTaskSummaryRowKind, string> = {
|
||||
state: "github:prTaskStatusState",
|
||||
review: "github:prTaskStatusReview",
|
||||
@@ -75,6 +90,7 @@ const STATUS_LABEL_KEYS: Record<Exclude<ChangeRequestTaskSummaryStatus, "raw">,
|
||||
approved: "github:approved",
|
||||
changes_requested: "github:changesRequested",
|
||||
pending_review: "github:pendingReview",
|
||||
awaiting_approval: "github:pendingReview",
|
||||
passed: "github:checkBucketPassed",
|
||||
failed: "github:checkBucketFailed",
|
||||
in_progress: "github:checkBucketInProgress",
|
||||
@@ -83,6 +99,7 @@ const STATUS_LABEL_KEYS: Record<Exclude<ChangeRequestTaskSummaryStatus, "raw">,
|
||||
conflicts: "github:conflicts",
|
||||
behind: "github:behindBase",
|
||||
blocked: "github:blocked",
|
||||
unresolved_discussions: "github:blocked",
|
||||
mergeable: "github:mergeable",
|
||||
};
|
||||
|
||||
@@ -92,6 +109,7 @@ const STATUS_ICONS: Record<ChangeRequestTaskSummaryStatus, TablerIcon> = {
|
||||
approved: IconCheck,
|
||||
changes_requested: IconX,
|
||||
pending_review: IconClockHour4,
|
||||
awaiting_approval: IconClockHour4,
|
||||
passed: IconCheck,
|
||||
failed: IconX,
|
||||
in_progress: IconClockHour4,
|
||||
@@ -100,13 +118,30 @@ const STATUS_ICONS: Record<ChangeRequestTaskSummaryStatus, TablerIcon> = {
|
||||
conflicts: IconAlertTriangle,
|
||||
behind: IconAlertTriangle,
|
||||
blocked: IconAlertTriangle,
|
||||
unresolved_discussions: IconAlertTriangle,
|
||||
mergeable: IconGitMerge,
|
||||
raw: IconCircleDot,
|
||||
};
|
||||
|
||||
function getStatusText(row: ChangeRequestTaskSummaryRow, t: TFunction): string {
|
||||
const DEFAULT_PRESENTATION: ChangeRequestTaskStatusPresentation = {
|
||||
summaryTestId: "pr-task-status-summary",
|
||||
entryTestId: "pr-task-status-entry",
|
||||
identifierTestId: "pr-task-status-number",
|
||||
titleTestId: "pr-task-status-title",
|
||||
rowTestIdPrefix: "pr-task-status",
|
||||
icon: IconGitPullRequest,
|
||||
entryAriaLabelKey: "github:pullRequestStatus",
|
||||
identifierLabelKey: "github:prTaskStatusNumber",
|
||||
rowLabelKeys: ROW_LABEL_KEYS,
|
||||
};
|
||||
|
||||
function getStatusText(
|
||||
row: ChangeRequestTaskSummaryRow,
|
||||
presentation: ChangeRequestTaskStatusPresentation,
|
||||
t: TFunction,
|
||||
): string {
|
||||
if (row.status === "raw") return row.rawValue ?? "";
|
||||
return t(STATUS_LABEL_KEYS[row.status]);
|
||||
return t(presentation.statusLabelKeys?.[row.status] ?? STATUS_LABEL_KEYS[row.status]);
|
||||
}
|
||||
|
||||
function SummaryStatusIcon({ status }: { status: ChangeRequestTaskSummaryStatus }) {
|
||||
@@ -114,17 +149,23 @@ function SummaryStatusIcon({ status }: { status: ChangeRequestTaskSummaryStatus
|
||||
return <StatusIcon aria-hidden="true" className="size-3.5 shrink-0" />;
|
||||
}
|
||||
|
||||
function SummaryRow({ row }: { row: ChangeRequestTaskSummaryRow }) {
|
||||
function SummaryRow({
|
||||
row,
|
||||
presentation,
|
||||
}: {
|
||||
row: ChangeRequestTaskSummaryRow;
|
||||
presentation: ChangeRequestTaskStatusPresentation;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid={`pr-task-status-${row.kind}`}
|
||||
data-testid={`${presentation.rowTestIdPrefix}-${row.kind}`}
|
||||
className="grid grid-cols-[min-content_minmax(0,1fr)] items-start gap-x-3"
|
||||
>
|
||||
<span className="text-muted-foreground">{t(ROW_LABEL_KEYS[row.kind])}</span>
|
||||
<span className="text-muted-foreground">{t(presentation.rowLabelKeys[row.kind])}</span>
|
||||
<span className={cn("flex min-w-0 items-center gap-1.5 font-medium", TONE_CLASSES[row.tone])}>
|
||||
<SummaryStatusIcon status={row.status} />
|
||||
<span className="min-w-0 break-words">{getStatusText(row, t)}</span>
|
||||
<span className="min-w-0 break-words">{getStatusText(row, presentation, t)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -132,33 +173,42 @@ function SummaryRow({ row }: { row: ChangeRequestTaskSummaryRow }) {
|
||||
|
||||
export function ChangeRequestTaskStatusSummary({
|
||||
summaries,
|
||||
presentation = DEFAULT_PRESENTATION,
|
||||
}: {
|
||||
summaries: ChangeRequestTaskStatusSummaryData[];
|
||||
presentation?: ChangeRequestTaskStatusPresentation;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ProviderIcon = presentation.icon;
|
||||
return (
|
||||
<div data-testid="pr-task-status-summary" className="w-full">
|
||||
<div data-testid={presentation.summaryTestId} className="w-full">
|
||||
{summaries.map((summary, index) => (
|
||||
<section
|
||||
key={`${summary.number}-${index}`}
|
||||
data-testid="pr-task-status-entry"
|
||||
aria-label={t("github:pullRequestStatus", { number: summary.number })}
|
||||
data-testid={presentation.entryTestId}
|
||||
aria-label={t(presentation.entryAriaLabelKey, {
|
||||
number: summary.number,
|
||||
iid: summary.number,
|
||||
})}
|
||||
className={cn(index > 0 && "mt-3 border-t border-border/60 pt-3")}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<IconGitPullRequest
|
||||
<ProviderIcon
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
data-testid="pr-task-status-number"
|
||||
data-testid={presentation.identifierTestId}
|
||||
className="text-[11px] font-medium tabular-nums text-muted-foreground"
|
||||
>
|
||||
{t("github:prTaskStatusNumber", { number: summary.number })}
|
||||
{t(presentation.identifierLabelKey, {
|
||||
number: summary.number,
|
||||
iid: summary.number,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
data-testid="pr-task-status-title"
|
||||
data-testid={presentation.titleTestId}
|
||||
className="text-pretty break-words text-sm font-medium leading-snug text-foreground [overflow-wrap:anywhere]"
|
||||
>
|
||||
{summary.title}
|
||||
@@ -168,7 +218,7 @@ export function ChangeRequestTaskStatusSummary({
|
||||
{summary.rows.length > 0 && (
|
||||
<div className="mt-2.5 space-y-1.5 pl-6">
|
||||
{summary.rows.map((row) => (
|
||||
<SummaryRow key={row.kind} row={row} />
|
||||
<SummaryRow key={row.kind} row={row} presentation={presentation} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const mockAppState = {
|
||||
workspaces: { activeId: "ws-1" },
|
||||
kanban: { tasks: [] as Array<{ id: string; title: string }> },
|
||||
kanbanMulti: { snapshots: {} as Record<string, { tasks: Array<{ id: string }> }> },
|
||||
taskPRs: { byTaskId: {} as Record<string, unknown> },
|
||||
};
|
||||
|
||||
@@ -34,6 +35,7 @@ const SECOND_PLUGIN_ID = "kandev-plugin-second";
|
||||
const TAGS_TEST_ID = "plugin-tags";
|
||||
const TAGS_SLOT = "task-card-tags";
|
||||
const INDICATOR_TEST_ID = "plugin-indicator";
|
||||
const TITLE_TEST_ID = "task-card-title";
|
||||
const SLOT_PROPS_TEXT = "task-1|ws-1|step-1";
|
||||
|
||||
function SlotPropsProbe({ testId, slotProps }: { testId: string; slotProps?: unknown }) {
|
||||
@@ -139,7 +141,7 @@ describe("KanbanCardBody — task-card-tags slot", () => {
|
||||
|
||||
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
||||
|
||||
expect(screen.getByTestId("task-card-title").textContent).toBe(TASK.title);
|
||||
expect(screen.getByTestId(TITLE_TEST_ID).textContent).toBe(TASK.title);
|
||||
expect(screen.getByTestId(INDICATOR_TEST_ID)).toBeTruthy();
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
@@ -150,7 +152,7 @@ describe("KanbanCardBody — task-card-tags slot", () => {
|
||||
describe("KanbanCardBody — linked PR/MR badges (AC30)", () => {
|
||||
it("renders both the PR badge and the MR badge, PR first", () => {
|
||||
const { container } = render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
||||
const title = screen.getByTestId("task-card-title");
|
||||
const title = screen.getByTestId(TITLE_TEST_ID);
|
||||
const row = title.parentElement;
|
||||
expect(row).not.toBeNull();
|
||||
const prIcon = screen.getByTestId("pr-task-icon");
|
||||
@@ -161,3 +163,17 @@ describe("KanbanCardBody — linked PR/MR badges (AC30)", () => {
|
||||
expect(prIcon.compareDocumentPosition(mrIcon) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KanbanCardBody — title hover card gating", () => {
|
||||
it("does not mount a hover card trigger when enableTitleHover is unset (drag preview)", () => {
|
||||
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
||||
const title = screen.getByTestId(TITLE_TEST_ID);
|
||||
expect(title.getAttribute("data-slot")).not.toBe("hover-card-trigger");
|
||||
});
|
||||
|
||||
it("mounts the hover card trigger around the title when enableTitleHover is set", () => {
|
||||
render(<KanbanCardBody task={TASK} repositoryChips={[]} enableTitleHover />);
|
||||
const title = screen.getByTestId(TITLE_TEST_ID);
|
||||
expect(title.closest('[data-testid="task-title-preview-trigger"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type KanbanCardMenuEntry,
|
||||
} from "@/components/kanban-card-menu-items";
|
||||
import { TaskCardIndicators, TaskCardTags } from "@/components/kanban-card-plugin-slots";
|
||||
import { CardTitle } from "@/components/kanban-card-title";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
|
||||
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
|
||||
@@ -133,10 +134,12 @@ export function KanbanCardBody({
|
||||
task,
|
||||
repositoryChips,
|
||||
actions,
|
||||
enableTitleHover,
|
||||
}: {
|
||||
task: Task;
|
||||
repositoryChips: RepositoryChip[];
|
||||
actions?: React.ReactNode;
|
||||
enableTitleHover?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -144,12 +147,7 @@ export function KanbanCardBody({
|
||||
<div className="min-w-0 flex-1">
|
||||
<RepoChipRow chips={repositoryChips} />
|
||||
<div className="flex items-center gap-1 min-w-0" data-testid="kanban-card-title-row">
|
||||
<p
|
||||
data-testid="task-card-title"
|
||||
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<CardTitle task={task} enableTitleHover={enableTitleHover} />
|
||||
<PRTaskIcon taskId={task.id} />
|
||||
<MRTaskIcon taskId={task.id} />
|
||||
<RegisteredChangeRequestTaskIcon taskId={task.id} />
|
||||
@@ -636,6 +634,7 @@ export function KanbanCardShell({
|
||||
<KanbanCardBody
|
||||
task={task}
|
||||
repositoryChips={repositoryChips ?? []}
|
||||
enableTitleHover
|
||||
actions={
|
||||
<KanbanCardActionSlot
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
|
||||
import type { Task } from "@/components/kanban-card";
|
||||
|
||||
/** The kanban card's title, optionally wrapped in the full-title/subtasks hover card. */
|
||||
export function CardTitle({ task, enableTitleHover }: { task: Task; enableTitleHover?: boolean }) {
|
||||
const title = (
|
||||
<p
|
||||
data-testid="task-card-title"
|
||||
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
);
|
||||
if (!enableTitleHover) return title;
|
||||
return (
|
||||
<TaskTitleHoverCard taskId={task.id} title={task.title}>
|
||||
{title}
|
||||
</TaskTitleHoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { act } from "react";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FileTreeNode } from "@/lib/types/backend";
|
||||
@@ -12,10 +12,12 @@ vi.mock("@/components/toast-provider", () => ({
|
||||
// actually sees. A key-echoing stub silently turns every migrated label into a
|
||||
// raw `ns:key` and the assertions then only prove the stub works.
|
||||
|
||||
import { FileContextMenu } from "./file-context-menu";
|
||||
import { FileContextMenu, TreeNodeName, useFileRename } from "./file-context-menu";
|
||||
|
||||
const FILE_NODE: FileTreeNode = { name: "README.md", path: "README.md", is_dir: false, size: 0 };
|
||||
const DIR_NODE: FileTreeNode = { name: "src", path: "src", is_dir: true, size: 0 };
|
||||
const RENAME_ROW = "rename-row";
|
||||
const FOCUS_ANCHOR = "focus-anchor";
|
||||
const BULK_TREE: FileTreeNode = {
|
||||
name: "root",
|
||||
path: "",
|
||||
@@ -27,7 +29,10 @@ const BULK_TREE: FileTreeNode = {
|
||||
],
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function openMenu(triggerTestId: string) {
|
||||
const trigger = screen.getByTestId(triggerTestId);
|
||||
@@ -56,6 +61,138 @@ function BulkDeleteHarness({ onDeleteFile }: { onDeleteFile: (path: string) => P
|
||||
);
|
||||
}
|
||||
|
||||
function RenameHarness() {
|
||||
const [tree, setTree] = React.useState<FileTreeNode | null>(FILE_NODE);
|
||||
const rename = useFileRename(FILE_NODE, tree, setTree, vi.fn().mockResolvedValue(true));
|
||||
|
||||
return (
|
||||
<FileContextMenu
|
||||
node={FILE_NODE}
|
||||
tree={tree}
|
||||
setTree={setTree}
|
||||
onRenameFile={vi.fn().mockResolvedValue(true)}
|
||||
onStartRename={rename.handleStartRename}
|
||||
>
|
||||
<div data-testid={RENAME_ROW}>
|
||||
<TreeNodeName node={FILE_NODE} isActive={false} gitStatus={undefined} rename={rename} />
|
||||
</div>
|
||||
</FileContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// `FileContextMenuSurface` defers `onStartRename` to `onCloseAutoFocus` and calls
|
||||
// `preventDefault()` there so Radix does not pull focus back to the trigger and
|
||||
// steal it from the freshly mounted input. Both halves of that are only correct
|
||||
// if the deferral is scoped to a *pending* rename: any other menu item, and any
|
||||
// dismissal, must leave Radix's default focus restoration alone and must never
|
||||
// enter edit mode. A pending flag that outlives its close is the failure mode.
|
||||
// The anchor stands in for whatever held focus when the user opened the menu.
|
||||
// Radix restores focus to it on close, so asserting against it distinguishes a
|
||||
// real restoration from focus falling back to `document.body`.
|
||||
function PendingRenameHarness({ onStartRename }: { onStartRename: () => void }) {
|
||||
const [tree, setTree] = React.useState<FileTreeNode | null>(FILE_NODE);
|
||||
return (
|
||||
<>
|
||||
<button data-testid={FOCUS_ANCHOR}>anchor</button>
|
||||
<FileContextMenu
|
||||
node={FILE_NODE}
|
||||
tree={tree}
|
||||
setTree={setTree}
|
||||
onRenameFile={vi.fn().mockResolvedValue(true)}
|
||||
onDownloadFile={vi.fn().mockResolvedValue(true)}
|
||||
onStartRename={onStartRename}
|
||||
>
|
||||
<div data-testid={RENAME_ROW}>row</div>
|
||||
</FileContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("FileContextMenu rename", () => {
|
||||
it("does not start rename when a different menu item is selected", async () => {
|
||||
const onStartRename = vi.fn();
|
||||
render(<PendingRenameHarness onStartRename={onStartRename} />);
|
||||
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.click(screen.getByText("Download"));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Download")).toBeNull());
|
||||
expect(onStartRename).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not start rename when the menu is dismissed without a selection", async () => {
|
||||
const onStartRename = vi.fn();
|
||||
render(<PendingRenameHarness onStartRename={onStartRename} />);
|
||||
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Rename")).toBeNull());
|
||||
expect(onStartRename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replay a consumed rename on the next menu close", async () => {
|
||||
const onStartRename = vi.fn();
|
||||
render(<PendingRenameHarness onStartRename={onStartRename} />);
|
||||
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.click(screen.getByText("Rename"));
|
||||
await waitFor(() => expect(onStartRename).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Reopening and dismissing must not re-fire the rename the previous close
|
||||
// already consumed.
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Rename")).toBeNull());
|
||||
expect(onStartRename).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The three tests above prove `onStartRename` stays unfired, which is only
|
||||
// half of the contract. `preventDefault()` suppresses Radix's focus
|
||||
// restoration, and hoisting it above the pending-rename guard would keep every
|
||||
// assertion above green while silently dropping the user back on
|
||||
// `document.body` after Download or Escape. These two pin the other half.
|
||||
it("restores focus to the opener when a different menu item is selected", async () => {
|
||||
render(<PendingRenameHarness onStartRename={vi.fn()} />);
|
||||
const anchor = screen.getByTestId(FOCUS_ANCHOR);
|
||||
anchor.focus();
|
||||
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.click(screen.getByText("Download"));
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(anchor));
|
||||
});
|
||||
|
||||
it("restores focus to the opener when the menu is dismissed without a selection", async () => {
|
||||
render(<PendingRenameHarness onStartRename={vi.fn()} />);
|
||||
const anchor = screen.getByTestId(FOCUS_ANCHOR);
|
||||
anchor.focus();
|
||||
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(document.activeElement).toBe(anchor));
|
||||
});
|
||||
|
||||
it("starts rename after the menu closes and immediately focuses the selected filename", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<RenameHarness />);
|
||||
|
||||
openMenu(RENAME_ROW);
|
||||
fireEvent.click(screen.getByText("Rename"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect((input as HTMLInputElement).selectionStart).toBe(0);
|
||||
expect((input as HTMLInputElement).selectionEnd).toBe(FILE_NODE.name.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileContextMenu Download item", () => {
|
||||
it("shows a Download item for a file when onDownloadFile is provided", () => {
|
||||
const onDownloadFile = vi.fn().mockResolvedValue(true);
|
||||
|
||||
@@ -294,11 +294,27 @@ function FileContextMenuSurface({
|
||||
onConfirmDelete,
|
||||
onDelete,
|
||||
}: FileContextMenuSurfaceProps) {
|
||||
const renamePendingRef = useRef(false);
|
||||
|
||||
const handleStartRename = useCallback(() => {
|
||||
renamePendingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = useCallback(
|
||||
(event: Event) => {
|
||||
if (!renamePendingRef.current) return;
|
||||
event.preventDefault();
|
||||
renamePendingRef.current = false;
|
||||
onStartRename();
|
||||
},
|
||||
[onStartRename],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuContent onCloseAutoFocus={handleCloseAutoFocus}>
|
||||
{showOpenInEditor && <OpenInEditorMenuItems node={node} />}
|
||||
{showOpenInEditor && (hasFileActions || showAddToChatContext) && <ContextMenuSeparator />}
|
||||
{showAddToChatContext && onAddToChatContext && (
|
||||
@@ -312,7 +328,7 @@ function FileContextMenuSurface({
|
||||
onDeleteFile={onDeleteFile}
|
||||
onRenameFile={onRenameFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
onStartRename={onStartRename}
|
||||
onStartRename={handleStartRename}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
@@ -504,15 +520,12 @@ export function TreeNodeName({
|
||||
useEffect(() => {
|
||||
if (rename.isRenaming) {
|
||||
blurEnabledRef.current = false;
|
||||
const focusTimer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 150);
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
const blurTimer = setTimeout(() => {
|
||||
blurEnabledRef.current = true;
|
||||
}, 400);
|
||||
return () => {
|
||||
clearTimeout(focusTimer);
|
||||
clearTimeout(blurTimer);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { classifyTask } from "./task-classify";
|
||||
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskAutopilotIcon } from "@/components/task/task-autopilot-icon";
|
||||
import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card";
|
||||
import type { WipQueueStatus } from "@/lib/kanban/wip-queue";
|
||||
import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon";
|
||||
|
||||
@@ -314,6 +315,21 @@ function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpe
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItemTitle({ taskId, title }: { taskId?: string; title: string }) {
|
||||
// w-full: ScrollOnOverflow's root is inline-block, so once it sits inside
|
||||
// the title-preview trigger's <button> (task-title-hover-card.tsx) rather
|
||||
// than being the flex row's direct child, shrink-to-fit sizing lets it grow
|
||||
// past the button's flex-shrunk width instead of clipping to it — losing
|
||||
// the overflow the hover-scroll marquee depends on.
|
||||
const content = <ScrollOnOverflow className="min-w-0 w-full">{title}</ScrollOnOverflow>;
|
||||
if (!taskId) return content;
|
||||
return (
|
||||
<TaskTitleHoverCard taskId={taskId} title={title} side="right" align="start">
|
||||
{content}
|
||||
</TaskTitleHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItemContent({
|
||||
title,
|
||||
autopilot,
|
||||
@@ -357,7 +373,7 @@ function TaskItemContent({
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1 min-w-0 text-[13px] font-medium text-foreground leading-tight">
|
||||
<ScrollOnOverflow className="min-w-0">{title}</ScrollOnOverflow>
|
||||
<TaskItemTitle taskId={taskId} title={title} />
|
||||
{autopilot && <TaskAutopilotIcon />}
|
||||
{isPinned && (
|
||||
<IconPinFilled
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "./task-select-helpers";
|
||||
import { useArchivedTaskState } from "./task-archived-context";
|
||||
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
|
||||
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
|
||||
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
|
||||
@@ -458,6 +459,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
const store = useAppStoreApi();
|
||||
const { t } = useTranslation();
|
||||
useRepositories(workspaceId);
|
||||
useWorkspaceMRs(workspaceId);
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { IconGitMerge, IconGitPullRequest } from "@tabler/icons-react";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { aggregatePRStatusColor } from "@/components/github/pr-task-icon";
|
||||
import { aggregateMRStatusColor } from "@/components/gitlab/mr-task-icon";
|
||||
import { useTaskMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { getTaskStateIcon } from "@/lib/ui/state-icons";
|
||||
import { formatTaskStateLabel } from "@/lib/ui/state-labels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskSubtask } from "@/hooks/domains/kanban/use-task-subtasks";
|
||||
|
||||
/**
|
||||
* A presentational CI glyph — not PRTaskIcon/MRTaskIcon. Those are Tooltip
|
||||
* roots, and nesting one inside hover-card content is the pattern
|
||||
* apps/web/AGENTS.md warns against (a Tooltip can close and unmount its
|
||||
* parent HoverCard before the pointer reaches it).
|
||||
*/
|
||||
function SubtaskCIGlyph({ taskId }: { taskId: string }) {
|
||||
const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId]) ?? [];
|
||||
const mrs = useTaskMRs(taskId);
|
||||
if (prs.length > 0) {
|
||||
return (
|
||||
<IconGitPullRequest
|
||||
aria-hidden="true"
|
||||
className={cn("size-3.5 shrink-0", aggregatePRStatusColor(prs))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (mrs.length > 0) {
|
||||
return (
|
||||
<IconGitMerge
|
||||
aria-hidden="true"
|
||||
className={cn("size-3.5 shrink-0", aggregateMRStatusColor(mrs))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TaskSubtaskRow({ subtask }: { subtask: TaskSubtask }) {
|
||||
return (
|
||||
<Link
|
||||
href={linkToTask(subtask.id)}
|
||||
data-testid={`task-subtask-row-${subtask.id}`}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-sm px-1 py-1 hover:bg-accent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* The state label names the icon, not the link: an aria-label on the
|
||||
anchor would replace its accessible name, so every row would announce
|
||||
as its lifecycle state with the subtask title never read out. */}
|
||||
<span role="img" aria-label={formatTaskStateLabel(subtask.state)} className="inline-flex">
|
||||
{getTaskStateIcon(subtask.state, "h-3.5 w-3.5 shrink-0")}
|
||||
</span>
|
||||
<SubtaskCIGlyph taskId={subtask.id} />
|
||||
<span className="min-w-0 flex-1 text-pretty break-words text-xs leading-snug [overflow-wrap:anywhere]">
|
||||
{subtask.title}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
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";
|
||||
|
||||
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 {
|
||||
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("task-title-preview-trigger");
|
||||
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" })]);
|
||||
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: opens with only the bold title when there are no subtasks", async () => {
|
||||
renderCard([makeTask({ id: "parent-1" })]);
|
||||
await openCard();
|
||||
|
||||
expect(screen.queryAllByTestId("task-title-hover-subtasks")).toHaveLength(0);
|
||||
});
|
||||
|
||||
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" })],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
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" })] },
|
||||
}}
|
||||
>
|
||||
<div onKeyDown={onParentKeyDown}>
|
||||
<TaskTitleHoverCard taskId="parent-1" title={LONG_TITLE}>
|
||||
<span data-testid="trigger">{LONG_TITLE}</span>
|
||||
</TaskTitleHoverCard>
|
||||
</div>
|
||||
</StateProvider>,
|
||||
);
|
||||
const trigger = screen.getByTestId("task-title-preview-trigger");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type FocusEvent, type MouseEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@kandev/ui/popover";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useTaskSubtasks } from "@/hooks/domains/kanban/use-task-subtasks";
|
||||
import { useHoverPopover } from "@/components/integrations/use-hover-popover";
|
||||
import { TaskSubtaskRow } from "./task-subtask-row";
|
||||
|
||||
const MAX_VISIBLE_SUBTASKS = 12;
|
||||
const OPEN_DELAY_MS = 200;
|
||||
const CLOSE_DELAY_MS = 100;
|
||||
|
||||
function SubtasksSection({ taskId }: { taskId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const subtasks = useTaskSubtasks(taskId);
|
||||
if (subtasks.length === 0) return null;
|
||||
|
||||
const visible = subtasks.slice(0, MAX_VISIBLE_SUBTASKS);
|
||||
const overflowCount = subtasks.length - visible.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-2.5 border-t border-border/60 pt-2.5"
|
||||
data-testid="task-title-hover-subtasks"
|
||||
>
|
||||
<div className="text-[11px] font-medium text-muted-foreground">
|
||||
{t("task:subtasksHeading", { count: subtasks.length })}
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{visible.map((subtask) => (
|
||||
<TaskSubtaskRow key={subtask.id} subtask={subtask} />
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div className="px-1 py-1 text-xs text-muted-foreground">
|
||||
{t("task:moreNotShown", { count: overflowCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopTaskTitlePreview({
|
||||
taskId,
|
||||
title,
|
||||
children,
|
||||
side,
|
||||
align,
|
||||
}: {
|
||||
taskId: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
side: "top" | "right" | "bottom" | "left";
|
||||
align: "start" | "center" | "end";
|
||||
}) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardSessionRef = useRef(false);
|
||||
const hover = useHoverPopover({ openDelayMs: OPEN_DELAY_MS, closeDelayMs: CLOSE_DELAY_MS });
|
||||
|
||||
const handleTriggerClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.detail !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
keyboardSessionRef.current = true;
|
||||
hover.onOpenChange(true);
|
||||
};
|
||||
|
||||
const handleContentBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) hover.onContentLeave();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={hover.open} onOpenChange={hover.onOpenChange}>
|
||||
<PopoverAnchor asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
data-testid="task-title-preview-trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={hover.open}
|
||||
onPointerEnter={hover.onTriggerEnter}
|
||||
onPointerLeave={hover.onTriggerLeave}
|
||||
onKeyDown={(event) => {
|
||||
// Only swallow keys while the preview is open (protects it from
|
||||
// the card/row's own keyboard shortcuts, e.g. drag pickup).
|
||||
// Escape must always fall through to close the surface it sits
|
||||
// on (kanban preview panel, dialog, ...) even when the trigger
|
||||
// merely has focus from a plain click and the preview isn't open.
|
||||
if (!hover.open && event.key === "Escape") return;
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={handleTriggerClick}
|
||||
className="min-w-0 max-w-full cursor-pointer text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
side={side}
|
||||
align={align}
|
||||
data-testid="task-title-hover-card"
|
||||
className="w-80 max-w-[calc(100vw-1rem)] max-h-80 overflow-y-auto p-3"
|
||||
onPointerEnter={hover.onContentEnter}
|
||||
onPointerLeave={() => {
|
||||
if (!contentRef.current?.contains(document.activeElement)) hover.onContentLeave();
|
||||
}}
|
||||
onFocusCapture={hover.onContentEnter}
|
||||
onBlurCapture={handleContentBlur}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
if (keyboardSessionRef.current) {
|
||||
contentRef.current
|
||||
?.querySelector<HTMLElement>(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
if (keyboardSessionRef.current) triggerRef.current?.focus();
|
||||
keyboardSessionRef.current = false;
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="text-pretty break-words text-sm font-semibold leading-snug text-foreground [overflow-wrap:anywhere]">
|
||||
{title}
|
||||
</div>
|
||||
<SubtasksSection taskId={taskId} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** Interactive desktop preview with direct mobile navigation as the coarse-pointer fallback. */
|
||||
export function TaskTitleHoverCard({
|
||||
taskId,
|
||||
title,
|
||||
children,
|
||||
side = "bottom",
|
||||
align = "start",
|
||||
}: {
|
||||
taskId: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
const { isFinePointer } = useResponsiveBreakpoint();
|
||||
|
||||
if (!isFinePointer) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<DesktopTaskTitlePreview taskId={taskId} title={title} side={side} align={align}>
|
||||
{children}
|
||||
</DesktopTaskTitlePreview>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
/**
|
||||
* The AppSidebar (app/layout.tsx) never unmounts across a client-side route
|
||||
* change, and this branch gave it both its own MR badges and its own
|
||||
* `useWorkspaceMRs` call. `useWorkspaceMRs(null)` resets the whole
|
||||
* `taskMRs.byWorkspaceId` map — not just one workspace — and the hook's
|
||||
* per-instance `fetchedRef` then stops the sidebar refilling what was deleted.
|
||||
*
|
||||
* So a second consumer that merely does not *want* MRs can blank the sidebar's
|
||||
* badges. `/tasks` was exactly that consumer: it gated its call on
|
||||
* `tasksListShowDetails`, which is off by default.
|
||||
*
|
||||
* A full page load hides this — both consumers mount in one commit, the reset
|
||||
* runs synchronously before the sidebar's fetch resolves, and the late response
|
||||
* repopulates. Only a client-side navigation, with the sidebar already hydrated,
|
||||
* exposes it. Hence the command palette below rather than `page.goto`.
|
||||
*/
|
||||
|
||||
let nextIID = 700;
|
||||
|
||||
function nextMRIID(): number {
|
||||
nextIID += 1;
|
||||
return nextIID;
|
||||
}
|
||||
|
||||
async function seedAndLinkMR(apiClient: ApiClient, seedData: SeedData, taskId: string) {
|
||||
const iid = nextMRIID();
|
||||
const now = new Date().toISOString();
|
||||
await apiClient.mockGitLabAddMRs(seedData.workspaceId, GITLAB_PROJECT, [
|
||||
{
|
||||
iid,
|
||||
id: iid + 10_000,
|
||||
project_id: 101,
|
||||
title: `Sidebar cache fixture MR ${iid}`,
|
||||
url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
web_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
state: "open",
|
||||
head_branch: `feature/cache-${iid}`,
|
||||
head_sha: `sha-${iid}`,
|
||||
base_branch: "main",
|
||||
author_username: "contributor",
|
||||
project_namespace: "platform",
|
||||
project_path: GITLAB_PROJECT,
|
||||
body: "",
|
||||
draft: false,
|
||||
merge_status: "can_be_merged",
|
||||
has_conflicts: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
reviewers: [],
|
||||
assignees: [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
]);
|
||||
await apiClient.linkTaskGitLabMR(seedData.workspaceId, {
|
||||
task_id: taskId,
|
||||
repository_id: seedData.repositoryId,
|
||||
mr_url: `${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${iid}`,
|
||||
});
|
||||
return iid;
|
||||
}
|
||||
|
||||
async function configureGitLab(apiClient: ApiClient, seedData: SeedData) {
|
||||
await apiClient.configureGitLab(seedData.workspaceId, GITLAB_HOST);
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
}
|
||||
|
||||
// No agent: an auto-started session's on_turn_complete would move the task
|
||||
// mid-test (see mr-task-card-badge.spec.ts for the full rationale).
|
||||
async function seedTask(apiClient: ApiClient, seedData: SeedData, title: string) {
|
||||
return apiClient.createTask(seedData.workspaceId, title, {
|
||||
description: "MR badge fixture task",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("GitLab MR badge on the sidebar and the /tasks list", () => {
|
||||
test("the sidebar's MR badge survives navigating into /tasks with task details off", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// tasksListShowDetails is deliberately left at its default (off) — that is
|
||||
// the configuration the bug needed, so setting it here would test nothing.
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
enable_preview_on_click: false,
|
||||
});
|
||||
await configureGitLab(apiClient, seedData);
|
||||
|
||||
const task = await seedTask(apiClient, seedData, "Sidebar MR cache task");
|
||||
await seedAndLinkMR(apiClient, seedData, task.id);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await expect(kanban.taskCard(task.id)).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
// The sidebar row's badge, not the board card's — the board has its own
|
||||
// useWorkspaceMRs via kanban-board.tsx and would recover on its own.
|
||||
const sidebarBadge = testPage.getByTestId("app-sidebar").getByTestId(`mr-task-icon-${task.id}`);
|
||||
await expect(sidebarBadge).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Client-side navigation through the app's own command, so the sidebar
|
||||
// instance (and its already-satisfied fetchedRef) stays mounted.
|
||||
await testPage.keyboard.press("ControlOrMeta+k");
|
||||
await testPage.getByRole("option", { name: "Switch to List View" }).click();
|
||||
await expect(testPage).toHaveURL(/\/tasks/);
|
||||
await expect(testPage.getByTestId("tasks-list")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The regression: TasksPageClient mounting must not empty the slice the
|
||||
// sidebar is still rendering from. Given a few seconds so a wipe that is
|
||||
// merely slow still fails the assertion rather than racing past it.
|
||||
await expect(sidebarBadge).toBeVisible({ timeout: 10_000 });
|
||||
await expect(sidebarBadge).toHaveAttribute("data-mr-count", "1");
|
||||
});
|
||||
|
||||
test("AC11: the /tasks rich list row renders the MR badge and its structured hover", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// The badge only renders on this row in the rich ("show details") mode —
|
||||
// PrimaryTaskLine is given showContributions={false} otherwise.
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
enable_preview_on_click: false,
|
||||
tasks_list_show_details: true,
|
||||
});
|
||||
await configureGitLab(apiClient, seedData);
|
||||
|
||||
const task = await seedTask(apiClient, seedData, "Tasks list MR badge task");
|
||||
const iid = await seedAndLinkMR(apiClient, seedData, task.id);
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const row = testPage
|
||||
.getByTestId("tasks-list")
|
||||
.getByTestId("tasks-list-row")
|
||||
.filter({ hasText: "Tasks list MR badge task" })
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
// AC11: the badge reaches this surface at all. It needs the page's own
|
||||
// useWorkspaceMRs to have hydrated the slice, so this also guards the
|
||||
// fetch that the sidebar-cache fix above un-gated.
|
||||
const badge = row.getByTestId(`mr-task-icon-${task.id}`);
|
||||
await expect(badge).toBeVisible({ timeout: 30_000 });
|
||||
await expect(badge).toHaveAttribute("data-mr-count", "1");
|
||||
|
||||
// AC1/AC24: the same structured summary the board card gets, not a
|
||||
// pipe-delimited string.
|
||||
await badge.hover();
|
||||
const summary = testPage
|
||||
.locator(
|
||||
'[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="mr-task-status-summary"]',
|
||||
)
|
||||
.first();
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary.getByTestId("mr-task-status-iid")).toHaveText(`MR !${iid}`);
|
||||
await expect(summary).not.toContainText("|");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,18 @@ import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { GITLAB_HOST, GITLAB_PROJECT } from "../../helpers/gitlab";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// Mirrors visibleTaskPRSummary in e2e/tests/pr/pr-status-badge.spec.ts:
|
||||
// scoped to the visible (non-closed) tooltip portal so a leftover
|
||||
// force-mounted measurement copy never matches.
|
||||
function visibleMRTaskStatusSummary(page: Page) {
|
||||
return page
|
||||
.locator(
|
||||
'[data-slot="tooltip-content"]:not([data-state="closed"]) > [data-testid="mr-task-status-summary"]',
|
||||
)
|
||||
.first();
|
||||
}
|
||||
|
||||
let nextIID = 400;
|
||||
|
||||
@@ -229,8 +241,73 @@ test.describe("GitLab MR badge on the Kanban card", () => {
|
||||
});
|
||||
expect(order).toBe("pr-then-mr");
|
||||
|
||||
// AC37: state is never colour-only — the tooltip names it explicitly.
|
||||
// AC37: state is never colour-only — the structured summary names it
|
||||
// explicitly via a State row (AC4/AC24: "Merged", not the raw enum value).
|
||||
await mrIcon.hover();
|
||||
await expect(testPage.getByRole("tooltip")).toContainText("merged");
|
||||
const summary = visibleMRTaskStatusSummary(testPage);
|
||||
await expect(summary.getByTestId("mr-task-status-state")).toContainText("Merged");
|
||||
});
|
||||
|
||||
test("AC1/AC7/AC20/AC24: hovering and focusing the badge render the structured MR summary, and Escape dismisses it", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await ensureGitLabConfigured(apiClient, seedData);
|
||||
const iid = nextMRIID();
|
||||
const mrTitle = "Make the MR badge read like the GitHub one";
|
||||
await seedMR(apiClient, seedData.workspaceId, iid, { title: mrTitle, state: "open" });
|
||||
await apiClient.mockGitLabAddPipelines(seedData.workspaceId, GITLAB_PROJECT, [
|
||||
{
|
||||
id: iid + 50_000,
|
||||
iid: 1,
|
||||
status: "success",
|
||||
source: "push",
|
||||
ref: `feature/badge-${iid}`,
|
||||
sha: `sha-${iid}`,
|
||||
web_url: "",
|
||||
jobs_total: 2,
|
||||
jobs_passing: 2,
|
||||
},
|
||||
]);
|
||||
const task = await seedBoardTask(apiClient, seedData, "Structured MR summary task");
|
||||
await linkMR(apiClient, seedData, task.id, iid);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCard(task.id);
|
||||
await expect(card).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
const icon = card.getByTestId(`mr-task-icon-${task.id}`);
|
||||
await expect(icon).toBeVisible({ timeout: 15_000 });
|
||||
await expect(icon).toHaveAttribute("role", "img");
|
||||
|
||||
await icon.hover();
|
||||
const summary = visibleMRTaskStatusSummary(testPage);
|
||||
await expect(summary).toBeVisible();
|
||||
await expect(summary.getByTestId("mr-task-status-iid")).toHaveText(`MR !${iid}`);
|
||||
await expect(summary.getByTestId("mr-task-status-title")).toHaveText(mrTitle);
|
||||
const ciRow = summary.getByTestId("mr-task-status-ci");
|
||||
await expect(ciRow).toContainText("CI");
|
||||
await expect(ciRow).toContainText("Passed");
|
||||
// No pipe-delimited string anywhere in the summary (AC1).
|
||||
await expect(summary).not.toContainText("|");
|
||||
|
||||
// AC20: no document-level horizontal overflow while the summary is open.
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// AC7: keyboard focus opens the same summary, and Escape dismisses it.
|
||||
await testPage.mouse.move(0, 0);
|
||||
await icon.focus();
|
||||
await expect(icon).toBeFocused();
|
||||
const focusedSummary = visibleMRTaskStatusSummary(testPage);
|
||||
await expect(focusedSummary).toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(focusedSummary).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
|
||||
test.describe("mobile task title hover on the Kanban card", () => {
|
||||
test("AC18/AC20: no hover card mounts on a coarse pointer, and tapping the title navigates as before", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
enable_preview_on_click: false,
|
||||
});
|
||||
// No agent, deliberately — see the desktop title-hover spec's rationale.
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Mobile title hover parent", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const childTitle = "Mobile child title that stays available in the task switcher";
|
||||
await apiClient.createTask(seedData.workspaceId, childTitle, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: task.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCard(task.id);
|
||||
await expect(card).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
const title = card.getByTestId("task-card-title");
|
||||
await expect(testPage.getByTestId("task-title-hover-card")).toHaveCount(0);
|
||||
|
||||
// Tapping the title performs the surface's existing navigation (opening
|
||||
// the task), not a hover disclosure — there is no separate touch target.
|
||||
await title.tap();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`));
|
||||
await expect(testPage.getByTestId("task-title-hover-card")).toHaveCount(0);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
const taskSwitcher = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
await expect(taskSwitcher.getByText(childTitle)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile Kanban board after tapping title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
/**
|
||||
* Seeds a parent task with two subtasks. No agent is started (mirrors the
|
||||
* MR/PR badge specs' rationale): an auto-started session's on_turn_complete
|
||||
* would move a card mid-test and detach the hover target from under the
|
||||
* assertion.
|
||||
*/
|
||||
async function seedParentWithSubtasks(apiClient: ApiClient, seedData: SeedData, title: string) {
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
enable_preview_on_click: false,
|
||||
});
|
||||
const parent = await apiClient.createTask(seedData.workspaceId, title, {
|
||||
description: "Title hover fixture parent",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const childOne = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"First subtask with a long enough title to wrap",
|
||||
{
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: parent.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
},
|
||||
);
|
||||
const childTwo = await apiClient.createTask(seedData.workspaceId, "Second subtask", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: parent.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
});
|
||||
return { parent, childOne, childTwo };
|
||||
}
|
||||
|
||||
test.describe("Task title hover card on the Kanban card", () => {
|
||||
test("AC12/AC13/AC14: hovering the title shows the full bold title and subtask rows, and clicking a row navigates", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const longTitle = "A parent task title long enough to truncate on card";
|
||||
const { parent, childOne, childTwo } = await seedParentWithSubtasks(
|
||||
apiClient,
|
||||
seedData,
|
||||
longTitle,
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCard(parent.id);
|
||||
await expect(card).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
const title = card.getByTestId("task-card-title");
|
||||
await title.hover();
|
||||
|
||||
const hoverCard = testPage.getByTestId("task-title-hover-card");
|
||||
await expect(hoverCard).toBeVisible();
|
||||
// AC12: the full, untruncated title renders (the card's own <p> is
|
||||
// line-clamped, so this line only passes if the hover card itself
|
||||
// carries the un-clamped copy).
|
||||
await expect(hoverCard).toContainText(longTitle);
|
||||
|
||||
const rowOne = hoverCard.getByTestId(`task-subtask-row-${childOne.id}`);
|
||||
const rowTwo = hoverCard.getByTestId(`task-subtask-row-${childTwo.id}`);
|
||||
await expect(rowOne).toBeVisible();
|
||||
await expect(rowTwo).toBeVisible();
|
||||
await expect(rowOne).toContainText("First subtask");
|
||||
await expect(rowTwo).toContainText("Second subtask");
|
||||
|
||||
// AC20: no document-level horizontal overflow while the hover card is open.
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// AC14: clicking a subtask row navigates without triggering the parent
|
||||
// card's own click handler (which would open the parent task instead).
|
||||
await rowOne.click();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${childOne.id}`));
|
||||
});
|
||||
|
||||
test("AC15: a task with no subtasks still opens the hover card with only the bold title", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Childless title hover task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCard(task.id);
|
||||
await expect(card).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
await card.getByTestId("task-card-title").hover();
|
||||
const hoverCard = testPage.getByTestId("task-title-hover-card");
|
||||
await expect(hoverCard).toBeVisible();
|
||||
await expect(hoverCard).toContainText("Childless title hover task");
|
||||
await expect(hoverCard.getByTestId("task-title-hover-subtasks")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the open hover card does not swallow the click that opens the parent task", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// The card is an interactive HoverCard, not a pointer-events-none tooltip,
|
||||
// so it really does sit over the board once open. Opening the parent task
|
||||
// by clicking its card is the board's primary action and must survive.
|
||||
const { parent } = await seedParentWithSubtasks(apiClient, seedData, "Clickable parent card");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCard(parent.id);
|
||||
await expect(card).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
const title = card.getByTestId("task-card-title");
|
||||
await title.hover();
|
||||
await expect(testPage.getByTestId("task-title-hover-card")).toBeVisible();
|
||||
|
||||
await title.click();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${parent.id}`));
|
||||
});
|
||||
});
|
||||
@@ -313,7 +313,10 @@ test.describe("saved Dockview layouts", () => {
|
||||
await expect(testPage.getByText("parent session")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const sidebar = testPage.getByTestId("app-sidebar");
|
||||
await sidebar.getByRole("button", { name: /Shared environment layout child/ }).click();
|
||||
// Not getByRole("button", {name}): the row's title now also renders its
|
||||
// own nested button (the keyboard-operable title-preview trigger) with
|
||||
// the same accessible name, so a name-based query is ambiguous.
|
||||
await sidebar.locator(`[data-task-row-id="${child.id}"]`).click();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${child.id}(?:\\?|$)`), { timeout: 10_000 });
|
||||
|
||||
await expect
|
||||
|
||||
@@ -435,6 +435,9 @@ test.describe("PR status badge", () => {
|
||||
).toBe(true);
|
||||
|
||||
await taskRow.focus();
|
||||
// Tab once lands on the row's title-preview trigger (now keyboard-operable,
|
||||
// see task-title-hover-card.tsx), tab again reaches the PR badge.
|
||||
await testPage.keyboard.press("Tab");
|
||||
await testPage.keyboard.press("Tab");
|
||||
await expect(icon).toBeFocused();
|
||||
const focusedSummary = visibleTaskPRSummary(testPage);
|
||||
|
||||
@@ -4,9 +4,12 @@ import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
const WORKSPACE_SOURCES_PATH = /^\/api\/v1\/tasks\/[^/]+\/workspace-sources$/;
|
||||
|
||||
async function chooseDirectory(
|
||||
page: Page,
|
||||
trigger: Locator,
|
||||
@@ -48,6 +51,15 @@ function activeFileTab(page: Page, filename: string) {
|
||||
return page.locator(".dv-tab.dv-active-tab", { hasText: filename }).first();
|
||||
}
|
||||
|
||||
async function submitWorkspaceSources(page: Page, submit: Locator) {
|
||||
const responsePromise = waitForHttp(page, "POST", WORKSPACE_SOURCES_PATH);
|
||||
|
||||
await submit.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBe(true);
|
||||
await response.finished();
|
||||
}
|
||||
|
||||
async function waitForWorkspaceReady(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
@@ -180,8 +192,8 @@ test.describe("Attach local workspace sources", () => {
|
||||
repositoryPath,
|
||||
);
|
||||
await repositoryRow.getByRole("textbox", { name: "Base branch" }).fill("main");
|
||||
await submit.click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 30_000 });
|
||||
await submitWorkspaceSources(testPage, submit);
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(
|
||||
session.files
|
||||
.getByTestId("file-tree-node")
|
||||
@@ -205,8 +217,8 @@ test.describe("Attach local workspace sources", () => {
|
||||
await prCapture.screenshot("workspace-actions-mixed-sources", {
|
||||
caption: "Desktop Add to workspace dialog with a local folder configured",
|
||||
});
|
||||
await submit.click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 30_000 });
|
||||
await submitWorkspaceSources(testPage, submit);
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }),
|
||||
|
||||
@@ -14,8 +14,8 @@ import { dwell } from "../../helpers/causal-waits";
|
||||
// Inline rename lives in file-context-menu.tsx (useFileRename + TreeNodeName).
|
||||
// Entry points (today, in product code):
|
||||
// - Right-click -> "Rename" menu item
|
||||
// - The input is given focus 150ms after isRenaming=true and blur-commit is
|
||||
// gated by a 400ms ref so the initial focus race doesn't fire onBlur.
|
||||
// - The input is focused immediately after isRenaming=true, while blur-commit is
|
||||
// gated by a 400ms ref so the initial focus handoff does not fire onBlur.
|
||||
// Commit on Enter, cancel on Escape, commit on blur.
|
||||
// We test the user-visible flow only (no direct DOM hacks), so the 400ms
|
||||
// blur gate is exercised implicitly.
|
||||
@@ -48,9 +48,7 @@ async function startRenameViaContextMenu(testPage: Page, node: ReturnType<Page["
|
||||
// the row is the only one with this className combo; locate via role.
|
||||
const input = node.getByRole("textbox");
|
||||
await expect(input).toBeVisible({ timeout: 2_000 });
|
||||
// The rename input is focused in an effect after the row switches into
|
||||
// edit mode; allow a little extra time when the browser is under CI load.
|
||||
await expect(input).toBeFocused({ timeout: 5_000 });
|
||||
await expect(input).toBeFocused();
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
|
||||
/**
|
||||
* The kanban card is covered by tests/kanban/task-title-hover-subtasks.spec.ts.
|
||||
* The title hover also ships on the two other surfaces named in the plan (the
|
||||
* /tasks rich list row and the AppSidebar task row), and those read their
|
||||
* subtasks from the same store slices the kanban board fills. Whether those
|
||||
* slices are populated on a page that never mounts the board is exactly the
|
||||
* kind of thing a unit test with a hand-seeded store cannot answer, so it is
|
||||
* asserted here against a real backend.
|
||||
*/
|
||||
// Backend caps task titles at 60 characters, so these sit just under it.
|
||||
const PARENT_TITLE = "Surface hover parent with a title long enough to clamp";
|
||||
const CHILD_ONE_TITLE = "Surface child one with its own long title";
|
||||
const CHILD_TWO_TITLE = "Surface child two";
|
||||
|
||||
async function seedParentWithSubtasks(apiClient: ApiClient, seedData: SeedData) {
|
||||
await apiClient.saveUserSettings({
|
||||
workspace_id: seedData.workspaceId,
|
||||
enable_preview_on_click: false,
|
||||
});
|
||||
// No agent is started: an auto-started session's on_turn_complete would move
|
||||
// the task mid-test and detach the hover target from under the assertion.
|
||||
const parent = await apiClient.createTask(seedData.workspaceId, PARENT_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
});
|
||||
const childOne = await apiClient.createTask(seedData.workspaceId, CHILD_ONE_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: parent.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
});
|
||||
const childTwo = await apiClient.createTask(seedData.workspaceId, CHILD_TWO_TITLE, {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
parent_id: parent.id,
|
||||
workspace_mode: "inherit_parent",
|
||||
});
|
||||
return { parent, childOne, childTwo };
|
||||
}
|
||||
|
||||
test.describe("Task title hover card on the non-Kanban surfaces", () => {
|
||||
test("AC12/AC13/AC14: the /tasks rich list row shows the full title and clickable subtask rows", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { parent, childOne } = await seedParentWithSubtasks(apiClient, seedData);
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const parentRow = testPage
|
||||
.getByTestId("tasks-list")
|
||||
.getByTestId("tasks-list-row")
|
||||
.filter({ hasText: PARENT_TITLE })
|
||||
.first();
|
||||
await expect(parentRow).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
await parentRow.getByTestId("tasks-list-row-title").hover();
|
||||
|
||||
const hoverCard = testPage.getByTestId("task-title-hover-card");
|
||||
await expect(hoverCard).toBeVisible();
|
||||
await expect(hoverCard).toContainText(PARENT_TITLE);
|
||||
|
||||
// AC13: the subtask rows are the point of the card. On this page the task
|
||||
// list is local component state, so an empty list here would mean the
|
||||
// hover silently degrades to a title-only tooltip.
|
||||
const rowOne = hoverCard.getByTestId(`task-subtask-row-${childOne.id}`);
|
||||
await expect(rowOne).toBeVisible();
|
||||
await expect(rowOne).toContainText(CHILD_ONE_TITLE);
|
||||
await expect(hoverCard).toContainText(CHILD_TWO_TITLE);
|
||||
|
||||
// AC20: opening the card must not push the document sideways.
|
||||
expect(
|
||||
await testPage.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// AC14: the row navigates to the subtask, not to the parent it sits on.
|
||||
await rowOne.click();
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${childOne.id}`));
|
||||
expect(testPage.url()).not.toContain(parent.id);
|
||||
});
|
||||
|
||||
test("AC16: an archived subtask drops out of the hover card, the live one stays", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { childOne, childTwo } = await seedParentWithSubtasks(apiClient, seedData);
|
||||
await apiClient.archiveTask(childTwo.id);
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const parentRow = testPage
|
||||
.getByTestId("tasks-list")
|
||||
.getByTestId("tasks-list-row")
|
||||
.filter({ hasText: PARENT_TITLE })
|
||||
.first();
|
||||
await expect(parentRow).toBeVisible({ timeout: 45_000 });
|
||||
await parentRow.getByTestId("tasks-list-row-title").hover();
|
||||
|
||||
const hoverCard = testPage.getByTestId("task-title-hover-card");
|
||||
await expect(hoverCard.getByTestId(`task-subtask-row-${childOne.id}`)).toBeVisible();
|
||||
// The archived child must not keep advertising itself from the parent's card.
|
||||
await expect(hoverCard.getByTestId(`task-subtask-row-${childTwo.id}`)).toHaveCount(0);
|
||||
await expect(hoverCard).not.toContainText(CHILD_TWO_TITLE);
|
||||
});
|
||||
|
||||
test("keyboard activation opens a child task without opening its parent", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { parent, childOne } = await seedParentWithSubtasks(apiClient, seedData);
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const parentRow = testPage
|
||||
.getByTestId("tasks-list")
|
||||
.getByTestId("tasks-list-row")
|
||||
.filter({ hasText: PARENT_TITLE });
|
||||
await expect(parentRow).toHaveCount(1, { timeout: 45_000 });
|
||||
|
||||
const trigger = parentRow.getByTestId("task-title-preview-trigger");
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
|
||||
const hoverCard = testPage.getByTestId("task-title-hover-card");
|
||||
const childRow = hoverCard.getByTestId(`task-subtask-row-${childOne.id}`);
|
||||
await expect(hoverCard).toBeVisible();
|
||||
await expect(childRow).toBeFocused();
|
||||
|
||||
await childRow.press("Enter");
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${childOne.id}`));
|
||||
expect(testPage.url()).not.toContain(parent.id);
|
||||
});
|
||||
|
||||
test("AC12/AC13: the AppSidebar task row shows the full title and its subtask rows", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { childOne } = await seedParentWithSubtasks(apiClient, seedData);
|
||||
|
||||
await testPage.goto("/tasks");
|
||||
await testPage.waitForLoadState("networkidle");
|
||||
|
||||
const sidebarRow = testPage
|
||||
.getByTestId("app-sidebar")
|
||||
.getByTestId("sidebar-task-item")
|
||||
.filter({ hasText: PARENT_TITLE })
|
||||
.first();
|
||||
await expect(sidebarRow).toBeVisible({ timeout: 45_000 });
|
||||
|
||||
await sidebarRow.getByText(PARENT_TITLE, { exact: false }).first().hover();
|
||||
|
||||
const hoverCard = testPage.getByTestId("task-title-hover-card");
|
||||
await expect(hoverCard).toBeVisible();
|
||||
await expect(hoverCard).toContainText(PARENT_TITLE);
|
||||
await expect(hoverCard.getByTestId(`task-subtask-row-${childOne.id}`)).toBeVisible();
|
||||
await expect(hoverCard).toContainText(CHILD_TWO_TITLE);
|
||||
});
|
||||
});
|
||||
@@ -1422,6 +1422,7 @@ export const i18nGuardFiles = [
|
||||
"components/github/pr-status-chip.tsx",
|
||||
"components/github/pr-topbar-button.tsx",
|
||||
"components/gitlab/mr-commits-section.tsx",
|
||||
"components/gitlab/mr-task-status-summary.tsx",
|
||||
"components/gitlab/mr-detail-panel.tsx",
|
||||
"components/gitlab/mr-discussions-section.tsx",
|
||||
"components/gitlab/mr-files-section.tsx",
|
||||
@@ -1584,6 +1585,9 @@ export const i18nGuardFiles = [
|
||||
"components/kanban-board.tsx",
|
||||
"components/kanban-card-content.tsx",
|
||||
"components/kanban-card-menu-items.tsx",
|
||||
// Extracted out of kanban-card-content.tsx, which is guarded: without its own
|
||||
// entry the card title markup would silently leave whole-file guard coverage.
|
||||
"components/kanban-card-title.tsx",
|
||||
"components/kanban-column.tsx",
|
||||
"components/kanban-display-dropdown.tsx",
|
||||
"components/kanban-with-preview.tsx",
|
||||
|
||||
@@ -89,6 +89,29 @@ function workspaceProbe(workspaceId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Simulates AppSidebar and /tasks mounting separate hook instances for the
|
||||
// same workspace. Both instances share one store and one active request.
|
||||
function DualWorkspaceMRInstance() {
|
||||
useWorkspaceMRs("ws-1");
|
||||
return null;
|
||||
}
|
||||
function DualWorkspaceMRHarness({ mountSecond }: { mountSecond: boolean }) {
|
||||
const mrs = useAppStore((state) => state.taskMRs.byWorkspaceId["ws-1"] ?? EMPTY_TASK_MRS);
|
||||
return createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement(DualWorkspaceMRInstance, { key: "first" }),
|
||||
mountSecond ? createElement(DualWorkspaceMRInstance, { key: "second" }) : null,
|
||||
createElement("output", { "data-testid": WORKSPACE_MRS_TEST_ID }, JSON.stringify(mrs)),
|
||||
);
|
||||
}
|
||||
function dualWorkspaceMRTree(mountSecond: boolean) {
|
||||
return createElement(StateProvider, {
|
||||
initialState: { workspaces: { items: [], activeId: "ws-1" } },
|
||||
children: createElement(DualWorkspaceMRHarness, { mountSecond }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("useWorkspaceMRs", () => {
|
||||
beforeEach(() => {
|
||||
listWorkspaceTaskMRsMock.mockReset();
|
||||
@@ -209,6 +232,69 @@ describe("useWorkspaceMRs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceMRs cache failures", () => {
|
||||
beforeEach(() => {
|
||||
listWorkspaceTaskMRsMock.mockReset();
|
||||
});
|
||||
|
||||
it("removes cached workspace data when its refresh fails", async () => {
|
||||
const staleMR = makeMR({ id: "stale", task_id: "task-1" });
|
||||
listWorkspaceTaskMRsMock.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
useWorkspaceMRs("ws-1");
|
||||
return useAppStore((state) => state.taskMRs.byWorkspaceId["ws-1"] ?? EMPTY_TASK_MRS);
|
||||
},
|
||||
{
|
||||
wrapper: ({ children }) =>
|
||||
createElement(StateProvider, {
|
||||
initialState: {
|
||||
workspaces: { items: [], activeId: "ws-1" },
|
||||
taskMRs: { byWorkspaceId: { "ws-1": { "task-1": [staleMR] } } },
|
||||
},
|
||||
children,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current["task-1"]).toEqual([staleMR]);
|
||||
await waitFor(() => expect(result.current).toEqual({}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceMRs, co-mounted instances", () => {
|
||||
beforeEach(() => {
|
||||
listWorkspaceTaskMRsMock.mockReset();
|
||||
});
|
||||
|
||||
it("shares an in-flight request but permits a later refresh", async () => {
|
||||
const mr = makeMR({ task_id: "task-1" });
|
||||
let resolveFirst: (v: { task_mrs: Record<string, TaskMR[]> }) => void = () => {};
|
||||
const firstPromise = new Promise<{ task_mrs: Record<string, TaskMR[]> }>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
listWorkspaceTaskMRsMock.mockReturnValueOnce(firstPromise);
|
||||
|
||||
const view = render(dualWorkspaceMRTree(true));
|
||||
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst({ task_mrs: { "task-1": [mr] } });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId(WORKSPACE_MRS_TEST_ID).textContent).toEqual(
|
||||
JSON.stringify({ "task-1": [mr] }),
|
||||
),
|
||||
);
|
||||
|
||||
view.rerender(dualWorkspaceMRTree(false));
|
||||
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } });
|
||||
view.rerender(dualWorkspaceMRTree(true));
|
||||
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
// AC13: pins the existing full-clear behaviour that AC12 requires callers
|
||||
// (tasks-page-client.tsx) to route around by never invoking the hook with
|
||||
// null while a workspace is active. Kept to a single renderHook tree so
|
||||
|
||||
@@ -8,11 +8,28 @@ import { useToast } from "@/components/toast-provider";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { useGitLabStatus } from "./use-gitlab-status";
|
||||
|
||||
type WorkspaceMRResponse = Awaited<ReturnType<typeof listWorkspaceTaskMRs>>;
|
||||
const workspaceMRRequests = new Map<string, Promise<WorkspaceMRResponse>>();
|
||||
|
||||
function acquireWorkspaceMRRequest(workspaceId: string) {
|
||||
const activeRequest = workspaceMRRequests.get(workspaceId);
|
||||
if (activeRequest) return activeRequest;
|
||||
|
||||
const request = listWorkspaceTaskMRs(workspaceId, { cache: "no-store" });
|
||||
workspaceMRRequests.set(workspaceId, request);
|
||||
const release = () => {
|
||||
if (workspaceMRRequests.get(workspaceId) === request) {
|
||||
workspaceMRRequests.delete(workspaceId);
|
||||
}
|
||||
};
|
||||
void request.then(release, release);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the gitlab task-MRs slice for a workspace. Fetches once per
|
||||
* workspaceId switch and clears the cache on null. Mirrors useWorkspacePRs
|
||||
* for GitHub but stays minimal (no WS subscription yet — that lands with
|
||||
* the poller in a follow-up phase).
|
||||
* Hydrate the GitLab task-MRs slice for a workspace. Co-mounted consumers
|
||||
* share one in-flight request. A later mount can start a fresh request. The
|
||||
* hook clears the cache when the workspace becomes null or a refresh fails.
|
||||
*/
|
||||
export function useWorkspaceMRs(workspaceId: string | null) {
|
||||
const setTaskMRs = useAppStore((state) => state.setTaskMRs);
|
||||
@@ -33,14 +50,17 @@ export function useWorkspaceMRs(workspaceId: string | null) {
|
||||
if (fetchedRef.current === workspaceId) return;
|
||||
const requestId = ++requestRef.current;
|
||||
fetchedRef.current = workspaceId;
|
||||
resetTaskMRs(workspaceId);
|
||||
listWorkspaceTaskMRs(workspaceId, { cache: "no-store" })
|
||||
const request = acquireWorkspaceMRRequest(workspaceId);
|
||||
// Keep the current data visible until the shared request succeeds. A
|
||||
// failure clears that workspace below, so stale data cannot remain.
|
||||
request
|
||||
.then((response) => {
|
||||
if (requestRef.current !== requestId) return;
|
||||
setTaskMRs(workspaceId, response?.task_mrs ?? {});
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestRef.current === requestId) {
|
||||
resetTaskMRs(workspaceId);
|
||||
fetchedRef.current = null; // allow retry on failure
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import { useTaskSubtasks } from "./use-task-subtasks";
|
||||
|
||||
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 renderWithState(initialState: Parameters<typeof StateProvider>[0]["initialState"]) {
|
||||
return renderHook(() => useTaskSubtasks("parent-1"), {
|
||||
wrapper: ({ children }) => (
|
||||
<StateProvider initialState={initialState}>{children}</StateProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe("useTaskSubtasks", () => {
|
||||
it("returns only direct children of the given task", () => {
|
||||
const { result } = renderWithState({
|
||||
kanban: {
|
||||
workflowId: "wf-1",
|
||||
steps: [],
|
||||
tasks: [
|
||||
makeTask({ id: "parent-1", title: "Parent" }),
|
||||
makeTask({ id: "child-1", title: "Child 1", parentTaskId: "parent-1", position: 1 }),
|
||||
makeTask({ id: "child-2", title: "Child 2", parentTaskId: "parent-1", position: 2 }),
|
||||
makeTask({ id: "other", title: "Unrelated", parentTaskId: "other-parent" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.current.map((t) => t.id)).toEqual(["child-1", "child-2"]);
|
||||
});
|
||||
|
||||
it("excludes grandchildren", () => {
|
||||
const { result } = renderWithState({
|
||||
kanban: {
|
||||
workflowId: "wf-1",
|
||||
steps: [],
|
||||
tasks: [
|
||||
makeTask({ id: "parent-1", title: "Parent" }),
|
||||
makeTask({ id: "child-1", title: "Child", parentTaskId: "parent-1", position: 1 }),
|
||||
makeTask({
|
||||
id: "grandchild-1",
|
||||
title: "Grandchild",
|
||||
parentTaskId: "child-1",
|
||||
position: 1,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.current.map((t) => t.id)).toEqual(["child-1"]);
|
||||
});
|
||||
|
||||
it("includes cross-workflow children from kanbanMulti snapshots, de-duplicated against the active board", () => {
|
||||
const { result } = renderWithState({
|
||||
kanban: {
|
||||
workflowId: "wf-1",
|
||||
steps: [],
|
||||
tasks: [
|
||||
makeTask({ id: "parent-1", title: "Parent" }),
|
||||
makeTask({ id: "child-1", title: "Child 1", parentTaskId: "parent-1", position: 1 }),
|
||||
],
|
||||
},
|
||||
kanbanMulti: {
|
||||
isLoading: false,
|
||||
snapshots: {
|
||||
"wf-2": {
|
||||
workflowId: "wf-2",
|
||||
workflowName: "Other workflow",
|
||||
steps: [],
|
||||
tasks: [
|
||||
// Duplicate of the active-board child-1: must not appear twice.
|
||||
makeTask({ id: "child-1", title: "Child 1", parentTaskId: "parent-1", position: 1 }),
|
||||
makeTask({
|
||||
id: "child-3",
|
||||
title: "Cross-workflow child",
|
||||
parentTaskId: "parent-1",
|
||||
position: 2,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.current.map((t) => t.id)).toEqual(["child-1", "child-3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskSubtasks — ordering and stability", () => {
|
||||
it("orders by position then createdAt", () => {
|
||||
const { result } = renderWithState({
|
||||
kanban: {
|
||||
workflowId: "wf-1",
|
||||
steps: [],
|
||||
tasks: [
|
||||
makeTask({ id: "parent-1", title: "Parent" }),
|
||||
makeTask({
|
||||
id: "b",
|
||||
title: "B",
|
||||
parentTaskId: "parent-1",
|
||||
position: 1,
|
||||
createdAt: "2026-01-02T00:00:00Z",
|
||||
}),
|
||||
makeTask({
|
||||
id: "a",
|
||||
title: "A",
|
||||
parentTaskId: "parent-1",
|
||||
position: 1,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.current.map((t) => t.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("returns a stable empty reference across renders when there are no subtasks", () => {
|
||||
const { result, rerender } = renderWithState({
|
||||
kanban: {
|
||||
workflowId: "wf-1",
|
||||
steps: [],
|
||||
tasks: [makeTask({ id: "parent-1", title: "Parent" })],
|
||||
},
|
||||
});
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current).toEqual([]);
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { KanbanState, KanbanMultiState } from "@/lib/state/slices/kanban/types";
|
||||
|
||||
export type TaskSubtask = Pick<
|
||||
KanbanState["tasks"][number],
|
||||
"id" | "title" | "state" | "position" | "createdAt"
|
||||
>;
|
||||
|
||||
// Stable empty reference so the hook's output stays referentially equal
|
||||
// across renders when a task has no subtasks — an unstable array from a
|
||||
// selector causes an infinite re-render loop (see hooks/domains/gitlab/use-task-mr.ts).
|
||||
const EMPTY_SUBTASKS: TaskSubtask[] = [];
|
||||
|
||||
function compareSubtasks(a: TaskSubtask, b: TaskSubtask): number {
|
||||
const positionDelta = (a.position ?? 0) - (b.position ?? 0);
|
||||
if (positionDelta !== 0) return positionDelta;
|
||||
return (a.createdAt ?? "").localeCompare(b.createdAt ?? "");
|
||||
}
|
||||
|
||||
function computeSubtasks(
|
||||
tasks: KanbanState["tasks"],
|
||||
snapshots: KanbanMultiState["snapshots"],
|
||||
taskId: string,
|
||||
): TaskSubtask[] {
|
||||
const seen = new Map<string, TaskSubtask>();
|
||||
for (const task of tasks) {
|
||||
if (task.parentTaskId === taskId) seen.set(task.id, task);
|
||||
}
|
||||
for (const snapshot of Object.values(snapshots)) {
|
||||
for (const task of snapshot.tasks) {
|
||||
if (task.parentTaskId === taskId && !seen.has(task.id)) seen.set(task.id, task);
|
||||
}
|
||||
}
|
||||
if (seen.size === 0) return EMPTY_SUBTASKS;
|
||||
return Array.from(seen.values()).sort(compareSubtasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct children of `taskId`, sourced from the active board (state.kanban)
|
||||
* plus every loaded cross-workflow snapshot (state.kanbanMulti), so a task
|
||||
* whose subtasks live on a different workflow's board still shows them.
|
||||
* Mirrors separateSubtasks (lib/sidebar/apply-view.ts) rather than inventing
|
||||
* a second grouping rule. De-duplicated by id, ordered by position then
|
||||
* createdAt.
|
||||
*
|
||||
* `tasks` and `snapshots` are read as their own selectors (rather than
|
||||
* filtering inside one selector) so the derived array is memoized against
|
||||
* their stable store references — filtering inline would return a fresh
|
||||
* array on every store update and re-render the caller unnecessarily.
|
||||
*/
|
||||
export function useTaskSubtasks(taskId: string | null): TaskSubtask[] {
|
||||
const tasks = useAppStore((state) => state.kanban.tasks);
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
return useMemo(
|
||||
() => (taskId ? computeSubtasks(tasks, snapshots, taskId) : EMPTY_SUBTASKS),
|
||||
[tasks, snapshots, taskId],
|
||||
);
|
||||
}
|
||||
@@ -326,10 +326,24 @@
|
||||
"mrPopoverUnresolvedComments_other": "{{count}} unresolved comments",
|
||||
"mrPopoverUpdatedJustNow": "updated just now",
|
||||
"mrPopoverUpdatedAgo": "updated {{elapsed}} ago",
|
||||
"mrTooltipState": "State: {{state}}",
|
||||
"mrTooltipReview": "Review: {{state}}",
|
||||
"mrTooltipPipeline": "Pipeline: {{state}}",
|
||||
"mrTooltipReadyToMerge": "Ready to merge",
|
||||
"mrTaskStatusState": "State",
|
||||
"mrTaskStatusReview": "Review",
|
||||
"mrTaskStatusCi": "CI",
|
||||
"mrTaskStatusMerge": "Merge",
|
||||
"mrTaskStatusIid": "MR !{{iid}}",
|
||||
"mergeRequestStatus": "Merge request !{{iid}} status",
|
||||
"mergeRequestStatuses_one": "{{count}} merge request status",
|
||||
"mergeRequestStatuses_other": "{{count}} merge request statuses",
|
||||
"merged": "Merged",
|
||||
"closed": "Closed",
|
||||
"approved": "Approved",
|
||||
"awaitingApproval": "Awaiting approval",
|
||||
"passed": "Passed",
|
||||
"failed": "Failed",
|
||||
"inProgress": "In progress",
|
||||
"readyToMerge": "Ready to merge",
|
||||
"mergeable": "Mergeable",
|
||||
"unresolvedDiscussions": "Unresolved discussions",
|
||||
"mrChipAriaLabelSingle": "Merge request !{{mriid}}, {{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} merge request, {{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} merge requests, {{status}}",
|
||||
|
||||
@@ -1420,6 +1420,8 @@
|
||||
"submitUnavailable": "Submit unavailable",
|
||||
"submitting": "Submitting…",
|
||||
"subtask": "Subtask",
|
||||
"subtasksHeading_one": "{{count}} subtask",
|
||||
"subtasksHeading_other": "{{count}} subtasks",
|
||||
"subtasksBecomeRootTasksUnlessYou": "Subtasks become root tasks unless you tick this. They may still be in progress.",
|
||||
"subtasksStayActiveUnlessYouTick": "Subtasks stay active unless you tick this. They may still be in progress.",
|
||||
"successful": "{{operationName}} successful",
|
||||
|
||||
@@ -326,10 +326,24 @@
|
||||
"mrPopoverUnresolvedComments_other": "{{count}} ũńŕēśōĺvēď ćōḿḿēńţś",
|
||||
"mrPopoverUpdatedJustNow": "ũƥďàţēď ĵũśţ ńōŵ",
|
||||
"mrPopoverUpdatedAgo": "ũƥďàţēď {{elapsed}} àĝō",
|
||||
"mrTooltipState": "Śţàţē: {{state}}",
|
||||
"mrTooltipReview": "Ŕēvĩēŵ: {{state}}",
|
||||
"mrTooltipPipeline": "Ƥĩƥēĺĩńē: {{state}}",
|
||||
"mrTooltipReadyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
|
||||
"mrTaskStatusState": "Śţàţē",
|
||||
"mrTaskStatusReview": "Ŕēvĩēŵ",
|
||||
"mrTaskStatusCi": "ĆĨ",
|
||||
"mrTaskStatusMerge": "Ḿēŕĝē",
|
||||
"mrTaskStatusIid": "ḾŔ !{{iid}}",
|
||||
"mergeRequestStatus": "Ḿēŕĝē ŕēqũēśţ !{{iid}} śţàţũś",
|
||||
"mergeRequestStatuses_one": "{{count}} ḿēŕĝē ŕēqũēśţ śţàţũś",
|
||||
"mergeRequestStatuses_other": "{{count}} ḿēŕĝē ŕēqũēśţ śţàţũśēś",
|
||||
"merged": "Ḿēŕĝēď",
|
||||
"closed": "Ćĺōśēď",
|
||||
"approved": "Àƥƥŕōvēď",
|
||||
"awaitingApproval": "Àŵàĩţĩńĝ àƥƥŕōvàĺ",
|
||||
"passed": "Ƥàśśēď",
|
||||
"failed": "Ƒàĩĺēď",
|
||||
"inProgress": "Ĩń ƥŕōĝŕēśś",
|
||||
"readyToMerge": "Ŕēàďŷ ţō ḿēŕĝē",
|
||||
"mergeable": "Ḿēŕĝēàƀĺē",
|
||||
"unresolvedDiscussions": "Ũńŕēśōĺvēď ďĩśćũśśĩōńś",
|
||||
"mrChipAriaLabelSingle": "Ḿēŕĝē ŕēqũēśţ !{{mriid}}, {{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} ḿēŕĝē ŕēqũēśţ, {{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} ḿēŕĝē ŕēqũēśţś, {{status}}",
|
||||
|
||||
@@ -1420,6 +1420,8 @@
|
||||
"submitUnavailable": "Śũƀḿĩţ ũńàvàĩĺàƀĺē",
|
||||
"submitting": "Śũƀḿĩţţĩńĝ…",
|
||||
"subtask": "Śũƀţàśķ",
|
||||
"subtasksHeading_one": "{{count}} śũƀţàśķ",
|
||||
"subtasksHeading_other": "{{count}} śũƀţàśķś",
|
||||
"subtasksBecomeRootTasksUnlessYou": "Śũƀţàśķś ƀēćōḿē ŕōōţ ţàśķś ũńĺēśś ŷōũ ţĩćķ ţĥĩś. Ţĥēŷ ḿàŷ śţĩĺĺ ƀē ĩń ƥŕōĝŕēśś.",
|
||||
"subtasksStayActiveUnlessYouTick": "Śũƀţàśķś śţàŷ àćţĩvē ũńĺēśś ŷōũ ţĩćķ ţĥĩś. Ţĥēŷ ḿàŷ śţĩĺĺ ƀē ĩń ƥŕōĝŕēśś.",
|
||||
"successful": "{{operationName}} śũććēśśƒũĺ",
|
||||
|
||||
@@ -326,10 +326,24 @@
|
||||
"mrPopoverUnresolvedComments_other": "{{count}} comentários por resolver",
|
||||
"mrPopoverUpdatedJustNow": "atualizado agora mesmo",
|
||||
"mrPopoverUpdatedAgo": "atualizado há {{elapsed}}",
|
||||
"mrTooltipState": "Estado: {{state}}",
|
||||
"mrTooltipReview": "Revisão: {{state}}",
|
||||
"mrTooltipPipeline": "Pipeline: {{state}}",
|
||||
"mrTooltipReadyToMerge": "Pronto para merge",
|
||||
"mrTaskStatusState": "Estado",
|
||||
"mrTaskStatusReview": "Revisão",
|
||||
"mrTaskStatusCi": "CI",
|
||||
"mrTaskStatusMerge": "Integração",
|
||||
"mrTaskStatusIid": "MR !{{iid}}",
|
||||
"mergeRequestStatus": "Estado do merge request !{{iid}}",
|
||||
"mergeRequestStatuses_one": "{{count}} estado de merge request",
|
||||
"mergeRequestStatuses_other": "{{count}} estados de merge request",
|
||||
"merged": "Integrado",
|
||||
"closed": "Fechado",
|
||||
"approved": "Aprovado",
|
||||
"awaitingApproval": "A aguardar aprovação",
|
||||
"passed": "Passou",
|
||||
"failed": "Falhou",
|
||||
"inProgress": "Em curso",
|
||||
"readyToMerge": "Pronto a integrar",
|
||||
"mergeable": "Integrável",
|
||||
"unresolvedDiscussions": "Discussões por resolver",
|
||||
"gitlabRejectedAssociation": "O GitLab rejeitou a associação.",
|
||||
"taskCreatedMrNotLinked": "Tarefa criada, mas o merge request não foi associado",
|
||||
"taskCreatedMrNotLinkedDescription": "Abra a tarefa e use Associar merge request do GitLab para tentar novamente. {{error}}",
|
||||
|
||||
@@ -1375,6 +1375,8 @@
|
||||
"submitUnavailable": "Submissão indisponível",
|
||||
"submitting": "A submeter…",
|
||||
"subtask": "Subtarefa",
|
||||
"subtasksHeading_one": "{{count}} subtarefa",
|
||||
"subtasksHeading_other": "{{count}} subtarefas",
|
||||
"subtasksBecomeRootTasksUnlessYou": "As subtarefas passam a tarefas de raiz se não assinalar esta opção. Podem ainda estar em curso.",
|
||||
"subtasksStayActiveUnlessYouTick": "As subtarefas mantêm-se ativas se não assinalar esta opção. Podem ainda estar em curso.",
|
||||
"successful": "{{operationName}} bem-sucedida",
|
||||
|
||||
@@ -326,10 +326,24 @@
|
||||
"mrPopoverUnresolvedComments_other": "{{count}} 条未解决的评论",
|
||||
"mrPopoverUpdatedJustNow": "刚刚更新",
|
||||
"mrPopoverUpdatedAgo": "{{elapsed}}前更新",
|
||||
"mrTooltipState": "状态:{{state}}",
|
||||
"mrTooltipReview": "审查:{{state}}",
|
||||
"mrTooltipPipeline": "流水线:{{state}}",
|
||||
"mrTooltipReadyToMerge": "可以合并",
|
||||
"mrTaskStatusState": "状态",
|
||||
"mrTaskStatusReview": "审阅",
|
||||
"mrTaskStatusCi": "CI",
|
||||
"mrTaskStatusMerge": "合并",
|
||||
"mrTaskStatusIid": "MR !{{iid}}",
|
||||
"mergeRequestStatus": "合并请求 !{{iid}} 状态",
|
||||
"mergeRequestStatuses_one": "{{count}} 个合并请求状态",
|
||||
"mergeRequestStatuses_other": "{{count}} 个合并请求状态",
|
||||
"merged": "已合并",
|
||||
"closed": "已关闭",
|
||||
"approved": "已批准",
|
||||
"awaitingApproval": "等待批准",
|
||||
"passed": "通过",
|
||||
"failed": "失败",
|
||||
"inProgress": "进行中",
|
||||
"readyToMerge": "可以合并",
|
||||
"mergeable": "可合并",
|
||||
"unresolvedDiscussions": "未解决的讨论",
|
||||
"mrChipAriaLabelSingle": "合并请求 !{{mriid}},{{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} 个合并请求,{{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} 个合并请求,{{status}}",
|
||||
|
||||
@@ -1374,6 +1374,8 @@
|
||||
"submitUnavailable": "无法提交",
|
||||
"submitting": "正在提交…",
|
||||
"subtask": "子任务",
|
||||
"subtasksHeading_one": "{{count}} 个子任务",
|
||||
"subtasksHeading_other": "{{count}} 个子任务",
|
||||
"subtasksBecomeRootTasksUnlessYou": "除非勾选此项,否则子任务将成为根任务。它们可能仍在进行中。",
|
||||
"subtasksStayActiveUnlessYouTick": "除非勾选此项,否则子任务保持活动。它们可能仍在进行中。",
|
||||
"successful": "{{operationName}} 成功",
|
||||
|
||||
@@ -326,10 +326,24 @@
|
||||
"mrPopoverUnresolvedComments_other": "{{count}} 條未解決的評論",
|
||||
"mrPopoverUpdatedJustNow": "剛剛更新",
|
||||
"mrPopoverUpdatedAgo": "{{elapsed}}前更新",
|
||||
"mrTooltipState": "狀態:{{state}}",
|
||||
"mrTooltipReview": "審查:{{state}}",
|
||||
"mrTooltipPipeline": "流水線:{{state}}",
|
||||
"mrTooltipReadyToMerge": "可以合併",
|
||||
"mrTaskStatusState": "狀態",
|
||||
"mrTaskStatusReview": "審閱",
|
||||
"mrTaskStatusCi": "CI",
|
||||
"mrTaskStatusMerge": "合併",
|
||||
"mrTaskStatusIid": "MR !{{iid}}",
|
||||
"mergeRequestStatus": "合併請求 !{{iid}} 狀態",
|
||||
"mergeRequestStatuses_one": "{{count}} 個合併請求狀態",
|
||||
"mergeRequestStatuses_other": "{{count}} 個合併請求狀態",
|
||||
"merged": "已合併",
|
||||
"closed": "已關閉",
|
||||
"approved": "已批准",
|
||||
"awaitingApproval": "等待批准",
|
||||
"passed": "通過",
|
||||
"failed": "失敗",
|
||||
"inProgress": "進行中",
|
||||
"readyToMerge": "可以合併",
|
||||
"mergeable": "可合併",
|
||||
"unresolvedDiscussions": "未解決的討論",
|
||||
"mrChipAriaLabelSingle": "合併請求 !{{mriid}},{{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} 個合併請求,{{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} 個合併請求,{{status}}",
|
||||
|
||||
@@ -1374,6 +1374,8 @@
|
||||
"submitUnavailable": "無法提交",
|
||||
"submitting": "正在提交…",
|
||||
"subtask": "子任務",
|
||||
"subtasksHeading_one": "{{count}} 個子任務",
|
||||
"subtasksHeading_other": "{{count}} 個子任務",
|
||||
"subtasksBecomeRootTasksUnlessYou": "除非勾選此項,否則子任務將成為根任務。它們可能仍在進行中。",
|
||||
"subtasksStayActiveUnlessYouTick": "除非勾選此項,否則子任務保持活動。它們可能仍在進行中。",
|
||||
"successful": "{{operationName}} 成功",
|
||||
|
||||
@@ -326,10 +326,24 @@
|
||||
"mrPopoverUnresolvedComments_other": "{{count}} 條未解決的評論",
|
||||
"mrPopoverUpdatedJustNow": "剛剛更新",
|
||||
"mrPopoverUpdatedAgo": "{{elapsed}}前更新",
|
||||
"mrTooltipState": "狀態:{{state}}",
|
||||
"mrTooltipReview": "審查:{{state}}",
|
||||
"mrTooltipPipeline": "流水線:{{state}}",
|
||||
"mrTooltipReadyToMerge": "可以合併",
|
||||
"mrTaskStatusState": "狀態",
|
||||
"mrTaskStatusReview": "審閱",
|
||||
"mrTaskStatusCi": "CI",
|
||||
"mrTaskStatusMerge": "合併",
|
||||
"mrTaskStatusIid": "MR !{{iid}}",
|
||||
"mergeRequestStatus": "合併請求 !{{iid}} 狀態",
|
||||
"mergeRequestStatuses_one": "{{count}} 個合併請求狀態",
|
||||
"mergeRequestStatuses_other": "{{count}} 個合併請求狀態",
|
||||
"merged": "已合併",
|
||||
"closed": "已關閉",
|
||||
"approved": "已批准",
|
||||
"awaitingApproval": "等待批准",
|
||||
"passed": "通過",
|
||||
"failed": "失敗",
|
||||
"inProgress": "進行中",
|
||||
"readyToMerge": "可以合併",
|
||||
"mergeable": "可合併",
|
||||
"unresolvedDiscussions": "未解決的討論",
|
||||
"mrChipAriaLabelSingle": "合併請求 !{{mriid}},{{status}}",
|
||||
"mrChipAriaLabelMulti_one": "{{count}} 個合併請求,{{status}}",
|
||||
"mrChipAriaLabelMulti_other": "{{count}} 個合併請求,{{status}}",
|
||||
|
||||
@@ -1374,6 +1374,8 @@
|
||||
"submitUnavailable": "無法提交",
|
||||
"submitting": "正在提交…",
|
||||
"subtask": "子任務",
|
||||
"subtasksHeading_one": "{{count}} 個子任務",
|
||||
"subtasksHeading_other": "{{count}} 個子任務",
|
||||
"subtasksBecomeRootTasksUnlessYou": "除非勾選此項,否則子任務將成為根任務。它們可能仍在進行中。",
|
||||
"subtasksStayActiveUnlessYouTick": "除非勾選此項,否則子任務保持活動。它們可能仍在進行中。",
|
||||
"successful": "{{operationName}} 成功",
|
||||
|
||||
@@ -6,6 +6,10 @@ status: draft
|
||||
|
||||
# Implementation Plan: GitLab MR Badge on the Sidebar and Tasks-List Rows
|
||||
|
||||
> **Amendment:** The later
|
||||
> [rich task title preview plan](../rich-task-title-previews/plan.md) extends
|
||||
> this plan. It owns rich badge content and shared hydration behavior.
|
||||
|
||||
## Overview
|
||||
|
||||
`MRTaskIcon` ships today with exactly one call site (the Kanban card). This plan
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
spec: docs/specs/tasks/rich-task-title-previews.md
|
||||
created: 2026-08-18
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation plan: Rich task title previews
|
||||
|
||||
## Overview
|
||||
|
||||
Add one reusable title preview to the Kanban card, sidebar task row, and rich
|
||||
task list. Reuse the existing task and contribution stores. Keep provider data
|
||||
derivation separate from the shared change-request presentation.
|
||||
|
||||
## Backend
|
||||
|
||||
None. The feature uses existing task and contribution APIs.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Preview and task hierarchy
|
||||
|
||||
`TaskTitleHoverCard` owns pointer and keyboard preview behavior.
|
||||
`useTaskSubtasks` selects direct active subtasks. `TaskSubtaskRow` owns subtask
|
||||
navigation and prevents its activation from reaching the parent row.
|
||||
|
||||
### Contribution status
|
||||
|
||||
`ChangeRequestTaskStatusSummary` owns the generic status-summary layout.
|
||||
GitHub and GitLab components derive provider-specific rows and presentation data.
|
||||
|
||||
### GitLab state
|
||||
|
||||
`useWorkspaceMRs` shares one in-flight workspace request between mounted consumers.
|
||||
It permits later refreshes and clears the workspace after failure.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** pointer and keyboard preview behavior, subtask limits, and navigation isolation.
|
||||
**File:** `apps/web/components/task/task-title-hover-card.test.tsx`.
|
||||
**How:** component tests with a shared state provider.
|
||||
- **What:** GitLab state derivation, request sharing, and failed-refresh cleanup.
|
||||
**File:** `apps/web/hooks/domains/gitlab/use-task-mr.test.ts` and GitLab summary tests.
|
||||
**How:** hook and pure-function tests with mocked API responses.
|
||||
- **What:** provider-neutral status presentation.
|
||||
**File:** GitHub and GitLab status-summary render tests.
|
||||
**How:** render tests with provider-specific presentation data.
|
||||
|
||||
## E2E tests
|
||||
|
||||
- **Scenario:** A pointer opens a preview on each desktop task surface.
|
||||
**File:** `apps/web/e2e/tests/kanban/task-title-hover-subtasks.spec.ts` and
|
||||
`apps/web/e2e/tests/task/task-title-hover-surfaces.spec.ts`.
|
||||
**What to verify:** full title, direct subtasks, child navigation, and no overflow.
|
||||
- **Scenario:** A coarse pointer opens the task directly.
|
||||
**File:** `apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts`.
|
||||
**What to verify:** no hover content and task-tree access through the mobile switcher.
|
||||
|
||||
## Verification results
|
||||
|
||||
- Seven focused Vitest files passed: 130 tests.
|
||||
- TypeScript, ESLint, the locale audit, and the i18n ratchet passed.
|
||||
- The desktop Chromium flow passed four tests.
|
||||
- The mobile Chrome flow passed one test.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## Implementation order
|
||||
|
||||
- [x] [Task 01: Preview and hierarchy](task-01-preview-and-hierarchy.md)
|
||||
- [x] [Task 02: Contribution status and state](task-02-contribution-status-and-state.md)
|
||||
- [x] [Task 03: End-to-end verification](task-03-end-to-end-verification.md)
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
id: "01-preview-and-hierarchy"
|
||||
title: "Preview and hierarchy"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/rich-task-title-previews.md"
|
||||
---
|
||||
|
||||
# Task 01: Preview and hierarchy
|
||||
|
||||
- **Acceptance:** Fine-pointer task titles show the reusable preview. Keyboard activation opens it. Subtask activation does not open the parent.
|
||||
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- --run components/task/task-title-hover-card.test.tsx hooks/domains/kanban/use-task-subtasks.test.tsx`.
|
||||
- **Files likely touched:** task title, subtask, task-row, Kanban-card, and rich-list components and tests.
|
||||
- **Dependencies:** None.
|
||||
- **Parallelism:** sequential.
|
||||
|
||||
## Results
|
||||
|
||||
`cd apps && pnpm --filter @kandev/web test -- --run components/task/task-title-hover-card.test.tsx hooks/domains/kanban/use-task-subtasks.test.tsx` passed as part of the final focused run. The full focused run passed 130 tests across seven files. A new focus regression failed before the explicit focus fix and passed after it. External side effects: None.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
id: "02-contribution-status-and-state"
|
||||
title: "Contribution status and state"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/rich-task-title-previews.md"
|
||||
---
|
||||
|
||||
# Task 02: Contribution status and state
|
||||
|
||||
- **Acceptance:** GitHub and GitLab use one generic summary renderer. Co-mounted GitLab consumers share one in-flight request. Later mounts can refresh, and failed refreshes clear stale workspace data.
|
||||
- **Verification:** `cd apps && pnpm --filter @kandev/web test -- --run hooks/domains/gitlab/use-task-mr.test.ts components/gitlab/mr-task-status-summary.test.ts components/gitlab/mr-task-icon.render.test.tsx components/github/pr-task-icon.test.ts components/github/pr-task-icon.render.test.tsx`.
|
||||
- **Files likely touched:** change-request summary components, GitLab adapters, hydration hook, and focused tests.
|
||||
- **Dependencies:** None.
|
||||
- **Parallelism:** sequential because the renderer and hook share GitLab consumers.
|
||||
|
||||
## Results
|
||||
|
||||
The final seven-file Vitest command passed 130 tests. It includes 21 GitLab hook tests and the GitHub and GitLab renderer tests. `cd apps/web && pnpm run typecheck && pnpm run lint && pnpm run i18n:check && pnpm run i18n:ratchet` passed. External side effects: None.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
id: "03-end-to-end-verification"
|
||||
title: "End-to-end verification"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-preview-and-hierarchy", "02-contribution-status-and-state"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/rich-task-title-previews.md"
|
||||
---
|
||||
|
||||
# Task 03: End-to-end verification
|
||||
|
||||
- **Acceptance:** Desktop pointer and keyboard flows work on real task data. Mobile direct navigation and task-tree access work on a coarse pointer.
|
||||
- **Verification:** `cd apps/web && pnpm e2e:run tests/task/task-title-hover-surfaces.spec.ts && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-title-hover-subtasks.spec.ts`.
|
||||
- **Files likely touched:** the desktop and mobile task-title Playwright specifications.
|
||||
- **Dependencies:** Tasks 01 and 02.
|
||||
- **Parallelism:** sequential.
|
||||
|
||||
## Results
|
||||
|
||||
`cd apps/web && pnpm e2e:run tests/task/task-title-hover-surfaces.spec.ts` first found the missing child-link focus. After the fix, all four Chromium tests passed. `cd apps/web && pnpm e2e:run --project mobile-chrome tests/kanban/mobile-task-title-hover-subtasks.spec.ts` passed one test. The managed runner removed its temporary backends and fixture data. External side effects: local temporary E2E data only.
|
||||
@@ -74,6 +74,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [interrupted-task-indicator](tasks/interrupted-task-indicator.md) | complete |
|
||||
| [labels](tasks/labels.md) | shipped |
|
||||
| [title-length-limit](tasks/title-length-limit.md) | complete |
|
||||
| [rich-task-title-previews](tasks/rich-task-title-previews.md) | building |
|
||||
| [active clarification lifecycle](clarification-active-lifecycle/spec.md) | approved |
|
||||
| [model-unification](tasks/model-unification.md) | draft |
|
||||
| [run-scheduling](tasks/run-scheduling.md) | building |
|
||||
|
||||
@@ -6,6 +6,11 @@ owner: tbd
|
||||
|
||||
# GitLab MR Badge on the Sidebar and Tasks-List Rows
|
||||
|
||||
> **Amendment:** [Rich task title previews](../tasks/rich-task-title-previews.md)
|
||||
> extends this draft. It replaces the requirements that `MRTaskIcon` stay
|
||||
> unmodified, add no copy, and have only one hydration owner. The badge
|
||||
> placement and provider data-model requirements in this document still apply.
|
||||
|
||||
## Why
|
||||
|
||||
A task whose work lives on GitHub shows a pull-request badge on all three
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: building
|
||||
created: 2026-08-18
|
||||
owner: tbd
|
||||
---
|
||||
|
||||
# Rich task title previews
|
||||
|
||||
## Why
|
||||
|
||||
Task titles can be too long for a task row. Users also need a fast way to see
|
||||
the direct subtasks and contribution state without opening the parent task.
|
||||
|
||||
## What
|
||||
|
||||
- A fine-pointer task row shows a preview after the user points at its title.
|
||||
- The preview shows the full title and as many as 12 direct, active subtasks.
|
||||
- Each subtask row shows its task state and its GitHub or GitLab contribution state.
|
||||
- A keyboard user can focus the title trigger and open the same preview.
|
||||
- A keyboard user can open a subtask without opening the parent task.
|
||||
- A coarse-pointer device keeps the title's direct navigation action.
|
||||
- On a task page, the mobile task switcher gives access to the same task tree.
|
||||
- GitHub and GitLab status summaries use one provider-neutral presentation contract.
|
||||
|
||||
## Data model
|
||||
|
||||
The feature adds no persistent data. It reads task and subtask data from the
|
||||
existing task store. It reads pull requests from `taskPRs.byTaskId`. It reads
|
||||
merge requests from `taskMRs.byWorkspaceId[workspaceId]`.
|
||||
|
||||
The GitLab hydration hook shares an in-flight workspace request between mounted
|
||||
consumers. A later mount can start a fresh request. A successful request replaces
|
||||
the workspace cache. A failed request clears that workspace cache.
|
||||
|
||||
## API surface
|
||||
|
||||
The feature adds no HTTP route, WebSocket message, or plugin API. It uses the
|
||||
existing workspace task, pull-request, and merge-request contracts.
|
||||
|
||||
The public status-summary presentation uses generic change-request concepts.
|
||||
Provider-specific code supplies labels, icons, test identifiers, and status rows.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If a task has no active direct subtask, the preview shows only the full title.
|
||||
- If GitLab refresh fails, the UI removes cached merge-request data for that workspace.
|
||||
- If the device has no fine pointer, the UI does not mount a hover preview.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a clipped title, **WHEN** a user points at it, **THEN** the preview shows the full title.
|
||||
- **GIVEN** a parent with active subtasks, **WHEN** the preview opens, **THEN** it shows each direct subtask up to the limit.
|
||||
- **GIVEN** a focused title trigger, **WHEN** a user presses Enter, **THEN** the preview opens and moves focus into its interactive content.
|
||||
- **GIVEN** an open preview, **WHEN** a user presses Enter on a subtask, **THEN** the app opens the subtask and not the parent.
|
||||
- **GIVEN** a coarse-pointer device, **WHEN** a user taps the title, **THEN** the app opens the task without a preview.
|
||||
- **GIVEN** two mounted GitLab hydration consumers, **WHEN** both request one workspace, **THEN** the frontend sends one request.
|
||||
- **GIVEN** cached GitLab data, **WHEN** its refresh fails, **THEN** the cached workspace data is removed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The feature does not add nested preview levels.
|
||||
- The feature does not add backend status projection fields.
|
||||
- The feature does not change mobile task navigation.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
See [the implementation plan](../../plans/rich-task-title-previews/plan.md).
|
||||
Reference in New Issue
Block a user