diff --git a/CONTEXT.md b/CONTEXT.md index 94c4f5a4..c58b7aa8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/docs/design/multi-spaces.md b/docs/design/multi-spaces.md index bf6bffb1..399e25af 100644 --- a/docs/design/multi-spaces.md +++ b/docs/design/multi-spaces.md @@ -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 diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 0b592ba2..d72f61e0 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -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 (
@@ -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); } }} > diff --git a/web/src/components/AppSidebar/routes.ts b/web/src/components/AppSidebar/routes.ts index 06cc798f..40b3a147 100644 --- a/web/src/components/AppSidebar/routes.ts +++ b/web/src/components/AppSidebar/routes.ts @@ -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", diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index ef68e5c7..8652ca67 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -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; diff --git a/web/src/components/MemoView/navigation.ts b/web/src/components/MemoView/navigation.ts index ca1c1732..94ccf3c4 100644 --- a/web/src/components/MemoView/navigation.ts +++ b/web/src/components/MemoView/navigation.ts @@ -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 }; }; diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index 81d19107..4e4c29a3 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -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) => { {t("common.profile")} + navigateFromMenu(Routes.ARCHIVED)} + > + + {t("common.archived")} + + @@ -186,6 +200,7 @@ const UserMenu = (props: Props) => { {t("common.settings")} + {t("common.sign-out")} diff --git a/web/src/contexts/AppSidebarContext.tsx b/web/src/contexts/AppSidebarContext.tsx index c62d7d3b..80fca6f9 100644 --- a/web/src/contexts/AppSidebarContext.tsx +++ b/web/src/contexts/AppSidebarContext.tsx @@ -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(null); @@ -39,7 +39,7 @@ export const AppSidebarProvider = ({ children }: { children: ReactNode }) => { const [memoDetail, setMemoDetailState] = useState(); const [mobileOpen, setMobileOpen] = useState(false); const [quickFindOpen, setQuickFindOpen] = useState(false); - const [memoScope, setMemoScope] = useState("home"); + const [memoScope, setMemoScope] = useState("home"); useEffect(() => { setMobileOpen(false); diff --git a/web/src/lib/memo-views.ts b/web/src/lib/memo-views.ts index 742ab4bb..65309387 100644 --- a/web/src/lib/memo-views.ts +++ b/web/src/lib/memo-views.ts @@ -1,6 +1,7 @@ import { ROUTES } from "@/router/routes"; export type MemoScope = "home" | "explore" | "archived"; +export type PrimaryMemoScope = Exclude; 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; }; diff --git a/web/src/pages/Archived.tsx b/web/src/pages/Archived.tsx index 014aa52c..f0a1cb70 100644 --- a/web/src/pages/Archived.tsx +++ b/web/src/pages/Archived.tsx @@ -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 ( ( - - )} + renderer={(memo: Memo, { compact }) => } listSort={listSort} state={State.ARCHIVED} orderBy={orderBy} filter={memoFilter} - contextFilter={contextFilter} + renderLeading={({ useGrid }) => ( +
+ +

{t("common.archived")}

+
+ )} /> ); }; diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx index 250c0253..6bfbecf2 100644 --- a/web/tests/app-sidebar-logo.test.tsx +++ b/web/tests/app-sidebar-logo.test.tsx @@ -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( + + + , + ); + + 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( - + , ); - 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( + + + , + ); + + 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", () => { diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts index 204f1e49..5d75df9e 100644 --- a/web/tests/app-sidebar-routes.test.ts +++ b/web/tests/app-sidebar-routes.test.ts @@ -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", diff --git a/web/tests/global-memo-editor.test.tsx b/web/tests/global-memo-editor.test.tsx index e69b728d..ac6251ae 100644 --- a/web/tests/global-memo-editor.test.tsx +++ b/web/tests/global-memo-editor.test.tsx @@ -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", diff --git a/web/tests/memo-action-navigation.test.tsx b/web/tests/memo-action-navigation.test.tsx index 0973e58b..52e3e1ba 100644 --- a/web/tests/memo-action-navigation.test.tsx +++ b/web/tests/memo-action-navigation.test.tsx @@ -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 () => { diff --git a/web/tests/memo-view-navigation.test.ts b/web/tests/memo-view-navigation.test.ts index a3042588..c574ffc2 100644 --- a/web/tests/memo-view-navigation.test.ts +++ b/web/tests/memo-view-navigation.test.ts @@ -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", () => { diff --git a/web/tests/memo-views.test.ts b/web/tests/memo-views.test.ts index 11b24bc2..17eb37f3 100644 --- a/web/tests/memo-views.test.ts +++ b/web/tests/memo-views.test.ts @@ -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"); }); }); diff --git a/web/tests/quick-find.test.ts b/web/tests/quick-find.test.ts index 60035bee..f0bdca70 100644 --- a/web/tests/quick-find.test.ts +++ b/web/tests/quick-find.test.ts @@ -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: [ diff --git a/web/tests/space-context.test.tsx b/web/tests/space-context.test.tsx index 50f36d80..20fb5d3b 100644 --- a/web/tests/space-context.test.tsx +++ b/web/tests/space-context.test.tsx @@ -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]; diff --git a/web/tests/space-feed-pages.test.tsx b/web/tests/space-feed-pages.test.tsx index 0d50aacb..e030aa19 100644 --- a/web/tests/space-feed-pages.test.tsx +++ b/web/tests/space-feed-pages.test.tsx @@ -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) => { 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
{renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })}
; + return ( +
+ {renderLeading?.({ useGrid: false })} + {renderer?.({ name: "memos/test", space: "spaces/product" }, { compact: false })} +
+ ); }, 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( <> @@ -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(); + + expect(state.listProps[0]).not.toHaveProperty("contextFilter"); + expect(state.memoViewProps[0]).toMatchObject({ showSpace: true }); + }); }); diff --git a/web/tests/user-menu.test.tsx b/web/tests/user-menu.test.tsx new file mode 100644 index 00000000..eae24d1d --- /dev/null +++ b/web/tests/user-menu.test.tsx @@ -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(); + 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( + + + , + ); + + 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"); + }); +});