MUL-6082: fix Board assignee avatar interactions (#6854)

* fix(issues): route board avatars to actor profiles

Co-authored-by: multica-agent <[email protected]>

* fix(issues): cancel deferred card navigation

Co-authored-by: multica-agent <[email protected]>

* fix(issues): open assignee picker from board avatars

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiayuan Zhang
2026-08-13 01:47:20 +08:00
committed by GitHub
co-authored by multica-agent Lambda
parent 13706d60d0
commit a3dfd24393
6 changed files with 274 additions and 9 deletions
@@ -10,8 +10,10 @@
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { AppLink } from "../navigation/app-link";
import { NavigationProvider } from "../navigation/context";
import type { NavigationAdapter } from "../navigation/types";
import { DeferredPopup } from "./deferred-popup";
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
@@ -72,6 +74,47 @@ function renderAvatar(adapter: NavigationAdapter) {
);
}
function PickerWrapper({ children }: { children: React.ReactNode }) {
const stop = (event: React.SyntheticEvent) => {
event.stopPropagation();
event.preventDefault();
};
return (
<div onClick={stop} onMouseDown={stop} onPointerDown={stop}>
{children}
</div>
);
}
function renderCardPicker(adapter: NavigationAdapter) {
return render(
<NavigationProvider value={adapter}>
<AppLink href="/acme/issues/issue-1">
<PickerWrapper>
<DeferredPopup
trigger={
<span>
<ActorAvatar
actorType="member"
actorId={MEMBER_ID}
enableHoverCard
/>
<span>Change assignee</span>
</span>
}
>
{(open) => (
<span data-testid="mounted-picker" data-open={String(open)}>
Picker mounted
</span>
)}
</DeferredPopup>
</PickerWrapper>
</AppLink>
</NavigationProvider>,
);
}
describe("ActorAvatar profile link", () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -88,6 +131,26 @@ describe("ActorAvatar profile link", () => {
expect(open).not.toHaveBeenCalled();
});
it("opens the deferred picker while cancelling the enclosing card link", () => {
const push = vi.fn();
renderCardPicker(makeAdapter({ push }));
expect(fireEvent.click(screen.getByText("Change assignee"))).toBe(false);
expect(screen.getByTestId("mounted-picker")).toHaveAttribute("data-open", "true");
expect(push).not.toHaveBeenCalled();
});
it("yields to the picker by default without leaking native card navigation", () => {
const push = vi.fn();
const { container } = renderCardPicker(makeAdapter({ push }));
const avatar = container.querySelector('[data-slot="avatar"]');
expect(avatar).not.toBeNull();
expect(fireEvent.click(avatar!)).toBe(false);
expect(screen.getByTestId("mounted-picker")).toHaveAttribute("data-open", "true");
expect(push).not.toHaveBeenCalled();
});
it("uses openInNewTab for cmd/ctrl click when available (desktop)", () => {
const push = vi.fn();
const openInNewTab = vi.fn();
+3 -6
View File
@@ -150,12 +150,9 @@ export function ActorAvatar({
/**
* Not an `<a>` on purpose: the avatar is often composed inside menu items,
* options and buttons, where it must yield the click to the surrounding
* control untouched (no preventDefault — a real anchor would need one to
* suppress its own navigation, and a pre-prevented click can change how the
* host control behaves). The cost is no native context menu; modifier and
* middle clicks are implemented here instead, with the same intent semantics
* as AppLink.
* options and buttons, where it yields the click to the surrounding control.
* The cost is no native context menu, so modifier and middle clicks are
* implemented here with the same intent semantics as AppLink.
*/
function ActorAvatarProfileLink({
href,
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DeferredPopup } from "./deferred-popup";
function popupContent(open: boolean) {
return (
<span data-testid="mounted-popup" data-open={String(open)}>
Popup mounted
</span>
);
}
describe("DeferredPopup", () => {
it("opens its default trigger and cancels an enclosing link's native navigation", () => {
render(
<a href="/issues/issue-1">
<DeferredPopup trigger={<span>Open picker</span>}>
{popupContent}
</DeferredPopup>
</a>,
);
expect(fireEvent.click(screen.getByText("Open picker"))).toBe(false);
expect(screen.getByTestId("mounted-popup")).toHaveAttribute("data-open", "true");
});
it("does the same for a custom trigger element", () => {
render(
<a href="/issues/issue-1">
<DeferredPopup triggerRender={<button type="button">Open priority</button>}>
{popupContent}
</DeferredPopup>
</a>,
);
expect(fireEvent.click(screen.getByText("Open priority"))).toBe(false);
expect(screen.getByTestId("mounted-popup")).toHaveAttribute("data-open", "true");
});
});
+5 -3
View File
@@ -19,9 +19,10 @@ import { cloneElement, useState, type ReactElement, type ReactNode } from "react
* swaps the element mid-gesture (no pointerenter/pointerdown warming),
* every event of the gesture lands on a node that is still attached —
* both for real pointers and for synthetic sequences (tests, assistive
* tech). The in-flight click is stopped from propagating so the popup's
* just-mounted outside-press dismissal doesn't treat it as an outside
* click and close the popover in the same breath.
* tech). The in-flight click is prevented and stopped so an enclosing link
* cannot perform native navigation, and the popup's just-mounted
* outside-press dismissal doesn't treat it as an outside click and close
* the popover in the same breath.
* - `Enter`/`Space` do the same for keyboard.
*
* Only for uncontrolled usages: callers that pass `open`/`onOpenChange`/
@@ -70,6 +71,7 @@ export function DeferredPopup({
};
const handlers = {
onClick: (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
mountOpen();
},
@@ -0,0 +1,163 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { Issue, IssueAssigneeType } from "@multica/core/types";
import { AppLink, NavigationProvider, type NavigationAdapter } from "../../navigation";
import {
IssueSurfaceActionsProvider,
type IssueSurfaceActions,
} from "../surface/actions-context";
import { BoardCardContent } from "./board-card";
vi.mock("@tanstack/react-query", async (importOriginal) => ({
...(await importOriginal<typeof import("@tanstack/react-query")>()),
useQuery: () => ({ data: [] }),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/properties", () => ({
propertyListOptions: () => ({ queryKey: ["properties"] }),
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: "viewer-1" } }),
}));
vi.mock("@multica/core/agents", () => ({
isAgentRuntimeBound: () => true,
useAgentPresenceDetail: () => ({ availability: "offline", workload: null }),
}));
vi.mock("@multica/core/paths", () => ({
useCurrentWorkspace: () => ({ id: "ws-1", slug: "acme" }),
useWorkspacePaths: () => ({
memberDetail: (id: string) => `/acme/members/${id}`,
agentDetail: (id: string) => `/acme/agents/${id}`,
squadDetail: (id: string) => `/acme/squads/${id}`,
}),
}));
const viewState = vi.hoisted(() => ({
cardProperties: {
priority: false,
description: false,
assignee: true,
startDate: true,
dueDate: true,
project: false,
childProgress: false,
labels: false,
},
cardPropertyIds: [],
}));
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
useViewStore: (selector: (state: typeof viewState) => unknown) => selector(viewState),
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (type: string) => `Assigned ${type}`,
getActorInitials: () => "AA",
getActorAvatarUrl: () => null,
}),
}));
vi.mock("../../i18n", () => ({
useT: () => ({ t: () => "Translated" }),
useTimeAgo: () => () => "now",
}));
vi.mock("../../agents/components/agent-profile-card", () => ({
AgentProfileCard: () => null,
}));
vi.mock("../../agents/components/agent-live-peek-card", () => ({
AgentLivePeekCard: () => null,
}));
vi.mock("../../members/member-profile-card", () => ({
MemberProfileCard: () => null,
}));
vi.mock("../../squads/components/squad-profile-card", () => ({
SquadProfileCard: () => null,
}));
vi.mock("./issue-agent-activity-indicator", () => ({
IssueAgentActivityIndicator: () => null,
}));
const navigation: NavigationAdapter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
getShareableUrl: (path) => `https://app.example${path}`,
};
const actions: IssueSurfaceActions = {
isPending: false,
createIssue: vi.fn(),
updateIssue: vi.fn(),
moveIssue: vi.fn(),
batchUpdate: vi.fn().mockResolvedValue(undefined),
batchDelete: vi.fn().mockResolvedValue(undefined),
};
function makeIssue(assigneeType: IssueAssigneeType): Issue {
return {
id: `issue-${assigneeType}`,
workspace_id: "ws-1",
number: 6082,
identifier: "MUL-6082",
title: "Fix Board assignee interaction",
description: null,
status: "todo",
priority: "none",
assignee_type: assigneeType,
assignee_id: `${assigneeType}-1`,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: null,
project_id: null,
position: 1,
stage: null,
start_date: "2026-08-12",
due_date: "2026-08-13",
metadata: {},
properties: {},
labels: [],
created_at: "2026-08-12T00:00:00Z",
updated_at: "2026-08-12T00:00:00Z",
};
}
describe("BoardCardContent assignee picker", () => {
it.each<IssueAssigneeType>(["member", "agent", "squad"])(
"opens the picker from an avatar-only %s assignee without navigating the card",
(assigneeType) => {
const issue = makeIssue(assigneeType);
const { container } = render(
<NavigationProvider value={navigation}>
<IssueSurfaceActionsProvider actions={actions}>
<AppLink href={`/acme/issues/${issue.id}`}>
<BoardCardContent issue={issue} editable />
</AppLink>
</IssueSurfaceActionsProvider>
</NavigationProvider>,
);
const avatar = container.querySelector('[data-slot="avatar"]');
expect(avatar).not.toBeNull();
expect(avatar!.closest('[role="link"]')).toBeNull();
expect(fireEvent.click(avatar!)).toBe(false);
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(navigation.push).not.toHaveBeenCalled();
},
);
});
@@ -141,6 +141,7 @@ export const BoardCardContent = memo(function BoardCardContent({
actorId={issue.assignee_id!}
size="sm"
enableHoverCard
profileLink={false}
className="shrink-0"
/>
{assigneeName && (