memos/web/tests/memo-filter-context.test.tsx
johnnyjoygh af008b2153 feat(web): scope Spaces by URL path instead of a remembered selection
The selected Space used to live in sessionStorage and ride along invisibly
with every collection route. It now lives in the URL: collection pages exist
both globally and beneath /spaces/:spaceUid, and the Space context derives
its scope from the current location.

- Add a Space route branch (Home, Explore, Calendar, Attachments) guarded by
  a SpaceRoute that shows loading, unavailable, and retry states.
- Resolve collection routes through one matchPath-based helper built from the
  route constants; build Space-scoped paths with buildCollectionPath.
- Fetch the route Space directly, seeded from the switcher list so pages are
  not held behind a second round-trip.
- Turn the Space switcher into plain links that preserve the current
  collection view and query.
- Drop the remembered-Space machinery: clearSelectedSpace/selectMemos,
  the compose-placement policy, routeSupportsCollectionScope, and the
  quick-find switchToAll flag are all implied by the URL now.
- Return from memo details to the full origin URL, including its Space.
- Add space.unavailable and space.back-to-memos strings; align the
  Azerbaijani catalog with English.
2026-09-07 09:29:50 +08:00

117 lines
4.7 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, MemoryRouter, RouterProvider, useLocation } from "react-router-dom";
import { describe, expect, it } from "vitest";
import {
type MemoFilter,
MemoFilterProvider,
parseFilterQuery,
stringifyFilters,
useMemoFilterContext,
} from "@/contexts/MemoFilterContext";
import { BUILTIN_TASKS_VIEW_ID } from "@/lib/memo-views";
const expression = 'tags.exists(t, t.contains("50%, café & C++"))\n || space == null';
const Harness = () => {
const { filters, setFilters, setMemoView, memoView } = useMemoFilterContext();
const location = useLocation();
return (
<div>
<output data-testid="filters">{JSON.stringify(filters)}</output>
<output data-testid="memoView">{memoView}</output>
<output data-testid="url">{location.search}</output>
<button type="button" onClick={() => setFilters([{ factor: "celSearch", value: expression }])}>
Search CEL
</button>
<button type="button" onClick={() => setFilters([{ factor: "contentSearch", value: "plan" }])}>
Search plan
</button>
<button type="button" onClick={() => setMemoView(BUILTIN_TASKS_VIEW_ID)}>
Select Tasks
</button>
</div>
);
};
describe("MemoFilterProvider", () => {
it("restores CEL after URL synchronization, navigation history, and remount", async () => {
const routes = [
{
path: "*",
element: (
<MemoFilterProvider>
<Harness />
</MemoFilterProvider>
),
},
];
const router = createMemoryRouter(routes, { initialEntries: ["/inbox", "/explore"], initialIndex: 1 });
const rendered = render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Search CEL" }));
const expected: MemoFilter[] = [{ factor: "celSearch", value: expression }];
await waitFor(() => expect(parseFilterQuery(new URLSearchParams(router.state.location.search).get("filter"))).toEqual(expected));
const savedUrl = `/explore${router.state.location.search}`;
await act(async () => {
await router.navigate(-1);
});
await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent("[]"));
await act(async () => {
await router.navigate(1);
});
await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent(JSON.stringify(expected)));
rendered.unmount();
render(<RouterProvider router={createMemoryRouter(routes, { initialEntries: [savedUrl] })} />);
expect(screen.getByTestId("filters")).toHaveTextContent(JSON.stringify(expected));
expect(new URLSearchParams(router.state.location.search).get("filter")).toBe(stringifyFilters(expected));
});
it("preserves the Space origin when navigation removes collection filters", async () => {
const origin = "/spaces/product?filter=tagSearch%3Awork";
const router = createMemoryRouter(
[
{
path: "*",
element: (
<MemoFilterProvider>
<Harness />
</MemoFilterProvider>
),
},
],
{ initialEntries: [origin] },
);
render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Select Tasks" }));
const state = { from: origin, fromScope: "preserve" };
await act(() => router.navigate("/memos/1", { state }));
await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent("[]"));
expect(router.state.location.search).toBe("");
expect(router.state.location.state).toEqual(state);
await act(() => router.navigate(-1));
expect(router.state.location.pathname + router.state.location.search).toBe(origin);
expect(screen.getByTestId("filters")).toHaveTextContent('"work"');
await act(() => router.navigate("/spaces/research?filter=tagSearch%3Awork"));
expect(screen.getByTestId("memoView")).toHaveTextContent(BUILTIN_TASKS_VIEW_ID);
});
it("keeps encoded values containing colons intact", () => {
expect(parseFilterQuery("contentSearch:https://example.com:8080/path")).toEqual([
{ factor: "contentSearch", value: "https://example.com:8080/path" },
]);
});
it("keeps search filters when selecting a view", () => {
render(
<MemoryRouter>
<MemoFilterProvider>
<Harness />
</MemoFilterProvider>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "Search plan" }));
fireEvent.click(screen.getByRole("button", { name: "Select Tasks" }));
expect(screen.getByTestId("filters")).toHaveTextContent('[{"factor":"contentSearch","value":"plan"}]');
expect(screen.getByTestId("memoView")).toHaveTextContent(BUILTIN_TASKS_VIEW_ID);
});
});