mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
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:
co-authored by
multica-agent
Lambda
parent
13706d60d0
commit
a3dfd24393
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user