diff --git a/apps/client/src/features/space/components/sidebar/space-select.tsx b/apps/client/src/features/space/components/sidebar/space-select.tsx index dab784103..47c499757 100644 --- a/apps/client/src/features/space/components/sidebar/space-select.tsx +++ b/apps/client/src/features/space/components/sidebar/space-select.tsx @@ -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()); 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} /> ); diff --git a/apps/client/src/features/space/components/sidebar/switch-space.test.tsx b/apps/client/src/features/space/components/sidebar/switch-space.test.tsx new file mode 100644 index 000000000..0cf31fcb8 --- /dev/null +++ b/apps/client/src/features/space/components/sidebar/switch-space.test.tsx @@ -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
{location.pathname}
; +} + +function renderSwitcher() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + + + , + ); +} + +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"), + ); + }); +}); diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index 7531a5926..fd14a6edc 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -64,12 +64,15 @@ export function SwitchSpace({ + {/* keep the options dropdown inside the popover; a portaled dropdown + registers as an outside click and closes the popover mid-selection */} handleSelect(space.slug)} width={300} opened={true} + withinPortal={false} />