fix(web): make archived independent of space scope

This commit is contained in:
amblued 2026-08-26 23:43:05 +08:00
parent fc7970a2d1
commit 040f9ae717
20 changed files with 242 additions and 79 deletions

View file

@ -33,5 +33,9 @@ An accepted relationship between an active Memos user and a Space, carrying eith
_Avoid_: Invitation, application role
**Memo collection scope**:
The Space-placement dimension applied to memo and derived-resource collections. It is either `all`, which adds no Space predicate, or one exact Space. An unassigned Memo remains part of `all`; unassigned is a placement, not a collection scope.
The Space-placement dimension applied to Space-aware memo and derived-resource collections. It is either `all`, which adds no Space predicate, or one exact Space. An unassigned Memo remains part of `all`; unassigned is a placement, not a collection scope.
_Avoid_: Unassigned scope, no-Space collection
**Archived memo collection**:
The signed-in user's memos in the archived lifecycle state across all placements. It is user-level and independent of the current Space collection scope.
_Avoid_: Space archive, Space-scoped archive

View file

@ -170,7 +170,7 @@ MCP memo operations reuse the same memo policy; Space management is not exposed
### UI shape
The active Space scopes collaborative resource browsing and creation, including Home and attachment lists. Inbox and user profiles remain user-global and their routes do not inherit the active Space. Global Settings provides a Spaces section for viewing received invitations and managing joined Spaces, metadata, members, roles, and pending invitations; it is a management surface rather than another Space switcher.
The active Space scopes collaborative resource browsing and creation, including Home, Explore, and attachment lists. Archived, Inbox, and user profiles remain user-global and their routes do not inherit the active Space. Global Settings provides a Spaces section for viewing received invitations and managing joined Spaces, metadata, members, roles, and pending invitations; it is a management surface rather than another Space switcher.
### Security invariants

View file

