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 _Avoid_: Invitation, application role
**Memo collection scope**: **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 _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 ### 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 ### Security invariants

View file

@ -55,7 +55,7 @@ import {
getMemoScopePath, getMemoScopePath,
getMemoViewId, getMemoViewId,
isMemoScopeRoute, isMemoScopeRoute,
type MemoScope, type PrimaryMemoScope,
resolveMemoScope, resolveMemoScope,
} from "@/lib/memo-views"; } from "@/lib/memo-views";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -257,8 +257,9 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
enabled: context === "profile" && !!profileMatch?.params.username, enabled: context === "profile" && !!profileMatch?.params.username,
}); });
const statsUserName = context === "home" ? currentUser?.name : context === "profile" ? profileUser?.name : undefined; 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. // User-level collections stay aligned with their unscoped feeds even when a Space is remembered.
const statsFilter = context === "profile" ? undefined : memoFilter; const isUserLevelCollection = context === "profile" || context === "archived";
const statsFilter = isUserLevelCollection ? undefined : memoFilter;
const { statistics, tags } = useFilteredMemoStats({ const { statistics, tags } = useFilteredMemoStats({
context, context,
userName: statsUserName, userName: statsUserName,
@ -272,8 +273,9 @@ const CollectionSidebarContent = ({ context }: { context: MemoStatsContext }) =>
// clicks must land somewhere that renders the filtered feed. // clicks must land somewhere that renders the filtered feed.
const onCollectionRoute = isMemoScopeRoute(location.pathname) || !!profileMatch; const onCollectionRoute = isMemoScopeRoute(location.pathname) || !!profileMatch;
const filterTarget = onCollectionRoute ? undefined : context === "explore" ? ROUTES.EXPLORE : ROUTES.HOME; const filterTarget = onCollectionRoute ? undefined : context === "explore" ? ROUTES.EXPLORE : ROUTES.HOME;
const tagStateScope = const tagStateScope = isUserLevelCollection
context === "profile" ? (statsUserName ?? context) : `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`; ? (statsUserName ?? context)
: `${statsUserName ?? context}${selectedSpaceName ? `:${selectedSpaceName}` : ""}`;
return ( return (
<div className={SIDEBAR_SECTION_STACK_CLASSES}> <div className={SIDEBAR_SECTION_STACK_CLASSES}>
@ -484,24 +486,25 @@ const GlobalNavigation = () => {
memoArchived: memoDetail?.memo.state === State.ARCHIVED, memoArchived: memoDetail?.memo.state === State.ARCHIVED,
fallback: memoScope, fallback: memoScope,
}); });
const routeOwnsScope = isMemoScopeRoute(location.pathname) || routeKind === "profile" || routeKind === "memo"; const primaryScope: PrimaryMemoScope = resolvedScope === "archived" ? memoScope : resolvedScope;
const scopeRouteActive = isMemoScopeRoute(location.pathname); const routeOwnsPrimaryScope =
resolvedScope !== "archived" && (routeKind === "home" || routeKind === "explore" || routeKind === "profile" || routeKind === "memo");
const scopeRouteActive = routeKind === "home" || routeKind === "explore";
useEffect(() => { useEffect(() => {
if (routeOwnsScope && resolvedScope !== memoScope) { if (routeOwnsPrimaryScope && primaryScope !== memoScope) {
setMemoScope(resolvedScope); 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: "home", label: t("common.home"), icon: HouseIcon },
{ id: "explore", label: t("common.explore"), icon: EarthIcon }, { 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 ActiveScopeIcon = activeScopeItem.icon;
const navigateToScope = (scope: MemoScope) => { const navigateToScope = (scope: PrimaryMemoScope) => {
const filterQuery = stringifyFilters(filters); const filterQuery = stringifyFilters(filters);
setMemoScope(scope); setMemoScope(scope);
navigate({ pathname: getMemoScopePath(scope), search: filterQuery ? `?filter=${filterQuery}` : "" }); navigate({ pathname: getMemoScopePath(scope), search: filterQuery ? `?filter=${filterQuery}` : "" });
@ -568,7 +571,7 @@ const GlobalNavigation = () => {
// menu and navigate to the scope instead. // menu and navigate to the scope instead.
if (open && !scopeRouteActive) { if (open && !scopeRouteActive) {
eventDetails.cancel(); 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 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 type RouteComposePlacement = "remembered-space" | "unassigned";
export interface RouteActionPolicy { export interface RouteActionPolicy {
@ -29,7 +29,7 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => {
/** Routes whose collections are filtered by the remembered All / Space scope. */ /** Routes whose collections are filtered by the remembered All / Space scope. */
export const routeSupportsCollectionScope = (path: string): boolean => { export const routeSupportsCollectionScope = (path: string): boolean => {
const kind = getSidebarRouteKind(path); 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 => { export const getRouteActionPolicy = (path: string): RouteActionPolicy => {
const kind = getSidebarRouteKind(path); const kind = getSidebarRouteKind(path);
if (kind === "home" || kind === "explore" || kind === "archived") { if (kind === "home" || kind === "explore") {
return { return {
searchScope: "remembered-collection", searchScope: "remembered-collection",
composePlacement: "remembered-space", composePlacement: "remembered-space",
}; };
} }
if (kind === "archived") {
return {
searchScope: "user-collection",
composePlacement: "unassigned",
};
}
if (kind === "attachments") { if (kind === "attachments") {
return { return {
searchScope: "remembered-collection", searchScope: "remembered-collection",

View file

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

View file

@ -40,7 +40,7 @@ export const isMemoResourcePath = (pathname: string): boolean => {
return memoID.length > 0 && !memoID.includes("/"); 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 => { export const isMemoCollectionOrigin = (page: string): boolean => {
const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME; const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME;
return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS; return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS;
@ -62,7 +62,9 @@ export const resolveMemoDetailOrigin = (
? value.fromScope ? value.fromScope
: hasExplicitParent && isMemoCollectionOrigin(parentPage) : hasExplicitParent && isMemoCollectionOrigin(parentPage)
? "preserve" ? "preserve"
: "all"; : options.memoArchived
? "preserve"
: "all";
return { parentPage, parentScope }; return { parentPage, parentScope };
}; };

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
export type MemoScope = "home" | "explore" | "archived"; 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_ID = "__built_in_tasks__";
export const BUILTIN_TASKS_VIEW_FILTER = "has_task_list && has_incomplete_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; 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 === "explore") return ROUTES.EXPLORE;
if (scope === "archived") return ROUTES.ARCHIVED;
return ROUTES.HOME; return ROUTES.HOME;
}; };

View file

@ -1,14 +1,16 @@
import { ArchiveIcon } from "lucide-react";
import MemoView from "@/components/MemoView"; import MemoView from "@/components/MemoView";
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList"; import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
import { useSpaceContext } from "@/contexts/SpaceContext";
import { useMemoFilters, useMemoSorting } from "@/hooks"; import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const Archived = () => { const Archived = () => {
const user = useCurrentUser(); const user = useCurrentUser();
const { memoFilter: contextFilter, selectedSpaceName } = useSpaceContext(); const t = useTranslate();
const memoFilter = useMemoFilters({ const memoFilter = useMemoFilters({
creatorName: user?.name, creatorName: user?.name,
@ -24,14 +26,17 @@ const Archived = () => {
return ( return (
<PagedMemoList <PagedMemoList
renderer={(memo: Memo, { compact }) => ( renderer={(memo: Memo, { compact }) => <MemoView key={getMemoKey(memo)} memo={memo} showVisibility showSpace compact={compact} />}
<MemoView key={getMemoKey(memo)} memo={memo} showVisibility showSpace={!selectedSpaceName} compact={compact} />
)}
listSort={listSort} listSort={listSort}
state={State.ARCHIVED} state={State.ARCHIVED}
orderBy={orderBy} orderBy={orderBy}
filter={memoFilter} 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 }>, notifications: [] as Array<{ status: number }>,
})); }));
const sidebarState = vi.hoisted(() => ({ const sidebarState = vi.hoisted(() => ({
memoScope: "home" as "home" | "explore" | "archived", memoScope: "home" as "home" | "explore",
mobileOpen: false, mobileOpen: false,
})); }));
const globalEditorState = vi.hoisted(() => ({ const globalEditorState = vi.hoisted(() => ({
@ -193,7 +193,6 @@ describe("App sidebar logo", () => {
it.each([ it.each([
"/", "/",
"/explore", "/explore",
"/archived",
"/attachments", "/attachments",
"/Explore/", "/Explore/",
"/Attachments/", "/Attachments/",
@ -214,6 +213,7 @@ describe("App sidebar logo", () => {
}); });
it.each([ it.each([
"/archived",
"/inbox", "/inbox",
"/u/alice", "/u/alice",
"/setting", "/setting",
@ -276,6 +276,20 @@ describe("App sidebar logo", () => {
expect(tagsSectionHook).toHaveBeenCalledWith(expect.objectContaining({ scope: "profile" })); 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", () => { it("keeps Inbox compact while exposing its unread state accessibly", () => {
authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }]; authState.notifications = [{ status: 1 }, { status: 1 }, { status: 2 }];
@ -423,7 +437,7 @@ describe("App sidebar logo", () => {
fireEvent.click(scopeTrigger); fireEvent.click(scopeTrigger);
expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument(); expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.explore" })).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 () => { 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(); expect(screen.getByRole("heading", { name: label, level: 2 })).toBeInTheDocument();
}); });
it.each([ it("keeps the Explore scope available from a global destination", async () => {
["explore", "common.explore", "/inbox"], sidebarState.memoScope = "explore";
["archived", "common.archived", "/attachments"],
] as const)("keeps the %s scope available from a global destination", async (scope, label, destination) => {
sidebarState.memoScope = scope;
render( render(
<MemoryRouter initialEntries={[destination]}> <MemoryRouter initialEntries={["/inbox"]}>
<AppSidebar /> <AppSidebar />
</MemoryRouter>, </MemoryRouter>,
); );
const scopeTrigger = screen.getByRole("button", { name: label }); const scopeTrigger = screen.getByRole("button", { name: "common.explore" });
expectCollapsedNavPill(scopeTrigger, label); expectCollapsedNavPill(scopeTrigger, "common.explore");
fireEvent.click(scopeTrigger); 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", () => { it("keeps the mobile header limited to navigation and context", () => {

View file

@ -31,10 +31,10 @@ describe("sidebar route content", () => {
it.each([ it.each([
["/", true], ["/", true],
["/explore", true], ["/explore", true],
["/archived", true], ["/archived", false],
["/attachments", true], ["/attachments", true],
["/Explore/", true], ["/Explore/", true],
["/ARCHIVED/", true], ["/ARCHIVED/", false],
["/Attachments/", true], ["/Attachments/", true],
["/u/steven", false], ["/u/steven", false],
["/inbox", false], ["/inbox", false],
@ -48,13 +48,20 @@ describe("sidebar route content", () => {
expect(routeSupportsCollectionScope(path)).toBe(expected); 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({ expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection", searchScope: "remembered-collection",
composePlacement: "remembered-space", 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", () => { it("keeps the remembered scope when Attachments sends search to Home", () => {
expect(getRouteActionPolicy("/attachments")).toEqual({ expect(getRouteActionPolicy("/attachments")).toEqual({
searchScope: "remembered-collection", 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({ expect(getRouteActionPolicy(path)).toEqual({
searchScope: "remembered-collection", searchScope: "remembered-collection",
composePlacement: "remembered-space", 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.pathname = pathname;
mocks.selectedSpaceName = "spaces/product"; mocks.selectedSpaceName = "spaces/product";
await openViaTrigger(); await openViaTrigger();
@ -297,6 +297,7 @@ describe("GlobalMemoEditorProvider", () => {
it.each([ it.each([
"/u/steven", "/u/steven",
"/archived",
"/inbox", "/inbox",
"/setting", "/setting",
"/views", "/views",

View file

@ -80,7 +80,7 @@ describe("Memo detail mutation navigation", () => {
it.each([ it.each([
[State.NORMAL, "/archived"], [State.NORMAL, "/archived"],
[State.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"); const { result } = renderActions(state, "all");
await act(async () => { await act(async () => {
@ -88,7 +88,7 @@ describe("Memo detail mutation navigation", () => {
}); });
await waitFor(() => expect(result.current.pathname).toBe(destination)); 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 () => { 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" }); expect(resolveMemoDetailOrigin({ unrelated: true })).toEqual({ parentPage: "/", parentScope: "all" });
}); });
it("uses an All Archived origin for a direct archived memo", () => { it("uses a user-level Archived origin without changing the remembered Space", () => {
expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "all" }); expect(resolveMemoDetailOrigin(undefined, { memoArchived: true })).toEqual({ parentPage: "/archived", parentScope: "preserve" });
}); });
it("keeps an explicit origin ahead of the archived fallback", () => { 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"); 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("/")).toBe(true);
expect(isMemoScopeRoute("/explore")).toBe(true); expect(isMemoScopeRoute("/explore")).toBe(true);
expect(isMemoScopeRoute("/archived")).toBe(true); expect(isMemoScopeRoute("/archived")).toBe(true);
expect(isMemoScopeRoute("/attachments")).toBe(false); expect(isMemoScopeRoute("/attachments")).toBe(false);
expect(getMemoScopePath("home")).toBe("/"); expect(getMemoScopePath("home")).toBe("/");
expect(getMemoScopePath("explore")).toBe("/explore"); 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" }]); 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({ expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
filters: [ filters: [
{ factor: "tagSearch", value: "work" }, { 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", () => { it("keeps the remembered collection filters when searching from Attachments", () => {
expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters)).toEqual({ expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters)).toEqual({
filters: [ filters: [

View file

@ -130,12 +130,7 @@ describe("SpaceProvider", () => {
expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name); expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name);
}); });
it.each([ it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
"/",
"/explore",
"/archived?filter=tagSearch%3Awork",
"/attachments",
])("preserves the current collection route when switching to a Space from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" }; const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product]; state.query.data = [product];
renderProvider(initialPath); renderProvider(initialPath);
@ -146,12 +141,7 @@ describe("SpaceProvider", () => {
expect(screen.getByTestId("path").textContent).toBe(initialPath); expect(screen.getByTestId("path").textContent).toBe(initialPath);
}); });
it.each([ it.each(["/", "/explore", "/attachments"])("preserves the current collection route when switching to All from %s", (initialPath) => {
"/",
"/explore",
"/archived?filter=tagSearch%3Awork",
"/attachments",
])("preserves the current collection route when switching to All from %s", (initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" }; const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product]; state.query.data = [product];
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name); sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name);
@ -165,7 +155,9 @@ describe("SpaceProvider", () => {
it.each([ it.each([
["Select first Space", "/inbox"], ["Select first Space", "/inbox"],
["Select first Space", "/archived?filter=tagSearch%3Awork"],
["Select Memos", "/u/alice"], ["Select Memos", "/u/alice"],
["Select Memos", "/archived?filter=tagSearch%3Awork"],
])("falls back to Home when using %s outside a collection route", (action, initialPath) => { ])("falls back to Home when using %s outside a collection route", (action, initialPath) => {
const product = { name: "spaces/product", title: "Product", description: "" }; const product = { name: "spaces/product", title: "Product", description: "" };
state.query.data = [product]; 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 { beforeEach, describe, expect, it, vi } from "vitest";
import Archived from "@/pages/Archived"; import Archived from "@/pages/Archived";
import Explore from "@/pages/Explore"; import Explore from "@/pages/Explore";
@ -22,10 +22,16 @@ vi.mock("@/components/MemoView", () => ({
vi.mock("@/components/PagedMemoList", () => ({ vi.mock("@/components/PagedMemoList", () => ({
default: (props: Record<string, unknown>) => { default: (props: Record<string, unknown>) => {
state.listProps.push(props); state.listProps.push(props);
const renderLeading = props.renderLeading as ((options: { useGrid: boolean }) => React.ReactNode) | undefined;
const renderer = props.renderer as const renderer = props.renderer as
| ((memo: { name: string; space: string }, options: { compact: boolean }) => React.ReactNode) | ((memo: { name: string; space: string }, options: { compact: boolean }) => React.ReactNode)
| undefined; | 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, getMemoKey: (memo: { name: string }) => memo.name,
})); }));
@ -46,7 +52,7 @@ vi.mock("@/hooks/useCurrentUser", () => ({
default: () => ({ name: "users/test" }), default: () => ({ name: "users/test" }),
})); }));
describe("Space-scoped feed pages", () => { describe("Memo feed collection scope", () => {
beforeEach(() => { beforeEach(() => {
state.selectedSpaceName = undefined; state.selectedSpaceName = undefined;
state.memoFilter = undefined; state.memoFilter = undefined;
@ -55,7 +61,7 @@ describe("Space-scoped feed pages", () => {
state.filterOptions = []; 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( render(
<> <>
<Explore /> <Explore />
@ -65,11 +71,12 @@ describe("Space-scoped feed pages", () => {
expect(state.listProps).toHaveLength(2); expect(state.listProps).toHaveLength(2);
expect(state.listProps[0]).toMatchObject({ contextFilter: undefined }); 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({ expect(state.filterOptions[0]).toMatchObject({
visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE], visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE],
}); });
expect(state.memoViewProps).toEqual([expect.objectContaining({ showSpace: true }), expect.objectContaining({ showSpace: true })]); 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", () => { 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 }); 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");
});
});