mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 10:56:45 +08:00
* feat(navigation): unify internal link click semantics (resolver + board/content-link pilots) One shared resolveClickIntent() now defines the click behavior table (plain = in place, cmd = background tab, cmd+shift = foreground tab, middle = background). AppLink consumes it; openLink() threads the intent through multica:navigate so content links in markdown and the editor honor modifiers on all three platform listeners. Content links now navigate in place on plain click (desktop previously forced a new foreground tab). Editor mentions reuse the readonly mention cards instead of a hand-rolled anchor. The "open issue links in new tab" preference is removed (store, settings row, four locales). Co-Authored-By: Claude Fable 5 <[email protected]> * fix(navigation): restore mention click shielding and type the content-link event Review follow-ups: editor mention chips regain the stopPropagation shield the hand-rolled handlers had (parity with the readonly wrappers), and the desktop shell's multica:navigate listener types its event detail like the other two listeners. Co-Authored-By: Claude Fable 5 <[email protected]> * feat(navigation): complete the click-semantics unification across all surfaces Applies the shared click-intent contract everywhere the spec covers: - AppLink: caller onClick now runs before every navigation path and its preventDefault cancels navigation (fixes the sidebar pin drag guard, enables guarded links). - useRowLink and a new useIntentNavigate execute intents for row and callback surfaces; DataTable forwards the mouse event and middle clicks; table view navigates in place with modifier support. - Chat thread rows, inbox rows, search palette results, actor avatars, and editor links (middle click) honor modifiers. - "Open in new tab" added to inbox context menu and every entity row menu (projects, agents, skills, squads, autopilots, runtimes). - ~20 push-on-click buttons converted to AppLink; agent-create back affordances use useBackOrReplace. - Desktop: Cmd+[/], Cmd+arrows, mouse side buttons bound to per-tab history; Windows app-command forwarding; middle click closes tabs. - Web: scroll restoration provider capturing data-tab-scroll-root offsets keyed by pathname, served through the shared pull protocol. - Sub-issues section collapse moved to an in-memory keyed store so it survives in-session back navigation. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(navigation): address review findings on the unification pass - Mouse side buttons: single cross-platform source (renderer mouseup); main process no longer forwards app-command, which double-navigated on Windows/Linux. Bindings hook moved to use-tab-history with tests. - useIntentNavigate requires a NavigationProvider again (no silent test-shaped fallback); inbox tests mount real providers and assert the new modifier-click and context-menu behavior. - Web scroll restoration: writes are suppressed briefly after a memento is served, so clamped restore scrolls can't overwrite or delete it. - Create-issue dialogs keep the modal open when "Customize fields" is modifier-clicked into a background tab. - Entity row menus share one "Open in new tab" shape (adapter call); DataTable click path honors defaultPrevented; ActorAvatar reuses useIntentNavigate instead of a fourth copy of the intent switch. - Search palette: behavioral tests for cmd+click, cmd+Enter, and stale-intent reset; navigation mocks repointed at the context module so the real hooks stay under test. Co-Authored-By: Claude Fable 5 <[email protected]> * fix(navigation): review blockers — tab insertion position and auxclick cell shielding - openTab inserts the new tab immediately right of the opener (the active tab), per MUL-5860 and browser convention, capped at the pinned-first boundary. The explicit "+" button (addTab) still appends; a dedupe hit focuses without reordering. Regression tests cover all four shapes. - Issue table interactive cells (pickers, checkboxes, expand/rename/ create-sub-issue buttons, in-rename capture guard) now stop auxclick the same way they stop click, so middle-clicking a control no longer bubbles into a background-tab row open. Test asserts controls are inert and row dead space still opens. - Cross-workspace open keeping focus-follows is documented at the adapter as a deliberate product exception (MUL-5860): a background tab in a non-visible workspace group would give zero feedback. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>
287 lines
9.8 KiB
TypeScript
287 lines
9.8 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { I18nProvider } from "@multica/core/i18n/react";
|
|
import enCommon from "../../locales/en/common.json";
|
|
import enSettings from "../../locales/en/settings.json";
|
|
|
|
const mockUpdateWorkspace = vi.hoisted(() => vi.fn());
|
|
const mockDeleteInstallation = vi.hoisted(() => vi.fn());
|
|
const mockGetConnectURL = vi.hoisted(() => vi.fn());
|
|
const mockInvalidate = vi.hoisted(() => vi.fn());
|
|
const mockNavPush = vi.hoisted(() => vi.fn());
|
|
const mockSetQueryData = vi.hoisted(() => vi.fn());
|
|
const mockToastSuccess = vi.hoisted(() => vi.fn());
|
|
|
|
const workspaceRef = vi.hoisted(() => ({
|
|
current: {
|
|
id: "workspace-1",
|
|
name: "Acme",
|
|
slug: "acme",
|
|
settings: {} as Record<string, unknown>,
|
|
repos: [{ url: "https://github.com/acme/api" }] as { url: string }[],
|
|
},
|
|
}));
|
|
type MemberRole = "owner" | "admin" | "member" | "guest";
|
|
const membersRef = vi.hoisted(() => ({
|
|
current: [{ user_id: "user-1", role: "owner" as MemberRole }],
|
|
}));
|
|
const installationsRef = vi.hoisted(() => ({
|
|
current: {
|
|
installations: [] as {
|
|
id: string;
|
|
account_login: string;
|
|
installation_id?: number;
|
|
connected_by?: string;
|
|
}[],
|
|
configured: true,
|
|
can_manage: true as boolean,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQuery: (opts: { queryKey: unknown[] }) => {
|
|
const key = JSON.stringify(opts.queryKey);
|
|
if (key.includes("members")) return { data: membersRef.current };
|
|
if (key.includes("installations")) return { data: installationsRef.current };
|
|
return { data: undefined };
|
|
},
|
|
useQueryClient: () => ({
|
|
setQueryData: mockSetQueryData,
|
|
invalidateQueries: mockInvalidate,
|
|
}),
|
|
queryOptions: <T,>(opts: T) => opts,
|
|
}));
|
|
|
|
vi.mock("@multica/core/hooks", () => ({
|
|
useWorkspaceId: () => "workspace-1",
|
|
}));
|
|
|
|
vi.mock("@multica/core/paths", () => ({
|
|
useCurrentWorkspace: () => workspaceRef.current,
|
|
}));
|
|
|
|
vi.mock("@multica/core/workspace/queries", () => ({
|
|
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
|
workspaceKeys: { list: () => ["workspaces"] },
|
|
}));
|
|
|
|
vi.mock("@multica/core/github", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/github")>("@multica/core/github");
|
|
return {
|
|
...actual,
|
|
githubInstallationsOptions: () => ({
|
|
queryKey: ["github", "installations"],
|
|
queryFn: vi.fn(),
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {
|
|
updateWorkspace: mockUpdateWorkspace,
|
|
deleteGitHubInstallation: mockDeleteInstallation,
|
|
getGitHubConnectURL: mockGetConnectURL,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@multica/core/auth", () => {
|
|
const useAuthStore = Object.assign(
|
|
(sel?: (s: { user: { id: string } }) => unknown) =>
|
|
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
|
|
{ getState: () => ({ user: { id: "user-1" } }) },
|
|
);
|
|
return { useAuthStore };
|
|
});
|
|
|
|
// Mocked at the context module rather than the barrel so <AppLink> stays the
|
|
// real component and its click contract is what the test exercises.
|
|
vi.mock("../../navigation/context", () => ({
|
|
useNavigation: () => ({
|
|
push: mockNavPush,
|
|
replace: vi.fn(),
|
|
back: vi.fn(),
|
|
pathname: "/acme/settings",
|
|
searchParams: new URLSearchParams("tab=github"),
|
|
getShareableUrl: (p: string) => `https://app.example${p}`,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: { success: mockToastSuccess, error: vi.fn() },
|
|
}));
|
|
|
|
import { GitHubTab } from "./github-tab";
|
|
|
|
const TEST_RESOURCES = {
|
|
en: { common: enCommon, settings: enSettings },
|
|
};
|
|
|
|
function I18nWrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
|
{children}
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
function resetFixtures() {
|
|
vi.clearAllMocks();
|
|
workspaceRef.current = {
|
|
id: "workspace-1",
|
|
name: "Acme",
|
|
slug: "acme",
|
|
settings: {},
|
|
repos: [{ url: "https://github.com/acme/api" }],
|
|
};
|
|
membersRef.current = [{ user_id: "user-1", role: "owner" }];
|
|
installationsRef.current = { installations: [], configured: true, can_manage: true };
|
|
}
|
|
|
|
describe("GitHubTab", () => {
|
|
beforeEach(resetFixtures);
|
|
|
|
it("folds the non-dev hint into the master switch description (no separate callout)", () => {
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
expect(screen.getByText(/Not a development team\? Just turn it off here\./)).toBeTruthy();
|
|
// The old standalone callout (title + dedicated "Turn GitHub off" button) is gone.
|
|
expect(screen.queryByRole("button", { name: /^Turn GitHub off$/ })).toBeNull();
|
|
});
|
|
|
|
it("does not show the hint once the master switch is off", () => {
|
|
workspaceRef.current.settings = { github_enabled: false };
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
expect(screen.queryByText(/Not a development team\?/)).toBeNull();
|
|
});
|
|
|
|
it("disables every feature switch when the master switch is off", () => {
|
|
workspaceRef.current.settings = { github_enabled: false };
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
|
|
const master = screen.getByRole("switch", { name: /enable github features/i });
|
|
expect(master.getAttribute("aria-checked")).toBe("false");
|
|
|
|
const switches = screen.getAllByRole("switch");
|
|
// First switch is master; remaining must be disabled (aria-disabled or disabled attr)
|
|
const features = switches.slice(1);
|
|
expect(features.length).toBeGreaterThan(0);
|
|
for (const sw of features) {
|
|
const ariaDisabled = sw.getAttribute("aria-disabled");
|
|
const disabled = sw.hasAttribute("disabled");
|
|
expect(ariaDisabled === "true" || disabled).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("flipping the master switch off persists github_enabled=false and merges existing settings", async () => {
|
|
const user = userEvent.setup();
|
|
workspaceRef.current.settings = { co_authored_by_enabled: true };
|
|
mockUpdateWorkspace.mockResolvedValue({
|
|
...workspaceRef.current,
|
|
settings: { co_authored_by_enabled: true, github_enabled: false },
|
|
});
|
|
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("switch", { name: /enable github features/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateWorkspace).toHaveBeenCalledWith("workspace-1", {
|
|
settings: { co_authored_by_enabled: true, github_enabled: false },
|
|
});
|
|
expect(mockToastSuccess).toHaveBeenCalledWith("Changes saved", {
|
|
id: "settings-auto-save",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("clicking Disconnect opens the confirmation and only fires on confirm", async () => {
|
|
const user = userEvent.setup();
|
|
installationsRef.current = {
|
|
configured: true,
|
|
can_manage: true,
|
|
installations: [{ id: "inst-42", account_login: "acme", installation_id: 42 }],
|
|
};
|
|
mockDeleteInstallation.mockResolvedValue(undefined);
|
|
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("button", { name: /^Disconnect$/ }));
|
|
expect(screen.getByText(/Multica will stop receiving webhooks/i)).toBeTruthy();
|
|
expect(mockDeleteInstallation).not.toHaveBeenCalled();
|
|
|
|
const dialogConfirm = screen
|
|
.getAllByRole("button", { name: /^Disconnect$/ })
|
|
.find((b) => b.getAttribute("data-slot")?.includes("alert-dialog"));
|
|
await user.click(dialogConfirm ?? screen.getAllByRole("button", { name: /^Disconnect$/ })[1]!);
|
|
|
|
await waitFor(() => {
|
|
expect(mockDeleteInstallation).toHaveBeenCalledWith("workspace-1", "inst-42");
|
|
});
|
|
});
|
|
|
|
it("Disconnect button is still visible when the master switch is off", () => {
|
|
workspaceRef.current.settings = { github_enabled: false };
|
|
installationsRef.current = {
|
|
configured: true,
|
|
can_manage: true,
|
|
installations: [{ id: "inst-1", account_login: "acme", installation_id: 1 }],
|
|
};
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
expect(screen.getByRole("button", { name: /^Disconnect$/ })).toBeTruthy();
|
|
});
|
|
|
|
it("non-admin sees the existing connection but no Connect/Disconnect controls", () => {
|
|
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
|
installationsRef.current = {
|
|
configured: true,
|
|
can_manage: false,
|
|
installations: [{ id: "inst-1", account_login: "acme" }],
|
|
};
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
|
|
expect(screen.getByText(/Connected to acme/i)).toBeTruthy();
|
|
expect(screen.getByText(/Read-only view\./i)).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: /^Connect GitHub$/ })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /^Disconnect$/ })).toBeNull();
|
|
});
|
|
|
|
it("non-admin with no connection sees the contact-admin hint", () => {
|
|
membersRef.current = [{ user_id: "user-1", role: "member" }];
|
|
installationsRef.current = {
|
|
configured: true,
|
|
can_manage: false,
|
|
installations: [],
|
|
};
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
|
|
expect(screen.getByText(/Ask an admin or owner/i)).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: /^Connect GitHub$/ })).toBeNull();
|
|
});
|
|
|
|
it("renders the connected_by line when the backend provides it", () => {
|
|
installationsRef.current = {
|
|
configured: true,
|
|
can_manage: true,
|
|
installations: [
|
|
{
|
|
id: "inst-7",
|
|
account_login: "acme",
|
|
installation_id: 7,
|
|
connected_by: "Jiayuan",
|
|
},
|
|
],
|
|
};
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
expect(screen.getByText(/Connected by Jiayuan/)).toBeTruthy();
|
|
});
|
|
|
|
it("repositories shortcut navigates to the repositories tab", async () => {
|
|
const user = userEvent.setup();
|
|
render(<GitHubTab />, { wrapper: I18nWrapper });
|
|
await user.click(screen.getByRole("button", { name: /Manage repositories/ }));
|
|
expect(mockNavPush).toHaveBeenCalledWith("/acme/settings?tab=repositories");
|
|
});
|
|
});
|