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
|
_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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -61,6 +61,8 @@ export const resolveMemoDetailOrigin = (
|
||||||
value?.fromScope === "all" || value?.fromScope === "preserve"
|
value?.fromScope === "all" || value?.fromScope === "preserve"
|
||||||
? value.fromScope
|
? value.fromScope
|
||||||
: hasExplicitParent && isMemoCollectionOrigin(parentPage)
|
: hasExplicitParent && isMemoCollectionOrigin(parentPage)
|
||||||
|
? "preserve"
|
||||||
|
: options.memoArchived
|
||||||
? "preserve"
|
? "preserve"
|
||||||
: "all";
|
: "all";
|
||||||
return { parentPage, parentScope };
|
return { parentPage, parentScope };
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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