mirror of
https://github.com/docmost/docmost.git
synced 2026-08-15 08:01:36 +10:00
fix(client): keep space switcher dropdown inside its popover
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Group, Select, SelectProps, Text } from "@mantine/core";
|
||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -14,6 +14,7 @@ interface SpaceSelectProps {
|
||||
width?: number;
|
||||
opened?: boolean;
|
||||
clearable?: boolean;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||
@@ -41,6 +42,7 @@ export function SpaceSelect({
|
||||
width,
|
||||
opened,
|
||||
clearable,
|
||||
withinPortal = true,
|
||||
}: SpaceSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
@@ -50,9 +52,13 @@ export function SpaceSelect({
|
||||
limit: 50,
|
||||
});
|
||||
const [data, setData] = useState([]);
|
||||
const fetchedSpaces = useRef(new Map<string, ISpace>());
|
||||
|
||||
useEffect(() => {
|
||||
if (spaces) {
|
||||
spaces.items.forEach((space: ISpace) =>
|
||||
fetchedSpaces.current.set(space.slug, space),
|
||||
);
|
||||
const spaceData = spaces?.items
|
||||
.filter((space: ISpace) => space.slug !== value)
|
||||
.map((space: ISpace) => {
|
||||
@@ -83,14 +89,19 @@ export function SpaceSelect({
|
||||
onSearchChange={setSearchValue}
|
||||
clearable={clearable}
|
||||
variant="filled"
|
||||
onChange={(slug) =>
|
||||
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||
}
|
||||
onChange={(slug) => {
|
||||
// options accumulate across fetches; resolve against everything
|
||||
// fetched, not just the latest query result
|
||||
const space = slug && fetchedSpaces.current.get(slug);
|
||||
if (space) {
|
||||
onChange(space);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
nothingFoundMessage={t("No space found")}
|
||||
limit={50}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
|
||||
dropdownOpened={opened}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { SwitchSpace } from "./switch-space.tsx";
|
||||
|
||||
vi.mock("@/main.tsx", async () => {
|
||||
const { QueryClient } = await import("@tanstack/react-query");
|
||||
return { queryClient: new QueryClient() };
|
||||
});
|
||||
|
||||
const SPACES = [
|
||||
{ id: "1", name: "General", slug: "general", logo: null },
|
||||
{ id: "2", name: "Engineering", slug: "engineering", logo: null },
|
||||
{ id: "3", name: "Marketing", slug: "marketing", logo: null },
|
||||
];
|
||||
|
||||
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||
getSpaces: vi.fn(async () => ({ items: SPACES, meta: {} })),
|
||||
getSpaceById: vi.fn(),
|
||||
getSpaceMembers: vi.fn(),
|
||||
addSpaceMember: vi.fn(),
|
||||
changeMemberRole: vi.fn(),
|
||||
removeSpaceMember: vi.fn(),
|
||||
createSpace: vi.fn(),
|
||||
updateSpace: vi.fn(),
|
||||
deleteSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
window.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof window.ResizeObserver;
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
});
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location">{location.pathname}</div>;
|
||||
}
|
||||
|
||||
function renderSwitcher() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={["/s/general"]}>
|
||||
<SwitchSpace spaceName="General" spaceSlug="general" />
|
||||
<LocationProbe />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function getOption(name: string) {
|
||||
const options = screen.getAllByRole("option", { hidden: true });
|
||||
const match = options.find((option) => option.textContent?.includes(name));
|
||||
if (!match) {
|
||||
throw new Error(`option ${name} not found`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
async function openSwitcher() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /general/i }));
|
||||
const dialog = await screen.findByRole("dialog", { hidden: true });
|
||||
await screen.findAllByRole("option", { hidden: true });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
describe("SwitchSpace", () => {
|
||||
it("renders the space options inside the popover so option clicks are not outside clicks", async () => {
|
||||
renderSwitcher();
|
||||
const dialog = await openSwitcher();
|
||||
const listbox = screen.getByRole("listbox", { hidden: true });
|
||||
expect(dialog.contains(listbox)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the popover open when an option is pressed with the mouse", async () => {
|
||||
renderSwitcher();
|
||||
const dialog = await openSwitcher();
|
||||
fireEvent.mouseDown(getOption("Marketing"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
expect(document.body.contains(dialog)).toBe(true);
|
||||
});
|
||||
|
||||
it("navigates to the space picked from search results", async () => {
|
||||
renderSwitcher();
|
||||
await openSwitcher();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search for spaces"), {
|
||||
target: { value: "mark" },
|
||||
});
|
||||
await waitFor(() => getOption("Marketing"));
|
||||
const option = getOption("Marketing");
|
||||
fireEvent.mouseDown(option);
|
||||
fireEvent.mouseUp(option);
|
||||
fireEvent.click(option);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("location").textContent).toBe("/s/marketing"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,12 +64,15 @@ export function SwitchSpace({
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{/* keep the options dropdown inside the popover; a portaled dropdown
|
||||
registers as an outside click and closes the popover mid-selection */}
|
||||
<SpaceSelect
|
||||
label={spaceName}
|
||||
value={spaceSlug}
|
||||
onChange={(space) => handleSelect(space.slug)}
|
||||
width={300}
|
||||
opened={true}
|
||||
withinPortal={false}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user