@ -55,7 +55,7 @@ import {
getMemoScopePath,
getMemoViewId,
isMemoScopeRoute,
type MemoScope,
type PrimaryMemoScope,
resolveMemoScope,
} from "@/lib/memo-views";
import { cn } from "@/lib/utils";
@ -257,8 +257,9 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
enabled: context === "profile" && !!profileMatch?.params.username,
});
const statsUserName = context === "home" ? currentUser?.name : context === "profile" ? profileUser?.name : undefined;
// Profile remains instance-level, so its calendar must stay aligned with the unscoped profile feed.
const statsFilter = context === "profile" ? undefined : memoFilter;
// User-level collections stay aligned with their unscoped feeds even when a Space is remembered.
const isUserLevelCollection = context === "profile" || context === "archived";
const statsFilter = isUserLevelCollection ? undefined : memoFilter;
const { statistics, tags } = useFilteredMemoStats({
context,
userName: statsUserName,
@ -272,8 +273,9 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
// clicks must land somewhere that renders the filtered feed.
const onCollectionRoute = isMemoScopeRoute(location.pathname) || !!profileMatch;
const filterTarget = onCollectionRoute ? undefined : context === "explore" ? ROUTES.EXPLORE : ROUTES.HOME;
const tagStateScope =
context === "profile" ? (statsUserName ?? context) : `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`;
const tagStateScope = isUserLevelCollection
? (statsUserName ?? context)
: `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`;
return (
<div className={SIDEBAR_SECTION_STACK_CLASSES}>
@ -484,24 +486,25 @@ const GlobalNavigation = () => {
memoArchived: memoDetail?.memo.state === State.ARCHIVED,
fallback: memoScope,
});
const routeOwnsScope = isMemoScopeRoute(location.pathname) || routeKind === "profile" || routeKind === "memo";
const scopeRouteActive = isMemoScopeRoute(location.pathname);
const primaryScope: PrimaryMemoScope = resolvedScope === "archived" ? memoScope : resolvedScope;
const routeOwnsPrimaryScope =
resolvedScope !== "archived" && (routeKind === "home" || routeKind === "explore" || routeKind === "profile" || routeKind === "memo");
const scopeRouteActive = routeKind === "home" || routeKind === "explore";
useEffect(() => {
if (routeOwnsScope && resolvedScope !== memoScope) {
setMemoScope(resolvedScope);
if (routeOwnsPrimaryScope && primaryScope !== memoScope) {
setMemoScope(primaryScope);
}
}, [memoScope, resolvedScope, routeOwnsScope, setMemoScope]);
}, [memoScope, primaryScope, routeOwnsPrimaryScope, setMemoScope]);
const scopeItems: Array<{ id: MemoScope; label: string; icon: LucideIcon }> = [
const scopeItems: Array<{ id: PrimaryMemoScope; label: string; icon: LucideIcon }> = [
{ id: "home", label: t("common.home"), icon: HouseIcon },
{ id: "explore", label: t("common.explore"), icon: EarthIcon },
{ id: "archived", label: t("common.archived"), icon: ArchiveIcon },
];
const activeScopeItem = scopeItems.find((item) => item.id === resolvedScope) ?? scopeItems[0];
const activeScopeItem = scopeItems.find((item) => item.id === primaryScope) ?? scopeItems[0];
const ActiveScopeIcon = activeScopeItem.icon;
const navigateToScope = (scope: MemoScope) => {
const navigateToScope = (scope: PrimaryMemoScope) => {
const filterQuery = stringifyFilters(filters);
setMemoScope(scope);
navigate({ pathname: getMemoScopePath(scope), search: filterQuery ? `?filter=${filterQuery}` : "" });
@ -568,7 +571,7 @@ const GlobalNavigation = () => {
// menu and navigate to the scope instead.
if (open && !scopeRouteActive) {
eventDetails.cancel();
navigateToScope(resolvedScope);
navigateToScope(primaryScope);
}
}}
>

View file

@ -4,7 +4,7 @@ import { ROUTES } from "@/router/routes";
export type SidebarRouteKind = MemoScope | "profile" | "views" | "attachments" | "inbox" | "settings" | "memo" | "empty";
export type RouteSearchScope = "remembered-collection" | "profile" | "all";
export type RouteSearchScope = "remembered-collection" | "user-collection" | "profile" | "all";
export type RouteComposePlacement = "remembered-space" | "unassigned";
export interface RouteActionPolicy {
@ -29,7 +29,7 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
/** Routes whose collections are filtered by the remembered All / Space scope. */
export const routeSupportsCollectionScope = (path: string): boolean => {
const kind = getSidebarRouteKind(path);
return kind === "home" || kind === "explore" || kind === "archived" || kind === "attachments";
return kind === "home" || kind === "explore" || kind === "attachments";
};
/**
@ -40,13 +40,20 @@ export const routeSupportsCollectionScope = (path: string): boolean => {
export const getRouteActionPolicy = (path: string): RouteActionPolicy => {
const kind = getSidebarRouteKind(path);
if (kind === "home" || kind === "explore" || kind === "archived") {
if (kind === "home" || kind === "explore") {
return {
searchScope: "remembered-collection",
composePlacement: "remembered-space",
};
}
if (kind === "archived") {
return {
searchScope: "user-collection",
composePlacement: "unassigned",
};
}
if (kind === "attachments") {
return {
searchScope: "remembered-collection",

View file

@ -105,13 +105,10 @@ export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDial
}
if (isInMemoDetailPage) {
if (parentScope === "all") {
clearSelectedSpace();
}
navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
}
memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, parentScope, clearSelectedSpace, navigateTo, memoUpdatedCallback, updateMemo]);
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
const handleCopyLink = useCallback(() => {
let host = profile.instanceUrl;

View file

@ -40,7 +40,7 @@ export const isMemoResourcePath = (pathname: string): boolean => {
return memoID.length > 0 && !memoID.includes("/");
};
/** Whether a page consumes the remembered All / exact-Space collection scope. */
/** Whether returning from this collection should preserve the remembered All / Space state. */
export const isMemoCollectionOrigin = (page: string): boolean => {
const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME;
return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS;
@ -62,7 +62,9 @@ export const resolveMemoDetailOrigin = (
? value.fromScope
: hasExplicitParent && isMemoCollectionOrigin(parentPage)
? "preserve"
: "all";
: options.memoArchived
? "preserve"
: "all";
return { parentPage, parentScope };
};

View file

@ -1,4 +1,5 @@
import {
ArchiveIcon,
CheckIcon,
ChevronsUpDownIcon,
GlobeIcon,
@ -9,6 +10,7 @@ import {
SquareUserIcon,
User2Icon,
} from "lucide-react";
import { matchPath, useLocation } from "react-router-dom";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -25,6 +27,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
@ -39,6 +42,7 @@ interface Props {
const UserMenu = (props: Props) => {
const { collapsed } = props;
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const { setMobileOpen } = useAppSidebar();
const currentUser = useCurrentUser();
@ -47,6 +51,7 @@ const UserMenu = (props: Props) => {
const sseStatus = useSSEConnectionStatus();
const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale);
const currentTheme = getThemeWithFallback(userGeneralSetting?.theme);
const archivedActive = Boolean(matchPath(Routes.ARCHIVED, location.pathname));
const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return;
@ -154,6 +159,15 @@ const UserMenu = (props: Props) => {
<SquareUserIcon className="size-4 text-muted-foreground" />
{t("common.profile")}
</DropdownMenuItem>
<DropdownMenuItem
aria-current={archivedActive ? "page" : undefined}
className={cn(archivedActive && "bg-accent text-accent-foreground")}
onClick={() => navigateFromMenu(Routes.ARCHIVED)}
>
<ArchiveIcon className="size-4 text-muted-foreground" />
{t("common.archived")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<GlobeIcon className="size-4 text-muted-foreground" />
@ -186,6 +200,7 @@ const UserMenu = (props: Props) => {
<SettingsIcon className="size-4 text-muted-foreground" />
{t("common.settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOutIcon className="size-4 text-muted-foreground" />
{t("common.sign-out")}

View file

@ -1,7 +1,7 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import type { MemoOriginScope } from "@/components/MemoView/navigation";
import type { MemoScope } from "@/lib/memo-views";
import type { PrimaryMemoScope } from "@/lib/memo-views";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export type AttachmentSection = "all" | "media" | "audio" | "documents" | "unused";
@ -26,8 +26,8 @@ interface AppSidebarContextValue {
setMobileOpen: (open: boolean) => void;
quickFindOpen: boolean;
setQuickFindOpen: (open: boolean) => void;
memoScope: MemoScope;
setMemoScope: (scope: MemoScope) => void;
memoScope: PrimaryMemoScope;
setMemoScope: (scope: PrimaryMemoScope) => void;
}
const AppSidebarContext = createContext<AppSidebarContextValue | null>(null);
@ -39,7 +39,7 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => {
const [memoDetail, setMemoDetailState] = useState<MemoDetailSidebarDescriptor>();
const [mobileOpen, setMobileOpen] = useState(false);
const [quickFindOpen, setQuickFindOpen] = useState(false);
const [memoScope, setMemoScope] = useState<MemoScope>("home");
const [memoScope, setMemoScope] = useState<PrimaryMemoScope>("home");
useEffect(() => {
setMobileOpen(false);

View file

@ -1,6 +1,7 @@
import { ROUTES } from "@/router/routes";
export type MemoScope = "home" | "explore" | "archived";
export type PrimaryMemoScope = Exclude<MemoScope, "archived">;
export const BUILTIN_TASKS_VIEW_ID = "__built_in_tasks__";
export const BUILTIN_TASKS_VIEW_FILTER = "has_task_list && has_incomplete_tasks";
@ -20,9 +21,8 @@ export const isMemoScopeRoute = (pathname: string): boolean => {
return comparablePath === ROUTES.HOME || comparablePath === ROUTES.EXPLORE || comparablePath === ROUTES.ARCHIVED;
};
export const getMemoScopePath = (scope: MemoScope): string => {
export const getMemoScopePath = (scope: PrimaryMemoScope): string => {
if (scope === "explore") return ROUTES.EXPLORE;
if (scope === "archived") return ROUTES.ARCHIVED;
return ROUTES.HOME;
};

View file

@ -1,14 +1,16 @@
import { ArchiveIcon } from "lucide-react";
import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useSpaceContext } from "@/contexts/SpaceContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const Archived = () => {
const user = useCurrentUser();
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext();
const t = useTranslate();
const memoFilter = useMemoFilters({
creatorName: user?.name,
@ -24,14 +26,17 @@ const Archived = () => {
return (
<PagedMemoList
renderer={(memo: Memo, { compact }) => (
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showSpace={!selectedSpaceName} compact={compact} />
)}
renderer={(memo: Memo, { compact }) => <MemoView key={getMemoKey(memo)} memo={memo} showVisibility showSpace compact={compact} />}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}
filter={memoFilter}
contextFilter={contextFilter}
renderLeading={({ useGrid }) => (
<header className={cn("flex items-center gap-2 px-1", !useGrid && "mb-4")}>
<ArchiveIcon className="size-5 text-muted-foreground" strokeWidth={1.8} />
<h1 className="text-xl font-semibold tracking-tight text-foreground">{t("common.archived")}</h1>
</header>
)}
/>
);
};

View file

@ -11,7 +11,7 @@ const authState = vi.hoisted(() => ({
notifications: [] as Array<{ status: number }>,
}));
const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore" | "archived",
memoScope: "home" as "home" | "explore",
mobileOpen: false,
}));
const globalEditorState = vi.hoisted(() => ({
@ -193,7 +193,6 @@ describe("App sidebar logo", () => {
it.each([
"/",
"/explore",
"/archived",
"/attachments",
"/Explore/",
"/Attachments/",
@ -214,6 +213,7 @@ describe("App sidebar logo", () => {
});
it.each([
"/archived",
"/inbox",
"/u/alice",
"/setting",
@ -276,6 +276,20 @@ describe("App sidebar logo", () => {
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "profile" }));
});
it("keeps Archived statistics and tag UI state independent of the remembered Space", () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.memoFilter = 'space == "spaces/product"';
render(
<MemoryRouter initialEntries={["/archived"]}>
<AppSidebar />
</MemoryRouter>,
);
expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "archived", filter: undefined }));
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "archived" }));
});
it("keeps Inbox compact while exposing its unread state accessibly", () => {
authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
@ -423,7 +437,7 @@ describe("App sidebar logo", () => {
fireEvent.click(scopeTrigger);
expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.explore" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.archived" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "common.archived" })).not.toBeInTheDocument();
});
it("uses compact text-only actions for a saved view", async () => {
@ -484,22 +498,34 @@ describe("App sidebar logo", () => {
expect(screen.getByRole("heading", { name: label, level: 2 })).toBeInTheDocument();
});
it.each([
["explore", "common.explore", "/inbox"],
["archived", "common.archived", "/attachments"],
] as const)("keeps the %s scope available from a global destination", async (scope, label, destination) => {
sidebarState.memoScope = scope;
it("keeps the Explore scope available from a global destination", async () => {
sidebarState.memoScope = "explore";
render(
<MemoryRouter initialEntries={[destination]}>
<MemoryRouter initialEntries={["/inbox"]}>
<AppSidebar />
</MemoryRouter>,
);
const scopeTrigger = screen.getByRole("button", { name: label });
expectCollapsedNavPill(scopeTrigger, label);
const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
expectCollapsedNavPill(scopeTrigger, "common.explore");
fireEvent.click(scopeTrigger);
expectActiveNavPill(await screen.findByRole("button", { name: label, current: "page" }), label);
expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore");
});
it("leaves Archived through the remembered primary feed without presenting it as a scope", async () => {
sidebarState.memoScope = "explore";
render(
<MemoryRouter initialEntries={["/archived"]}>
<AppSidebar />
</MemoryRouter>,
);
const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
expectCollapsedNavPill(scopeTrigger, "common.explore");
fireEvent.click(scopeTrigger);
expectActiveNavPill(await screen.findByRole("button", { name: "common.explore", current: "page" }), "common.explore");
});
it("keeps the mobile header limited to navigation and context", () => {

View file

@ -31,10 +31,10 @@ describe("sidebar route content", () => {
it.each([
["/", true],
["/explore", true],
["/archived", true],
["/archived", false],
["/attachments", true],
["/Explore/", true],
["/ARCHIVED/", true],
["/ARCHIVED/", false],
["/Attachments/", true],
["/u/steven", false],
["/inbox", false],
@ -48,13 +48,20 @@ describe("sidebar route content", () => {
expect(routeSupportsCollectionScope(path)).toBe(expected);
});
it.each(["/", "/explore", "/archived"])("keeps search and Compose in the remembered collection on %s", (path) => {
it.each(["/", "/explore"])("keeps search and Compose in the remembered collection on %s", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection",
composePlacement: "remembered-space",
});
});
it.each(["/archived", "/ARCHIVED/"])("keeps %s in the user archive without inheriting Space placement", (path) => {
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "user-collection",
composePlacement: "unassigned",
});
});
it("keeps the remembered scope when Attachments sends search to Home", () => {
expect(getRouteActionPolicy("/attachments")).toEqual({
searchScope: "remembered-collection",
@ -79,7 +86,8 @@ describe("sidebar route content", () => {
});
});
it.each(["/Explore/", "/ARCHIVED/"])("keeps normalized collection route %s in the remembered scope", (path) => {
it("keeps a normalized Explore route in the remembered scope", () => {
const path = "/Explore/";
expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection",
composePlacement: "remembered-space",

View file

@ -284,7 +284,7 @@ describe("GlobalMemoEditorProvider", () => {
});
});
it.each(["/explore", "/archived", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => {
it.each(["/explore", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => {
mocks.pathname = pathname;
mocks.selectedSpaceName = "spaces/product";
await openViaTrigger();
@ -297,6 +297,7 @@ describe("GlobalMemoEditorProvider", () => {
it.each([
"/u/steven",
"/archived",
"/inbox",
"/setting",
"/views",

View file

@ -80,7 +80,7 @@ describe("Memo detail mutation navigation", () => {
it.each([
[State.NORMAL, "/archived"],
[State.ARCHIVED, "/"],
])("switches an All-origin %s memo to the global destination", async (state, destination) => {
])("switches an All-origin %s memo to the user-level destination without changing the remembered Space", async (state, destination) => {
const { result } = renderActions(state, "all");
await act(async () => {
@ -88,7 +88,7 @@ describe("Memo detail mutation navigation", () => {
});
await waitFor(() => expect(result.current.pathname).toBe(destination));
expect(mocks.clearSelectedSpace).toHaveBeenCalledOnce();
expect(mocks.clearSelectedSpace).not.toHaveBeenCalled();
});
it("preserves an exact-Space origin when archiving", async () => {

View file

@ -51,8 +51,8 @@ describe("memo view navigation", () => {
expect(resolveMemoDetailOrigin({ unrelated: true })).toEqual({ parentPage: "/", parentScope: "all" });
});
it("uses an All Archived origin for a direct archived memo", () => {
expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "all" });
it("uses a user-level Archived origin without changing the remembered Space", () => {
expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "preserve" });
});
it("keeps an explicit origin ahead of the archived fallback", () => {

View file

@ -21,14 +21,13 @@ describe("memo scopes", () => {
expect(resolveMemoScope("/settings", { fallback: "explore" })).toBe("explore");
});
it("maps only the three collection routes to memo scopes", () => {
it("maps collection routes while limiting primary scope paths to Home and Explore", () => {
expect(isMemoScopeRoute("/")).toBe(true);
expect(isMemoScopeRoute("/explore")).toBe(true);
expect(isMemoScopeRoute("/archived")).toBe(true);
expect(isMemoScopeRoute("/attachments")).toBe(false);
expect(getMemoScopePath("home")).toBe("/");
expect(getMemoScopePath("explore")).toBe("/explore");
expect(getMemoScopePath("archived")).toBe("/archived");
});
});

View file

@ -22,7 +22,7 @@ describe("Quick Find", () => {
expect(buildQuickFindFilters("project", scopedFilters, false)).toEqual([{ factor: "contentSearch", value: "project" }]);
});
it.each(["/", "/explore", "/archived"])("keeps scoped filters and stays on %s", (pathname) => {
it.each(["/", "/explore"])("keeps scoped filters and stays on %s", (pathname) => {
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
@ -34,6 +34,18 @@ describe("Quick Find", () => {
});
});
it("searches Archived as a user collection without clearing the remembered Space", () => {
expect(resolveQuickFindSubmission("/archived", "project", scopedFilters)).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
{ factor: "contentSearch", value: "project" },
],
destination: undefined,
switchToAll: false,
});
});
it("keeps the remembered collection filters when searching from Attachments", () => {
expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters)).toEqual({
filters: [

View file

@ -130,12 +130,7 @@ describe("SpaceProvider", () => {
expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name);
});
it.each([
"/",
"/explore",
"/archived?filter=tagSearch%3Awork",
"/attachments",
])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
renderProvider(initialPath);
@ -146,12 +141,7 @@ describe("SpaceProvider", () => {
expect(screen.getByTestId("path").textContent).toBe(initialPath);
});
it.each([
"/",
"/explore",
"/archived?filter=tagSearch%3Awork",
"/attachments",
])("preserves the current collection route when switching to All from %s", (initialPath) => {
it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to All from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
@ -165,7 +155,9 @@ describe("SpaceProvider", () => {
it.each([
["Select first Space", "/inbox"],
["Select first Space", "/archived?filter=tagSearch%3Awork"],
["Select Memos", "/u/alice"],
["Select Memos", "/archived?filter=tagSearch%3Awork"],
])("falls back to Home when using %s outside a collection route", (action, initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product];

View file

@ -1,4 +1,4 @@
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Archived from "@/pages/Archived";
import Explore from "@/pages/Explore";
@ -22,10 +22,16 @@ vi.mock("@/components/MemoView", () => ({
vi.mock("@/components/PagedMemoList", () => ({
default: (props: Record<string, unknown>) => {
state.listProps.push(props);
const renderLeading = props.renderLeading as ((options: { useGrid: boolean }) => React.ReactNode) | undefined;
const renderer = props.renderer as
| ((memo: { name: string; space: string }, options: { compact: boolean }) => React.ReactNode)
| undefined;
return <div>{renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}</div>;
return (
<div>
{renderLeading?.({ useGrid: false })}
{renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}
</div>
);
},
getMemoKey: (memo: { name: string }) => memo.name,
}));
@ -46,7 +52,7 @@ vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/test" }),
}));
describe("Space-scoped feed pages", () => {
describe("Memo feed collection scope", () => {
beforeEach(() => {
state.selectedSpaceName = undefined;
state.memoFilter = undefined;
@ -55,7 +61,7 @@ describe("Space-scoped feed pages", () => {
state.filterOptions = [];
});
it("uses the All collection without a Space filter for Explore and Archived", () => {
it("uses the All collection without a Space filter for Explore and the user archive", () => {
render(
<>
<Explore />
@ -65,11 +71,12 @@ describe("Space-scoped feed pages", () => {
expect(state.listProps).toHaveLength(2);
expect(state.listProps[0]).toMatchObject({ contextFilter: undefined });
expect(state.listProps[1]).toMatchObject({ contextFilter: undefined });
expect(state.listProps[1]).not.toHaveProperty("contextFilter");
expect(state.filterOptions[0]).toMatchObject({
visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE],
});
expect(state.memoViewProps).toEqual([expect.objectContaining({ showSpace: true }), expect.objectContaining({ showSpace: true })]);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Archived");
});
it("uses the selected Space filter and includes its member audience in Explore", () => {
@ -83,4 +90,13 @@ describe("Space-scoped feed pages", () => {
});
expect(state.memoViewProps[0]).toMatchObject({ showSpace: false });
});
it("keeps Archived independent of a remembered Space", () => {
state.selectedSpaceName = "spaces/product";
state.memoFilter = 'space == "spaces/product"';
render(<Archived />);
expect(state.listProps[0]).not.toHaveProperty("contextFilter");
expect(state.memoViewProps[0]).toMatchObject({ showSpace: true });
});
});

View file

@ -0,0 +1,76 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import UserMenu from "@/components/UserMenu";
const mocks = vi.hoisted(() => ({
navigateTo: vi.fn(),
setMobileOpen: vi.fn(),
logout: vi.fn(),
}));
vi.mock("@/contexts/AppSidebarContext", () => ({
useAppSidebar: () => ({ setMobileOpen: mocks.setMobileOpen }),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({
userGeneralSetting: undefined,
refetchSettings: vi.fn(),
logout: mocks.logout,
}),
}));
vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/steven", username: "steven", displayName: "Steven" }),
}));
vi.mock("@/hooks/useLiveMemoRefresh", () => ({
useSSEConnectionStatus: () => "connected",
}));
vi.mock("@/hooks/useNavigateTo", () => ({
default: () => mocks.navigateTo,
}));
vi.mock("@/hooks/useUserQueries", () => ({
useUpdateUserGeneralSetting: () => ({ mutate: vi.fn() }),
}));
vi.mock("@/utils/i18n", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/utils/i18n")>();
return {
...actual,
getLocaleWithFallback: () => "en",
loadLocale: vi.fn(),
useTranslate: () => (key: string) => key,
};
});
describe("User menu", () => {
beforeEach(() => {
mocks.navigateTo.mockReset();
mocks.setMobileOpen.mockReset();
mocks.logout.mockReset();
});
it("groups Archived with Profile and marks it active", async () => {
render(
<MemoryRouter initialEntries={["/archived"]}>
<UserMenu />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: /Steven/ }));
const profile = await screen.findByRole("menuitem", { name: "common.profile" });
const archived = screen.getByRole("menuitem", { name: "common.archived" });
expect(profile.compareDocumentPosition(archived) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(archived).toHaveAttribute("aria-current", "page");
expect(screen.getAllByRole("separator")).toHaveLength(2);
fireEvent.click(archived);
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
expect(mocks.navigateTo).toHaveBeenCalledWith("/archived");
});
});