Files
multica/packages/views/settings/components/preferences-tab.test.tsx
Jiayuan Zhang 80e54094a8 feat(issues): sticky setting for the issue comment bar (MUL-4435) (#5293)
* 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>
2026-07-12 15:04:40 +08:00

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);
});
});