feat(web): scope Spaces by URL path instead of a remembered selection
The selected Space used to live in sessionStorage and ride along invisibly with every collection route. It now lives in the URL: collection pages exist both globally and beneath /spaces/:spaceUid, and the Space context derives its scope from the current location. - Add a Space route branch (Home, Explore, Calendar, Attachments) guarded by a SpaceRoute that shows loading, unavailable, and retry states. - Resolve collection routes through one matchPath-based helper built from the route constants; build Space-scoped paths with buildCollectionPath. - Fetch the route Space directly, seeded from the switcher list so pages are not held behind a second round-trip. - Turn the Space switcher into plain links that preserve the current collection view and query. - Drop the remembered-Space machinery: clearSelectedSpace/selectMemos, the compose-placement policy, routeSupportsCollectionScope, and the quick-find switchToAll flag are all implied by the URL now. - Return from memo details to the full origin URL, including its Space. - Add space.unavailable and space.back-to-memos strings; align the Azerbaijani catalog with English.
This commit is contained in:
parent
0716eaac2c
commit
af008b2153
45 changed files with 704 additions and 686 deletions
|
|
@ -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 <SpaceSwitcher className={className} size={size} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<QuickFindMode>("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);
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</span>
|
||||
);
|
||||
|
||||
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 }) => (
|
||||
<DropdownMenuItem
|
||||
role="menuitemradio"
|
||||
aria-checked={selected}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
closeOnClick
|
||||
onClick={onSelect}
|
||||
render={
|
||||
<Link
|
||||
to={to}
|
||||
onClick={(event) => {
|
||||
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<number>();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -141,7 +141,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
|
|||
style={menuWidth ? { width: `${menuWidth}px` } : undefined}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<ContextItem selected={!selectedSpaceName} onSelect={selectMemos}>
|
||||
<ContextItem selected={!selectedSpaceName} to={memosPath}>
|
||||
<span className="min-w-0 flex-1">
|
||||
<MemosLogo compact size="sm" />
|
||||
</span>
|
||||
|
|
@ -157,7 +157,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
|
|||
<ContextItem
|
||||
key={space.name}
|
||||
selected={space.name === selectedSpaceName}
|
||||
onSelect={() => selectSpace(space)}
|
||||
to={getSpaceSwitchPath(location, space.name)}
|
||||
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
|
||||
>
|
||||
<SpaceMark size="sm" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Link
|
||||
to={`/${memoName}`}
|
||||
// Attachments is collection-scoped, but Memo filters render on Home.
|
||||
// Carry a Home origin so direct-resource handling preserves this scope.
|
||||
state={createMemoNavigationState(ROUTES.HOME, "preserve")}
|
||||
state={createMemoNavigationState(`${location.pathname}${location.search}`, "preserve")}
|
||||
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<span className="truncate">{t("attachment-library.labels.memo")}</span>
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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<LinkProps, "to"> {
|
||||
/** Pathname only; the current search string rides along so view and tag filters survive. */
|
||||
|
|
@ -12,8 +13,8 @@ export interface CalendarLinkProps extends Omit<LinkProps, "to"> {
|
|||
* pathname change.
|
||||
*/
|
||||
export const CalendarLink = forwardRef<HTMLAnchorElement, CalendarLinkProps>(({ to, ...props }, ref) => {
|
||||
const { search } = useLocation();
|
||||
return <Link ref={ref} to={{ pathname: to, search }} {...props} />;
|
||||
const { pathname, search } = useLocation();
|
||||
return <Link ref={ref} to={{ pathname: collectionPathForLocation(to, pathname), search }} {...props} />;
|
||||
});
|
||||
|
||||
CalendarLink.displayName = "CalendarLink";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||
confirmDeleteMemo,
|
||||
} = useMemoActionHandlers({
|
||||
memo,
|
||||
parentScope: props.parentScope,
|
||||
parentPage: props.parentPage,
|
||||
onEdit: props.onEdit,
|
||||
setDeleteDialogOpen,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLSpanElement> {
|
||||
node?: Element; // AST node from react-markdown
|
||||
|
|
@ -19,10 +18,9 @@ interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|||
}
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({ "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<TagProps> = ({ "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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<Link
|
||||
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
|
||||
to={parentPage}
|
||||
onClick={() => {
|
||||
if (parentScope === "all" && isMemoCollectionOrigin(parentPage)) {
|
||||
clearSelectedSpace();
|
||||
}
|
||||
}}
|
||||
viewTransition
|
||||
>
|
||||
<SidebarRowIconSlot icon={ArrowLeftIcon} />
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
|
|||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<MemoActionMenu memo={memo} parentScope={parentScope} readonly={readonly} onEdit={openEditor} />
|
||||
<MemoActionMenu memo={memo} parentPage={parentPage} readonly={readonly} onEdit={openEditor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<MemoFilterContextValue | null>(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]);
|
||||
|
|
|
|||
|
|
@ -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<string, Space>;
|
||||
|
|
@ -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<SpaceContextValue | null>(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<string, Space> = new Map();
|
||||
const NO_DUPLICATE_SPACE_TITLES: ReadonlySet<string> = 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<Space>();
|
||||
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<CollectionScope>(
|
||||
() => (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<SpaceContextValue>(
|
||||
() => ({
|
||||
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 <SpaceContext.Provider value={value}>{children}</SpaceContext.Provider>;
|
||||
}
|
||||
|
||||
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 <SpaceContext.Provider value={anonymousValue}>{children}</SpaceContext.Provider>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserSpaceSession key={currentUserName} userName={currentUserName}>
|
||||
{children}
|
||||
</UserSpaceSession>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <T>(queryClient: QueryClient, queryKey: QueryKey, updat
|
|||
|
||||
const removeSpaceFromViewerCache = (queryClient: QueryClient, viewerName: string, spaceName: string) => {
|
||||
updateCachedList<Space>(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<Space, ConnectError>({
|
||||
queryKey: spaceKeys.space(viewerName ?? "", spaceName ?? ""),
|
||||
queryFn: () => spaceServiceClient.getSpace({ name: spaceName! }),
|
||||
enabled: queryEnabled(viewerName, spaceName),
|
||||
initialData: () => queryClient.getQueryData<Space[]>(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<Space[]>(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<Space>(queryClient, spaceKeys.list(viewerName), (spaces) => upsertByName(spaces, space));
|
||||
},
|
||||
});
|
||||
|
|
@ -343,7 +363,8 @@ export function useUpdateSpaceMember(viewerName: string) {
|
|||
if (spaceName) {
|
||||
updateCachedList<SpaceMember>(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 });
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<MemoScope, "archived">;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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ı",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -966,5 +966,9 @@
|
|||
"description": "这些文件从未附加到任何备忘。可在此查看或删除。",
|
||||
"title": "未关联的上传"
|
||||
}
|
||||
},
|
||||
"space": {
|
||||
"unavailable": "空间不存在或你没有访问权限。",
|
||||
"back-to-memos": "返回 Memos"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,8 @@
|
|||
}
|
||||
},
|
||||
"space": {
|
||||
"unavailable": "空間不存在或你沒有存取權限。",
|
||||
"back-to-memos": "返回 Memos",
|
||||
"create": "建立空間",
|
||||
"create-description": "建立供備忘錄與成員共用的空間。",
|
||||
"create-success": "已建立空間",
|
||||
|
|
|
|||
|
|
@ -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<CalendarRouteParams>());
|
||||
if (!state) {
|
||||
return <Navigate to={buildCalendarPath(getCurrentMonth())} replace />;
|
||||
return (
|
||||
<Navigate
|
||||
to={{ pathname: collectionPathForLocation(buildCalendarPath(getCurrentMonth()), location.pathname), search: location.search }}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <CalendarView month={state.month} date={state.date} />;
|
||||
};
|
||||
|
|
|
|||
42
web/src/router/SpaceRoute.tsx
Normal file
42
web/src/router/SpaceRoute.tsx
Normal file
|
|
@ -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 <NotFound />;
|
||||
if (isSpaceReady) return <Outlet />;
|
||||
const unavailable = hasConnectCode(spaceError, Code.NotFound, Code.PermissionDenied);
|
||||
return (
|
||||
<section className="flex min-h-[50vh] w-full flex-col items-center justify-center gap-4 px-4 text-center" role="status">
|
||||
{!spaceError ? (
|
||||
<>
|
||||
<LoaderCircleIcon className="size-5 animate-spin text-muted-foreground" />
|
||||
<p>{t("space.loading")}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t(unavailable ? "space.unavailable" : "space.load-error")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{!unavailable && (
|
||||
<Button variant="outline" onClick={retrySpace}>
|
||||
{t("search.retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Link className={buttonVariants({ variant: "outline" })} to={ROUTES.HOME}>
|
||||
{t("space.back-to-memos")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
@ -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: <RequireFullInitializationRoute />,
|
||||
children: [
|
||||
{
|
||||
path: SPACE_ROUTE_PATTERN,
|
||||
children: [
|
||||
{
|
||||
element: <SpaceRoute />,
|
||||
children: [
|
||||
{
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{ path: "explore", element: <Explore /> },
|
||||
{ path: "calendar/:year?/:month?/:day?", element: <Calendar /> },
|
||||
],
|
||||
},
|
||||
{ path: "attachments", element: <Attachments /> },
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
|
||||
{ path: Routes.INBOX, element: <Inboxes /> },
|
||||
{ path: Routes.SETTING, element: <Setting /> },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
|
|
@ -315,19 +308,14 @@ describe("App sidebar logo", () => {
|
|||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/explore"]}>
|
||||
<MemoryRouter initialEntries={["/spaces/product/explore"]}>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={["/u/alice"]}>
|
||||
<AppSidebar />
|
||||
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={["/archived"]}>
|
||||
<AppSidebar />
|
||||
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={["/attachments"]}>
|
||||
<MemoryRouter initialEntries={["/spaces/product/attachments"]}>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe("<AttachmentMediaGrid>", () => {
|
|||
const onPreview = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/attachments"]}>
|
||||
<MemoryRouter initialEntries={["/spaces/product/attachments?filter=tagSearch%3Awork"]}>
|
||||
<AttachmentMediaGrid groups={groups} onPreview={onPreview} />
|
||||
<LocationStateProbe />
|
||||
</MemoryRouter>,
|
||||
|
|
@ -67,7 +67,9 @@ describe("<AttachmentMediaGrid>", () => {
|
|||
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<GlobalMemoEditorProvider>
|
||||
|
|
@ -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(<Trigger />);
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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(<MemoActionMenu memo={memo} parentScope="preserve" />);
|
||||
render(<MemoActionMenu memo={memo} />);
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: () => <div data-testid="share-panel" /> }));
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<MemoFilterProvider>
|
||||
<Harness />
|
||||
</MemoFilterProvider>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: [origin] },
|
||||
);
|
||||
render(<RouterProvider router={router} />);
|
||||
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" },
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/contexts/MemoFilterContext")>();
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/memos/parent"]}>
|
||||
<Tag data-tag="work">#work</Tag>
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<output data-testid="selected-name">{selectedSpaceName ?? "Memos"}</output>
|
||||
<output data-testid="selected-title">{selectedSpace?.title ?? ""}</output>
|
||||
<output data-testid="collection-scope">
|
||||
{collectionScope.kind === "space" ? `${collectionScope.kind}:${collectionScope.name}` : collectionScope.kind}
|
||||
</output>
|
||||
<output data-testid="memo-filter">{memoFilter ?? "all"}</output>
|
||||
<output data-testid="duplicate-titles">{[...duplicateSpaceTitles].join(",")}</output>
|
||||
<button type="button" onClick={selectMemos}>
|
||||
Select Memos
|
||||
</button>
|
||||
<button type="button" onClick={clearSelectedSpace}>
|
||||
Clear Space in place
|
||||
</button>
|
||||
<button type="button" onClick={() => spaces[0] && selectSpace(spaces[0])}>
|
||||
Select first Space
|
||||
</button>
|
||||
<button type="button" onClick={() => selectSpace(newlyCreatedSpace)}>
|
||||
Select new Space
|
||||
</button>
|
||||
<CurrentPath />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CurrentPath = () => {
|
||||
const location = useLocation();
|
||||
return <output data-testid="path">{`${location.pathname}${location.search}`}</output>;
|
||||
};
|
||||
|
||||
const renderProvider = (initialPath = "/explore") =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<SpaceProvider>
|
||||
<Probe />
|
||||
</SpaceProvider>
|
||||
</MemoryRouter>,
|
||||
const context = useSpaceContext();
|
||||
return (
|
||||
<>
|
||||
<output data-testid="path">{location.pathname + location.search}</output>
|
||||
<output data-testid="space">{context.selectedSpaceName || "all"}</output>
|
||||
<output data-testid="filter">{context.memoFilter || "all"}</output>
|
||||
<output data-testid="ready">{String(context.isSpaceReady)}</output>
|
||||
<output data-testid="duplicates">{[...context.duplicateSpaceTitles].join(",")}</output>
|
||||
<Link to={getSpaceSwitchPath(location, "spaces/b")}>B</Link>
|
||||
<Link to={getSpaceSwitchPath(location)}>Memos</Link>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
|
||||
describe("SpaceProvider", () => {
|
||||
};
|
||||
const setup = (path = "/") => {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: "*",
|
||||
element: (
|
||||
<SpaceProvider>
|
||||
<Probe />
|
||||
</SpaceProvider>
|
||||
),
|
||||
},
|
||||
],
|
||||
{ initialEntries: [path] },
|
||||
);
|
||||
render(<RouterProvider router={router} />);
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/explore"]}>
|
||||
<SpaceProvider>
|
||||
<Probe />
|
||||
</SpaceProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
131
web/tests/space-routes.test.tsx
Normal file
131
web/tests/space-routes.test.tsx
Normal file
|
|
@ -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<typeof import("@/utils/i18n")>()),
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/components/CalendarView/CalendarView", () => ({ CalendarView: () => <p>calendar content</p> }));
|
||||
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/spaces/a"]}>
|
||||
<Routes>
|
||||
<Route element={<SpaceRoute />}>
|
||||
<Route path="/spaces/a" element={<p>space content</p>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/spaces/a/calendar/2026/09?filter=tagSearch%3Awork"]}>
|
||||
<CalendarHeader month="2026-09" monthLabel="September 2026" today="2026-09-06" closable />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/spaces/a/calendar?filter=tagSearch%3Awork"]}>
|
||||
<Routes>
|
||||
<Route path="/spaces/:spaceUid/calendar/:year?/:month?/:day?" element={<Calendar />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await screen.findByText("calendar content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
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: <SpaceSwitcher /> }], { initialEntries: [path] });
|
||||
testingRender(<RouterProvider router={router} />);
|
||||
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(<SpaceSwitcher />);
|
||||
|
||||
|
|
@ -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(<SpaceSwitcher />);
|
||||
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" }));
|
||||
|
|
|
|||
Loading…
Reference in a new issue