mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
main
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11da9b07da |
@@ -86,14 +86,25 @@ vi.mock("@multica/core/paths", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mutable so individual tests can simulate the desktop shell (current path +
|
||||
// the desktop-only `openInNewTab` adapter method) for the "Open in new tab" item.
|
||||
const navMock: {
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
pathname: string;
|
||||
searchParams: URLSearchParams;
|
||||
back: ReturnType<typeof vi.fn>;
|
||||
replace: ReturnType<typeof vi.fn>;
|
||||
openInNewTab?: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
push: vi.fn(),
|
||||
pathname: "/test/issues/issue-1",
|
||||
searchParams: new URLSearchParams(),
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
openInNewTab: undefined,
|
||||
};
|
||||
vi.mock("../../../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
pathname: "/test/issues/issue-1",
|
||||
searchParams: new URLSearchParams(),
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useNavigation: () => navMock,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -142,8 +153,19 @@ function wrap(ui: React.ReactNode) {
|
||||
|
||||
beforeEach(() => {
|
||||
mockOpenModal.mockReset();
|
||||
navMock.push.mockReset();
|
||||
navMock.pathname = "/test/issues/issue-1";
|
||||
navMock.openInNewTab = undefined;
|
||||
delete (window as unknown as { desktopAPI?: unknown }).desktopAPI;
|
||||
});
|
||||
|
||||
/** Stub the preload bridge so `isDesktopShell()` returns true. */
|
||||
function enterDesktopShell() {
|
||||
(window as unknown as { desktopAPI?: unknown }).desktopAPI = {
|
||||
pickDirectory: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueActionsDropdown", () => {
|
||||
it("renders the top-level items when the trigger is clicked", async () => {
|
||||
render(
|
||||
@@ -165,12 +187,55 @@ describe("IssueActionsDropdown", () => {
|
||||
expect(screen.getByText("Copy link")).toBeInTheDocument();
|
||||
expect(screen.getByText("More")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete issue")).toBeInTheDocument();
|
||||
// "Open in new tab" is desktop-only; absent on web / outside the shell.
|
||||
expect(screen.queryByText("Open in new tab")).not.toBeInTheDocument();
|
||||
// Relationship actions are hidden inside the "More" submenu by default.
|
||||
expect(screen.queryByText("Create sub-issue")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Set parent issue...")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Open in new tab' on desktop from a different path and opens a foreground tab", async () => {
|
||||
enterDesktopShell();
|
||||
navMock.openInNewTab = vi.fn();
|
||||
navMock.pathname = "/test/issues"; // list view, not this issue's own tab
|
||||
render(
|
||||
wrap(
|
||||
<IssueActionsDropdown
|
||||
issue={mockIssue}
|
||||
trigger={<button data-testid="trigger">Menu</button>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("trigger"));
|
||||
fireEvent.click(await screen.findByText("Open in new tab"));
|
||||
|
||||
expect(navMock.openInNewTab).toHaveBeenCalledWith(
|
||||
"/test/issues/issue-1",
|
||||
undefined,
|
||||
{ activate: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("hides 'Open in new tab' on the issue's own detail tab (target === current path)", async () => {
|
||||
enterDesktopShell();
|
||||
navMock.openInNewTab = vi.fn();
|
||||
navMock.pathname = "/test/issues/issue-1";
|
||||
render(
|
||||
wrap(
|
||||
<IssueActionsDropdown
|
||||
issue={mockIssue}
|
||||
trigger={<button data-testid="trigger">Menu</button>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("trigger"));
|
||||
await screen.findByText("Copy link");
|
||||
expect(screen.queryByText("Open in new tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking the Assignee item opens the shared AssigneePicker popover", async () => {
|
||||
render(
|
||||
wrap(
|
||||
|
||||
@@ -58,15 +58,27 @@ vi.mock("@multica/core/paths", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mutable so individual tests can flip the current path and toggle the
|
||||
// desktop-only `openInNewTab` capability (absent on the web adapter).
|
||||
const navMock: {
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
pathname: string;
|
||||
searchParams: URLSearchParams;
|
||||
back: ReturnType<typeof vi.fn>;
|
||||
replace: ReturnType<typeof vi.fn>;
|
||||
getShareableUrl: (p: string) => string;
|
||||
openInNewTab?: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
push: vi.fn(),
|
||||
pathname: "/test/issues/issue-1",
|
||||
searchParams: new URLSearchParams(),
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
getShareableUrl: (p: string) => `https://app.multica.com${p}`,
|
||||
openInNewTab: undefined,
|
||||
};
|
||||
vi.mock("../../../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
pathname: "/test/issues/issue-1",
|
||||
searchParams: new URLSearchParams(),
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
getShareableUrl: (p: string) => `https://app.multica.com${p}`,
|
||||
}),
|
||||
useNavigation: () => navMock,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -111,12 +123,23 @@ beforeEach(() => {
|
||||
mockDeletePinMutate.mockReset();
|
||||
pinListRef.value = [];
|
||||
localStorage.clear();
|
||||
navMock.push.mockReset();
|
||||
navMock.pathname = "/test/issues/issue-1";
|
||||
navMock.openInNewTab = undefined;
|
||||
delete (window as unknown as { desktopAPI?: unknown }).desktopAPI;
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
/** Stub the preload bridge so `isDesktopShell()` returns true. */
|
||||
function enterDesktopShell() {
|
||||
(window as unknown as { desktopAPI?: unknown }).desktopAPI = {
|
||||
pickDirectory: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useIssueActions", () => {
|
||||
it("updateField dispatches useUpdateIssue.mutate with the correct payload", () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
@@ -241,6 +264,48 @@ describe("useIssueActions", () => {
|
||||
expect(mockCreatePinMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("canOpenInNewTab is false on web (no desktop shell, no adapter method)", () => {
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
expect(result.current.canOpenInNewTab).toBe(false);
|
||||
});
|
||||
|
||||
it("canOpenInNewTab is false on desktop when the issue is already the active tab", () => {
|
||||
enterDesktopShell();
|
||||
navMock.openInNewTab = vi.fn();
|
||||
navMock.pathname = "/test/issues/issue-1"; // === paths.issueDetail("issue-1")
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
expect(result.current.canOpenInNewTab).toBe(false);
|
||||
});
|
||||
|
||||
it("canOpenInNewTab is true on desktop from a different path (e.g. a list row)", () => {
|
||||
enterDesktopShell();
|
||||
navMock.openInNewTab = vi.fn();
|
||||
navMock.pathname = "/test/issues";
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
expect(result.current.canOpenInNewTab).toBe(true);
|
||||
});
|
||||
|
||||
it("openInNewTab opens the issue path in a foreground tab", () => {
|
||||
navMock.openInNewTab = vi.fn();
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.openInNewTab();
|
||||
});
|
||||
|
||||
expect(navMock.openInNewTab).toHaveBeenCalledWith(
|
||||
"/test/issues/issue-1",
|
||||
undefined,
|
||||
{ activate: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("openInNewTab is a safe no-op when the adapter lacks the capability (web)", () => {
|
||||
navMock.openInNewTab = undefined;
|
||||
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
|
||||
expect(() => act(() => result.current.openInNewTab())).not.toThrow();
|
||||
});
|
||||
|
||||
it("is a safe no-op when issue is null", () => {
|
||||
const { result } = renderHook(() => useIssueActions(null), { wrapper });
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ArrowUp,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
@@ -100,6 +101,8 @@ export function IssueActionsMenuItems({
|
||||
updateField,
|
||||
togglePin,
|
||||
copyLink,
|
||||
openInNewTab,
|
||||
canOpenInNewTab,
|
||||
openCreateSubIssue,
|
||||
openSetParent,
|
||||
openAddChild,
|
||||
@@ -262,6 +265,12 @@ export function IssueActionsMenuItems({
|
||||
)}
|
||||
{isPinned ? t(($) => $.actions.unpin_from_sidebar) : t(($) => $.actions.pin_to_sidebar)}
|
||||
</P.Item>
|
||||
{canOpenInNewTab && (
|
||||
<P.Item onClick={openInNewTab}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t(($) => $.actions.open_in_new_tab)}
|
||||
</P.Item>
|
||||
)}
|
||||
<P.Item onClick={copyLink}>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t(($) => $.actions.copy_link)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useModalStore } from "@multica/core/modals";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { isDesktopShell } from "../../platform";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed";
|
||||
@@ -20,6 +21,12 @@ export interface UseIssueActionsResult {
|
||||
updateField: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
togglePin: () => void;
|
||||
copyLink: () => Promise<void>;
|
||||
/** Desktop-only: open this issue in a new tab (foreground). */
|
||||
openInNewTab: () => void;
|
||||
/** Whether the "Open in new tab" item should render — desktop shell only,
|
||||
* and hidden when the issue is already the active tab (target === current
|
||||
* path), so the detail kebab never offers a no-op while list rows still do. */
|
||||
canOpenInNewTab: boolean;
|
||||
openCreateSubIssue: () => void;
|
||||
openSetParent: () => void;
|
||||
openAddChild: () => void;
|
||||
@@ -109,6 +116,24 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
}
|
||||
}, [paths, issueId, navigation, t]);
|
||||
|
||||
const openInNewTab = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
navigation.openInNewTab?.(paths.issueDetail(issueId), undefined, {
|
||||
activate: true,
|
||||
});
|
||||
}, [issueId, navigation, paths]);
|
||||
|
||||
// Desktop-only. `openInNewTab` is absent on the web adapter, and we hide the
|
||||
// item when the target path equals the current path so the issue's own detail
|
||||
// kebab doesn't surface a self-referential no-op (openTab would just refocus
|
||||
// the current tab). List-row context menus sit on a different path, so they
|
||||
// keep showing it.
|
||||
const canOpenInNewTab =
|
||||
!!issueId &&
|
||||
isDesktopShell() &&
|
||||
!!navigation.openInNewTab &&
|
||||
navigation.pathname !== paths.issueDetail(issueId);
|
||||
|
||||
const openCreateSubIssue = useCallback(() => {
|
||||
if (!issueId) return;
|
||||
openModal("create-issue", {
|
||||
@@ -145,6 +170,8 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
updateField,
|
||||
togglePin,
|
||||
copyLink,
|
||||
openInNewTab,
|
||||
canOpenInNewTab,
|
||||
openCreateSubIssue,
|
||||
openSetParent,
|
||||
openAddChild,
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"unassigned": "Unassigned",
|
||||
"pin_to_sidebar": "Pin to sidebar",
|
||||
"unpin_from_sidebar": "Unpin from sidebar",
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"copy_link": "Copy link",
|
||||
"copy_workdir_path": "Copy local workdir path",
|
||||
"more": "More",
|
||||
|
||||
@@ -373,6 +373,7 @@
|
||||
"unassigned": "未割り当て",
|
||||
"pin_to_sidebar": "サイドバーにピン留め",
|
||||
"unpin_from_sidebar": "サイドバーのピン留めを解除",
|
||||
"open_in_new_tab": "新しいタブで開く",
|
||||
"copy_link": "リンクをコピー",
|
||||
"copy_workdir_path": "ローカルの作業ディレクトリのパスをコピー",
|
||||
"more": "その他",
|
||||
|
||||
@@ -389,6 +389,7 @@
|
||||
"unassigned": "담당자 없음",
|
||||
"pin_to_sidebar": "사이드바에 고정",
|
||||
"unpin_from_sidebar": "사이드바 고정 해제",
|
||||
"open_in_new_tab": "새 탭에서 열기",
|
||||
"copy_link": "링크 복사",
|
||||
"copy_workdir_path": "로컬 작업 디렉터리 경로 복사",
|
||||
"more": "더 보기",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"unassigned": "未分配",
|
||||
"pin_to_sidebar": "固定到侧边栏",
|
||||
"unpin_from_sidebar": "从侧边栏取消固定",
|
||||
"open_in_new_tab": "在新标签页打开",
|
||||
"copy_link": "复制链接",
|
||||
"copy_workdir_path": "复制本地 workdir 路径",
|
||||
"more": "更多",
|
||||
|
||||
Reference in New Issue
Block a user