feat: move GitLab MR linking to task menus (#1868)
This commit is contained in:
@@ -1,8 +1,66 @@
|
||||
import { createElement } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mrTriggerClass, openDesktopMRReview, openMobileMRReview } from "./mr-topbar-button";
|
||||
import {
|
||||
MRTopbarButton,
|
||||
mrTriggerClass,
|
||||
openDesktopMRReview,
|
||||
openMobileMRReview,
|
||||
} from "./mr-topbar-button";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
|
||||
const gitlabMocks = vi.hoisted(() => ({ mrs: [] as TaskMR[] }));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
tasks: { activeTaskId: "task-1" },
|
||||
workspaces: { activeId: "workspace-1" },
|
||||
repositories: { itemsByWorkspaceId: { "workspace-1": [] } },
|
||||
removeTaskMR: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({
|
||||
useGitLabAvailable: () => true,
|
||||
useTaskMRs: () => gitlabMocks.mrs,
|
||||
useWorkspaceMRs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({
|
||||
useTaskById: () => ({ id: "task-1", repositories: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./task-mr-link-dialog", () => ({ TaskMRLinkDialog: () => null }));
|
||||
|
||||
describe("mrTriggerClass", () => {
|
||||
it("does not render a generic link control for an unlinked task", () => {
|
||||
gitlabMocks.mrs = [];
|
||||
render(createElement(MRTopbarButton));
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Link GitLab merge request" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the linked merge request status control", () => {
|
||||
gitlabMocks.mrs = [
|
||||
{
|
||||
id: "association-1",
|
||||
mr_iid: 81,
|
||||
state: "opened",
|
||||
project_path: "group/project",
|
||||
mr_url: "https://gitlab.example/group/project/-/merge_requests/81",
|
||||
} as TaskMR,
|
||||
];
|
||||
render(createElement(MRTopbarButton));
|
||||
|
||||
const trigger = screen.getByTestId("mr-topbar-button");
|
||||
expect(trigger.getAttribute("data-mr-iid")).toBe("81");
|
||||
});
|
||||
|
||||
it("uses a 44px trigger on mobile", () => {
|
||||
expect(mrTriggerClass(true, true)).toContain("h-11");
|
||||
expect(mrTriggerClass(true, true)).toContain("w-11");
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
IconClock,
|
||||
IconExternalLink,
|
||||
IconGitMerge,
|
||||
IconLink,
|
||||
IconPlus,
|
||||
IconUnlink,
|
||||
IconX,
|
||||
@@ -241,19 +240,7 @@ function MRTopbarControl({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!gitlabAvailable) return null;
|
||||
return (
|
||||
<Button
|
||||
size={compact ? "icon-sm" : "sm"}
|
||||
variant="outline"
|
||||
className={mrTriggerClass(compact, mobile)}
|
||||
onClick={onLink}
|
||||
aria-label="Link GitLab merge request"
|
||||
>
|
||||
<IconLink className="h-4 w-4 text-orange-500" />
|
||||
{!compact && <span className="text-xs font-medium">Link MR</span>}
|
||||
</Button>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export const MRTopbarButton = memo(function MRTopbarButton({
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import { TaskMRLinkDialog } from "./task-mr-link-dialog";
|
||||
|
||||
const appState = { setTaskMR: vi.fn() };
|
||||
const createTaskMR = vi.hoisted(() => vi.fn().mockResolvedValue({}));
|
||||
const repositories = [
|
||||
{
|
||||
id: "repository-1",
|
||||
name: "kandev",
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
},
|
||||
{
|
||||
id: "repository-2",
|
||||
name: "docs",
|
||||
provider_owner: "platform",
|
||||
provider_name: "docs",
|
||||
},
|
||||
] as Repository[];
|
||||
const taskRepositories = repositories.map((repository) => ({ repository_id: repository.id }));
|
||||
const mrURL = "https://gitlab.example.test/platform/kandev/-/merge_requests/81";
|
||||
const mergeRequestURLLabel = "Merge request URL";
|
||||
const linkMergeRequestButton = "Link merge request";
|
||||
const workspaceId = "workspace-1";
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: typeof appState) => unknown) => selector(appState),
|
||||
@@ -13,52 +33,127 @@ vi.mock("@/components/toast-provider", () => ({
|
||||
useToast: () => ({ toast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/gitlab-api", () => ({
|
||||
createTaskMR: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api/domains/gitlab-api", () => ({ createTaskMR }));
|
||||
|
||||
afterEach(cleanup);
|
||||
function dialog({
|
||||
open = true,
|
||||
repositoryOptions = repositories,
|
||||
taskRepositoryLinks = taskRepositories,
|
||||
}: {
|
||||
open?: boolean;
|
||||
repositoryOptions?: Repository[];
|
||||
taskRepositoryLinks?: { repository_id: string }[];
|
||||
} = {}) {
|
||||
return (
|
||||
<TaskMRLinkDialog
|
||||
open={open}
|
||||
onOpenChange={vi.fn()}
|
||||
taskId="task-1"
|
||||
workspaceId={workspaceId}
|
||||
taskRepositories={taskRepositoryLinks}
|
||||
repositories={repositoryOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TaskMRLinkDialog", () => {
|
||||
it("preserves the typed URL when repository props refresh while open", () => {
|
||||
const repository = {
|
||||
id: "repository-1",
|
||||
name: "kandev",
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
} as Repository;
|
||||
const props = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
taskId: "task-1",
|
||||
workspaceId: "workspace-1",
|
||||
};
|
||||
const { rerender } = render(
|
||||
<TaskMRLinkDialog
|
||||
{...props}
|
||||
taskRepositories={[{ repository_id: repository.id }]}
|
||||
repositories={[repository]}
|
||||
/>,
|
||||
);
|
||||
it("submits the default repository when initially mounted open", async () => {
|
||||
render(dialog());
|
||||
|
||||
const url = screen.getByLabelText("Merge request URL");
|
||||
fireEvent.change(url, {
|
||||
target: { value: "https://gitlab.example.test/platform/kandev/-/merge_requests/81" },
|
||||
fireEvent.change(screen.getByLabelText(mergeRequestURLLabel), {
|
||||
target: { value: mrURL },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton }));
|
||||
|
||||
await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1));
|
||||
expect(createTaskMR).toHaveBeenCalledWith(
|
||||
{
|
||||
task_id: "task-1",
|
||||
repository_id: "repository-1",
|
||||
mr_url: mrURL,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the default repository when repositories hydrate after opening", async () => {
|
||||
const { rerender } = render(dialog({ repositoryOptions: [], taskRepositoryLinks: [] }));
|
||||
|
||||
rerender(dialog());
|
||||
fireEvent.change(screen.getByLabelText(mergeRequestURLLabel), {
|
||||
target: { value: mrURL },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton }));
|
||||
|
||||
await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1));
|
||||
expect(createTaskMR).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repository_id: "repository-1" }),
|
||||
workspaceId,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the typed URL and repository selection when props refresh while open", async () => {
|
||||
const { rerender } = render(dialog());
|
||||
|
||||
const url = screen.getByLabelText(mergeRequestURLLabel);
|
||||
fireEvent.change(url, {
|
||||
target: { value: mrURL },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Task repository" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "platform/docs" }));
|
||||
|
||||
const newlyHydratedRepository = {
|
||||
id: "repository-3",
|
||||
name: "api",
|
||||
provider_owner: "platform",
|
||||
provider_name: "api",
|
||||
} as Repository;
|
||||
rerender(
|
||||
<TaskMRLinkDialog
|
||||
{...props}
|
||||
taskRepositories={[{ repository_id: repository.id }]}
|
||||
repositories={[{ ...repository }]}
|
||||
/>,
|
||||
dialog({
|
||||
repositoryOptions: [newlyHydratedRepository, ...repositories],
|
||||
taskRepositoryLinks: [{ repository_id: newlyHydratedRepository.id }, ...taskRepositories],
|
||||
}),
|
||||
);
|
||||
|
||||
expect((url as HTMLInputElement).value).toBe(
|
||||
"https://gitlab.example.test/platform/kandev/-/merge_requests/81",
|
||||
expect((url as HTMLInputElement).value).toBe(mrURL);
|
||||
fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton }));
|
||||
|
||||
await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1));
|
||||
expect(createTaskMR).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repository_id: "repository-2",
|
||||
mr_url: mrURL,
|
||||
}),
|
||||
workspaceId,
|
||||
);
|
||||
});
|
||||
|
||||
it("resets the URL and selection to the current default after closing", async () => {
|
||||
const { rerender } = render(dialog());
|
||||
|
||||
fireEvent.change(screen.getByLabelText(mergeRequestURLLabel), {
|
||||
target: { value: mrURL },
|
||||
});
|
||||
const reorderedLinks = [taskRepositories[1], taskRepositories[0]];
|
||||
rerender(dialog({ open: false, taskRepositoryLinks: reorderedLinks }));
|
||||
rerender(dialog({ taskRepositoryLinks: reorderedLinks }));
|
||||
|
||||
const url = screen.getByLabelText(mergeRequestURLLabel) as HTMLInputElement;
|
||||
expect(url.value).toBe("");
|
||||
fireEvent.change(url, {
|
||||
target: { value: "https://gitlab.example.test/platform/docs/-/merge_requests/12" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: linkMergeRequestButton }));
|
||||
|
||||
await waitFor(() => expect(createTaskMR).toHaveBeenCalledTimes(1));
|
||||
expect(createTaskMR).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ repository_id: "repository-2" }),
|
||||
workspaceId,
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Link merge request" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,13 +116,19 @@ export function TaskMRLinkDialog({
|
||||
taskRepositories: TaskRepositoryLink[];
|
||||
repositories: Repository[];
|
||||
}) {
|
||||
const options = useRepositoryOptions(repositories, taskRepositories);
|
||||
const defaultRepositoryID = options[0]?.id ?? NO_REPOSITORY;
|
||||
const [mrURL, setMRURL] = useState("");
|
||||
const [repositoryID, setRepositoryID] = useState(NO_REPOSITORY);
|
||||
const [repositoryID, setRepositoryID] = useState(defaultRepositoryID);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const setTaskMR = useAppStore((state) => state.setTaskMR);
|
||||
const { toast } = useToast();
|
||||
const options = useRepositoryOptions(repositories, taskRepositories);
|
||||
const defaultRepositoryID = options[0]?.id ?? NO_REPOSITORY;
|
||||
|
||||
useEffect(() => {
|
||||
if (repositoryID === NO_REPOSITORY && defaultRepositoryID !== NO_REPOSITORY) {
|
||||
setRepositoryID(defaultRepositoryID);
|
||||
}
|
||||
}, [defaultRepositoryID, repositoryID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
|
||||
@@ -98,6 +98,7 @@ describe("buildKanbanCardMenuEntries — external issue links", () => {
|
||||
stepsByWorkflowId: {},
|
||||
onLinkPullRequest: vi.fn(),
|
||||
onLinkIssue: vi.fn(),
|
||||
onLinkMergeRequest: vi.fn(),
|
||||
onLinkJiraTicket: vi.fn(),
|
||||
onLinkLinearIssue: vi.fn(),
|
||||
onLinkSentryIssue: vi.fn(),
|
||||
@@ -109,6 +110,7 @@ describe("buildKanbanCardMenuEntries — external issue links", () => {
|
||||
expect(itemLabels(linkMenu)).toEqual([
|
||||
"GitHub Pull Request",
|
||||
"GitHub Issue",
|
||||
"GitLab Merge Request",
|
||||
"Jira Ticket",
|
||||
"Linear Issue",
|
||||
"Sentry Issue",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArrowRight,
|
||||
IconBrandGitlab,
|
||||
IconBrandSentry,
|
||||
IconCircleDot,
|
||||
IconGitPullRequest,
|
||||
@@ -89,6 +90,7 @@ type BuildKanbanCardMenuEntriesArgs = {
|
||||
onDetach?: () => void;
|
||||
onLinkPullRequest?: () => void;
|
||||
onLinkIssue?: () => void;
|
||||
onLinkMergeRequest?: () => void;
|
||||
onLinkJiraTicket?: () => void;
|
||||
onLinkLinearIssue?: () => void;
|
||||
onLinkSentryIssue?: () => void;
|
||||
@@ -227,10 +229,30 @@ function buildSendToWorkflowSubmenu({
|
||||
};
|
||||
}
|
||||
|
||||
function buildGitLabMergeRequestLinkEntry({
|
||||
disabled,
|
||||
onLinkMergeRequest,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onLinkMergeRequest?: () => void;
|
||||
}): KanbanCardMenuEntry | null {
|
||||
if (!onLinkMergeRequest) return null;
|
||||
return {
|
||||
kind: "item",
|
||||
key: "link-gitlab-merge-request",
|
||||
testId: "task-context-link-gitlab-merge-request",
|
||||
icon: <IconBrandGitlab className="mr-2 h-4 w-4" />,
|
||||
label: "GitLab Merge Request",
|
||||
disabled,
|
||||
onSelect: onLinkMergeRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLinkSubmenu({
|
||||
disabled,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -238,6 +260,7 @@ function buildLinkSubmenu({
|
||||
disabled?: boolean;
|
||||
onLinkPullRequest?: () => void;
|
||||
onLinkIssue?: () => void;
|
||||
onLinkMergeRequest?: () => void;
|
||||
onLinkJiraTicket?: () => void;
|
||||
onLinkLinearIssue?: () => void;
|
||||
onLinkSentryIssue?: () => void;
|
||||
@@ -245,6 +268,7 @@ function buildLinkSubmenu({
|
||||
if (
|
||||
!onLinkPullRequest &&
|
||||
!onLinkIssue &&
|
||||
!onLinkMergeRequest &&
|
||||
!onLinkJiraTicket &&
|
||||
!onLinkLinearIssue &&
|
||||
!onLinkSentryIssue
|
||||
@@ -274,6 +298,8 @@ function buildLinkSubmenu({
|
||||
onSelect: onLinkIssue,
|
||||
});
|
||||
}
|
||||
const gitLabEntry = buildGitLabMergeRequestLinkEntry({ disabled, onLinkMergeRequest });
|
||||
if (gitLabEntry) children.push(gitLabEntry);
|
||||
if (onLinkJiraTicket) {
|
||||
children.push({
|
||||
kind: "item",
|
||||
@@ -335,6 +361,7 @@ export function buildKanbanCardMenuEntries({
|
||||
onDetach,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -376,6 +403,7 @@ export function buildKanbanCardMenuEntries({
|
||||
disabled: isProcessing,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
|
||||
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
|
||||
import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog";
|
||||
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
|
||||
import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select";
|
||||
import { useDetachTask } from "@/hooks/use-detach-task";
|
||||
@@ -206,6 +207,7 @@ function useKanbanCardMenus({
|
||||
const [showDetachConfirm, setShowDetachConfirm] = useState(false);
|
||||
const [showPRDialog, setShowPRDialog] = useState(false);
|
||||
const [showIssueDialog, setShowIssueDialog] = useState(false);
|
||||
const [showMRDialog, setShowMRDialog] = useState(false);
|
||||
const [externalLinkProvider, setExternalLinkProvider] = useState<ExternalLinkProvider | null>(
|
||||
null,
|
||||
);
|
||||
@@ -240,6 +242,7 @@ function useKanbanCardMenus({
|
||||
task.parentTaskId && !actingOnMultiSelection ? () => setShowDetachConfirm(true) : undefined,
|
||||
onLinkPullRequest: () => setShowPRDialog(true),
|
||||
onLinkIssue: () => setShowIssueDialog(true),
|
||||
onLinkMergeRequest: externalLinkAvailability.gitlab ? () => setShowMRDialog(true) : undefined,
|
||||
...externalLinkHandlers(externalLinkAvailability, setExternalLinkProvider),
|
||||
};
|
||||
|
||||
@@ -266,6 +269,8 @@ function useKanbanCardMenus({
|
||||
setShowPRDialog,
|
||||
showIssueDialog,
|
||||
setShowIssueDialog,
|
||||
showMRDialog,
|
||||
setShowMRDialog,
|
||||
externalLinkProvider,
|
||||
setExternalLinkProvider,
|
||||
};
|
||||
@@ -332,6 +337,16 @@ function KanbanCardDialogs({
|
||||
task={task}
|
||||
repositories={repositories}
|
||||
/>
|
||||
{workspaceId && (
|
||||
<TaskMRLinkDialog
|
||||
open={menu.showMRDialog}
|
||||
onOpenChange={menu.setShowMRDialog}
|
||||
taskId={task.id}
|
||||
workspaceId={workspaceId}
|
||||
taskRepositories={task.repositories ?? []}
|
||||
repositories={repositories}
|
||||
/>
|
||||
)}
|
||||
{menu.externalLinkProvider && workspaceId && (
|
||||
<TaskExternalLinkDialog
|
||||
open={true}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
||||
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
|
||||
|
||||
export type KanbanExternalLinkAvailability = {
|
||||
gitlab?: boolean;
|
||||
jira: boolean;
|
||||
linear: boolean;
|
||||
sentry: boolean;
|
||||
@@ -14,6 +16,7 @@ export function useKanbanExternalLinkAvailability(
|
||||
workspaceId: string | null,
|
||||
): KanbanExternalLinkAvailability {
|
||||
return {
|
||||
gitlab: useGitLabAvailable(),
|
||||
jira: useJiraAvailable(workspaceId),
|
||||
linear: useLinearAvailable(workspaceId),
|
||||
sentry: useSentryAvailable(workspaceId),
|
||||
|
||||
@@ -62,6 +62,7 @@ export function MobileTaskList({
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -80,6 +81,7 @@ export function MobileTaskList({
|
||||
onDetachTask: (taskId: string) => Promise<void> | void;
|
||||
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkMergeRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
@@ -129,6 +131,7 @@ export function MobileTaskList({
|
||||
onDetachTask={onDetachTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkMergeRequest={onLinkMergeRequest}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
@@ -289,6 +292,11 @@ function TaskSwitcherSurfaceContent({
|
||||
onOpenChange,
|
||||
linking.taskListHandlers.onLinkIssue,
|
||||
)}
|
||||
onLinkMergeRequest={surfaceAction(
|
||||
presentation,
|
||||
onOpenChange,
|
||||
linking.taskListHandlers.onLinkMergeRequest,
|
||||
)}
|
||||
onLinkJiraTicket={surfaceAction(
|
||||
presentation,
|
||||
onOpenChange,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TaskDetachTargetConfirmDialog } from "./task-detach-confirm-dialog";
|
||||
import { TaskExternalLinkDialog } from "./task-external-link-dialog";
|
||||
import { TaskGitHubIssueDialog } from "./task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "./task-github-pr-dialog";
|
||||
import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog";
|
||||
import type { Repository } from "@/lib/types/http";
|
||||
import type {
|
||||
SidebarExternalLinkTarget,
|
||||
@@ -42,6 +43,8 @@ export type SidebarDialogsActions = {
|
||||
setLinkingPullRequestTask: (next: LinkTarget) => void;
|
||||
linkingIssueTask: LinkTarget;
|
||||
setLinkingIssueTask: (next: LinkTarget) => void;
|
||||
linkingMergeRequestTask: LinkTarget;
|
||||
setLinkingMergeRequestTask: (next: LinkTarget) => void;
|
||||
linkingExternalIssueTask: SidebarExternalLinkTarget | null;
|
||||
setLinkingExternalIssueTask: (next: SidebarExternalLinkTarget | null) => void;
|
||||
};
|
||||
@@ -127,6 +130,8 @@ export function SidebarLinkDialogs({
|
||||
| "setLinkingPullRequestTask"
|
||||
| "linkingIssueTask"
|
||||
| "setLinkingIssueTask"
|
||||
| "linkingMergeRequestTask"
|
||||
| "setLinkingMergeRequestTask"
|
||||
| "linkingExternalIssueTask"
|
||||
| "setLinkingExternalIssueTask"
|
||||
>;
|
||||
@@ -138,6 +143,8 @@ export function SidebarLinkDialogs({
|
||||
setLinkingPullRequestTask,
|
||||
linkingIssueTask,
|
||||
setLinkingIssueTask,
|
||||
linkingMergeRequestTask,
|
||||
setLinkingMergeRequestTask,
|
||||
linkingExternalIssueTask,
|
||||
setLinkingExternalIssueTask,
|
||||
} = actions;
|
||||
@@ -163,6 +170,18 @@ export function SidebarLinkDialogs({
|
||||
repositories={repositories}
|
||||
/>
|
||||
)}
|
||||
{linkingMergeRequestTask && workspaceId && (
|
||||
<TaskMRLinkDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLinkingMergeRequestTask(null);
|
||||
}}
|
||||
taskId={linkingMergeRequestTask.id}
|
||||
workspaceId={workspaceId}
|
||||
taskRepositories={linkingMergeRequestTask.repositories ?? []}
|
||||
repositories={repositories}
|
||||
/>
|
||||
)}
|
||||
{linkingExternalIssueTask && workspaceId && (
|
||||
<TaskExternalLinkDialog
|
||||
open={true}
|
||||
|
||||
@@ -31,6 +31,9 @@ export function useSidebarLinkActions(store: StoreApi) {
|
||||
null,
|
||||
);
|
||||
const [linkingIssueTask, setLinkingIssueTask] = useState<SidebarLinkTarget | null>(null);
|
||||
const [linkingMergeRequestTask, setLinkingMergeRequestTask] = useState<SidebarLinkTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [linkingExternalIssueTask, setLinkingExternalIssueTask] =
|
||||
useState<SidebarExternalLinkTarget | null>(null);
|
||||
|
||||
@@ -64,6 +67,13 @@ export function useSidebarLinkActions(store: StoreApi) {
|
||||
[getLinkTarget],
|
||||
);
|
||||
|
||||
const handleLinkMergeRequestTask = useCallback(
|
||||
(taskId: string, fallbackTitle?: string) => {
|
||||
setLinkingMergeRequestTask(getLinkTarget(taskId, fallbackTitle));
|
||||
},
|
||||
[getLinkTarget],
|
||||
);
|
||||
|
||||
const handleLinkExternalIssueTask = useCallback(
|
||||
(provider: ExternalLinkProvider, taskId: string, fallbackTitle?: string) => {
|
||||
setLinkingExternalIssueTask({ provider, task: getLinkTarget(taskId, fallbackTitle) });
|
||||
@@ -96,6 +106,9 @@ export function useSidebarLinkActions(store: StoreApi) {
|
||||
linkingIssueTask,
|
||||
setLinkingIssueTask,
|
||||
handleLinkIssueTask,
|
||||
linkingMergeRequestTask,
|
||||
setLinkingMergeRequestTask,
|
||||
handleLinkMergeRequestTask,
|
||||
linkingExternalIssueTask,
|
||||
setLinkingExternalIssueTask,
|
||||
handleLinkJiraTicketTask,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
|
||||
@@ -9,6 +10,7 @@ type SidebarActions = Pick<
|
||||
ReturnType<typeof useSidebarActions>,
|
||||
| "handleLinkPullRequestTask"
|
||||
| "handleLinkIssueTask"
|
||||
| "handleLinkMergeRequestTask"
|
||||
| "handleLinkJiraTicketTask"
|
||||
| "handleLinkLinearIssueTask"
|
||||
| "handleLinkSentryIssueTask"
|
||||
@@ -16,12 +18,14 @@ type SidebarActions = Pick<
|
||||
|
||||
export function useSidebarTaskLinking(workspaceId: string | null, actions: SidebarActions) {
|
||||
const jiraAvailable = useJiraAvailable(workspaceId);
|
||||
const gitlabAvailable = useGitLabAvailable();
|
||||
const linearAvailable = useLinearAvailable(workspaceId);
|
||||
const sentryAvailable = useSentryAvailable(workspaceId);
|
||||
|
||||
return {
|
||||
onLinkPullRequest: actions.handleLinkPullRequestTask,
|
||||
onLinkIssue: actions.handleLinkIssueTask,
|
||||
onLinkMergeRequest: gitlabAvailable ? actions.handleLinkMergeRequestTask : undefined,
|
||||
onLinkJiraTicket: jiraAvailable ? actions.handleLinkJiraTicketTask : undefined,
|
||||
onLinkLinearIssue: linearAvailable ? actions.handleLinkLinearIssueTask : undefined,
|
||||
onLinkSentryIssue: sentryAvailable ? actions.handleLinkSentryIssueTask : undefined,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { cloneElement, isValidElement, useState } from "react";
|
||||
import {
|
||||
IconBrandGitlab,
|
||||
IconBrandSentry,
|
||||
IconCopy,
|
||||
IconCircleDot,
|
||||
@@ -51,6 +52,7 @@ type ContextMenuProps = {
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkMergeRequest?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void;
|
||||
@@ -82,6 +84,7 @@ export function TaskItemWithContextMenu({
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -123,6 +126,7 @@ export function TaskItemWithContextMenu({
|
||||
onDetachTask={onDetachTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkMergeRequest={onLinkMergeRequest}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
@@ -357,6 +361,7 @@ function selectTaskLinkActions(
|
||||
ContextMenuProps,
|
||||
| "onLinkPullRequest"
|
||||
| "onLinkIssue"
|
||||
| "onLinkMergeRequest"
|
||||
| "onLinkJiraTicket"
|
||||
| "onLinkLinearIssue"
|
||||
| "onLinkSentryIssue"
|
||||
@@ -365,6 +370,7 @@ function selectTaskLinkActions(
|
||||
return {
|
||||
onLinkPullRequest: createTaskLinkSelectAction(task, handlers.onLinkPullRequest, closeMenu),
|
||||
onLinkIssue: createTaskLinkSelectAction(task, handlers.onLinkIssue, closeMenu),
|
||||
onLinkMergeRequest: createTaskLinkSelectAction(task, handlers.onLinkMergeRequest, closeMenu),
|
||||
onLinkJiraTicket: createTaskLinkSelectAction(task, handlers.onLinkJiraTicket, closeMenu),
|
||||
onLinkLinearIssue: createTaskLinkSelectAction(task, handlers.onLinkLinearIssue, closeMenu),
|
||||
onLinkSentryIssue: createTaskLinkSelectAction(task, handlers.onLinkSentryIssue, closeMenu),
|
||||
@@ -504,6 +510,7 @@ function TaskLinkMenu({
|
||||
disabled,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -511,6 +518,7 @@ function TaskLinkMenu({
|
||||
disabled?: boolean;
|
||||
onLinkPullRequest?: () => void;
|
||||
onLinkIssue?: () => void;
|
||||
onLinkMergeRequest?: () => void;
|
||||
onLinkJiraTicket?: () => void;
|
||||
onLinkLinearIssue?: () => void;
|
||||
onLinkSentryIssue?: () => void;
|
||||
@@ -518,6 +526,7 @@ function TaskLinkMenu({
|
||||
if (
|
||||
!onLinkPullRequest &&
|
||||
!onLinkIssue &&
|
||||
!onLinkMergeRequest &&
|
||||
!onLinkJiraTicket &&
|
||||
!onLinkLinearIssue &&
|
||||
!onLinkSentryIssue
|
||||
@@ -543,6 +552,16 @@ function TaskLinkMenu({
|
||||
GitHub Issue
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onLinkMergeRequest && (
|
||||
<ContextMenuItem
|
||||
className="min-h-12! sm:min-h-7!"
|
||||
disabled={disabled}
|
||||
onSelect={onLinkMergeRequest}
|
||||
>
|
||||
<IconBrandGitlab className="mr-2 h-4 w-4" />
|
||||
GitLab Merge Request
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onLinkJiraTicket && (
|
||||
<ContextMenuItem disabled={disabled} onSelect={onLinkJiraTicket}>
|
||||
<IconTicket className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -221,6 +221,7 @@ describe("TaskSwitcher — detach menu", () => {
|
||||
|
||||
describe("TaskSwitcher — external issue link menu", () => {
|
||||
it("offers configured external issue providers from the task context menu", async () => {
|
||||
const onLinkMergeRequest = vi.fn();
|
||||
render(
|
||||
<Providers>
|
||||
<TaskSwitcher
|
||||
@@ -233,11 +234,10 @@ describe("TaskSwitcher — external issue link menu", () => {
|
||||
onSelectTask={vi.fn()}
|
||||
onLinkPullRequest={vi.fn()}
|
||||
onLinkIssue={vi.fn()}
|
||||
{...({
|
||||
onLinkJiraTicket: vi.fn(),
|
||||
onLinkLinearIssue: vi.fn(),
|
||||
onLinkSentryIssue: vi.fn(),
|
||||
} as Partial<Parameters<typeof TaskSwitcher>[0]>)}
|
||||
onLinkMergeRequest={onLinkMergeRequest}
|
||||
onLinkJiraTicket={vi.fn()}
|
||||
onLinkLinearIssue={vi.fn()}
|
||||
onLinkSentryIssue={vi.fn()}
|
||||
/>
|
||||
</Providers>,
|
||||
);
|
||||
@@ -251,21 +251,22 @@ describe("TaskSwitcher — external issue link menu", () => {
|
||||
expect(screen.getByText("Jira Ticket")).toBeTruthy();
|
||||
expect(screen.getByText("Linear Issue")).toBeTruthy();
|
||||
expect(screen.getByText("Sentry Issue")).toBeTruthy();
|
||||
expect(screen.getByText("GitLab Merge Request")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the visible task title to external issue link handlers", () => {
|
||||
it("targets the selected task when linking a GitLab merge request", () => {
|
||||
const archivedTask: TaskSwitcherItem = {
|
||||
id: "archived-task",
|
||||
title: "Archived task title",
|
||||
isArchived: true,
|
||||
};
|
||||
const closeMenu = vi.fn();
|
||||
const onLinkJiraTicket = vi.fn();
|
||||
const onLinkMergeRequest = vi.fn();
|
||||
|
||||
createTaskLinkSelectAction(archivedTask, onLinkJiraTicket, closeMenu)?.();
|
||||
createTaskLinkSelectAction(archivedTask, onLinkMergeRequest, closeMenu)?.();
|
||||
|
||||
expect(onLinkJiraTicket).toHaveBeenCalledWith(archivedTask.id, archivedTask.title);
|
||||
expect(onLinkMergeRequest).toHaveBeenCalledWith(archivedTask.id, archivedTask.title);
|
||||
expect(closeMenu).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ type TaskSwitcherProps = {
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkMergeRequest?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
@@ -146,6 +147,7 @@ type TaskRowProps = {
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkMergeRequest?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
@@ -169,6 +171,7 @@ function taskLinkHandlerProps(props: Pick<TaskRowProps, keyof TaskLinkHandlerPro
|
||||
return {
|
||||
onLinkPullRequest: props.onLinkPullRequest,
|
||||
onLinkIssue: props.onLinkIssue,
|
||||
onLinkMergeRequest: props.onLinkMergeRequest,
|
||||
onLinkJiraTicket: props.onLinkJiraTicket,
|
||||
onLinkLinearIssue: props.onLinkLinearIssue,
|
||||
onLinkSentryIssue: props.onLinkSentryIssue,
|
||||
@@ -178,6 +181,7 @@ function taskLinkHandlerProps(props: Pick<TaskRowProps, keyof TaskLinkHandlerPro
|
||||
type TaskLinkHandlerProps = {
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkMergeRequest?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
@@ -404,6 +408,7 @@ type GroupSectionProps = {
|
||||
onDetachTask?: (taskId: string) => void;
|
||||
onLinkPullRequest?: TaskLinkHandler;
|
||||
onLinkIssue?: TaskLinkHandler;
|
||||
onLinkMergeRequest?: TaskLinkHandler;
|
||||
onLinkJiraTicket?: TaskLinkHandler;
|
||||
onLinkLinearIssue?: TaskLinkHandler;
|
||||
onLinkSentryIssue?: TaskLinkHandler;
|
||||
@@ -444,6 +449,7 @@ function GroupSection({
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -482,6 +488,7 @@ function GroupSection({
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -538,6 +545,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onDetachTask,
|
||||
onLinkPullRequest,
|
||||
onLinkIssue,
|
||||
onLinkMergeRequest,
|
||||
onLinkJiraTicket,
|
||||
onLinkLinearIssue,
|
||||
onLinkSentryIssue,
|
||||
@@ -594,6 +602,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
|
||||
onDetachTask={onDetachTask}
|
||||
onLinkPullRequest={onLinkPullRequest}
|
||||
onLinkIssue={onLinkIssue}
|
||||
onLinkMergeRequest={onLinkMergeRequest}
|
||||
onLinkJiraTicket={onLinkJiraTicket}
|
||||
onLinkLinearIssue={onLinkLinearIssue}
|
||||
onLinkSentryIssue={onLinkSentryIssue}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
assertNoDocumentHorizontalOverflow,
|
||||
} from "../../helpers/layout-assertions";
|
||||
import { GitLabPage } from "../../pages/gitlab-page";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
test.describe("GitLab workspace parity", () => {
|
||||
test("keeps self-managed browse results isolated when switching workspaces", async ({
|
||||
@@ -115,8 +116,22 @@ test.describe("GitLab workspace parity", () => {
|
||||
await panel.getByRole("button", { name: "Unlink merge request" }).click();
|
||||
await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0);
|
||||
|
||||
await testPage.getByRole("button", { name: "Link GitLab merge request" }).click();
|
||||
const topbar = testPage.getByTestId("task-topbar");
|
||||
await expect(
|
||||
topbar.getByRole("button", { name: /Link (?:MR|GitLab merge request)/i }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
const activeTaskRow = session.sidebar.locator(
|
||||
'[data-testid="sidebar-task-item"][aria-current="true"]',
|
||||
);
|
||||
await expect(activeTaskRow).toBeVisible();
|
||||
await activeTaskRow.click({ button: "right" });
|
||||
await testPage.getByRole("menuitem", { name: "Link", exact: true }).hover();
|
||||
await testPage.getByRole("menuitem", { name: "GitLab Merge Request" }).click();
|
||||
|
||||
const linkDialog = testPage.getByRole("dialog", { name: "Link GitLab merge request" });
|
||||
await expect(linkDialog).toBeVisible();
|
||||
await linkDialog
|
||||
.getByLabel("Merge request URL")
|
||||
.fill(`${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/81`);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, expect, type SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import {
|
||||
assertLocatorWithinViewportX,
|
||||
assertNoDocumentHorizontalOverflow,
|
||||
} from "../../helpers/layout-assertions";
|
||||
import { GITLAB_PROJECT, seedGitLabReview } from "../../helpers/gitlab";
|
||||
import { GITLAB_HOST, GITLAB_PROJECT, seedGitLabReview } from "../../helpers/gitlab";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
import { GitLabPage } from "../../pages/gitlab-page";
|
||||
import { GitLabSettingsPage } from "../../pages/gitlab-settings-page";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
@@ -17,6 +22,43 @@ async function expectTouchTarget(locator: ReturnType<GitLabPage["mrRow"]>, label
|
||||
expect(box.height, `${label} height`).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
async function seedMultiRepoGitLabTask(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
tmpDir: string,
|
||||
mrIID: number,
|
||||
) {
|
||||
await seedGitLabReview(apiClient, seedData.workspaceId, mrIID, "Mobile contextual GitLab MR");
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
const secondaryRepoDir = path.join(tmpDir, "repos", "mobile-gitlab-secondary");
|
||||
fs.mkdirSync(secondaryRepoDir, { recursive: true });
|
||||
const gitEnv = makeGitEnv(tmpDir);
|
||||
execSync("git init -b main", { cwd: secondaryRepoDir, env: gitEnv });
|
||||
execSync('git commit --allow-empty -m "init"', { cwd: secondaryRepoDir, env: gitEnv });
|
||||
const secondaryRepo = await apiClient.createRepository(
|
||||
seedData.workspaceId,
|
||||
secondaryRepoDir,
|
||||
"main",
|
||||
{
|
||||
name: "Mobile GitLab secondary",
|
||||
provider: "gitlab",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "docs",
|
||||
},
|
||||
);
|
||||
return apiClient.createTask(seedData.workspaceId, "Mobile contextual GitLab link", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId, secondaryRepo.id],
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Mobile GitLab parity", () => {
|
||||
test("browses, quick launches, reviews, subscribes, and unlinks without overflow", async ({
|
||||
testPage,
|
||||
@@ -28,7 +70,7 @@ test.describe("Mobile GitLab parity", () => {
|
||||
await seedGitLabReview(apiClient, seedData.workspaceId, 111, "Mobile GitLab review");
|
||||
await apiClient.updateRepository(seedData.repositoryId, {
|
||||
provider: "gitlab",
|
||||
provider_host: "https://gitlab.example.test",
|
||||
provider_host: GITLAB_HOST,
|
||||
provider_owner: "platform",
|
||||
provider_name: "kandev",
|
||||
});
|
||||
@@ -85,6 +127,85 @@ test.describe("Mobile GitLab parity", () => {
|
||||
.toBe("chat");
|
||||
});
|
||||
|
||||
test("links a GitLab MR from the visible task actions menu", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 393, height: 851 });
|
||||
const mrIID = 113;
|
||||
const title = "Mobile contextual GitLab link";
|
||||
const task = await seedMultiRepoGitLabTask(apiClient, seedData, backend.tmpDir, mrIID);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0);
|
||||
|
||||
await testPage.getByTestId("mobile-session-menu").click();
|
||||
const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ hasText: title });
|
||||
await expect(taskRow).toBeVisible({ timeout: 10_000 });
|
||||
const taskActions = taskRow.getByRole("button", { name: "Task actions" });
|
||||
await expect(taskActions).toBeVisible();
|
||||
await expectTouchTarget(taskActions, "task actions button");
|
||||
await taskActions.click();
|
||||
|
||||
const linkTrigger = testPage.getByRole("menuitem", { name: "Link", exact: true });
|
||||
await expect(linkTrigger).toBeVisible();
|
||||
await linkTrigger.click();
|
||||
|
||||
const gitLabItem = testPage.getByRole("menuitem", { name: "GitLab Merge Request" });
|
||||
await expect(gitLabItem).toBeVisible();
|
||||
await expectTouchTarget(gitLabItem, "GitLab merge request link action");
|
||||
const nestedMenu = gitLabItem.locator("xpath=ancestor::*[@role='menu'][1]");
|
||||
await nestedMenu.evaluate((element) =>
|
||||
Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
),
|
||||
);
|
||||
const menuBox = await nestedMenu.boundingBox();
|
||||
const viewport = testPage.viewportSize();
|
||||
if (!menuBox || !viewport) throw new Error("mobile GitLab link menu has no layout box");
|
||||
expect(menuBox.x).toBeGreaterThanOrEqual(8);
|
||||
expect(menuBox.x).toBeLessThanOrEqual(18);
|
||||
expect(menuBox.width).toBeGreaterThanOrEqual(viewport.width - 36);
|
||||
expect(viewport.width - (menuBox.x + menuBox.width)).toBeGreaterThanOrEqual(8);
|
||||
expect(viewport.width - (menuBox.x + menuBox.width)).toBeLessThanOrEqual(18);
|
||||
expect(menuBox.y).toBeGreaterThanOrEqual(0);
|
||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(viewport.height);
|
||||
expect(viewport.height - (menuBox.y + menuBox.height)).toBeGreaterThanOrEqual(8);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab task link menu");
|
||||
|
||||
await gitLabItem.click();
|
||||
const linkDialog = testPage.getByRole("dialog", { name: "Link GitLab merge request" });
|
||||
await expect(linkDialog).toBeVisible();
|
||||
await assertLocatorWithinViewportX(linkDialog, "mobile GitLab link dialog");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile GitLab link dialog");
|
||||
await expect(linkDialog.getByRole("combobox", { name: "Task repository" })).toContainText(
|
||||
GITLAB_PROJECT,
|
||||
);
|
||||
await linkDialog
|
||||
.getByLabel("Merge request URL")
|
||||
.fill(`${GITLAB_HOST}/${GITLAB_PROJECT}/-/merge_requests/${mrIID}`);
|
||||
await linkDialog.getByRole("button", { name: "Link merge request" }).click();
|
||||
|
||||
const mrButton = testPage.getByTestId("mr-topbar-button");
|
||||
await expect(mrButton).toHaveAttribute("data-mr-iid", String(mrIID));
|
||||
await expect(mrButton).toHaveAttribute("data-mr-state", "opened");
|
||||
const response = await apiClient.rawRequest("GET", `/api/v1/gitlab/tasks/${task.id}/mrs`);
|
||||
expect(response.ok).toBe(true);
|
||||
const linked = (await response.json()) as {
|
||||
task_mrs: Array<{ repository_id?: string }>;
|
||||
};
|
||||
expect(linked.task_mrs).toEqual([
|
||||
expect.objectContaining({ repository_id: seedData.repositoryId }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("watch controls remain touch sized and persist a pause", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
spec: docs/specs/gitlab-integration/spec.md
|
||||
created: 2026-07-22
|
||||
status: approved
|
||||
---
|
||||
|
||||
# Implementation Plan: GitLab Task Link Menu
|
||||
|
||||
## Overview
|
||||
|
||||
Align GitLab task linking with the existing GitHub interaction: unlinked tasks
|
||||
do not reserve top-bar space, and linking starts from the task's contextual
|
||||
`Link` submenu. Reuse the existing GitLab MR dialog and task-menu plumbing, then
|
||||
update desktop and mobile E2E coverage to prove the new entry points.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Top-bar behavior
|
||||
|
||||
- Update `apps/web/components/gitlab/mr-topbar-button.tsx` so an unlinked task
|
||||
renders no generic link action.
|
||||
- Preserve the linked-MR status/dropdown control, including `Link another merge
|
||||
request`, open, and unlink behavior.
|
||||
|
||||
### Shared task link menu
|
||||
|
||||
- Extend `apps/web/components/kanban-card-menu-items.tsx` and
|
||||
`apps/web/components/task/task-switcher-context-menu.tsx` with a
|
||||
`GitLab Merge Request` link action.
|
||||
- Wire the existing `TaskMRLinkDialog` through kanban-card and sidebar/mobile
|
||||
task-switcher link state. Surface the action only while GitLab is configured
|
||||
for the active workspace.
|
||||
- Reuse the task's repository associations and the existing workspace
|
||||
repository list; do not add a new API or duplicate link submission logic.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- **Desktop outcome:** right-clicking a task opens `Link` > `GitLab Merge
|
||||
Request`; submitting the existing dialog links the MR and reveals its status
|
||||
control in the task top bar.
|
||||
- **Mobile entry point:** the visible `Task actions` ellipsis in the existing
|
||||
task-switcher drawer opens the same contextual menu; no long press is needed.
|
||||
- **Nearest exemplar:**
|
||||
`apps/web/components/task/mobile/session-task-switcher-sheet.tsx` and
|
||||
`apps/web/components/task/task-item.tsx` provide the discoverable ellipsis,
|
||||
responsive menu treatment, and touch geometry.
|
||||
- **Hierarchy and presentation:** task row > actions menu > `Link` submenu >
|
||||
`GitLab Merge Request` > existing link dialog. This is a short secondary
|
||||
action, so the responsive context-menu bottom sheet plus existing dialog is
|
||||
preferable to a new route or full-height surface.
|
||||
- **Scrolling and geometry:** retain the menu primitive's safe-area-aware,
|
||||
internally scrolling mobile surface and 44px menu rows. The task-switcher
|
||||
drawer remains the outer list scroll owner while the portalled menu is open.
|
||||
- **Shared logic:** desktop and mobile use the same availability hook, menu
|
||||
callbacks, target state, repository resolution, and `TaskMRLinkDialog`.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** the shared link-menu model includes `GitLab Merge Request` only
|
||||
when its callback is present.
|
||||
**File:** `apps/web/components/kanban-card-menu-items.test.tsx`.
|
||||
**How:** extend the existing menu-entry unit tests and assert ordering and
|
||||
omission.
|
||||
- **What:** a task-switcher link selection closes the menu and targets the
|
||||
selected task.
|
||||
**File:** `apps/web/components/task/task-switcher.test.tsx` or a focused
|
||||
colocated test if the existing helper coverage is sufficient.
|
||||
**How:** exercise the existing provider-neutral selection helper with the new
|
||||
callback plumbing.
|
||||
- **What:** the GitLab top bar renders no generic action without linked MRs and
|
||||
keeps the linked-MR control behavior.
|
||||
**File:** `apps/web/components/gitlab/mr-topbar-button.test.ts` or a focused
|
||||
component test where practical.
|
||||
**How:** prefer testing extracted render-policy logic; do not add broad React
|
||||
component tests solely for markup.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Desktop scenario:** update
|
||||
`apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts` so an unlinked task has no
|
||||
`Link MR` top-bar button, then right-click the task row, choose `Link` >
|
||||
`GitLab Merge Request`, submit the dialog, and verify the linked-MR status
|
||||
control appears and survives reload.
|
||||
- **Mobile scenario:** extend
|
||||
`apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts` or the focused mobile
|
||||
task-link menu spec so the visible `Task actions` ellipsis opens the menu,
|
||||
the nested GitLab action is touch reachable and viewport-contained, and the
|
||||
dialog opens without right-click or long press.
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [task-01-frontend-link-menu](task-01-frontend-link-menu.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [task-02-e2e-contextual-linking](task-02-e2e-contextual-linking.md)
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run components/kanban-card-menu-items.test.tsx components/task/task-switcher.test.tsx components/gitlab/mr-topbar-button.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps/web && pnpm e2e:run tests/gitlab/gitlab-parity.spec.ts tests/gitlab/mobile-gitlab-parity.spec.ts
|
||||
make fmt
|
||||
make typecheck test lint
|
||||
```
|
||||
|
||||
## Verification Status
|
||||
|
||||
- Targeted desktop and mobile GitLab Playwright coverage passes (6/6), including
|
||||
validated screenshots, mobile touch targets, and viewport containment.
|
||||
- Aggregate format, typecheck, unit, and lint checks pass. Aggregate tests pass
|
||||
with `TMPDIR=/tmp`.
|
||||
|
||||
## Risks
|
||||
|
||||
- Task menu props cross kanban, desktop sidebar, and mobile task-switcher
|
||||
surfaces; missing one caller would make the action viewport-dependent.
|
||||
- GitLab availability is workspace-scoped. The action must disappear when the
|
||||
active workspace has no configured GitLab connection.
|
||||
- Removing the generic top-bar entry invalidates the existing manual-link E2E
|
||||
steps; those must be changed rather than deleted.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
id: "01-frontend-link-menu"
|
||||
title: "GitLab MR contextual link action"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/gitlab-integration/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: GitLab MR Contextual Link Action
|
||||
|
||||
## Acceptance
|
||||
|
||||
- An unlinked task renders no generic GitLab `Link MR` action in desktop or
|
||||
mobile task top bars, while linked MR status/dropdown behavior is unchanged.
|
||||
- A GitLab-configured workspace exposes `GitLab Merge Request` in the shared
|
||||
task `Link` submenu on kanban cards, desktop sidebar rows, and the mobile task
|
||||
switcher; unconfigured workspaces omit it.
|
||||
- Selecting the action opens the existing `TaskMRLinkDialog` for the selected
|
||||
task and active workspace with that task's repositories.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/components/gitlab/mr-topbar-button.tsx`
|
||||
- `apps/web/components/gitlab/mr-topbar-button.test.ts`
|
||||
- `apps/web/components/kanban-external-link-availability.ts`
|
||||
- `apps/web/components/kanban-card-menu-items.tsx`
|
||||
- `apps/web/components/kanban-card-menu-items.test.tsx`
|
||||
- `apps/web/components/kanban-card.tsx`
|
||||
- `apps/web/components/task/task-switcher-context-menu.tsx`
|
||||
- `apps/web/components/task/task-switcher.tsx`
|
||||
- `apps/web/components/task/task-switcher.test.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar-link-actions.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-task-linking.ts`
|
||||
- `apps/web/components/task/task-session-sidebar-dialogs.tsx`
|
||||
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec `What` bullets and the three contextual-link/top-bar scenarios.
|
||||
- Plan `Frontend`, `Mobile design contract`, and `Tests` sections.
|
||||
- Existing GitHub menu callback plumbing and `TaskGitHubPRDialog` wiring.
|
||||
- Existing `TaskMRLinkDialog`, `useGitLabAvailable`, and linked-MR top-bar
|
||||
dropdown behavior.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Follow `/tdd` and `/mobile-parity`; do not spawn subagents.
|
||||
- Add the failing menu-policy/unit coverage before production changes.
|
||||
- Keep the action label provider-specific: `GitLab Merge Request`.
|
||||
- Preserve `Link another merge request` inside the linked-MR top-bar dropdown.
|
||||
- Do not add backend endpoints, state slices, or a second GitLab link dialog.
|
||||
- Update only this task file's status. Do not edit `plan.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run components/kanban-card-menu-items.test.tsx components/task/task-switcher.test.tsx components/gitlab/mr-topbar-button.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the behavior implemented, files changed, RED/GREEN evidence, targeted
|
||||
test and typecheck results, mobile parity observations, blockers, and residual
|
||||
risks. Set this task to `in_progress` before code changes and `done` only after
|
||||
acceptance and verification pass.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: "02-e2e-contextual-linking"
|
||||
title: "GitLab contextual linking E2E"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-frontend-link-menu"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/gitlab-integration/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: GitLab Contextual Linking E2E
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Desktop E2E proves an unlinked task has no generic top-bar link action and
|
||||
can link an MR through right-click `Link` > `GitLab Merge Request`.
|
||||
- Mobile E2E proves the visible `Task actions` ellipsis exposes the same nested
|
||||
action, opens the link dialog, and does not rely on right-click or long press.
|
||||
- The desktop flow proves the linked-MR status control appears after linking
|
||||
and survives reload; mobile menu surfaces remain viewport-contained with
|
||||
touch-sized rows and no document horizontal overflow.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/e2e/tests/gitlab/gitlab-parity.spec.ts`
|
||||
- `apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts`
|
||||
- Optional focused page-object helper under `apps/web/e2e/pages/` only when it
|
||||
removes repeated stable selectors.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `01-frontend-link-menu`
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec contextual-link/top-bar scenarios.
|
||||
- Plan `E2E Tests` and `Mobile design contract` sections.
|
||||
- Existing manual-link section in `gitlab-parity.spec.ts`.
|
||||
- Existing mobile task-actions geometry coverage in
|
||||
`apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts` and nested-link
|
||||
coverage in `apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Follow `/e2e`, `/tdd`, and `/mobile-parity`; do not spawn subagents.
|
||||
- Confirm the updated test fails for the expected missing menu action before
|
||||
relying on the implementation.
|
||||
- Use the managed `pnpm e2e:run` runner so production frontend assets are
|
||||
rebuilt before the tests execute.
|
||||
- Update only this task file's status. Do not edit `plan.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run tests/gitlab/gitlab-parity.spec.ts tests/gitlab/mobile-gitlab-parity.spec.ts
|
||||
```
|
||||
|
||||
## Verification Status
|
||||
|
||||
The targeted desktop and mobile GitLab Playwright scenarios pass (6/6).
|
||||
Desktop and mobile screenshots were validated, along with mobile menu touch
|
||||
targets and viewport containment. Aggregate format, typecheck, unit, and lint
|
||||
checks pass, and aggregate tests pass with `TMPDIR=/tmp`.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report changed E2E scenarios, the exact command and result, rendered desktop
|
||||
and phone observations, failure artifact paths if any, blockers, and residual
|
||||
risks. Set this task to `in_progress` before changes and `done` only after both
|
||||
desktop and mobile acceptance criteria pass.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
status: shipped
|
||||
status: building
|
||||
created: 2026-05-04
|
||||
updated: 2026-07-20
|
||||
updated: 2026-07-22
|
||||
owner: tbd
|
||||
---
|
||||
|
||||
@@ -43,6 +43,13 @@ workflows are not usable end to end.
|
||||
- Users can link an existing task to a merge request by pasting a full MR URL,
|
||||
including URLs from the workspace's configured self-managed host. They can
|
||||
unlink one association without deleting the task or upstream MR.
|
||||
- For a workspace with GitLab configured, task context menus expose
|
||||
`GitLab Merge Request` inside the shared `Link` submenu. Desktop users can
|
||||
reach it by right-clicking a task, and touch users can reach the same action
|
||||
through the task row's visible actions menu.
|
||||
- An unlinked task does not show a persistent `Link MR` action in its task top
|
||||
bar. After at least one MR is linked, the top bar shows the linked-MR status
|
||||
control and continues to allow opening, unlinking, or linking another MR.
|
||||
- Linked MRs are visible from both the GitLab list and task detail. Multiple
|
||||
tasks can link to one MR, and a multi-repository task can link one MR per
|
||||
repository.
|
||||
@@ -322,6 +329,16 @@ protocol action name for compatibility.
|
||||
- **GIVEN** a self-managed workspace connection, **WHEN** a user links a valid
|
||||
MR URL from that host, **THEN** the task shows the linked MR and its live
|
||||
review details after reload.
|
||||
- **GIVEN** a task with no linked MR in a GitLab-configured workspace, **WHEN**
|
||||
the task detail opens, **THEN** the top bar has no `Link MR` action and the
|
||||
task's contextual `Link` submenu offers `GitLab Merge Request`.
|
||||
- **GIVEN** a touch viewport and a task with no linked MR in a GitLab-configured
|
||||
workspace, **WHEN** the user opens the task row's visible actions menu and
|
||||
chooses `Link` then `GitLab Merge Request`, **THEN** the GitLab MR link dialog
|
||||
opens without relying on right-click or long press.
|
||||
- **GIVEN** a task with a linked GitLab MR, **WHEN** the task detail opens,
|
||||
**THEN** the top bar shows the linked MR status control rather than a generic
|
||||
link action.
|
||||
- **GIVEN** an MR URL from a different host, **WHEN** it is linked in the current
|
||||
workspace, **THEN** the request is rejected and no association is written.
|
||||
- **GIVEN** a linked MR, **WHEN** the user unlinks it, **THEN** it disappears
|
||||
|
||||
Reference in New Issue
Block a user