mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat(issues): add sticky setting for the issue comment bar (MUL-4435) The bottom comment composer can now pin itself to the scroll viewport so it stays reachable while reading a long timeline. A pin toggle on the bar itself persists the preference (default: on) via a new localStorage-backed useCommentComposerStore. While pinned, the editor area is height-capped so long drafts scroll internally instead of covering the timeline. Co-authored-by: multica-agent <github@multica.ai> * refactor(settings): move the sticky comment bar toggle to Settings > Preferences (MUL-4435) Per review feedback: pinning the comment bar is a low-frequency choice, so the toggle moves off the bar itself into Settings > Preferences as a Switch row. The composer keeps reading the store for the sticky behavior and the 40vh editor cap; the pin button and its issues-locale tooltips are removed. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import {
|
|
describe,
|
|
it,
|
|
expect,
|
|
beforeAll,
|
|
beforeEach,
|
|
afterAll,
|
|
afterEach,
|
|
vi,
|
|
} from "vitest";
|
|
import { render, screen, act, cleanup, 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 enAuth from "../../locales/en/auth.json";
|
|
import enSettings from "../../locales/en/settings.json";
|
|
|
|
const mockPersist = vi.hoisted(() => vi.fn());
|
|
const mockUpdateMe = vi.hoisted(() => vi.fn());
|
|
const mockReload = vi.hoisted(() => vi.fn());
|
|
const mockToastWarning = vi.hoisted(() => vi.fn());
|
|
const mockToastError = vi.hoisted(() => vi.fn());
|
|
const mockToastSuccess = vi.hoisted(() => vi.fn());
|
|
const mockSetTheme = vi.hoisted(() => vi.fn());
|
|
const mockSetUser = vi.hoisted(() => vi.fn());
|
|
const userRef = vi.hoisted(() => ({
|
|
current: null as { id: string; timezone?: string | null } | null,
|
|
}));
|
|
|
|
vi.mock("@multica/ui/components/common/theme-provider", () => ({
|
|
useTheme: () => ({ theme: "light", setTheme: mockSetTheme }),
|
|
}));
|
|
|
|
vi.mock("@multica/core/i18n/react", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/i18n/react")>(
|
|
"@multica/core/i18n/react",
|
|
);
|
|
return {
|
|
...actual,
|
|
useLocaleAdapter: () => ({
|
|
persist: mockPersist,
|
|
getUserChoice: () => null,
|
|
getSystemPreferences: () => [],
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: { updateMe: mockUpdateMe },
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
warning: mockToastWarning,
|
|
error: mockToastError,
|
|
success: mockToastSuccess,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@multica/core/auth", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@multica/core/auth")>(
|
|
"@multica/core/auth",
|
|
);
|
|
type AuthState = {
|
|
user: typeof userRef.current;
|
|
setUser: typeof mockSetUser;
|
|
};
|
|
const state = (): AuthState => ({
|
|
user: userRef.current,
|
|
setUser: mockSetUser,
|
|
});
|
|
const useAuthStore = Object.assign(
|
|
(sel?: (s: AuthState) => unknown) =>
|
|
sel ? sel(state()) : state(),
|
|
{ getState: state },
|
|
);
|
|
return { ...actual, useAuthStore };
|
|
});
|
|
|
|
import { PreferencesTab } from "./preferences-tab";
|
|
import { useCommentComposerStore } from "@multica/core/issues/stores";
|
|
|
|
const TEST_RESOURCES = {
|
|
en: { common: enCommon, auth: enAuth, settings: enSettings },
|
|
};
|
|
|
|
function I18nWrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
|
{children}
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
describe("PreferencesTab — Language switcher", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
userRef.current = null;
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
Object.defineProperty(window, "location", {
|
|
writable: true,
|
|
configurable: true,
|
|
value: { reload: mockReload },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function pickLanguage(
|
|
user: ReturnType<typeof userEvent.setup>,
|
|
name: string,
|
|
) {
|
|
await user.click(screen.getByRole("combobox", { name: "Language" }));
|
|
await user.click(await screen.findByRole("option", { name }));
|
|
}
|
|
|
|
it("does nothing when clicking the current locale", async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickLanguage(user, "English");
|
|
|
|
expect(mockPersist).not.toHaveBeenCalled();
|
|
expect(mockUpdateMe).not.toHaveBeenCalled();
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows a confirmation toast when the theme is saved locally", async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await user.click(screen.getByRole("combobox", { name: "Theme" }));
|
|
await user.click(await screen.findByRole("option", { name: "Dark" }));
|
|
|
|
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
|
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("when not logged in: persists + reloads, no PATCH", async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickLanguage(user, "한국어");
|
|
|
|
expect(mockPersist).toHaveBeenCalledWith("ko");
|
|
expect(mockUpdateMe).not.toHaveBeenCalled();
|
|
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
act(() => vi.advanceTimersByTime(900));
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
expect(mockToastWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("when not logged in: selecting Japanese persists ja + reloads, no PATCH", async () => {
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickLanguage(user, "日本語");
|
|
|
|
expect(mockPersist).toHaveBeenCalledWith("ja");
|
|
expect(mockUpdateMe).not.toHaveBeenCalled();
|
|
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
act(() => vi.advanceTimersByTime(900));
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
expect(mockToastWarning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("when logged in + PATCH success: confirms the save before reloading", async () => {
|
|
userRef.current = { id: "user-1" };
|
|
mockUpdateMe.mockResolvedValueOnce({});
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickLanguage(user, "中文");
|
|
|
|
expect(mockPersist).toHaveBeenCalledWith("zh-Hans");
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ language: "zh-Hans" });
|
|
expect(mockToastWarning).not.toHaveBeenCalled();
|
|
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
act(() => vi.advanceTimersByTime(900));
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("when logged in + PATCH fails: shows toast and delays reload by 2.5s", async () => {
|
|
userRef.current = { id: "user-1" };
|
|
mockUpdateMe.mockRejectedValueOnce(new Error("network"));
|
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickLanguage(user, "中文");
|
|
|
|
// Local persist still happened so the reload below sees the new locale.
|
|
expect(mockPersist).toHaveBeenCalledWith("zh-Hans");
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ language: "zh-Hans" });
|
|
// Toast surfaced the sync failure.
|
|
expect(mockToastWarning).toHaveBeenCalledTimes(1);
|
|
// Reload deferred so the toast is visible.
|
|
expect(mockReload).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(2500);
|
|
});
|
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("PreferencesTab — Timezone section", () => {
|
|
// Shrink the picker to the curated COMMON_TIMEZONES fallback. With the
|
|
// real Intl.supportedValuesOf the popup renders ~600 options, and
|
|
// userEvent traversal of that list blew past the per-test timeout on
|
|
// slow CI runners (MUL-4427). Everything these tests pick — Asia/Tokyo
|
|
// and the "(browser)" sentinel — exists in the fallback list too.
|
|
const intlWithValues = Intl as typeof Intl & {
|
|
supportedValuesOf?: (key: "timeZone") => string[];
|
|
};
|
|
const realSupportedValuesOf = intlWithValues.supportedValuesOf;
|
|
beforeAll(() => {
|
|
intlWithValues.supportedValuesOf = () => [];
|
|
});
|
|
afterAll(() => {
|
|
intlWithValues.supportedValuesOf = realSupportedValuesOf;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
userRef.current = null;
|
|
});
|
|
|
|
// Base UI Select portals its popup onto document.body; unmount each
|
|
// render fully between tests so a prior test's trigger/popup can't
|
|
// shadow the next one's.
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
// Opens the Select popup and clicks the option whose accessible name
|
|
// matches. Re-queries the trigger each call so it operates on the
|
|
// current render, never a stale node.
|
|
async function pickTimezone(
|
|
user: ReturnType<typeof userEvent.setup>,
|
|
name: RegExp | string,
|
|
) {
|
|
await user.click(screen.getByRole("combobox", { name: "Viewing Timezone" }));
|
|
await user.click(await screen.findByRole("option", { name }));
|
|
}
|
|
|
|
it("renders the stored timezone in the trigger", () => {
|
|
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
expect(
|
|
screen.getByRole("combobox", { name: "Viewing Timezone" }).textContent,
|
|
).toContain("Asia/Shanghai");
|
|
});
|
|
|
|
// handleChange PATCHes then updates the store asynchronously, so the
|
|
// post-pick assertions must waitFor it to settle.
|
|
it("saving a new timezone PATCHes /api/me and updates the auth store", async () => {
|
|
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
|
|
const updatedUser = { id: "user-1", timezone: "Asia/Tokyo" };
|
|
mockUpdateMe.mockResolvedValueOnce(updatedUser);
|
|
const user = userEvent.setup();
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickTimezone(user, "Asia/Tokyo");
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ timezone: "Asia/Tokyo" });
|
|
expect(mockSetUser).toHaveBeenCalledWith(updatedUser);
|
|
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it("surfaces a toast when the PATCH fails", async () => {
|
|
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
|
|
mockUpdateMe.mockRejectedValueOnce(new Error("network down"));
|
|
const user = userEvent.setup();
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
await pickTimezone(user, "Asia/Tokyo");
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ timezone: "Asia/Tokyo" });
|
|
expect(mockToastError).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(mockSetUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clearing the preference sends an empty-string timezone", async () => {
|
|
userRef.current = { id: "user-1", timezone: "Asia/Shanghai" };
|
|
const clearedUser = { id: "user-1", timezone: null };
|
|
mockUpdateMe.mockResolvedValueOnce(clearedUser);
|
|
const user = userEvent.setup();
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
// The "(browser)" sentinel option resets the preference to NULL; the
|
|
// wire payload is an empty string the backend translates to NULL.
|
|
await pickTimezone(user, /browser/i);
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateMe).toHaveBeenCalledWith({ timezone: "" });
|
|
// The PATCH response (timezone: null) is pushed into the auth store
|
|
// so the picker switches back to "(browser)" without a refetch.
|
|
expect(mockSetUser).toHaveBeenCalledWith(clearedUser);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("PreferencesTab — Sticky comment bar", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
userRef.current = null;
|
|
useCommentComposerStore.setState({ sticky: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it("renders on by default and toggles the preference off with a saved toast", async () => {
|
|
const user = userEvent.setup();
|
|
render(<PreferencesTab />, { wrapper: I18nWrapper });
|
|
|
|
const toggle = screen.getByRole("switch", { name: "Sticky comment bar" });
|
|
expect(toggle).toHaveAttribute("aria-checked", "true");
|
|
|
|
await user.click(toggle);
|
|
|
|
expect(useCommentComposerStore.getState().sticky).toBe(false);
|
|
expect(toggle).toHaveAttribute("aria-checked", "false");
|
|
expect(mockToastSuccess).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|