fix(web): make archived independent of space scope
This commit is contained in:
parent
fc7970a2d1
commit
040f9ae717
20 changed files with 242 additions and 79 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
76
web/tests/user-menu.test.tsx
Normal file
76
web/tests/user-menu.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue