diff --git a/web/src/components/AppSidebar/AppSidebar.tsx b/web/src/components/AppSidebar/AppSidebar.tsx index 5e681a7c..51e950f5 100644 --- a/web/src/components/AppSidebar/AppSidebar.tsx +++ b/web/src/components/AppSidebar/AppSidebar.tsx @@ -46,13 +46,13 @@ import { useNotifications, useUser } from "@/hooks/useUserQueries"; import { getMemoScopePath, getProfileUsername, type PrimaryMemoScope, resolveMemoScope } from "@/lib/memo-views"; import { userNamePrefix } from "@/lib/resource-names"; import { cn } from "@/lib/utils"; -import { ROUTES } from "@/router/routes"; +import { collectionPathForLocation, ROUTES } from "@/router/routes"; import { State } from "@/types/proto/api/v1/common_pb"; import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; import MemosLogo from "../MemosLogo"; import CommonSidebarContent from "./CommonSidebarContent"; -import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes"; +import { getSidebarRouteKind } from "./routes"; import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow"; import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection"; import SpaceSwitcher from "./SpaceSwitcher"; @@ -368,7 +368,7 @@ const GlobalNavigation = () => { const navigateToScope = (scope: PrimaryMemoScope) => { setMemoScope(scope); - navigate({ pathname: getMemoScopePath(scope), search: getFilterSearch(filters) }); + navigate({ pathname: collectionPathForLocation(getMemoScopePath(scope), location.pathname), search: getFilterSearch(filters) }); setMobileOpen(false); }; @@ -377,14 +377,14 @@ const GlobalNavigation = () => { { id: "calendar", label: t("common.calendar"), - path: ROUTES.CALENDAR, + path: collectionPathForLocation(ROUTES.CALENDAR, location.pathname), icon: CalendarDaysIcon, active: routeKind === "calendar", }, { id: "attachments", label: t("common.attachments"), - path: ROUTES.ATTACHMENTS, + path: collectionPathForLocation(ROUTES.ATTACHMENTS, location.pathname), icon: PaperclipIcon, active: routeKind === "attachments", }, @@ -541,12 +541,11 @@ const GlobalNavigation = () => { ); }; -/** The sidebar/header brand slot: collection scope on collection routes, instance brand elsewhere. */ +/** Signed-in users can navigate between Spaces from any page; global pages show Memos. */ const SidebarBrand = ({ className, size = "md" }: { className?: string; size?: "md" | "header" }) => { const currentUser = useCurrentUser(); - const location = useLocation(); - if (currentUser && routeSupportsCollectionScope(location.pathname)) { + if (currentUser) { return ; } diff --git a/web/src/components/AppSidebar/QuickFindDialog.tsx b/web/src/components/AppSidebar/QuickFindDialog.tsx index db13f88c..db22c3da 100644 --- a/web/src/components/AppSidebar/QuickFindDialog.tsx +++ b/web/src/components/AppSidebar/QuickFindDialog.tsx @@ -51,7 +51,6 @@ export const readQuickFindQuery = (filters: MemoFilter[]): { query: string; mode export interface QuickFindSubmission { filters: MemoFilter[]; destination?: string; - switchToAll: boolean; } export const resolveQuickFindSubmission = ( @@ -65,7 +64,6 @@ export const resolveQuickFindSubmission = ( return { filters, destination: routePolicy.searchDestination ? `${routePolicy.searchDestination}${getFilterSearch(filters)}` : undefined, - switchToAll: routePolicy.searchScope === "all", }; }; @@ -84,7 +82,7 @@ const QuickFindDialog = () => { const currentUser = useCurrentUser(); const { data: memoViews = [] } = useMemoViews(currentUser?.name); const { filters, setFilters, setMemoView, memoView } = useMemoFilterContext(); - const { clearSelectedSpace, duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext(); + const { duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext(); const { quickFindOpen, setQuickFindOpen } = useAppSidebar(); const [query, setQuery] = useState(""); const [mode, setMode] = useState("text"); @@ -93,7 +91,6 @@ const QuickFindDialog = () => { const selectedMemoView = viewApplies ? memoViews.find((item) => getMemoViewId(item.name) === memoView) : undefined; const lensLabel = viewApplies && memoView === BUILTIN_TASKS_VIEW_ID ? t("common.tasks") : selectedMemoView?.title || getScopeLabel(location.pathname, t); - const routePolicy = getRouteActionPolicy(location.pathname); const selectedSpaceUid = selectedSpaceName ? extractSpaceUidFromName(selectedSpaceName) : ""; const selectedSpaceUidDisplay = selectedSpaceName ? formatSpaceUidForDisplay(selectedSpaceName) : ""; const showSelectedSpaceUid = selectedSpace ? duplicateSpaceTitles.has(selectedSpace.title) : Boolean(selectedSpaceName); @@ -103,10 +100,8 @@ const QuickFindDialog = () => { const compactSelectedSpaceLabel = `${selectedSpace?.title || t("space.current")}${ showSelectedSpaceUid && selectedSpaceUidDisplay ? ` (${selectedSpaceUidDisplay})` : "" }`; - const scopeLabel = - routePolicy.searchScope === "remembered-collection" && selectedSpaceName ? `${selectedSpaceLabel} · ${lensLabel}` : lensLabel; - const compactScopeLabel = - routePolicy.searchScope === "remembered-collection" && selectedSpaceName ? `${compactSelectedSpaceLabel} · ${lensLabel}` : lensLabel; + const scopeLabel = selectedSpaceName ? `${selectedSpaceLabel} · ${lensLabel}` : lensLabel; + const compactScopeLabel = selectedSpaceName ? `${compactSelectedSpaceLabel} · ${lensLabel}` : lensLabel; useEffect(() => { if (!quickFindOpen) return; @@ -118,17 +113,11 @@ const QuickFindDialog = () => { const submitQuery = () => { const submission = resolveQuickFindSubmission(location.pathname, query, filters, mode); - if (submission.switchToAll) { - // This is an explicit cross-Space action, so switch the collection state - // to All without inserting an intermediate Home history entry. - clearSelectedSpace(); - } - - setFilters(submission.filters); - if (submission.destination) { setMemoView(undefined); navigate(submission.destination); + } else { + setFilters(submission.filters); } setQuickFindOpen(false); diff --git a/web/src/components/AppSidebar/SpaceSwitcher.tsx b/web/src/components/AppSidebar/SpaceSwitcher.tsx index 0da27384..52cb7815 100644 --- a/web/src/components/AppSidebar/SpaceSwitcher.tsx +++ b/web/src/components/AppSidebar/SpaceSwitcher.tsx @@ -1,5 +1,6 @@ import { CheckIcon, ChevronsUpDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react"; import { type ReactNode, useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; import CreateSpaceDialog from "@/components/CreateSpaceDialog"; import MemosLogo from "@/components/MemosLogo"; import SpaceMark from "@/components/SpaceMark"; @@ -15,6 +16,7 @@ import { import { useSpaceContext } from "@/contexts/SpaceContext"; import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display"; import { cn } from "@/lib/utils"; +import { getSpaceSwitchPath } from "@/router/routes"; import { useTranslate } from "@/utils/i18n"; import { sidebarSurfaceVariants } from "./sidebar-layout"; @@ -26,24 +28,21 @@ const RowIcon = ({ icon: Icon, className }: { icon: LucideIcon; className?: stri ); -const ContextItem = ({ - selected, - onSelect, - children, - ariaLabel, -}: { - selected: boolean; - onSelect: () => void; - children: ReactNode; - ariaLabel?: string; -}) => ( +const ContextItem = ({ selected, to, children, ariaLabel }: { selected: boolean; to: string; children: ReactNode; ariaLabel?: string }) => ( { + if (selected && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) event.preventDefault(); + }} + /> + } className={cn("min-w-0", selected && "bg-accent/60")} > {children} @@ -53,8 +52,9 @@ const ContextItem = ({ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?: "md" | "header" }) { const t = useTranslate(); - const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectMemos, selectSpace } = - useSpaceContext(); + const location = useLocation(); + const memosPath = getSpaceSwitchPath(location); + const { spaces, duplicateSpaceTitles, selectedSpace, selectedSpaceName, isLoadingSpaces, isSpacesError, selectSpace } = useSpaceContext(); const [createOpen, setCreateOpen] = useState(false); const [menuWidth, setMenuWidth] = useState(); const triggerRef = useRef(null); @@ -141,7 +141,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?: style={menuWidth ? { width: `${menuWidth}px` } : undefined} > - + @@ -157,7 +157,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?: selectSpace(space)} + to={getSpaceSwitchPath(location, space.name)} ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title} > diff --git a/web/src/components/AppSidebar/ViewsSection.tsx b/web/src/components/AppSidebar/ViewsSection.tsx index 96d95bdc..3a41c23c 100644 --- a/web/src/components/AppSidebar/ViewsSection.tsx +++ b/web/src/components/AppSidebar/ViewsSection.tsx @@ -15,7 +15,7 @@ import { useMemoViews, userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoCollectionRoute } from "@/lib/memo-views"; import { cn } from "@/lib/utils"; -import { ROUTES } from "@/router/routes"; +import { collectionPathForLocation, ROUTES } from "@/router/routes"; import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb"; import { useTranslate } from "@/utils/i18n"; import SidebarRow, { @@ -47,7 +47,8 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => { const handleView = (viewId: string) => { setMemoView(selectedMemoView === viewId ? undefined : viewId); - if (!isMemoCollectionRoute(location.pathname)) navigate(ROUTES.HOME); + if (!isMemoCollectionRoute(location.pathname)) + navigate({ pathname: collectionPathForLocation(ROUTES.HOME, location.pathname), search: location.search }); setMobileOpen(false); }; diff --git a/web/src/components/AppSidebar/routes.ts b/web/src/components/AppSidebar/routes.ts index 8ad3d3ab..d602f0de 100644 --- a/web/src/components/AppSidebar/routes.ts +++ b/web/src/components/AppSidebar/routes.ts @@ -1,21 +1,19 @@ import { matchPath } from "react-router-dom"; import { getProfileUsername, isCalendarRoute, isMemoScopeRoute, type MemoScope, resolveMemoScope } from "@/lib/memo-views"; -import { ROUTES } from "@/router/routes"; +import { collectionPathForLocation, ROUTES, resolveCollectionRoute } from "@/router/routes"; export type SidebarRouteKind = MemoScope | "profile" | "views" | "calendar" | "attachments" | "inbox" | "settings" | "memo" | "common"; -export type RouteSearchScope = "remembered-collection" | "user-collection" | "profile" | "all"; -export type RouteComposePlacement = "remembered-space" | "unassigned"; +export type RouteSearchScope = "route-collection" | "user-collection" | "profile" | "all"; export interface RouteActionPolicy { searchScope: RouteSearchScope; /** When absent, Quick Find stays on the current route. */ searchDestination?: string; - composePlacement: RouteComposePlacement; } export const getSidebarRouteKind = (path: string): SidebarRouteKind => { - const normalizedPath = path.length > 1 ? path.replace(/\/+$/, "") : path; + const normalizedPath = resolveCollectionRoute(path).pathname; if (isMemoScopeRoute(normalizedPath)) return resolveMemoScope(normalizedPath); if (getProfileUsername(normalizedPath) !== undefined) return "profile"; if (matchPath(ROUTES.VIEWS, normalizedPath)) return "views"; @@ -27,56 +25,26 @@ export const getSidebarRouteKind = (path: string): SidebarRouteKind => { return "common"; }; -/** 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 === "calendar" || kind === "attachments"; -}; - /** - * Keeps read/search scope and write placement explicit at route boundaries. - * A remembered Space is ambient collection state, so global and canonical - * resource routes must not silently use it as a creation target. + * Keeps read/search scope explicit at route boundaries. Compose placement needs no + * policy: the URL only carries a Space on collection pages. */ export const getRouteActionPolicy = (path: string): RouteActionPolicy => { const kind = getSidebarRouteKind(path); - if (kind === "home" || kind === "explore") { - return { - searchScope: "remembered-collection", - composePlacement: "remembered-space", - }; - } + if (kind === "home" || kind === "explore") return { searchScope: "route-collection" }; + if (kind === "archived") return { searchScope: "user-collection" }; - if (kind === "archived") { - return { - searchScope: "user-collection", - composePlacement: "unassigned", - }; - } - - // Calendar and attachments browse the remembered collection but are not memo lists - // themselves, so a search leaves for Home and Compose keeps the remembered Space. + // Calendar and attachments browse the route collection but are not memo lists + // themselves, so a search leaves for the same collection's Home. if (kind === "calendar" || kind === "attachments") { - return { - searchScope: "remembered-collection", - searchDestination: ROUTES.HOME, - composePlacement: "remembered-space", - }; + return { searchScope: "route-collection", searchDestination: collectionPathForLocation(ROUTES.HOME, path) }; } if (kind === "profile") { - return { - searchScope: "profile", - // Re-enter the memo list tab on the same profile (dropping ?view=map). - searchDestination: path.length > 1 ? path.replace(/\/+$/, "") : path, - composePlacement: "unassigned", - }; + // Re-enter the memo list tab on the same profile (dropping ?view=map). + return { searchScope: "profile", searchDestination: path.length > 1 ? path.replace(/\/+$/, "") : path }; } - return { - searchScope: "all", - searchDestination: ROUTES.HOME, - composePlacement: "unassigned", - }; + return { searchScope: "all", searchDestination: ROUTES.HOME }; }; diff --git a/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx b/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx index 600300f2..854e1b84 100644 --- a/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx +++ b/web/src/components/AttachmentLibrary/AttachmentLibraryPrimitives.tsx @@ -1,10 +1,9 @@ import { ExternalLinkIcon } from "lucide-react"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; import { createMemoNavigationState } from "@/components/MemoView/navigation"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { ROUTES } from "@/router/routes"; import { useTranslate } from "@/utils/i18n"; interface AttachmentMetadataLineProps { @@ -50,6 +49,7 @@ export const AttachmentSourceChip = ({ memoName, unlinkedLabelKey = "attachment-library.labels.not-linked", }: AttachmentSourceChipProps) => { + const location = useLocation(); const t = useTranslate(); if (!memoName) { @@ -63,9 +63,7 @@ export const AttachmentSourceChip = ({ return ( {t("attachment-library.labels.memo")} diff --git a/web/src/components/CalendarView/CalendarHeader.tsx b/web/src/components/CalendarView/CalendarHeader.tsx index 76a9bca9..b45a88a4 100644 --- a/web/src/components/CalendarView/CalendarHeader.tsx +++ b/web/src/components/CalendarView/CalendarHeader.tsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { buttonVariants } from "@/components/ui/button"; import { addMonths } from "@/lib/calendar-utils"; import { cn } from "@/lib/utils"; +import { collectionPathForLocation } from "@/router/routes"; import { useTranslate } from "@/utils/i18n"; import { CalendarLink } from "./CalendarLink"; import { MonthPicker } from "./MonthPicker"; @@ -33,7 +34,7 @@ export const getTodayPath = (month: string, activeDate: string | undefined, toda export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable }: CalendarHeaderProps) => { const t = useTranslate(); const navigate = useNavigate(); - const { search } = useLocation(); + const { pathname, search } = useLocation(); const todayOpen = activeDate === today; const iconControlClassName = cn(buttonVariants({ variant: "quiet", size: "icon-compact" })); @@ -55,7 +56,9 @@ export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable type="button" aria-pressed={todayOpen} className={cn(buttonVariants({ variant: "quiet", size: "sm" }), "ms-1.5")} - onClick={() => navigate({ pathname: getTodayPath(month, activeDate, today, closable), search })} + onClick={() => + navigate({ pathname: collectionPathForLocation(getTodayPath(month, activeDate, today, closable), pathname), search }) + } > {t("common.today")} diff --git a/web/src/components/CalendarView/CalendarLink.tsx b/web/src/components/CalendarView/CalendarLink.tsx index b7bb526b..96590c67 100644 --- a/web/src/components/CalendarView/CalendarLink.tsx +++ b/web/src/components/CalendarView/CalendarLink.tsx @@ -1,5 +1,6 @@ import { forwardRef } from "react"; import { Link, type LinkProps, useLocation } from "react-router-dom"; +import { collectionPathForLocation } from "@/router/routes"; export interface CalendarLinkProps extends Omit { /** Pathname only; the current search string rides along so view and tag filters survive. */ @@ -12,8 +13,8 @@ export interface CalendarLinkProps extends Omit { * pathname change. */ export const CalendarLink = forwardRef(({ to, ...props }, ref) => { - const { search } = useLocation(); - return ; + const { pathname, search } = useLocation(); + return ; }); CalendarLink.displayName = "CalendarLink"; diff --git a/web/src/components/CalendarView/CalendarView.tsx b/web/src/components/CalendarView/CalendarView.tsx index 4814f68d..b7d19271 100644 --- a/web/src/components/CalendarView/CalendarView.tsx +++ b/web/src/components/CalendarView/CalendarView.tsx @@ -15,6 +15,7 @@ import { formatMonthLabel, getToday } from "@/lib/calendar-utils"; import { combineCELFilters } from "@/lib/cel-filter"; import { buildMemoCreatorFilter } from "@/lib/resource-names"; import { isMemoBlurred } from "@/lib/tag"; +import { collectionPathForLocation } from "@/router/routes"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import { CalendarGrid } from "./CalendarGrid"; @@ -50,7 +51,7 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { const t = useTranslate(); const { i18n } = useTranslation(); const navigate = useNavigate(); - const { search } = useLocation(); + const { pathname, search } = useLocation(); const user = useCurrentUser(); const md = useMediaQuery("md"); const xl = useMediaQuery("xl"); @@ -90,7 +91,10 @@ export const CalendarView = ({ month, date }: CalendarViewProps) => { }); const monthLabel = useMemo(() => formatMonthLabel(month, i18n.language), [month, i18n.language]); - const closeDay = useCallback(() => navigate({ pathname: buildCalendarPath(month), search }), [navigate, month, search]); + const closeDay = useCallback( + () => navigate({ pathname: collectionPathForLocation(buildCalendarPath(month), pathname), search }), + [navigate, month, search, pathname], + ); const today = getToday(); const activeDate = date ?? (md ? undefined : getDefaultDate(month, today)); diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index 196c3754..853a4d31 100644 --- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -56,7 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { confirmDeleteMemo, } = useMemoActionHandlers({ memo, - parentScope: props.parentScope, + parentPage: props.parentPage, onEdit: props.onEdit, setDeleteDialogOpen, }); diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index 599f3117..578034d9 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -4,7 +4,6 @@ import { useCallback } from "react"; import toast from "react-hot-toast"; import { useLocation } from "react-router-dom"; import { useInstance } from "@/contexts/InstanceContext"; -import { useSpaceContext } from "@/contexts/SpaceContext"; import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { userKeys } from "@/hooks/useUserQueries"; @@ -14,22 +13,21 @@ import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions"; -import { isMemoDetailPath, type MemoOriginScope } from "../MemoView/navigation"; +import { isMemoDetailPath } from "../MemoView/navigation"; interface UseMemoActionHandlersOptions { memo: Memo; - parentScope: MemoOriginScope; + parentPage?: string; onEdit?: () => void; setDeleteDialogOpen: (open: boolean) => void; } -export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => { +export const useMemoActionHandlers = ({ memo, parentPage, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => { const t = useTranslate(); const location = useLocation(); const navigateTo = useNavigateTo(); const queryClient = useQueryClient(); const { profile } = useInstance(); - const { clearSelectedSpace } = useSpaceContext(); const { mutateAsync: updateMemo } = useUpdateMemo(); const { mutateAsync: deleteMemo } = useDeleteMemo(); const isInMemoDetailPage = isMemoDetailPath(location.pathname, memo.name); @@ -149,24 +147,10 @@ export const useMemoActionHandlers = ({ memo, parentScope, onEdit, setDeleteDial queryClient.invalidateQueries({ queryKey: memoKeys.detail(memo.parent) }); } if (isInMemoDetailPage) { - if (parentScope === "all") { - clearSelectedSpace(); - } - navigateTo(ROUTES.HOME); + navigateTo(parentPage || ROUTES.HOME); } memoUpdatedCallback(); - }, [ - memo.name, - memo.parent, - t, - isInMemoDetailPage, - parentScope, - clearSelectedSpace, - navigateTo, - memoUpdatedCallback, - deleteMemo, - queryClient, - ]); + }, [memo.name, memo.parent, t, isInMemoDetailPage, parentPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]); return { handleTogglePinMemoBtnClick, diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts index 0de0d0e5..78a864da 100644 --- a/web/src/components/MemoActionMenu/types.ts +++ b/web/src/components/MemoActionMenu/types.ts @@ -1,9 +1,8 @@ import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; -import type { MemoOriginScope } from "../MemoView/navigation"; export interface MemoActionMenuProps { memo: Memo; - parentScope: MemoOriginScope; + parentPage?: string; readonly?: boolean; onEdit?: () => void; } diff --git a/web/src/components/MemoContent/Tag.tsx b/web/src/components/MemoContent/Tag.tsx index 8be7c3b6..c8f9558a 100644 --- a/web/src/components/MemoContent/Tag.tsx +++ b/web/src/components/MemoContent/Tag.tsx @@ -2,7 +2,6 @@ import type { Element } from "hast"; import { useLocation } from "react-router-dom"; import { useAuth } from "@/contexts/AuthContext"; import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext"; -import { useSpaceContext } from "@/contexts/SpaceContext"; import useNavigateTo from "@/hooks/useNavigateTo"; import { colorToHex } from "@/lib/color"; import { tagStyles } from "@/lib/markdownStyles"; @@ -10,7 +9,7 @@ import { findTagMetadata } from "@/lib/tag"; import { cn } from "@/lib/utils"; import { Routes } from "@/router"; import { useMemoViewContext } from "../MemoView/MemoViewContext"; -import { isMemoCollectionOrigin, isMemoResourcePath, withMemoFilter } from "../MemoView/navigation"; +import { isMemoResourcePath, withMemoFilter } from "../MemoView/navigation"; interface TagProps extends React.HTMLAttributes { node?: Element; // AST node from react-markdown @@ -19,10 +18,9 @@ interface TagProps extends React.HTMLAttributes { } export const Tag: React.FC = ({ "data-tag": dataTag, children, className, style, node: _node, ...props }) => { - const { parentPage, parentScope } = useMemoViewContext(); + const { parentPage } = useMemoViewContext(); const location = useLocation(); const navigateTo = useNavigateTo(); - const { clearSelectedSpace } = useSpaceContext(); const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext(); const { userTagsSetting } = useAuth(); @@ -47,9 +45,6 @@ export const Tag: React.FC = ({ "data-tag": dataTag, children, classNa // If the tag is clicked in a memo detail page, we should navigate to the memo list page. if (isMemoResourcePath(location.pathname)) { - if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) { - clearSelectedSpace(); - } navigateTo(withMemoFilter(parentPage || Routes.HOME, stringifyFilters([{ factor: "tagSearch", value: tag }]))); return; } diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index b0fed719..17fc5d30 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -19,10 +19,9 @@ import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppS import { extractHeadings } from "@/components/MemoContent/pipeline"; import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers"; import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos"; -import { createMemoNavigationState, isMemoCollectionOrigin, type MemoOriginScope } from "@/components/MemoView/navigation"; +import { createMemoNavigationState, type MemoOriginScope } from "@/components/MemoView/navigation"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { useInstance } from "@/contexts/InstanceContext"; -import { useSpaceContext } from "@/contexts/SpaceContext"; import { useOverflowTitle } from "@/hooks"; import useCurrentUser from "@/hooks/useCurrentUser"; import { MEMO_COMMENTS_ANCHOR_ID } from "@/lib/memo-comments"; @@ -105,7 +104,6 @@ const MemoDetailSidebar = ({ const location = useLocation(); const currentUser = useCurrentUser(); const { profile } = useInstance(); - const { clearSelectedSpace } = useSpaceContext(); const [sharePanelOpen, setSharePanelOpen] = useState(false); const readonly = forceReadonly || (memo.creator !== currentUser?.name && !isSuperUser(currentUser)); @@ -183,11 +181,6 @@ const MemoDetailSidebar = ({ { - if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) { - clearSelectedSpace(); - } - }} viewTransition > diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index 897e4eb4..ca7abe08 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -130,7 +130,7 @@ const MemoHeader: React.FC = ({ timeDisplay = "relative", showC )} - + ); diff --git a/web/src/components/MemoView/navigation.ts b/web/src/components/MemoView/navigation.ts index c1618cbe..be0a9931 100644 --- a/web/src/components/MemoView/navigation.ts +++ b/web/src/components/MemoView/navigation.ts @@ -1,5 +1,5 @@ import { getProfileUsername, isMemoScopeRoute } from "@/lib/memo-views"; -import { ROUTES } from "@/router/routes"; +import { ROUTES, resolveCollectionRoute } from "@/router/routes"; export type MemoOriginScope = "all" | "preserve"; @@ -40,10 +40,10 @@ export const isMemoResourcePath = (pathname: string): boolean => { return memoID.length > 0 && !memoID.includes("/"); }; -/** Whether returning from this collection should preserve the remembered All / Space state. */ +/** Whether this origin represents a collection whose complete URL should be preserved. */ export const isMemoCollectionOrigin = (page: string): boolean => { const pathname = page.split(/[?#]/, 1)[0] || ROUTES.HOME; - return isMemoScopeRoute(pathname) || normalizePathname(pathname) === ROUTES.ATTACHMENTS; + return isMemoScopeRoute(pathname) || resolveCollectionRoute(pathname).isCollection; }; export const createMemoNavigationState = (from: string, fromScope: MemoOriginScope): MemoNavigationState => ({ from, fromScope }); diff --git a/web/src/contexts/GlobalMemoEditorContext.tsx b/web/src/contexts/GlobalMemoEditorContext.tsx index e1395a20..0bfae71b 100644 --- a/web/src/contexts/GlobalMemoEditorContext.tsx +++ b/web/src/contexts/GlobalMemoEditorContext.tsx @@ -1,7 +1,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; -import { getRouteActionPolicy } from "@/components/AppSidebar/routes"; +import { getSidebarRouteKind } from "@/components/AppSidebar/routes"; import { loadMemoEditor } from "@/components/MemoEditor/loader"; import type { MemoEditorProps } from "@/components/MemoEditor/types"; import { VisuallyHidden } from "@/components/ui/visually-hidden"; @@ -11,7 +11,6 @@ import { useSpaceContext } from "@/contexts/SpaceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; import { spaceScopedCacheKey } from "@/lib/resource-names"; -import { ROUTES } from "@/router/routes"; import { useTranslate } from "@/utils/i18n"; interface GlobalMemoEditorContextValue { @@ -44,14 +43,12 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) const t = useTranslate(); const location = useLocation(); const currentUserName = useCurrentUser()?.name; - const { selectedSpaceName } = useSpaceContext(); + const { selectedSpaceName, isSpaceReady } = useSpaceContext(); const { isUserSettingsInitialized } = useAuth(); const desktop = useMediaQuery("md"); const [initialHome] = useState(() => ({ location, user: currentUserName, space: selectedSpaceName })); - const homeAutoFocusPending = useRef(desktop && location.pathname === ROUTES.HOME && Boolean(currentUserName)); + const homeAutoFocusPending = useRef(desktop && getSidebarRouteKind(location.pathname) === "home" && Boolean(currentUserName)); const { setMobileOpen, setQuickFindOpen } = useAppSidebar(); - const routePolicy = getRouteActionPolicy(location.pathname); - const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined; // One snapshot taken when the composer opens: keyed by the user who opened it, so // signing out closes the composer in the same render and a different user signing // in cannot resurrect it, and pinned to the Space that was selected at that moment. @@ -97,7 +94,7 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) // The editor reads defaults out of user settings, so composing has to wait for // them. Keep the rule here so every entry point uses the same gate. - const canOpen = Boolean(currentUserName) && isUserSettingsInitialized; + const canOpen = Boolean(currentUserName) && isUserSettingsInitialized && isSpaceReady; const openEditor = useCallback(() => { if (!canOpen || !currentUserName) return; @@ -116,10 +113,10 @@ export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) .then(({ default: MemoEditor }) => { if (openRequestVersionRef.current !== requestVersion) return; setEditorComponent(() => MemoEditor); - setOpened({ user: currentUserName, space: composeSpace }); + setOpened({ user: currentUserName, space: selectedSpaceName }); }) .catch(() => undefined); - }, [canOpen, composeSpace, currentUserName, setMobileOpen, setQuickFindOpen]); + }, [canOpen, selectedSpaceName, currentUserName, setMobileOpen, setQuickFindOpen]); useEffect(() => { // RootLayout remains mounted when a public instance moves from Home to diff --git a/web/src/contexts/MemoFilterContext.tsx b/web/src/contexts/MemoFilterContext.tsx index d87dc950..7e2711b1 100644 --- a/web/src/contexts/MemoFilterContext.tsx +++ b/web/src/contexts/MemoFilterContext.tsx @@ -1,6 +1,6 @@ import { uniqBy } from "lodash-es"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router-dom"; export type FilterFactor = | "tagSearch" @@ -74,6 +74,8 @@ const MemoFilterContext = createContext(null); export function MemoFilterProvider({ children }: { children: ReactNode }) { const [searchParams, setSearchParams] = useSearchParams(); + const locationStateRef = useRef(useLocation().state); + const skipStoreSyncRef = useRef(false); const lastSyncedUrlRef = useRef(""); const lastSyncedStoreRef = useRef(""); @@ -87,6 +89,9 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) { useEffect(() => { const filterParam = searchParams.get("filter") || ""; if (filterParam !== lastSyncedUrlRef.current) { + // The store still contains the previous page’s filters in this commit. + // Do not write them back over the destination URL before state catches up. + skipStoreSyncRef.current = true; lastSyncedUrlRef.current = filterParam; const newFilters = parseFilterQuery(filterParam); setFiltersState(newFilters); @@ -96,6 +101,10 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) { // Sync state to URL when state changes useEffect(() => { + if (skipStoreSyncRef.current) { + skipStoreSyncRef.current = false; + return; + } const storeString = stringifyFilters(filters); if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) { lastSyncedStoreRef.current = storeString; @@ -105,7 +114,7 @@ export function MemoFilterProvider({ children }: { children: ReactNode }) { } else { newParams.delete("filter"); } - setSearchParams(newParams, { replace: true }); + setSearchParams(newParams, { replace: true, state: locationStateRef.current }); lastSyncedUrlRef.current = filters.length > 0 ? storeString : ""; } }, [filters, searchParams, setSearchParams]); diff --git a/web/src/contexts/SpaceContext.tsx b/web/src/contexts/SpaceContext.tsx index 6fd28122..85a23fb5 100644 --- a/web/src/contexts/SpaceContext.tsx +++ b/web/src/contexts/SpaceContext.tsx @@ -1,39 +1,13 @@ -import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import type { ConnectError } from "@connectrpc/connect"; +import { createContext, type ReactNode, useCallback, useContext, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { routeSupportsCollectionScope } from "@/components/AppSidebar/routes"; import useCurrentUser from "@/hooks/useCurrentUser"; -import { useSpaces } from "@/hooks/useSpaceQueries"; +import { useSpace, useSpaces } from "@/hooks/useSpaceQueries"; import { buildCollectionScopeFilter, type CollectionScope } from "@/lib/cel-filter"; import { getDuplicateSpaceTitles } from "@/lib/space-display"; -import { ROUTES } from "@/router/routes"; +import { buildCollectionPath, ROUTES, resolveCollectionRoute } from "@/router/routes"; import type { Space } from "@/types/proto/api/v1/space_service_pb"; -const SELECTED_SPACE_STORAGE_PREFIX = "memos-selected-space:"; -const ALL_COLLECTION_SCOPE: CollectionScope = { kind: "all" }; - -export const getSelectedSpaceStorageKey = (userName: string) => `${SELECTED_SPACE_STORAGE_PREFIX}${userName}`; - -const readSelectedSpaceName = (userName: string): string | undefined => { - try { - return sessionStorage.getItem(getSelectedSpaceStorageKey(userName)) || undefined; - } catch { - return undefined; - } -}; - -const writeSelectedSpaceName = (userName: string, spaceName: string | undefined) => { - try { - const key = getSelectedSpaceStorageKey(userName); - if (spaceName) { - sessionStorage.setItem(key, spaceName); - } else { - sessionStorage.removeItem(key); - } - } catch { - // sessionStorage can be unavailable in restricted browser contexts. - } -}; - interface SpaceContextValue { spaces: Space[]; spaceByName: ReadonlyMap; @@ -44,90 +18,39 @@ interface SpaceContextValue { memoFilter?: string; isLoadingSpaces: boolean; isSpacesError: boolean; - /** Selects All without changing the current route. */ - clearSelectedSpace: () => void; + isSpaceReady: boolean; + spaceError: ConnectError | null; + retrySpace: () => void; + /** Opens the Space Home after creation or from an invitation. */ selectSpace: (space: Space) => void; - selectMemos: () => void; } const SpaceContext = createContext(null); - -// Stable identity for the pre-load and error states, so the memoized context value -// below does not rebuild — and re-render every consumer — on each provider render. const NO_SPACES: Space[] = []; -const NO_SPACE_BY_NAME: ReadonlyMap = new Map(); -const NO_DUPLICATE_SPACE_TITLES: ReadonlySet = new Set(); -function UserSpaceSession({ userName, children }: { userName: string; children: ReactNode }) { - // Keep router values in refs so switching scope does not make the context callbacks - // change identity whenever the user navigates. +export function SpaceProvider({ children }: { children: ReactNode }) { + const userName = useCurrentUser()?.name; const location = useLocation(); const navigate = useNavigate(); - const pathnameRef = useRef(location.pathname); - const navigateRef = useRef(navigate); - pathnameRef.current = location.pathname; - navigateRef.current = navigate; - - const [selectedSpaceName, setSelectedSpaceName] = useState(() => readSelectedSpaceName(userName)); - const [optimisticSpace, setOptimisticSpace] = useState(); + const { spaceName: selectedSpaceName } = resolveCollectionRoute(location.pathname); const spacesQuery = useSpaces(userName); + const spaceQuery = useSpace(userName, selectedSpaceName); const spaces = spacesQuery.data ?? NO_SPACES; - const spaceByName = useMemo(() => new Map(spaces.map((space) => [space.name, space])), [spaces]); - const listedSelectedSpace = spaces.find((space) => space.name === selectedSpaceName); - const selectedSpace = listedSelectedSpace ?? (optimisticSpace?.name === selectedSpaceName ? optimisticSpace : undefined); - const duplicateSpaceTitles = useMemo( - () => getDuplicateSpaceTitles(selectedSpace && !spaceByName.has(selectedSpace.name) ? [...spaces, selectedSpace] : spaces), - [selectedSpace, spaceByName, spaces], - ); + const selectedSpace = spaceQuery.data; + const spaceByName = useMemo(() => { + const result = new Map(spaces.map((space) => [space.name, space])); + if (selectedSpace) result.set(selectedSpace.name, selectedSpace); + return result; + }, [spaces, selectedSpace]); + const duplicateSpaceTitles = useMemo(() => getDuplicateSpaceTitles([...spaceByName.values()]), [spaceByName]); const collectionScope = useMemo( - () => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : ALL_COLLECTION_SCOPE), + () => (selectedSpaceName ? { kind: "space", name: selectedSpaceName } : { kind: "all" }), [selectedSpaceName], ); - - useEffect(() => { - if (listedSelectedSpace && optimisticSpace?.name === listedSelectedSpace.name) { - setOptimisticSpace(undefined); - } - }, [listedSelectedSpace, optimisticSpace]); - - useEffect(() => { - if (!selectedSpaceName || !spacesQuery.isSuccess || selectedSpace) { - return; - } - - writeSelectedSpaceName(userName, undefined); - setSelectedSpaceName(undefined); - }, [selectedSpace, selectedSpaceName, spacesQuery.isSuccess, userName]); - - const navigateAfterScopeChange = useCallback(() => { - // Scope and collection lens are independent. Preserve the active lens when - // switching All/Space; global and resource routes fall back to My memos. - if (!routeSupportsCollectionScope(pathnameRef.current)) { - navigateRef.current(ROUTES.HOME); - } - }, []); - - const selectSpace = useCallback( - (space: Space) => { - writeSelectedSpaceName(userName, space.name); - setOptimisticSpace(space); - setSelectedSpaceName(space.name); - navigateAfterScopeChange(); - }, - [navigateAfterScopeChange, userName], - ); - - const clearSelectedSpace = useCallback(() => { - writeSelectedSpaceName(userName, undefined); - setOptimisticSpace(undefined); - setSelectedSpaceName(undefined); - }, [userName]); - - const selectMemos = useCallback(() => { - clearSelectedSpace(); - navigateAfterScopeChange(); - }, [clearSelectedSpace, navigateAfterScopeChange]); - + const selectSpace = useCallback((space: Space) => navigate(buildCollectionPath(ROUTES.HOME, space.name)), [navigate]); + const retrySpace = useCallback(() => { + void spaceQuery.refetch(); + }, [spaceQuery.refetch]); const value = useMemo( () => ({ spaces, @@ -139,58 +62,33 @@ function UserSpaceSession({ userName, children }: { userName: string; children: memoFilter: buildCollectionScopeFilter(collectionScope), isLoadingSpaces: spacesQuery.isPending, isSpacesError: spacesQuery.isError, - clearSelectedSpace, + isSpaceReady: !selectedSpaceName || (Boolean(userName) && spaceQuery.isSuccess), + spaceError: spaceQuery.error, + retrySpace, selectSpace, - selectMemos, }), [ - clearSelectedSpace, - collectionScope, + spaces, + spaceByName, duplicateSpaceTitles, - selectMemos, - selectSpace, selectedSpace, selectedSpaceName, - spaceByName, - spaces, - spacesQuery.isError, + collectionScope, spacesQuery.isPending, + spacesQuery.isError, + userName, + spaceQuery.isSuccess, + spaceQuery.error, + retrySpace, + selectSpace, ], ); return {children}; } -const anonymousValue: SpaceContextValue = { - spaces: [], - spaceByName: NO_SPACE_BY_NAME, - duplicateSpaceTitles: NO_DUPLICATE_SPACE_TITLES, - collectionScope: ALL_COLLECTION_SCOPE, - isLoadingSpaces: false, - isSpacesError: false, - clearSelectedSpace: () => undefined, - selectSpace: () => undefined, - selectMemos: () => undefined, -}; - -export function SpaceProvider({ children }: { children: ReactNode }) { - const currentUserName = useCurrentUser()?.name; - - if (!currentUserName) { - return {children}; - } - - return ( - - {children} - - ); -} - export function useSpaceContext() { const context = useContext(SpaceContext); - if (!context) { - throw new Error("useSpaceContext must be used within SpaceProvider"); - } + if (!context) throw new Error("useSpaceContext must be used within SpaceProvider"); return context; } diff --git a/web/src/hooks/useSpaceQueries.ts b/web/src/hooks/useSpaceQueries.ts index fa6624f0..80c2fbb0 100644 --- a/web/src/hooks/useSpaceQueries.ts +++ b/web/src/hooks/useSpaceQueries.ts @@ -1,10 +1,12 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"; +import { Code, type ConnectError } from "@connectrpc/connect"; import { type QueryClient, type QueryKey, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { spaceServiceClient } from "@/connect"; import { attachmentKeys } from "@/hooks/useAttachmentQueries"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; +import { hasConnectCode } from "@/lib/error"; import { type Space, type SpaceInvitation, @@ -90,7 +92,7 @@ const updateCachedList = (queryClient: QueryClient, queryKey: QueryKey, updat const removeSpaceFromViewerCache = (queryClient: QueryClient, viewerName: string, spaceName: string) => { updateCachedList(queryClient, spaceKeys.list(viewerName), (spaces) => removeByName(spaces, spaceName)); - queryClient.removeQueries({ queryKey: spaceKeys.space(viewerName, spaceName) }); + queryClient.resetQueries({ queryKey: spaceKeys.space(viewerName, spaceName) }); }; const invalidateMembershipSensitiveQueries = (queryClient: QueryClient) => { @@ -125,6 +127,22 @@ export function useSpaces(viewerName: string | undefined, options?: SpaceQueryOp }); } +/** Fetches the route Space; the switcher list seeds it so collection pages are not held behind a second round-trip. */ +export function useSpace(viewerName: string | undefined, spaceName: string | undefined) { + const queryClient = useQueryClient(); + const listKey = spaceKeys.list(viewerName ?? ""); + return useQuery({ + queryKey: spaceKeys.space(viewerName ?? "", spaceName ?? ""), + queryFn: () => spaceServiceClient.getSpace({ name: spaceName! }), + enabled: queryEnabled(viewerName, spaceName), + initialData: () => queryClient.getQueryData(listKey)?.find((space) => space.name === spaceName), + initialDataUpdatedAt: () => queryClient.getQueryState(listKey)?.dataUpdatedAt, + staleTime: SPACE_LIST_STALE_TIME, + refetchOnWindowFocus: false, + retry: (count, error) => !hasConnectCode(error, Code.NotFound, Code.PermissionDenied) && count < 2, + }); +} + export function useSpaceMembers(viewerName: string | undefined, spaceName: string | undefined, options?: SpaceQueryOptions) { return useQuery({ queryKey: spaceKeys.members(viewerName ?? "", spaceName ?? ""), @@ -204,6 +222,7 @@ export function useCreateSpace(viewerName: string) { spaceId, }), onSuccess: (space) => { + queryClient.setQueryData(spaceKeys.space(viewerName, space.name), space); queryClient.setQueryData(spaceKeys.list(viewerName), (spaces = []) => upsertByName(spaces, space)); }, }); @@ -219,6 +238,7 @@ export function useUpdateSpace(viewerName: string) { updateMask: create(FieldMaskSchema, { paths: updateMask }), }), onSuccess: (space) => { + queryClient.setQueryData(spaceKeys.space(viewerName, space.name), space); updateCachedList(queryClient, spaceKeys.list(viewerName), (spaces) => upsertByName(spaces, space)); }, }); @@ -343,7 +363,8 @@ export function useUpdateSpaceMember(viewerName: string) { if (spaceName) { updateCachedList(queryClient, spaceKeys.members(viewerName, spaceName), (members) => upsertByName(members, member)); } - if (member.user === viewerName) { + if (member.user === viewerName && spaceName) { + void queryClient.invalidateQueries({ queryKey: spaceKeys.space(viewerName, spaceName), exact: true }); void queryClient.invalidateQueries({ queryKey: spaceKeys.list(viewerName), exact: true }); } }, diff --git a/web/src/lib/memo-views.ts b/web/src/lib/memo-views.ts index dfb967c9..d162fcc1 100644 --- a/web/src/lib/memo-views.ts +++ b/web/src/lib/memo-views.ts @@ -1,4 +1,4 @@ -import { ROUTES } from "@/router/routes"; +import { ROUTES, resolveCollectionRoute } from "@/router/routes"; export type MemoScope = "home" | "explore" | "archived"; export type PrimaryMemoScope = Exclude; @@ -16,8 +16,11 @@ const cleanPathname = (value: string): string => { return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; }; +/** Lower-cased global pathname, so a Space-scoped URL compares like its global twin. */ +const comparablePathname = (pathname: string): string => resolveCollectionRoute(pathname).pathname.toLowerCase(); + export const isMemoScopeRoute = (pathname: string): boolean => { - const comparablePath = cleanPathname(pathname).toLowerCase(); + const comparablePath = comparablePathname(pathname); return comparablePath === ROUTES.HOME || comparablePath === ROUTES.EXPLORE || comparablePath === ROUTES.ARCHIVED; }; @@ -31,7 +34,7 @@ export const getProfileUsername = (pathname: string): string | undefined => { /** `/calendar` and any month or day beneath it. */ export const isCalendarRoute = (pathname: string): boolean => { - const comparablePath = cleanPathname(pathname).toLowerCase(); + const comparablePath = comparablePathname(pathname); return comparablePath === ROUTES.CALENDAR || comparablePath.startsWith(`${ROUTES.CALENDAR}/`); }; @@ -39,15 +42,12 @@ export const isCalendarRoute = (pathname: string): boolean => { * Routes that render a memo collection the sidebar can narrow: the scope routes, a user * profile and the calendar. Views, calendar days and tags apply in place on all of them. * This is a different question from `isMemoCollectionOrigin` (MemoView/navigation.ts), - * which asks whether returning to a route should keep the remembered Space. + * which asks whether a memo origin is a collection whose full URL is worth returning to. */ export const isMemoCollectionRoute = (pathname: string): boolean => isMemoScopeRoute(pathname) || getProfileUsername(pathname) !== undefined || isCalendarRoute(pathname); -export const getMemoScopePath = (scope: PrimaryMemoScope): string => { - if (scope === "explore") return ROUTES.EXPLORE; - return ROUTES.HOME; -}; +export const getMemoScopePath = (scope: PrimaryMemoScope): string => (scope === "explore" ? ROUTES.EXPLORE : ROUTES.HOME); interface ResolveMemoScopeOptions { currentUsername?: string; @@ -58,7 +58,7 @@ interface ResolveMemoScopeOptions { export const resolveMemoScope = (pathname: string, options: ResolveMemoScopeOptions = {}): MemoScope => { const cleanPath = cleanPathname(pathname); - const comparablePath = cleanPath.toLowerCase(); + const comparablePath = comparablePathname(cleanPath); if (comparablePath === ROUTES.EXPLORE) return "explore"; if (comparablePath === ROUTES.ARCHIVED) return "archived"; if (comparablePath === ROUTES.HOME) return "home"; diff --git a/web/src/locales/az.json b/web/src/locales/az.json index 361cb0cc..9a449a7b 100644 --- a/web/src/locales/az.json +++ b/web/src/locales/az.json @@ -54,7 +54,6 @@ "new-memo-on-day": "Bu gün üçün yeni qeyd", "next-year": "Növbəti il", "no-memos-in-month": "{{month}} ayında qeyd yoxdur", - "no-memos-on-day": "Bu gün üçün qeyd yoxdur", "previous-year": "Əvvəlki il", "resize-panel": "Gün panelinin ölçüsünü dəyiş", "select-month": "Ay seç" @@ -1071,6 +1070,8 @@ } }, "space": { + "unavailable": "Bu sahə mövcud deyil və ya ona giriş icazəniz yoxdur.", + "back-to-memos": "Memos-a qayıt", "create": "Sahə yarat", "create-description": "Qeydlər və üzvlər üçün ortaq yer yaradın.", "create-success": "Sahə yaradıldı", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 99bd859a..9c793af0 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -1070,6 +1070,8 @@ } }, "space": { + "unavailable": "This space does not exist or you don’t have access to it.", + "back-to-memos": "Back to Memos", "create": "Create space", "create-description": "Create a shared place for memos and members.", "create-success": "Space created", diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 7f4232d0..b5da408c 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -966,5 +966,9 @@ "description": "这些文件从未附加到任何备忘。可在此查看或删除。", "title": "未关联的上传" } + }, + "space": { + "unavailable": "空间不存在或你没有访问权限。", + "back-to-memos": "返回 Memos" } } diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index 1c976726..294545c5 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -1046,6 +1046,8 @@ } }, "space": { + "unavailable": "空間不存在或你沒有存取權限。", + "back-to-memos": "返回 Memos", "create": "建立空間", "create-description": "建立供備忘錄與成員共用的空間。", "create-success": "已建立空間", diff --git a/web/src/pages/Calendar.tsx b/web/src/pages/Calendar.tsx index b41a1f4c..5f25772d 100644 --- a/web/src/pages/Calendar.tsx +++ b/web/src/pages/Calendar.tsx @@ -1,13 +1,20 @@ -import { Navigate, useParams } from "react-router-dom"; +import { Navigate, useLocation, useParams } from "react-router-dom"; import { buildCalendarPath, CalendarView, parseCalendarParams } from "@/components/CalendarView"; import type { CalendarRouteParams } from "@/components/CalendarView/paths"; import { getCurrentMonth } from "@/lib/calendar-utils"; +import { collectionPathForLocation } from "@/router/routes"; /** `/calendar/:year?/:month?/:day?` — anything that is not a real date lands on this month. */ const Calendar = () => { + const location = useLocation(); const state = parseCalendarParams(useParams()); if (!state) { - return ; + return ( + + ); } return ; }; diff --git a/web/src/router/SpaceRoute.tsx b/web/src/router/SpaceRoute.tsx new file mode 100644 index 00000000..b8f68b48 --- /dev/null +++ b/web/src/router/SpaceRoute.tsx @@ -0,0 +1,42 @@ +import { Code } from "@connectrpc/connect"; +import { LoaderCircleIcon } from "lucide-react"; +import { Link, Outlet } from "react-router-dom"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { useSpaceContext } from "@/contexts/SpaceContext"; +import { hasConnectCode } from "@/lib/error"; +import NotFound from "@/pages/NotFound"; +import { useTranslate } from "@/utils/i18n"; +import { ROUTES } from "./routes"; + +/** Keeps inaccessible or unresolved Space content out of the page and composer. */ +export const SpaceRoute = () => { + const t = useTranslate(); + const { selectedSpaceName, isSpaceReady, spaceError, retrySpace } = useSpaceContext(); + if (!selectedSpaceName) return ; + if (isSpaceReady) return ; + const unavailable = hasConnectCode(spaceError, Code.NotFound, Code.PermissionDenied); + return ( +
+ {!spaceError ? ( + <> + +

{t("space.loading")}

+ + ) : ( + <> +

{t(unavailable ? "space.unavailable" : "space.load-error")}

+
+ {!unavailable && ( + + )} + + {t("space.back-to-memos")} + +
+ + )} +
+ ); +}; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 6c1b6b57..7c8308d7 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -11,7 +11,8 @@ import { RequireGuestRoute, RequireInstanceInitializationRoute, } from "./guards"; -import { CALENDAR_ROUTE_PATTERN, ROUTES } from "./routes"; +import { CALENDAR_ROUTE_PATTERN, ROUTES, SPACE_ROUTE_PATTERN } from "./routes"; +import { SpaceRoute } from "./SpaceRoute"; const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn")); const About = lazyWithReload(() => import("@/pages/About")); @@ -107,6 +108,26 @@ export const routeConfig: RouteObject[] = [ { element: , children: [ + { + path: SPACE_ROUTE_PATTERN, + children: [ + { + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + { path: "explore", element: }, + { path: "calendar/:year?/:month?/:day?", element: }, + ], + }, + { path: "attachments", element: }, + ], + }, + { path: "*", element: }, + ], + }, { path: Routes.ATTACHMENTS, element: }, { path: Routes.INBOX, element: }, { path: Routes.SETTING, element: }, diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index 3c798a4a..50afcc68 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -1,3 +1,6 @@ +import { matchPath } from "react-router-dom"; +import { extractSpaceUidFromName } from "@/lib/space-display"; + export const ROUTES = { HOME: "/", ABOUT: "/about", @@ -21,3 +24,51 @@ export const CALENDAR_ROUTE_PATTERN = `${ROUTES.CALENDAR}/:year?/:month?/:day?`; export type RouteKey = keyof typeof ROUTES; export type RoutePath = (typeof ROUTES)[RouteKey]; + +export const SPACE_ROUTE_PATTERN = "/spaces/:spaceUid"; + +/** Collection pages that exist both globally and beneath a Space. */ +const COLLECTION_ROUTE_PATTERNS = [ROUTES.HOME, ROUTES.EXPLORE, ROUTES.ATTACHMENTS, CALENDAR_ROUTE_PATTERN]; + +const isCollectionPathname = (pathname: string): boolean => + COLLECTION_ROUTE_PATTERNS.some((path) => matchPath({ path, caseSensitive: false }, pathname) !== null); + +export interface CollectionRoute { + spaceName?: string; + /** The global twin of a collection page; otherwise the normalized pathname. */ + pathname: string; + isCollection: boolean; +} + +/** The URL owns collection scope; unknown Space subroutes are not collection pages. */ +export const resolveCollectionRoute = (path: string): CollectionRoute => { + const pathname = (path.split(/[?#]/, 1)[0] || ROUTES.HOME).replace(/\/+$/, "") || ROUTES.HOME; + const spaceMatch = matchPath({ path: `${SPACE_ROUTE_PATTERN}/*`, caseSensitive: false }, pathname); + if (!spaceMatch) return { pathname, isCollection: isCollectionPathname(pathname) }; + const uid = spaceMatch.params.spaceUid ?? ""; + const collectionPath = `/${spaceMatch.params["*"] ?? ""}`; + // An encoded slash decodes into a UID that cannot round-trip through buildCollectionPath. + if (!uid || uid.includes("/") || !isCollectionPathname(collectionPath)) return { pathname, isCollection: false }; + return { spaceName: `spaces/${uid}`, pathname: collectionPath, isCollection: true }; +}; + +/** Builds a collection URL from a global collection pathname and an optional Space. */ +export const buildCollectionPath = (pathname: string, spaceName?: string): string => { + const route = resolveCollectionRoute(pathname); + if (!route.isCollection) return pathname; + if (!spaceName) return route.pathname; + const base = `/spaces/${encodeURIComponent(extractSpaceUidFromName(spaceName))}`; + return route.pathname === ROUTES.HOME ? base : `${base}${route.pathname}`; +}; + +/** Carries the current Space into another collection view. */ +export const collectionPathForLocation = (pathname: string, currentPath: string): string => + buildCollectionPath(pathname, resolveCollectionRoute(currentPath).spaceName); + +/** Switching preserves collection views and their query; other pages start at Home. */ +export const getSpaceSwitchPath = (location: { pathname: string; search: string; hash?: string }, spaceName?: string): string => { + const route = resolveCollectionRoute(location.pathname); + return route.isCollection + ? `${buildCollectionPath(route.pathname, spaceName)}${location.search}${location.hash || ""}` + : buildCollectionPath(ROUTES.HOME, spaceName); +}; diff --git a/web/tests/app-sidebar-logo.test.tsx b/web/tests/app-sidebar-logo.test.tsx index a0d0addb..80770e78 100644 --- a/web/tests/app-sidebar-logo.test.tsx +++ b/web/tests/app-sidebar-logo.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar"; import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection"; import { type MemoFilter, parseFilterQuery } from "@/contexts/MemoFilterContext"; +import { resolveCollectionRoute } from "@/router/routes"; const authState = vi.hoisted(() => ({ currentUser: { name: "users/test" } as { name: string } | undefined, @@ -24,10 +25,6 @@ const globalEditorState = vi.hoisted(() => ({ const spaceState = vi.hoisted(() => ({ spaces: [] as Array<{ name: string; title: string; description: string }>, selectedSpace: undefined as { name: string; title: string; description: string } | undefined, - selectedSpaceName: undefined as string | undefined, - memoFilter: undefined as string | undefined, - clearSelectedSpace: vi.fn(), - selectMemos: vi.fn(), selectSpace: vi.fn(), })); const filteredStatsHook = vi.hoisted(() => vi.fn()); @@ -105,6 +102,7 @@ vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => ({ vi.mock("@/contexts/SpaceContext", () => ({ useSpaceContext: () => { + const { spaceName } = resolveCollectionRoute(useLocation().pathname); const duplicateSpaceTitles = new Set( spaceState.spaces .filter((space, index) => spaceState.spaces.findIndex((candidate) => candidate.title === space.title) !== index) @@ -112,6 +110,9 @@ vi.mock("@/contexts/SpaceContext", () => ({ ); return { ...spaceState, + selectedSpaceName: spaceName, + selectedSpace: spaceName ? spaceState.selectedSpace : undefined, + memoFilter: spaceName ? `space == ${JSON.stringify(spaceName)}` : undefined, duplicateSpaceTitles, isLoadingSpaces: false, isSpacesError: false, @@ -200,10 +201,6 @@ describe("App sidebar logo", () => { globalEditorState.openEditor.mockClear(); spaceState.spaces = []; spaceState.selectedSpace = undefined; - spaceState.selectedSpaceName = undefined; - spaceState.memoFilter = undefined; - spaceState.clearSelectedSpace.mockClear(); - spaceState.selectMemos.mockClear(); spaceState.selectSpace.mockClear(); filteredStatsHook.mockClear(); filterState.filters = []; @@ -271,17 +268,15 @@ describe("App sidebar logo", () => { }); it.each([ - "/", - "/explore", - "/attachments", - "/Explore/", - "/Attachments/", + "/spaces/product", + "/spaces/product/explore", + "/spaces/product/attachments", + "/spaces/product/Explore/", + "/spaces/product/Attachments/", ])("shows the selected Space only on collection route %s", (path) => { const product = { name: "spaces/product", title: "Product", description: "" }; spaceState.spaces = [product]; spaceState.selectedSpace = product; - spaceState.selectedSpaceName = product.name; - spaceState.memoFilter = 'space == "spaces/product"'; render( @@ -302,12 +297,10 @@ describe("App sidebar logo", () => { "/memos/123", "/memos/shares/token", "/404", - ])("uses the instance brand instead of the remembered Space on %s", (path) => { + ])("shows Memos in the switcher on global page %s", (path) => { const product = { name: "spaces/product", title: "Product", description: "" }; spaceState.spaces = [product]; spaceState.selectedSpace = product; - spaceState.selectedSpaceName = product.name; - spaceState.memoFilter = 'space == "spaces/product"'; render( @@ -315,19 +308,14 @@ describe("App sidebar logo", () => { , ); - const brand = screen.getByRole("link", { name: "Memos logo" }); - expect(brand).toHaveAttribute("href", "/"); + const brand = screen.getByRole("button", { name: "space.switch: common.memos" }); expect(brand).toHaveClass("h-9", "gap-2", "px-2"); expect(within(brand).getByText("Memos logo")).toHaveAttribute("data-logo-size", "header"); - expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument(); }); it("scopes collection statistics to the selected Space", () => { - spaceState.selectedSpaceName = "spaces/product"; - spaceState.memoFilter = 'space == "spaces/product"'; - render( - + , ); @@ -345,10 +333,7 @@ describe("App sidebar logo", () => { expect(filteredStatsHook).toHaveBeenCalledWith(expect.objectContaining({ context: "explore", filter: undefined })); }); - it("keeps Profile statistics and tag UI state independent of the remembered Space", () => { - spaceState.selectedSpaceName = "spaces/product"; - spaceState.memoFilter = 'space == "spaces/product"'; - + it("keeps Profile statistics and tag UI state unscoped by Space", () => { render( @@ -359,10 +344,7 @@ 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"'; - + it("keeps Archived statistics and tag UI state unscoped by Space", () => { render( @@ -400,11 +382,8 @@ describe("App sidebar logo", () => { }); it("hides the instance-level unused attachment collection in a Space", () => { - spaceState.selectedSpaceName = "spaces/product"; - spaceState.memoFilter = 'space == "spaces/product"'; - render( - + , ); @@ -475,7 +454,7 @@ describe("App sidebar logo", () => { expect(screen.queryByRole("region", { name: "common.statistics" })).not.toBeInTheDocument(); expect(screen.queryByText("common.views")).not.toBeInTheDocument(); expect(screen.queryByText("Tags")).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/"); + expect(screen.getByRole("button", { name: "space.switch: common.memos" })).toBeInTheDocument(); const navigation = within(screen.getByRole("navigation", { name: "Primary" })); expectDefaultNavPill(navigation.getByRole("button", { name: "common.home" }), "common.home"); const attachments = navigation.getByRole("link", { name: "common.attachments" }); @@ -670,10 +649,8 @@ describe("App sidebar logo", () => { ); expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger"); - const mobileBrand = screen.getByRole("link", { name: "Memos logo" }); - expect(mobileBrand).toHaveAttribute("href", "/"); + const mobileBrand = screen.getByRole("button", { name: "space.switch: common.memos" }); expect(mobileBrand).toHaveClass("h-9", "gap-1.5", "px-1"); - expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument(); }); diff --git a/web/tests/app-sidebar-routes.test.ts b/web/tests/app-sidebar-routes.test.ts index 183de7ef..2a98b972 100644 --- a/web/tests/app-sidebar-routes.test.ts +++ b/web/tests/app-sidebar-routes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getRouteActionPolicy, getSidebarRouteKind, routeSupportsCollectionScope } from "@/components/AppSidebar/routes"; +import { getRouteActionPolicy, getSidebarRouteKind } from "@/components/AppSidebar/routes"; describe("sidebar route content", () => { it.each([ @@ -33,55 +33,29 @@ describe("sidebar route content", () => { expect(getSidebarRouteKind(path)).toBe(kind); }); - it.each([ - ["/", true], - ["/explore", true], - ["/archived", false], - ["/calendar", true], - ["/calendar/2026/08/02", true], - ["/attachments", true], - ["/Explore/", true], - ["/ARCHIVED/", false], - ["/Attachments/", true], - ["/u/steven", false], - ["/inbox", false], - ["/setting", false], - ["/views", false], - ["/about", false], - ["/memos/abc", false], - ["/memos/shares/token", false], - ["/404", false], - ])("reports whether %s supports the remembered collection scope", (path, expected) => { - expect(routeSupportsCollectionScope(path)).toBe(expected); - }); - - it.each(["/", "/explore"])("keeps search and Compose in the remembered collection on %s", (path) => { + it.each(["/", "/explore"])("keeps search in the route collection on %s", (path) => { expect(getRouteActionPolicy(path)).toEqual({ - searchScope: "remembered-collection", - composePlacement: "remembered-space", + searchScope: "route-collection", }); }); - it.each(["/archived", "/ARCHIVED/"])("keeps %s in the user archive without inheriting Space placement", (path) => { + it.each(["/archived", "/ARCHIVED/"])("keeps %s in the user archive", (path) => { expect(getRouteActionPolicy(path)).toEqual({ searchScope: "user-collection", - composePlacement: "unassigned", }); }); - it.each(["/attachments", "/calendar/2026/08/02"])("keeps the remembered scope when %s sends search to Home", (path) => { + it.each(["/attachments", "/calendar/2026/08/02"])("keeps the route scope when %s sends search to Home", (path) => { expect(getRouteActionPolicy(path)).toEqual({ - searchScope: "remembered-collection", + searchScope: "route-collection", searchDestination: "/", - composePlacement: "remembered-space", }); }); - it("keeps Profile search on Profile but makes Compose unassigned", () => { + it("keeps Profile search on Profile", () => { expect(getRouteActionPolicy("/u/steven")).toEqual({ searchScope: "profile", searchDestination: "/u/steven", - composePlacement: "unassigned", }); }); @@ -89,15 +63,13 @@ describe("sidebar route content", () => { expect(getRouteActionPolicy("/U/Steven/")).toEqual({ searchScope: "profile", searchDestination: "/U/Steven", - composePlacement: "unassigned", }); }); - it("keeps a normalized Explore route in the remembered scope", () => { + it("keeps a normalized Explore route in the route scope", () => { const path = "/Explore/"; expect(getRouteActionPolicy(path)).toEqual({ - searchScope: "remembered-collection", - composePlacement: "remembered-space", + searchScope: "route-collection", }); }); @@ -111,11 +83,10 @@ describe("sidebar route content", () => { "/403", "/404", "/unknown", - ])("sends search to All and makes Compose unassigned on %s", (path) => { + ])("sends search to All on %s", (path) => { expect(getRouteActionPolicy(path)).toEqual({ searchScope: "all", searchDestination: "/", - composePlacement: "unassigned", }); }); }); diff --git a/web/tests/attachment-library-source-link.test.tsx b/web/tests/attachment-library-source-link.test.tsx index c11adb03..8af19305 100644 --- a/web/tests/attachment-library-source-link.test.tsx +++ b/web/tests/attachment-library-source-link.test.tsx @@ -52,7 +52,7 @@ describe("", () => { const onPreview = vi.fn(); render( - + , @@ -67,7 +67,9 @@ describe("", () => { fireEvent.click(memoLink); expect(onPreview).not.toHaveBeenCalled(); - expect(screen.getByTestId("location-state")).toHaveTextContent('{"from":"/","fromScope":"preserve"}'); + expect(screen.getByTestId("location-state")).toHaveTextContent( + '{"from":"/spaces/product/attachments?filter=tagSearch%3Awork","fromScope":"preserve"}', + ); }); it("keeps video duration inside the existing play badge", () => { diff --git a/web/tests/global-memo-editor.test.tsx b/web/tests/global-memo-editor.test.tsx index 8c8a35df..63d47e29 100644 --- a/web/tests/global-memo-editor.test.tsx +++ b/web/tests/global-memo-editor.test.tsx @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ setMobileOpen: vi.fn(), setQuickFindOpen: vi.fn(), selectedSpaceName: undefined as string | undefined, + isSpaceReady: true, pathname: "/", desktop: true, })); @@ -36,7 +37,7 @@ vi.mock("@/contexts/AuthContext", () => ({ })); vi.mock("@/contexts/SpaceContext", () => ({ - useSpaceContext: () => ({ selectedSpaceName: mocks.selectedSpaceName }), + useSpaceContext: () => ({ selectedSpaceName: mocks.selectedSpaceName, isSpaceReady: mocks.isSpaceReady }), })); vi.mock("@/hooks/useCurrentUser", () => ({ @@ -128,6 +129,7 @@ describe("GlobalMemoEditorProvider", () => { mocks.setMobileOpen.mockClear(); mocks.setQuickFindOpen.mockClear(); mocks.selectedSpaceName = undefined; + mocks.isSpaceReady = true; mocks.pathname = "/"; mocks.desktop = true; }); @@ -173,6 +175,7 @@ describe("GlobalMemoEditorProvider", () => { // Returning to the original route, Space, or viewport must not rearm focus. mocks.pathname = "/"; mocks.selectedSpaceName = undefined; + mocks.isSpaceReady = true; mocks.desktop = true; rerender( @@ -324,6 +327,15 @@ describe("GlobalMemoEditorProvider", () => { expect(mocks.loadMemoEditor).not.toHaveBeenCalled(); }); + it("disables new memo while the route Space is unresolved or inaccessible", () => { + mocks.pathname = "/spaces/product"; + mocks.selectedSpaceName = "spaces/product"; + mocks.isSpaceReady = false; + renderProvider(); + fireEvent.click(screen.getByRole("button", { name: "Open editor" })); + expect(mocks.loadMemoEditor).not.toHaveBeenCalled(); + }); + it("snapshots the selected Space when opening the composer", async () => { mocks.selectedSpaceName = "spaces/product"; await openViaTrigger(); @@ -340,7 +352,11 @@ describe("GlobalMemoEditorProvider", () => { }); }); - it.each(["/explore", "/attachments"])("inherits the remembered Space when composing from %s", async (pathname) => { + it.each([ + "/spaces/product/explore", + "/spaces/product/attachments", + "/spaces/product/calendar/2026/09", + ])("inherits the route Space when composing from %s", async (pathname) => { mocks.pathname = pathname; mocks.selectedSpaceName = "spaces/product"; await openViaTrigger(); @@ -363,9 +379,9 @@ describe("GlobalMemoEditorProvider", () => { "/403", "/404", "/unknown", - ])("creates an unassigned memo from %s even when a Space is remembered", async (pathname) => { + ])("creates an unassigned memo from %s", async (pathname) => { + // The URL only carries a Space on collection pages, so the context reports none here. mocks.pathname = pathname; - mocks.selectedSpaceName = "spaces/product"; await openViaTrigger(); expect(mocks.editorProps).toMatchObject({ diff --git a/web/tests/memo-action-menu.test.tsx b/web/tests/memo-action-menu.test.tsx index 78704de7..ea92eab3 100644 --- a/web/tests/memo-action-menu.test.tsx +++ b/web/tests/memo-action-menu.test.tsx @@ -32,7 +32,7 @@ vi.mock("@/utils/i18n", () => ({ describe("MemoActionMenu", () => { it("is a quiet compact control that takes the accent fill while open", async () => { const memo = create(MemoSchema, { name: "memos/1", state: State.NORMAL, pinned: false }); - render(); + render(); const trigger = screen.getByRole("button", { name: "common.more" }); expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "data-popup-open:bg-accent"); diff --git a/web/tests/memo-action-navigation.test.tsx b/web/tests/memo-action-navigation.test.tsx index 4a190bd8..6de53723 100644 --- a/web/tests/memo-action-navigation.test.tsx +++ b/web/tests/memo-action-navigation.test.tsx @@ -4,14 +4,12 @@ import type { ReactNode } from "react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useMemoActionHandlers } from "@/components/MemoActionMenu/hooks"; -import type { MemoOriginScope } from "@/components/MemoView/navigation"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; const mocks = vi.hoisted(() => ({ updateMemo: vi.fn(), deleteMemo: vi.fn(), - clearSelectedSpace: vi.fn(), })); vi.mock("@/hooks/useMemoQueries", () => ({ @@ -31,10 +29,6 @@ vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ profile: { instanceUrl: "" } }), })); -vi.mock("@/contexts/SpaceContext", () => ({ - useSpaceContext: () => ({ clearSelectedSpace: mocks.clearSelectedSpace }), -})); - vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key, })); @@ -51,7 +45,7 @@ const createMemo = (state: State, parent = ""): Memo => parent, }) as Memo; -const renderActions = (state: State, parentScope: MemoOriginScope, parent = "") => { +const renderActions = (state: State, parent = "", parentPage?: string) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); const wrapper = ({ children }: { children: ReactNode }) => ( @@ -65,10 +59,10 @@ const renderActions = (state: State, parentScope: MemoOriginScope, parent = "") const location = useLocation(); const handlers = useMemoActionHandlers({ memo: createMemo(state, parent), - parentScope, + parentPage, setDeleteDialogOpen: vi.fn(), }); - return { handlers, pathname: location.pathname }; + return { handlers, pathname: location.pathname, search: location.search }; }, { wrapper }, ); @@ -79,47 +73,40 @@ describe("Memo detail mutation navigation", () => { beforeEach(() => { mocks.updateMemo.mockReset().mockResolvedValue(undefined); mocks.deleteMemo.mockReset().mockResolvedValue(undefined); - mocks.clearSelectedSpace.mockReset(); }); it.each([ [State.NORMAL, "/archived"], [State.ARCHIVED, "/"], - ])("switches an All-origin %s memo to the user-level destination without changing the remembered Space", async (state, destination) => { - const { result } = renderActions(state, "all"); + ])("switches a %s memo to its user-level destination", async (state, destination) => { + const { result } = renderActions(state); await act(async () => { await result.current.handlers.handleToggleMemoStatusClick(); }); await waitFor(() => expect(result.current.pathname).toBe(destination)); - expect(mocks.clearSelectedSpace).not.toHaveBeenCalled(); }); - it("preserves an exact-Space origin when archiving", async () => { - const { result } = renderActions(State.NORMAL, "preserve"); - - await act(async () => { - await result.current.handlers.handleToggleMemoStatusClick(); - }); - - await waitFor(() => expect(result.current.pathname).toBe("/archived")); - expect(mocks.clearSelectedSpace).not.toHaveBeenCalled(); + it("returns to the full Space origin after deleting a memo detail", async () => { + const origin = "/spaces/product/calendar/2026/09/06?filter=tagSearch%3Awork"; + const { result } = renderActions(State.NORMAL, "", origin); + await act(() => result.current.handlers.confirmDeleteMemo()); + expect(result.current.pathname + result.current.search).toBe(origin); }); - it("clears a stale Space before deleting from an All origin", async () => { - const { result } = renderActions(State.NORMAL, "all"); + it("returns directly to a global origin after deleting", async () => { + const { result } = renderActions(State.NORMAL); await act(async () => { await result.current.handlers.confirmDeleteMemo(); }); await waitFor(() => expect(result.current.pathname).toBe("/")); - expect(mocks.clearSelectedSpace).toHaveBeenCalledOnce(); }); it("refreshes a parent memo after deleting one of its comments", async () => { - const { result, invalidateQueries } = renderActions(State.NORMAL, "preserve", "memos/parent"); + const { result, invalidateQueries } = renderActions(State.NORMAL, "memos/parent"); await act(async () => { await result.current.handlers.confirmDeleteMemo(); diff --git a/web/tests/memo-detail-sidebar.test.tsx b/web/tests/memo-detail-sidebar.test.tsx index 39dbe720..73bf6bf2 100644 --- a/web/tests/memo-detail-sidebar.test.tsx +++ b/web/tests/memo-detail-sidebar.test.tsx @@ -16,7 +16,6 @@ import { const copyToClipboard = vi.hoisted(() => vi.fn()); const currentUserState = vi.hoisted(() => ({ value: { name: "users/alice" } as { name: string } | undefined })); -const clearSelectedSpace = vi.hoisted(() => vi.fn()); vi.mock("copy-to-clipboard", () => ({ default: copyToClipboard })); vi.mock("react-hot-toast", () => ({ default: { success: vi.fn() } })); @@ -26,7 +25,6 @@ vi.mock("@/components/MemoDetailSidebar/MemoOutline", () => ({ vi.mock("@/components/MemoDetailSidebar/MemoSharePanel", () => ({ default: () =>
})); vi.mock("@/components/MemoMetadata/Relation/useResolvedRelationMemos", () => ({ useResolvedRelationMemos: () => ({}) })); vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ profile: { instanceUrl: "https://memos.example/" } }) })); -vi.mock("@/contexts/SpaceContext", () => ({ useSpaceContext: () => ({ clearSelectedSpace }) })); vi.mock("@/hooks/useCurrentUser", () => ({ default: () => currentUserState.value })); vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string, values?: { source?: string }) => (values?.source ? `${key}:${values.source}` : key), @@ -45,7 +43,6 @@ const createIncomingReference = (memoName: string, sourceName = "memos/incoming" describe("MemoDetailSidebar", () => { beforeEach(() => { copyToClipboard.mockReset(); - clearSelectedSpace.mockReset(); currentUserState.value = { name: "users/alice" }; }); @@ -155,8 +152,7 @@ describe("MemoDetailSidebar", () => { expect(screen.getByRole("button", { name: "memo.comment.write-a-comment" })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "common.referenced-by: Incoming backlink" })).toBeInTheDocument(); - fireEvent.click(screen.getByRole("link", { name: "memo.go-to:common.explore" })); - expect(clearSelectedSpace).toHaveBeenCalledOnce(); + expect(screen.getByRole("link", { name: "memo.go-to:common.explore" })).toHaveAttribute("href", "/explore"); }); it("keeps a simple memo useful without rendering empty navigation or connections", () => { diff --git a/web/tests/memo-filter-context.test.tsx b/web/tests/memo-filter-context.test.tsx index 11449d5d..a0d96283 100644 --- a/web/tests/memo-filter-context.test.tsx +++ b/web/tests/memo-filter-context.test.tsx @@ -64,6 +64,35 @@ describe("MemoFilterProvider", () => { expect(screen.getByTestId("filters")).toHaveTextContent(JSON.stringify(expected)); expect(new URLSearchParams(router.state.location.search).get("filter")).toBe(stringifyFilters(expected)); }); + it("preserves the Space origin when navigation removes collection filters", async () => { + const origin = "/spaces/product?filter=tagSearch%3Awork"; + const router = createMemoryRouter( + [ + { + path: "*", + element: ( + + + + ), + }, + ], + { initialEntries: [origin] }, + ); + render(); + fireEvent.click(screen.getByRole("button", { name: "Select Tasks" })); + const state = { from: origin, fromScope: "preserve" }; + await act(() => router.navigate("/memos/1", { state })); + await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent("[]")); + expect(router.state.location.search).toBe(""); + expect(router.state.location.state).toEqual(state); + await act(() => router.navigate(-1)); + expect(router.state.location.pathname + router.state.location.search).toBe(origin); + expect(screen.getByTestId("filters")).toHaveTextContent('"work"'); + await act(() => router.navigate("/spaces/research?filter=tagSearch%3Awork")); + expect(screen.getByTestId("memoView")).toHaveTextContent(BUILTIN_TASKS_VIEW_ID); + }); + it("keeps encoded values containing colons intact", () => { expect(parseFilterQuery("contentSearch:https://example.com:8080/path")).toEqual([ { factor: "contentSearch", value: "https://example.com:8080/path" }, diff --git a/web/tests/memo-tag-navigation.test.tsx b/web/tests/memo-tag-navigation.test.tsx index e38d9cec..ceea8a9a 100644 --- a/web/tests/memo-tag-navigation.test.tsx +++ b/web/tests/memo-tag-navigation.test.tsx @@ -4,11 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { Tag } from "@/components/MemoContent/Tag"; const navigateTo = vi.hoisted(() => vi.fn()); -const clearSelectedSpace = vi.hoisted(() => vi.fn()); -const origin = vi.hoisted(() => ({ - parentPage: "/" as string, - parentScope: "all" as "all" | "preserve", -})); +const origin = vi.hoisted(() => ({ parentPage: "/" as string })); vi.mock("@/hooks/useNavigateTo", () => ({ default: () => navigateTo, @@ -18,10 +14,6 @@ vi.mock("@/components/MemoView/MemoViewContext", () => ({ useMemoViewContext: () => origin, })); -vi.mock("@/contexts/SpaceContext", () => ({ - useSpaceContext: () => ({ clearSelectedSpace }), -})); - vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => { const actual = await importOriginal(); return { @@ -41,12 +33,10 @@ vi.mock("@/contexts/AuthContext", () => ({ describe("Memo tag navigation", () => { beforeEach(() => { navigateTo.mockClear(); - clearSelectedSpace.mockClear(); origin.parentPage = "/"; - origin.parentScope = "all"; }); - it("switches to All only when a global detail tag enters a collection", () => { + it("navigates directly to the global collection from a global detail", () => { render( #work @@ -54,12 +44,10 @@ describe("Memo tag navigation", () => { ); fireEvent.click(screen.getByText("#work")); - - expect(clearSelectedSpace).toHaveBeenCalledOnce(); expect(navigateTo).toHaveBeenCalledWith("/?filter=tagSearch%3Awork"); }); - it("returns a Profile-origin tag without clearing the remembered Space", () => { + it("returns a Profile-origin tag to that profile's memo list", () => { origin.parentPage = "/u/alice?view=map"; render( @@ -69,8 +57,6 @@ describe("Memo tag navigation", () => { ); fireEvent.click(screen.getByText("#work")); - - expect(clearSelectedSpace).not.toHaveBeenCalled(); expect(navigateTo).toHaveBeenCalledWith("/u/alice?filter=tagSearch%3Awork"); }); }); diff --git a/web/tests/quick-find-navigation.test.tsx b/web/tests/quick-find-navigation.test.tsx index 32d5ae4d..9da1ef2f 100644 --- a/web/tests/quick-find-navigation.test.tsx +++ b/web/tests/quick-find-navigation.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import QuickFindDialog from "@/components/AppSidebar/QuickFindDialog"; import MemoFilters from "@/components/MemoFilters"; import { AppSidebarProvider, useAppSidebar } from "@/contexts/AppSidebarContext"; -import { getSelectedSpaceStorageKey, SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext"; +import { SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext"; const state = vi.hoisted(() => ({ currentUser: { name: "users/alice" } as { name: string } | undefined, @@ -35,6 +35,12 @@ vi.mock("@/hooks/useCurrentUser", () => ({ vi.mock("@/hooks/useSpaceQueries", () => ({ useSpaces: () => ({ data: state.spaces, isSuccess: true, isPending: false, isError: false }), + useSpace: (_user: string, name: string) => ({ + data: state.spaces.find((space) => space.name === name), + isSuccess: true, + error: null, + refetch: vi.fn(), + }), })); vi.mock("@/hooks/useUserQueries", () => ({ @@ -163,22 +169,18 @@ describe("Quick Find navigation", () => { expect(state.filters.filter((filter) => !predicate(filter))).toEqual([{ factor: "tagSearch", value: "work" }]); }); - it("switches to All in one history step so Back returns directly to Inbox", async () => { - const storageKey = getSelectedSpaceStorageKey("users/alice"); - sessionStorage.setItem(storageKey, "spaces/product"); - + it("searches from Inbox into global Home in one history step", async () => { const router = renderSearch("/inbox"); - expect(screen.getByTestId("scope")).toHaveTextContent("spaces/product"); + expect(screen.getByTestId("scope")).toHaveTextContent("all"); openQuickFind(); const input = await screen.findByRole("textbox"); fireEvent.change(input, { target: { value: "roadmap" } }); fireEvent.submit(input.closest("form")!); await waitFor(() => expect(screen.getByTestId("path")).toHaveTextContent("/?filter=contentSearch%3Aroadmap")); - expect(state.setFilters).toHaveBeenCalledWith([{ factor: "contentSearch", value: "roadmap" }]); + expect(state.setFilters).not.toHaveBeenCalled(); expect(screen.getByTestId("scope")).toHaveTextContent("all"); - expect(sessionStorage.getItem(storageKey)).toBeNull(); await act(async () => { await router.navigate(-1); @@ -187,10 +189,8 @@ describe("Quick Find navigation", () => { expect(screen.getByTestId("path")).toHaveTextContent("/inbox"); }); - it("keeps a unique selected Space title compact in remembered-collection search", async () => { - sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), "spaces/product"); - - renderSearch("/"); + it("keeps a unique selected Space title compact in route-collection search", async () => { + renderSearch("/spaces/product"); openQuickFind(); const input = await screen.findByRole("textbox"); @@ -204,9 +204,7 @@ describe("Quick Find navigation", () => { { name: `spaces/${uuid}`, title: "Product", description: "" }, { name: "spaces/product-roadmap", title: "Product", description: "" }, ]; - sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), `spaces/${uuid}`); - - renderSearch("/"); + renderSearch(`/spaces/${uuid}`); openQuickFind(); const input = await screen.findByRole("textbox"); diff --git a/web/tests/quick-find.test.ts b/web/tests/quick-find.test.ts index 05b4a0ea..6ee5ccd2 100644 --- a/web/tests/quick-find.test.ts +++ b/web/tests/quick-find.test.ts @@ -22,7 +22,6 @@ describe("Quick Find", () => { ...scopedFilters.filter((filter) => filter.factor !== "contentSearch"), { factor: "celSearch", value: expression }, ]); - expect(submission.switchToAll).toBe(false); }); it("replaces both kinds of search without dropping facets", () => { @@ -66,11 +65,10 @@ describe("Quick Find", () => { { factor: "contentSearch", value: "project" }, ], destination: undefined, - switchToAll: false, }); }); - it("searches Archived as a user collection without clearing the remembered Space", () => { + it("searches Archived as a user collection", () => { expect(resolveQuickFindSubmission("/archived", "project", scopedFilters, "text")).toEqual({ filters: [ { factor: "tagSearch", value: "work" }, @@ -78,11 +76,10 @@ describe("Quick Find", () => { { factor: "contentSearch", value: "project" }, ], destination: undefined, - switchToAll: false, }); }); - it("keeps the remembered collection filters when searching from Attachments", () => { + it("keeps the collection filters when searching from Attachments", () => { expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters, "text")).toEqual({ filters: [ { factor: "tagSearch", value: "work" }, @@ -90,11 +87,10 @@ describe("Quick Find", () => { { factor: "contentSearch", value: "project" }, ], destination: "/?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject", - switchToAll: false, }); }); - it("keeps the remembered collection on a normalized Attachments route", () => { + it("keeps the collection on a normalized Attachments route", () => { expect(resolveQuickFindSubmission("/Attachments/", "project", scopedFilters, "text")).toEqual({ filters: [ { factor: "tagSearch", value: "work" }, @@ -102,7 +98,6 @@ describe("Quick Find", () => { { factor: "contentSearch", value: "project" }, ], destination: "/?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject", - switchToAll: false, }); }); @@ -114,7 +109,6 @@ describe("Quick Find", () => { { factor: "contentSearch", value: "project" }, ], destination: "/u/steven?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject", - switchToAll: false, }); }); @@ -130,7 +124,6 @@ describe("Quick Find", () => { expect(resolveQuickFindSubmission(pathname, "project", scopedFilters, "text")).toEqual({ filters: [{ factor: "contentSearch", value: "project" }], destination: "/?filter=contentSearch%3Aproject", - switchToAll: true, }); }); diff --git a/web/tests/router-config.test.tsx b/web/tests/router-config.test.tsx index 8a6b4d58..1d5d5601 100644 --- a/web/tests/router-config.test.tsx +++ b/web/tests/router-config.test.tsx @@ -1,9 +1,10 @@ import { isValidElement } from "react"; -import type { RouteObject } from "react-router-dom"; +import { matchRoutes, type RouteObject } from "react-router-dom"; import { describe, expect, it } from "vitest"; import { ROUTES, routeConfig } from "@/router"; import { RequireAuthRoute, RequireFullInitializationRoute, RequireGuestRoute, RequireInstanceInitializationRoute } from "@/router/guards"; -import { CALENDAR_ROUTE_PATTERN } from "@/router/routes"; +import { CALENDAR_ROUTE_PATTERN, SPACE_ROUTE_PATTERN } from "@/router/routes"; +import { SpaceRoute } from "@/router/SpaceRoute"; // Walk the nested route config and find the first route with the given path, // starting from the provided roots. Returns undefined if nothing matches. @@ -40,6 +41,23 @@ function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unkno } describe("router configuration", () => { + it.each([ + "/spaces/product", + "/spaces/product/explore", + "/spaces/product/calendar/2026/09", + "/spaces/product/attachments", + ])("gates %s with authentication and Space access", (path) => { + const types = matchRoutes(routeConfig, path)?.map(({ route }) => elementType(route)); + expect(types).toContain(RequireAuthRoute); + expect(types).toContain(SpaceRoute); + expect(findByPath(routeConfig, SPACE_ROUTE_PATTERN)).toBeDefined(); + }); + it("keeps unknown Space subroutes outside collection pages", () => { + const matches = matchRoutes(routeConfig, "/spaces/product/unknown"); + expect(matches?.at(-1)?.route.path).toBe("*"); + expect(matches?.map(({ route }) => elementType(route))).not.toContain(SpaceRoute); + }); + it("keeps /auth/callback outside the guest-only guard", () => { // Regression guard for issue #5846 follow-up: an authenticated tab elsewhere // must not short-circuit the OAuth callback via RequireGuestRoute. diff --git a/web/tests/space-context.test.tsx b/web/tests/space-context.test.tsx index f1844af6..20adb868 100644 --- a/web/tests/space-context.test.tsx +++ b/web/tests/space-context.test.tsx @@ -1,225 +1,97 @@ -import { create } from "@bufbuild/protobuf"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createMemoryRouter, Link, Outlet, RouterProvider, useLocation } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getSelectedSpaceStorageKey, SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext"; -import { SpaceSchema } from "@/types/proto/api/v1/space_service_pb"; - -const newlyCreatedSpace = create(SpaceSchema, { name: "spaces/new", title: "New", description: "" }); +import { SpaceProvider, useSpaceContext } from "@/contexts/SpaceContext"; +import { getSpaceSwitchPath } from "@/router/routes"; const state = vi.hoisted(() => ({ - currentUser: { name: "users/alice" } as { name: string } | undefined, - query: { - data: [] as Array<{ name: string; title: string; description: string }>, - isSuccess: true, - isPending: false, - isError: false, - }, + user: "users/alice", + spaces: [ + { name: "spaces/a", title: "Same" }, + { name: "spaces/b", title: "Same" }, + ], + ready: true, })); - -vi.mock("@/hooks/useCurrentUser", () => ({ - default: () => state.currentUser, -})); - +vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: state.user }) })); vi.mock("@/hooks/useSpaceQueries", () => ({ - useSpaces: () => state.query, + useSpaces: () => ({ data: state.spaces, isPending: false, isError: false }), + useSpace: (_user: string, name?: string) => ({ + data: state.ready ? state.spaces.find((s) => s.name === name) : undefined, + isSuccess: state.ready, + error: null, + refetch: vi.fn(), + }), })); - const Probe = () => { - const { - clearSelectedSpace, - collectionScope, - duplicateSpaceTitles, - memoFilter, - spaces, - selectedSpace, - selectedSpaceName, - selectMemos, - selectSpace, - } = useSpaceContext(); - return ( -
- {selectedSpaceName ?? "Memos"} - {selectedSpace?.title ?? ""} - - {collectionScope.kind === "space" ? `${collectionScope.kind}:${collectionScope.name}` : collectionScope.kind} - - {memoFilter ?? "all"} - {[...duplicateSpaceTitles].join(",")} - - - - - -
- ); -}; - -const CurrentPath = () => { const location = useLocation(); - return {`${location.pathname}${location.search}`}; -}; - -const renderProvider = (initialPath = "/explore") => - render( - - - - - , + const context = useSpaceContext(); + return ( + <> + {location.pathname + location.search} + {context.selectedSpaceName || "all"} + {context.memoFilter || "all"} + {String(context.isSpaceReady)} + {[...context.duplicateSpaceTitles].join(",")} + B + Memos + + ); - -describe("SpaceProvider", () => { +}; +const setup = (path = "/") => { + const router = createMemoryRouter( + [ + { + path: "*", + element: ( + + + + ), + }, + ], + { initialEntries: [path] }, + ); + render(); + return router; +}; +describe("URL-owned Space context", () => { beforeEach(() => { - sessionStorage.clear(); - state.currentUser = { name: "users/alice" }; - state.query = { data: [], isSuccess: true, isPending: false, isError: false }; + state.ready = true; + state.user = "users/alice"; }); - - it("uses the All collection when the user has no stored Space selection", () => { - renderProvider(); - - expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos"); - expect(screen.getByTestId("collection-scope")).toHaveTextContent("all"); - expect(screen.getByTestId("memo-filter")).toHaveTextContent("all"); + it("reads All on global pages", () => { + setup("/explore"); + expect(screen.getByTestId("space")).toHaveTextContent("all"); + expect(screen.getByTestId("filter")).toHaveTextContent("all"); }); - - it("restores a valid Space and stores changes only for the current user", () => { - const product = { name: "spaces/product", title: "Product", description: "" }; - state.query.data = [product]; - sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name); - renderProvider(); - - expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name); - expect(screen.getByTestId("selected-title")).toHaveTextContent("Product"); - expect(screen.getByTestId("collection-scope")).toHaveTextContent("space:spaces/product"); - expect(screen.getByTestId("memo-filter")).toHaveTextContent('space == "spaces/product"'); - - fireEvent.click(screen.getByRole("button", { name: "Select Memos" })); - expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Select first Space" })); - expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(product.name); + it("restores Space from a deep link before metadata has loaded", () => { + state.ready = false; + setup("/spaces/a/calendar/2026/09/06"); + expect(screen.getByTestId("space")).toHaveTextContent("spaces/a"); + expect(screen.getByTestId("filter")).toHaveTextContent('space == "spaces/a"'); + expect(screen.getByTestId("ready")).toHaveTextContent("false"); }); - - it("clears a stored selection only after a successful list proves it unavailable", async () => { - const key = getSelectedSpaceStorageKey("users/alice"); - sessionStorage.setItem(key, "spaces/removed"); - renderProvider(); - - await waitFor(() => expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos")); - expect(sessionStorage.getItem(key)).toBeNull(); + it("preserves dates and filters through A, B, All and browser history", async () => { + const suffix = "/calendar/2026/09/06?filter=tagSearch%3Awork"; + const router = setup(`/spaces/a${suffix}`); + fireEvent.click(screen.getByText("B")); + expect(screen.getByTestId("path").textContent).toBe(`/spaces/b${suffix}`); + expect(screen.getByTestId("space")).toHaveTextContent("spaces/b"); + expect(screen.getByTestId("duplicates")).toHaveTextContent("Same"); + fireEvent.click(screen.getByText("Memos")); + expect(screen.getByTestId("path").textContent).toBe(suffix); + await act(() => router.navigate(-1)); + expect(screen.getByTestId("space")).toHaveTextContent("spaces/b"); + await act(() => router.navigate(-1)); + expect(screen.getByTestId("space")).toHaveTextContent("spaces/a"); + await act(() => router.navigate(1)); + expect(screen.getByTestId("space")).toHaveTextContent("spaces/b"); }); - - it("preserves a stored selection when the Space list fails transiently", () => { - const key = getSelectedSpaceStorageKey("users/alice"); - sessionStorage.setItem(key, "spaces/product"); - state.query = { data: [], isSuccess: false, isPending: false, isError: true }; - renderProvider(); - - expect(screen.getByTestId("selected-name")).toHaveTextContent("spaces/product"); - expect(sessionStorage.getItem(key)).toBe("spaces/product"); - }); - - it("keeps a newly created Space selected until the refreshed list includes it", () => { - renderProvider(); - - fireEvent.click(screen.getByRole("button", { name: "Select new Space" })); - - expect(screen.getByTestId("selected-name")).toHaveTextContent(newlyCreatedSpace.name); - expect(screen.getByTestId("selected-title")).toHaveTextContent(newlyCreatedSpace.title); - expect(screen.getByTestId("memo-filter")).toHaveTextContent('space == "spaces/new"'); - expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBe(newlyCreatedSpace.name); - }); - - it("includes an optimistic selected Space when deriving matching titles", () => { - state.query.data = [{ name: "spaces/existing-new", title: "New", description: "" }]; - renderProvider(); - - fireEvent.click(screen.getByRole("button", { name: "Select new Space" })); - - expect(screen.getByTestId("duplicate-titles")).toHaveTextContent("New"); - }); - - 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); - - fireEvent.click(screen.getByRole("button", { name: "Select first Space" })); - - expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name); - expect(screen.getByTestId("path").textContent).toBe(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); - renderProvider(initialPath); - - fireEvent.click(screen.getByRole("button", { name: "Select Memos" })); - - expect(screen.getByTestId("selected-name")).toHaveTextContent("Memos"); - expect(screen.getByTestId("path").textContent).toBe(initialPath); - }); - - 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]; - sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name); - renderProvider(initialPath); - - fireEvent.click(screen.getByRole("button", { name: action })); - - expect(screen.getByTestId("path").textContent).toBe("/"); - }); - - it("can select All on a resource route without navigating", () => { - const product = { name: "spaces/product", title: "Product", description: "" }; - state.query.data = [product]; - sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name); - renderProvider("/memos/123"); - - fireEvent.click(screen.getByRole("button", { name: "Clear Space in place" })); - - expect(screen.getByTestId("collection-scope")).toHaveTextContent("all"); - expect(screen.getByTestId("path")).toHaveTextContent("/memos/123"); - expect(sessionStorage.getItem(getSelectedSpaceStorageKey("users/alice"))).toBeNull(); - }); - - it("isolates the active selection across account changes", () => { - const product = { name: "spaces/product", title: "Product", description: "" }; - const personal = { name: "spaces/personal", title: "Personal", description: "" }; - state.query.data = [product, personal]; - sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), product.name); - sessionStorage.setItem(getSelectedSpaceStorageKey("users/bob"), personal.name); - const view = renderProvider(); - - expect(screen.getByTestId("selected-name")).toHaveTextContent(product.name); - - state.currentUser = { name: "users/bob" }; - view.rerender( - - - - - , - ); - - expect(screen.getByTestId("selected-name")).toHaveTextContent(personal.name); + it.each(["/memos/123", "/archived", "/inbox", "/setting", "/u/alice"])("keeps %s global and switches to Space Home", (path) => { + setup(`${path}?filter=tagSearch%3Awork`); + expect(screen.getByTestId("space")).toHaveTextContent("all"); + fireEvent.click(screen.getByText("B")); + expect(screen.getByTestId("path").textContent).toBe("/spaces/b"); }); }); diff --git a/web/tests/space-queries.test.tsx b/web/tests/space-queries.test.tsx index 05bfc3bd..45279fb0 100644 --- a/web/tests/space-queries.test.tsx +++ b/web/tests/space-queries.test.tsx @@ -1,4 +1,5 @@ import { create } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; @@ -12,6 +13,7 @@ import { useDeleteSpace, useDeleteSpaceInvitation, useDeleteSpaceMember, + useSpace, useSpaceInvitations, useSpaceMembers, useSpaces, @@ -32,6 +34,7 @@ const clients = vi.hoisted(() => ({ listSpaceInvitations: vi.fn(), listSpaceMembers: vi.fn(), listSpaces: vi.fn(), + getSpace: vi.fn(), listUserSpaceInvitations: vi.fn(), updateSpace: vi.fn(), updateSpaceMember: vi.fn(), @@ -65,6 +68,35 @@ describe("Space queries", () => { } }); + it("fetches the route Space with viewer-isolated cache entries", async () => { + clients.getSpace.mockResolvedValue(create(SpaceSchema, { name: SPACE_NAME, title: "Product" })); + const client = createQueryClient(); + const view = renderHook(({ user }) => useSpace(user, SPACE_NAME), { initialProps: { user: VIEWER }, wrapper: createWrapper(client) }); + await waitFor(() => expect(view.result.current.isSuccess).toBe(true)); + expect(clients.getSpace).toHaveBeenCalledWith({ name: SPACE_NAME }); + clients.getSpace.mockRejectedValue(new ConnectError("not found", Code.NotFound)); + view.rerender({ user: OTHER_VIEWER }); + expect(view.result.current.data).toBeUndefined(); + await waitFor(() => expect(view.result.current.isError).toBe(true)); + expect(clients.getSpace).toHaveBeenCalledTimes(2); + }); + + it("refetches an active Space after leaving and exposes the access failure", async () => { + const client = createQueryClient(); + const member = create(SpaceMemberSchema, { name: `${SPACE_NAME}/members/test`, user: VIEWER }); + client.setQueryData(spaceKeys.members(VIEWER, SPACE_NAME), [member]); + clients.getSpace.mockResolvedValue(create(SpaceSchema, { name: SPACE_NAME, title: "Product" })); + clients.deleteSpaceMember.mockResolvedValue({}); + const view = renderHook(() => ({ space: useSpace(VIEWER, SPACE_NAME), leave: useDeleteSpaceMember(VIEWER) }), { + wrapper: createWrapper(client), + }); + await waitFor(() => expect(view.result.current.space.isSuccess).toBe(true)); + clients.getSpace.mockRejectedValue(new ConnectError("not found", Code.NotFound)); + await act(() => view.result.current.leave.mutateAsync({ name: member.name })); + await waitFor(() => expect(view.result.current.space.isError).toBe(true)); + expect(view.result.current.space.data).toBeUndefined(); + }); + it("loads every page so the switcher can show all available Spaces", async () => { clients.listSpaces .mockResolvedValueOnce({ @@ -224,7 +256,7 @@ describe("Space queries", () => { }); expect(clients.deleteSpace).toHaveBeenCalledWith({ name: SPACE_NAME }); expect(queryClient.getQueryData(spaceKeys.list(VIEWER))).toEqual([createdSpace]); - expect(queryClient.getQueryState(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined(); + expect(queryClient.getQueryData(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined(); }); it("synchronizes admin invitation caches after create and delete", async () => { @@ -374,6 +406,6 @@ describe("Space queries", () => { }); expect(clients.deleteSpaceMember).toHaveBeenLastCalledWith({ name: viewerMember.name }); expect(queryClient.getQueryData(spaceKeys.list(VIEWER))).toEqual([]); - expect(queryClient.getQueryState(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined(); + expect(queryClient.getQueryData(spaceKeys.members(VIEWER, SPACE_NAME))).toBeUndefined(); }); }); diff --git a/web/tests/space-routes.test.tsx b/web/tests/space-routes.test.tsx new file mode 100644 index 00000000..e6fab18a --- /dev/null +++ b/web/tests/space-routes.test.tsx @@ -0,0 +1,131 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveQuickFindSubmission } from "@/components/AppSidebar/QuickFindDialog"; +import { getRouteActionPolicy, getSidebarRouteKind } from "@/components/AppSidebar/routes"; +import { CalendarHeader } from "@/components/CalendarView/CalendarHeader"; +import { resolveMemoDetailOrigin, resolveMemoOrigin } from "@/components/MemoView/navigation"; +import Calendar from "@/pages/Calendar"; +import { buildCollectionPath, getSpaceSwitchPath, resolveCollectionRoute } from "@/router/routes"; +import { SpaceRoute } from "@/router/SpaceRoute"; + +const state = vi.hoisted(() => ({ + selectedSpaceName: "spaces/a", + isSpaceReady: false, + spaceError: null as ConnectError | null, + retrySpace: vi.fn(), +})); +vi.mock("@/contexts/SpaceContext", () => ({ useSpaceContext: () => state })); +vi.mock("@/utils/i18n", async (original) => ({ + ...(await original()), + useTranslate: () => (key: string) => key, +})); +vi.mock("@/components/CalendarView/CalendarView", () => ({ CalendarView: () =>

calendar content

})); + +beforeEach(() => { + state.isSpaceReady = false; + state.spaceError = null; + state.retrySpace.mockClear(); +}); +describe("Space route contract", () => { + it.each([ + ["/spaces/a", "home"], + ["/spaces/a/explore", "explore"], + ["/spaces/a/calendar/2026/09/06", "calendar"], + ["/spaces/a/attachments", "attachments"], + ["/spaces/a/archived", "common"], + ["/spaces/a/unknown", "common"], + ])("classifies %s as %s", (path, kind) => expect(getSidebarRouteKind(path)).toBe(kind)); + it("does not interpret unknown space routes or encoded slashes as global collections", () => { + expect(resolveCollectionRoute("/spaces/a/unknown").isCollection).toBe(false); + expect(resolveCollectionRoute("/spaces/a/archived").spaceName).toBeUndefined(); + expect(resolveCollectionRoute("/spaces/a%2Fb").isCollection).toBe(false); + expect(resolveCollectionRoute("/spaces/A").spaceName).toBe("spaces/A"); + expect(buildCollectionPath("/calendar/2026/09", "spaces/A")).toBe("/spaces/A/calendar/2026/09"); + }); + it("preserves collection parameters but drops unrelated global page parameters on switching", () => { + expect(getSpaceSwitchPath({ pathname: "/spaces/a/explore", search: "?filter=tagSearch%3Ax" }, "spaces/b")).toBe( + "/spaces/b/explore?filter=tagSearch%3Ax", + ); + expect(getSpaceSwitchPath({ pathname: "/setting", search: "?section=spaces" }, "spaces/b")).toBe("/spaces/b"); + }); + it("searches from calendar and attachments into the same Space Home", () => { + for (const path of ["/spaces/a/calendar/2026/09", "/spaces/a/attachments"]) { + expect(resolveQuickFindSubmission(path, "roadmap", [], "text").destination).toBe("/spaces/a?filter=contentSearch%3Aroadmap"); + } + expect(getRouteActionPolicy("/memos/a").searchDestination).toBe("/"); + }); + it("carries the full calendar origin through a permanent memo link", () => { + const parentPage = "/spaces/a/calendar/2026/09/06?filter=tagSearch%3Awork"; + expect( + resolveMemoOrigin({ pathname: "/spaces/a/calendar/2026/09/06", search: "?filter=tagSearch%3Awork", memoName: "memos/x" }), + ).toEqual({ parentPage, parentScope: "preserve" }); + expect(resolveMemoDetailOrigin({ from: parentPage })).toEqual({ parentPage, parentScope: "preserve" }); + expect(resolveMemoDetailOrigin(undefined)).toEqual({ parentPage: "/", parentScope: "all" }); + }); +}); + +describe("Space page availability", () => { + const setup = () => + render( + + + }> + space content

} /> +
+
+
, + ); + it("withholds content while loading", () => { + setup(); + expect(screen.queryByText("space content")).toBeNull(); + expect(screen.getByRole("status")).toHaveTextContent("space.loading"); + }); + it("renders content only after membership resolves", () => { + state.isSpaceReady = true; + setup(); + expect(screen.getByText("space content")).toBeInTheDocument(); + }); + it.each([Code.NotFound, Code.PermissionDenied])("shows the same unavailable state for code %s", (code) => { + state.spaceError = new ConnectError("unavailable", code); + setup(); + expect(screen.getByRole("status")).toHaveTextContent("space.unavailable"); + expect(screen.getByRole("link", { name: "space.back-to-memos" })).toHaveAttribute("href", "/"); + expect(screen.queryByText("space content")).toBeNull(); + }); + it("offers retry for a transient failure", () => { + state.spaceError = new ConnectError("offline", Code.Unavailable); + setup(); + fireEvent.click(screen.getByRole("button", { name: "search.retry" })); + expect(state.retrySpace).toHaveBeenCalledOnce(); + }); +}); + +describe("Space calendar navigation", () => { + it("keeps Space and filters in month links", () => { + render( + + + , + ); + expect(screen.getByRole("link", { name: "common.next-month" })).toHaveAttribute( + "href", + "/spaces/a/calendar/2026/10?filter=tagSearch%3Awork", + ); + expect(screen.getByRole("link", { name: "common.previous-month" })).toHaveAttribute( + "href", + "/spaces/a/calendar/2026/08?filter=tagSearch%3Awork", + ); + }); + it("resolves an incomplete Space calendar address inside the Space", async () => { + render( + + + } /> + + , + ); + expect(await screen.findByText("calendar content")).toBeInTheDocument(); + }); +}); diff --git a/web/tests/space-switcher.test.tsx b/web/tests/space-switcher.test.tsx index 7aa32cfd..8f3d7a19 100644 --- a/web/tests/space-switcher.test.tsx +++ b/web/tests/space-switcher.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { createEvent, fireEvent, screen, render as testingRender, waitFor, within } from "@testing-library/react"; +import { createMemoryRouter, MemoryRouter, RouterProvider } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import SpaceSwitcher from "@/components/AppSidebar/SpaceSwitcher"; @@ -9,7 +10,6 @@ const spaceState = vi.hoisted(() => ({ ], selectedSpace: undefined as { name: string; title: string; description: string } | undefined, selectedSpaceName: undefined as string | undefined, - selectMemos: vi.fn(), selectSpace: vi.fn(), })); @@ -49,6 +49,8 @@ vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key, })); +const render = (ui: React.ReactNode) => testingRender({ui}); + describe("SpaceSwitcher", () => { beforeEach(() => { spaceState.spaces = [ @@ -57,10 +59,29 @@ describe("SpaceSwitcher", () => { ]; spaceState.selectedSpace = undefined; spaceState.selectedSpaceName = undefined; - spaceState.selectMemos.mockClear(); spaceState.selectSpace.mockClear(); }); + it.each([ + "/", + "/setting", + "/memos/direct", + ])("does not navigate the current scope from %s and leaves modified clicks native", async (path) => { + const router = createMemoryRouter([{ path: "*", element: }], { initialEntries: [path] }); + testingRender(); + const key = router.state.location.key; + fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" })); + fireEvent.click(await screen.findByRole("menuitemradio", { name: "Memos" })); + expect(router.state.location.key).toBe(key); + await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" })); + const product = await screen.findByRole("menuitemradio", { name: "Product" }); + const click = createEvent.click(product, { ctrlKey: true, cancelable: true }); + fireEvent(product, click); + expect(click.defaultPrevented).toBe(false); + expect(router.state.location.pathname).toBe(path); + }); + it("lists Memos, every available Space, and the create entry", async () => { render(); @@ -205,18 +226,18 @@ describe("SpaceSwitcher", () => { expect(researchRow).not.toHaveTextContent("research-space"); }); - it("switches context without navigation and opens Space creation", async () => { + it("links to spaces and opens Space creation", async () => { render(); fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" })); - fireEvent.click(await screen.findByRole("menuitemradio", { name: "Product" })); - expect(spaceState.selectSpace).toHaveBeenCalledWith(spaceState.spaces[0]); + const product = await screen.findByRole("menuitemradio", { name: "Product" }); + expect(product).toHaveAttribute("href", "/spaces/product"); + fireEvent.click(product); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); // Selecting Memos is how a signed-in user gets back to the home feed. fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" })); fireEvent.click(await screen.findByRole("menuitemradio", { name: "Memos" })); - expect(spaceState.selectMemos).toHaveBeenCalledOnce(); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument()); fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));