feat(web): add a global memo composer to the sidebar (#6219)
This commit is contained in:
parent
57a4b7aee6
commit
f36ca9317d
41 changed files with 720 additions and 173 deletions
|
|
@ -9,9 +9,9 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
|
||||
"lint": "tsc --noEmit --skipLibCheck && biome check src",
|
||||
"lint:fix": "biome check --write src",
|
||||
"format": "biome format --write src",
|
||||
"lint": "tsc --noEmit --skipLibCheck && biome check src tests",
|
||||
"lint:fix": "biome check --write src tests",
|
||||
"format": "biome format --write src tests",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -39,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { memoViewServiceClient } from "@/connect";
|
||||
import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
|
||||
|
|
@ -72,6 +74,32 @@ import SidebarSection, {
|
|||
import TagsSection from "./TagsSection";
|
||||
|
||||
const SIDEBAR_HORIZONTAL_PADDING = "px-3";
|
||||
const SIDEBAR_HEADER_ACTION_CLASSES = "size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground";
|
||||
|
||||
const NewMemoAction = ({ onClick }: { onClick: () => void }) => {
|
||||
const t = useTranslate();
|
||||
const label = t("editor.new-memo");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={SIDEBAR_HEADER_ACTION_CLASSES}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
data-new-memo-trigger
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SquarePenIcon className="size-4" strokeWidth={1.8} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
|
||||
const t = useTranslate();
|
||||
|
|
@ -604,6 +632,7 @@ const AppSidebar = ({ className }: { className?: string }) => {
|
|||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
|
||||
const { canOpen: canCompose, openEditor } = useGlobalMemoEditor();
|
||||
return (
|
||||
<aside className={cn("flex h-full w-full select-none flex-col bg-sidebar text-sidebar-foreground", className)}>
|
||||
<div className={cn("flex h-13 shrink-0 items-center justify-between gap-2", SIDEBAR_HORIZONTAL_PADDING)}>
|
||||
|
|
@ -613,18 +642,21 @@ const AppSidebar = ({ className }: { className?: string }) => {
|
|||
>
|
||||
<MemosLogo compact />
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
setQuickFindOpen(true);
|
||||
}}
|
||||
aria-label={t("common.search")}
|
||||
>
|
||||
<SearchIcon className="size-4" strokeWidth={1.8} />
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={SIDEBAR_HEADER_ACTION_CLASSES}
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
setQuickFindOpen(true);
|
||||
}}
|
||||
aria-label={t("common.search")}
|
||||
>
|
||||
<SearchIcon className="size-4" strokeWidth={1.8} />
|
||||
</Button>
|
||||
{canCompose && <NewMemoAction onClick={openEditor} />}
|
||||
</div>
|
||||
</div>
|
||||
<GlobalNavigation />
|
||||
<div className="mx-3 mt-2 border-t border-border/70" />
|
||||
|
|
@ -660,7 +692,14 @@ export const MobileAppHeader = () => {
|
|||
const { setMobileOpen } = useAppSidebar();
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-12 w-full items-center justify-start gap-1 border-b border-border/70 bg-background/90 px-2 backdrop-blur-md md:hidden">
|
||||
<Button variant="ghost" size="icon-sm" className="size-8" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Open navigation"
|
||||
data-mobile-navigation-trigger
|
||||
>
|
||||
<MenuIcon className="size-[18px]" />
|
||||
</Button>
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -42,18 +42,6 @@ const QuickFindDialog = () => {
|
|||
const scopeLabel =
|
||||
viewApplies && memoView === BUILTIN_TASKS_VIEW_ID ? t("common.tasks") : selectedMemoView?.title || getScopeLabel(location.pathname, t);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
setQuickFindOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [setQuickFindOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickFindOpen) return;
|
||||
setQuery(
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ MemoEditor is a three-layer component. At its core is a single editor — `Edito
|
|||
└─────────────────┬───────────────────────┘
|
||||
│ EditorController
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ State Layer (Reducer + Context) │
|
||||
│ - state/, useEditorContext() │
|
||||
│ State Layer (reducer over an external │
|
||||
│ store; per-slice subscriptions) │
|
||||
│ - state/, useEditorContext(), │
|
||||
│ useEditorSelector() │
|
||||
│ - state.content ← markdown (the │
|
||||
│ single source of truth) │
|
||||
└─────────────────┬───────────────────────┘
|
||||
|
|
@ -29,16 +31,18 @@ MemoEditor is a three-layer component. At its core is a single editor — `Edito
|
|||
|
||||
```
|
||||
MemoEditor/
|
||||
├── index.tsx # The shell: EditorProvider + MemoEditorImpl
|
||||
├── loader.ts # loadMemoEditor(): the shared lazy-load entry point
|
||||
├── state/ # State management (reducer, actions, context)
|
||||
├── services/ # Business logic (pure functions)
|
||||
├── components/ # UI components
|
||||
│ ├── EditorContent.tsx # Hosts Editor; forwards its EditorController ref
|
||||
│ ├── EditorToolbar.tsx # Toolbar
|
||||
│ ├── EditorMetadata.tsx # Attachment strip below the document
|
||||
│ └── ...
|
||||
├── hooks/ # React hooks (utilities)
|
||||
│ ├── useMemoSave.ts # Save transaction, cache invalidation, and reset
|
||||
│ └── useFocusMode.ts # Scroll lock and layout-stable focus presentation
|
||||
├── Editor/ # The CodeMirror 6 decorated-source editor
|
||||
├── Editor/ # The CodeMirror 6 decorated-source editor
|
||||
│ ├── index.tsx # React wrapper: mounts the EditorView, owns the
|
||||
│ │ # controller refs, syncs initialContent in/out
|
||||
│ ├── extensions.ts # buildEditorExtensions(): assembles the CM extension set
|
||||
|
|
@ -46,31 +50,35 @@ MemoEditor/
|
|||
│ ├── tagMentionDecorations.ts# ViewPlugin that decorates #tag / @mention spans
|
||||
│ ├── markdownTagRanges.ts # Markdown syntax-tree adapter for the shared tag scanner
|
||||
│ ├── tagAutocomplete.ts # CM autocompletion source for #tag
|
||||
│ ├── uploadAnchors.ts # Widget decorations holding a slot per in-flight upload
|
||||
│ ├── formatting.ts # FormattingController impl (toggle marks, headings, lists)
|
||||
│ └── controller.ts # EditorController impl over an EditorView
|
||||
│ ├── controller.ts # EditorController impl over an EditorView
|
||||
│ └── ... # Heading/list/viewport decorations, editor.css
|
||||
├── formatting/
|
||||
│ └── commands.ts # Backend-agnostic catalog of formatting verbs
|
||||
├── Toolbar/ # Toolbar sub-components (InsertMenu, VisibilitySelector)
|
||||
├── Toolbar/ # EditorToolbar, FormattingToolbar, InsertMenu, VisibilitySelector
|
||||
├── constants.ts
|
||||
└── types/
|
||||
└── editorController.ts # EditorController / FormattingController interfaces
|
||||
└── types/ # EditorController / FormattingController, component props,
|
||||
# attachment and insert-menu types
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### State Management
|
||||
|
||||
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
|
||||
A reducer (`state/reducer.ts`) drives an **external store**, not a `useReducer` in the provider, and consumers subscribe to just the slice they read via `useEditorSelector` (`useSyncExternalStore` under the hood). Content changes on every keystroke, so routing it through a single context value would re-render every consumer — toolbar, insert menu, metadata — per keystroke; with per-slice subscriptions only the components whose slice actually changed re-render. All state changes still go through action creators.
|
||||
|
||||
`state.content` holds the document as a **markdown string** and is the single source of truth. Because the editor stores markdown verbatim, `state.content` is exactly the editor's document — there is no encoding or normalization step.
|
||||
|
||||
### The editor contract
|
||||
|
||||
`types/editorController.ts` defines `EditorController` — `focus`, `getMarkdown`, `setMarkdown`, `insertMarkdown`, `selectAll`, `scrollToCursor`, plus an optional `formatting` capability. Callers outside the editor implementation use this interface exclusively and never reach into CodeMirror internals.
|
||||
`types/editorController.ts` defines `EditorController` — document access (`getMarkdown`, `setMarkdown`, `insertMarkdown`), cursor and focus (`focus`, `hasFocus`, `isEmpty`, `getCursor`, `setCursor`, `scrollToCursor`, `selectAll`), the upload-anchor group below, plus an optional `formatting` capability. Callers outside the editor implementation use this interface exclusively and never reach into CodeMirror internals.
|
||||
|
||||
`Editor/controller.ts` implements `EditorController` over a CodeMirror `EditorView`: `getMarkdown` is just `view.state.doc.toString()`, `setMarkdown` replaces the whole document, and `insertMarkdown` block-pads the insertion so it lands as its own block.
|
||||
|
||||
`FormattingController` (same file in `types/`) is the rich-formatting surface the focus-mode `FormattingToolbar` drives: `run(commandId, ctx?)`, `getActiveFormats()`, `getSelectedText()`, and `subscribe(listener)`. `Editor/formatting.ts` implements it by editing the markdown source directly — toggling inline marks (`**`/`*`/`` ` ``), line prefixes (`- `, `1. `, `- [ ] `), and ATX heading prefixes (`#`…) — and by reading active state from the Lezer syntax tree at the caret.
|
||||
`createUploadAnchor`/`updateUploadAnchor`/`resolveUploadAnchor`/`cancelUploadAnchor` drive `Editor/uploadAnchors.ts`, a `StateField` of widget decorations that hold a place in the document while an attachment uploads and carry its progress, failure message, and retry/keep affordances. `resolveUploadAnchor` replaces the anchor with the finished markdown (block-padded the same way `insertMarkdown` is); resolving with empty markdown cancels instead, as does `cancelUploadAnchor`.
|
||||
|
||||
`FormattingController` (same file in `types/`) is the rich-formatting surface the focus-mode `FormattingToolbar` drives: `run(commandId, ctx?)`, `getActiveFormats()`, and `subscribe(listener)`. `Editor/formatting.ts` implements it by editing the markdown source directly — toggling inline marks (`**` and `*`) and single-backtick code delimiters, line prefixes (`-`, `1.`, and `- [ ]`, each followed by a space), and ATX heading prefixes (`#`…) — and by reading active state from the Lezer syntax tree at the caret.
|
||||
|
||||
### Formatting command catalog
|
||||
|
||||
|
|
@ -78,7 +86,7 @@ Uses `useReducer` + Context for predictable state transitions. All state changes
|
|||
|
||||
### Editor extensions
|
||||
|
||||
`Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a reconfigurable placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`. Native CodeMirror paste/drop handlers intercept file payloads before its text insertion behavior and pass them to the attachment layer; ordinary markdown text paste/drop remains CodeMirror-owned.
|
||||
`Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a reconfigurable placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`. It also binds the save shortcut: `Meta-Enter` and `Ctrl-Enter` both call `onSubmit`, bound explicitly rather than through the platform-dependent `Mod-` so either works everywhere, and ordered ahead of `defaultKeymap`'s own `Mod-Enter` (`insertBlankLine`) so saving never also edits the document. Native CodeMirror paste/drop handlers intercept file payloads before its text insertion behavior and pass them to the attachment layer; ordinary markdown text paste/drop remains CodeMirror-owned.
|
||||
|
||||
`Editor/theme.ts` defines the decorated-source look: a `HighlightStyle` over the Lezer markdown highlight tags (headings, strong, emphasis, code, links, quotes, markers) and an `EditorView.theme`. Colors come from CSS custom properties so light/dark themes just work. This is the editor's own styling — the read-only memo view styles itself separately via `@/lib/markdownStyles`.
|
||||
|
||||
|
|
@ -111,8 +119,9 @@ Thin presentation components that dispatch actions and render UI.
|
|||
```typescript
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
|
||||
// Create mode: omit `memo`. Pass an existing Memo to edit it instead.
|
||||
<MemoEditor
|
||||
memoName="memos/123"
|
||||
cacheKey="home-composer"
|
||||
onConfirm={(name) => console.log('Saved:', name)}
|
||||
onCancel={() => console.log('Cancelled')}
|
||||
/>
|
||||
|
|
@ -123,6 +132,6 @@ import MemoEditor from "@/components/MemoEditor";
|
|||
Services are pure functions — easy to unit test without React.
|
||||
|
||||
```typescript
|
||||
const state = mockEditorState();
|
||||
const state = createInitialState(); // from state/types.ts
|
||||
const result = await memoService.save(state, { memoName: 'memos/123' });
|
||||
```
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
defaultCreateTime,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onSavingChange,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
|
|
@ -54,6 +55,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
// typing (which changes content) does not re-render the editor shell and its
|
||||
// toolbar/metadata children.
|
||||
const isFocusMode = useEditorSelector((s) => s.ui.isFocusMode);
|
||||
const isSaving = useEditorSelector((s) => s.ui.isLoading.saving);
|
||||
const hasTimestamp = useEditorSelector((s) => Boolean(s.timestamps.createTime));
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const { aiSetting, fetchSetting } = useInstance();
|
||||
|
|
@ -89,6 +91,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
});
|
||||
const isDraftCacheEnabled = !memo;
|
||||
|
||||
useEffect(() => {
|
||||
onSavingChange?.(isSaving);
|
||||
}, [isSaving, onSavingChange]);
|
||||
|
||||
// Auto-save content to localStorage (subscribes to the store internally).
|
||||
const { discardDraft } = useAutoSave(currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface MemoEditorProps {
|
|||
defaultCreateTime?: Date;
|
||||
onConfirm?: (memoName: string) => void;
|
||||
onCancel?: () => void;
|
||||
/** Reports save activity so external presentations can prevent dismissal mid-transaction. */
|
||||
onSavingChange?: (isSaving: boolean) => void;
|
||||
}
|
||||
|
||||
export interface EditorContentProps {
|
||||
|
|
|
|||
164
web/src/contexts/GlobalMemoEditorContext.tsx
Normal file
164
web/src/contexts/GlobalMemoEditorContext.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { loadMemoEditor } from "@/components/MemoEditor/loader";
|
||||
import type { MemoEditorProps } from "@/components/MemoEditor/types";
|
||||
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
||||
import { useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface GlobalMemoEditorContextValue {
|
||||
/** Whether the signed-in user is ready to compose; gates every visible entry point. */
|
||||
canOpen: boolean;
|
||||
openEditor: () => void;
|
||||
}
|
||||
|
||||
const GlobalMemoEditorContext = createContext<GlobalMemoEditorContextValue | null>(null);
|
||||
|
||||
const isVisibleFocusTarget = (element: HTMLElement | null): element is HTMLElement => {
|
||||
if (!element?.isConnected || element.tabIndex < 0 || element.matches(":disabled, [aria-disabled='true']")) return false;
|
||||
|
||||
// Walk ancestors: the mobile trigger is hidden by a `md:hidden` parent, which
|
||||
// never shows up in the element's own computed style.
|
||||
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
|
||||
const style = window.getComputedStyle(current);
|
||||
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const findVisibleFocusTarget = (selector: string): HTMLElement | null =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(selector)).find(isVisibleFocusTarget) ?? null;
|
||||
|
||||
export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) {
|
||||
const t = useTranslate();
|
||||
const currentUserName = useCurrentUser()?.name;
|
||||
const { isUserSettingsInitialized } = useAuth();
|
||||
const { setMobileOpen, setQuickFindOpen } = useAppSidebar();
|
||||
// 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.
|
||||
const [openedFor, setOpenedFor] = useState<string>();
|
||||
const [EditorComponent, setEditorComponent] = useState<ComponentType<MemoEditorProps>>();
|
||||
// Only ever read from event handlers, so a ref keeps each save from
|
||||
// re-rendering the provider and the whole editor tree it hosts.
|
||||
const isSavingRef = useRef(false);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const openRequestVersionRef = useRef(0);
|
||||
|
||||
const closeEditor = useCallback(() => {
|
||||
openRequestVersionRef.current += 1;
|
||||
isSavingRef.current = false;
|
||||
setOpenedFor(undefined);
|
||||
}, []);
|
||||
const requestCloseEditor = useCallback(() => {
|
||||
if (!isSavingRef.current) closeEditor();
|
||||
}, [closeEditor]);
|
||||
const reportSaving = useCallback((saving: boolean) => {
|
||||
isSavingRef.current = saving;
|
||||
}, []);
|
||||
|
||||
// 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 openEditor = useCallback(() => {
|
||||
if (!canOpen || !currentUserName) return;
|
||||
|
||||
// Owned here so no caller can leave a sidebar surface open underneath.
|
||||
setMobileOpen(false);
|
||||
setQuickFindOpen(false);
|
||||
|
||||
const requestVersion = ++openRequestVersionRef.current;
|
||||
isSavingRef.current = false;
|
||||
const activeElement = document.activeElement;
|
||||
returnFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : null;
|
||||
|
||||
void loadMemoEditor()
|
||||
.then(({ default: MemoEditor }) => {
|
||||
if (openRequestVersionRef.current !== requestVersion) return;
|
||||
setEditorComponent(() => MemoEditor);
|
||||
setOpenedFor(currentUserName);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [canOpen, currentUserName, setMobileOpen, setQuickFindOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// RootLayout remains mounted when a public instance moves from Home to
|
||||
// Explore on sign-out. Treat every auth/readiness transition as a session
|
||||
// boundary so an open composer—or a pending lazy import—cannot survive it.
|
||||
closeEditor();
|
||||
returnFocusRef.current = null;
|
||||
}, [closeEditor, currentUserName, isUserSettingsInitialized]);
|
||||
|
||||
const resolveFinalFocus = useCallback(() => {
|
||||
// Release the remembered node here rather than in closeEditor: base-ui asks
|
||||
// for it after close, and a trigger that unmounted with the drawer would
|
||||
// otherwise keep its whole detached subtree alive until the next open.
|
||||
const remembered = isVisibleFocusTarget(returnFocusRef.current) ? returnFocusRef.current : null;
|
||||
returnFocusRef.current = null;
|
||||
|
||||
return remembered ?? findVisibleFocusTarget("[data-new-memo-trigger]") ?? findVisibleFocusTarget("[data-mobile-navigation-trigger]");
|
||||
}, []);
|
||||
|
||||
const editorIsOpen = openedFor !== undefined && openedFor === currentUserName;
|
||||
const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]);
|
||||
|
||||
return (
|
||||
<GlobalMemoEditorContext.Provider value={value}>
|
||||
{children}
|
||||
<DialogPrimitive.Root
|
||||
open={editorIsOpen}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
if (!open && isSavingRef.current) {
|
||||
eventDetails.cancel();
|
||||
return;
|
||||
}
|
||||
if (open) {
|
||||
setOpenedFor(currentUserName);
|
||||
} else {
|
||||
closeEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{EditorComponent && (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Backdrop className="fixed inset-0 z-overlay bg-transparent" />
|
||||
<DialogPrimitive.Popup
|
||||
className="fixed inset-0 z-overlay outline-none"
|
||||
initialFocus
|
||||
finalFocus={resolveFinalFocus}
|
||||
aria-modal="true"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogPrimitive.Title>{t("editor.new-memo")}</DialogPrimitive.Title>
|
||||
</VisuallyHidden>
|
||||
<EditorComponent
|
||||
autoFocus
|
||||
initialFocusMode
|
||||
cacheKey="global-memo-editor"
|
||||
placeholder={t("editor.any-thoughts")}
|
||||
onConfirm={closeEditor}
|
||||
onCancel={closeEditor}
|
||||
onFocusModeExit={requestCloseEditor}
|
||||
onSavingChange={reportSaving}
|
||||
/>
|
||||
<DialogPrimitive.Close className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:end-3 focus-visible:top-3 focus-visible:z-dropdown focus-visible:h-9 focus-visible:rounded-md focus-visible:bg-background focus-visible:px-3 focus-visible:text-sm focus-visible:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
|
||||
{t("common.close")}
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPrimitive.Portal>
|
||||
)}
|
||||
</DialogPrimitive.Root>
|
||||
</GlobalMemoEditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGlobalMemoEditor() {
|
||||
const context = useContext(GlobalMemoEditorContext);
|
||||
if (!context) {
|
||||
throw new Error("useGlobalMemoEditor must be used within GlobalMemoEditorProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import AppSidebar, {
|
|||
useSidebarWidth,
|
||||
} from "@/components/AppSidebar";
|
||||
import { AppSidebarProvider } from "@/contexts/AppSidebarContext";
|
||||
import { GlobalMemoEditorProvider } from "@/contexts/GlobalMemoEditorContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
|
|
@ -67,35 +68,37 @@ const RootLayoutContent = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<AppSidebarProvider>
|
||||
<div ref={shellRef} className="min-h-full w-full bg-background" style={{ [SIDEBAR_WIDTH_VAR]: `${sidebarWidth}px` } as CSSProperties}>
|
||||
{md && (
|
||||
<div className="fixed inset-y-0 start-0 z-30 w-(--app-sidebar-width) border-e border-border/70">
|
||||
<AppSidebar />
|
||||
<SidebarResizeHandle
|
||||
width={sidebarWidth}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
onWidthChange={setSidebarWidth}
|
||||
targetRef={shellRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MobileAppSidebar />
|
||||
<main className="flex min-h-full w-full min-w-0 flex-col items-center md:ps-(--app-sidebar-width)">
|
||||
<MobileAppHeader />
|
||||
{profile.demo && <DemoBanner />}
|
||||
<Outlet />
|
||||
</main>
|
||||
<QuickFindDialog />
|
||||
</div>
|
||||
</AppSidebarProvider>
|
||||
<div ref={shellRef} className="min-h-full w-full bg-background" style={{ [SIDEBAR_WIDTH_VAR]: `${sidebarWidth}px` } as CSSProperties}>
|
||||
{md && (
|
||||
<div className="fixed inset-y-0 start-0 z-30 w-(--app-sidebar-width) border-e border-border/70">
|
||||
<AppSidebar />
|
||||
<SidebarResizeHandle
|
||||
width={sidebarWidth}
|
||||
minWidth={minWidth}
|
||||
maxWidth={maxWidth}
|
||||
onWidthChange={setSidebarWidth}
|
||||
targetRef={shellRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MobileAppSidebar />
|
||||
<main className="flex min-h-full w-full min-w-0 flex-col items-center md:ps-(--app-sidebar-width)">
|
||||
<MobileAppHeader />
|
||||
{profile.demo && <DemoBanner />}
|
||||
<Outlet />
|
||||
</main>
|
||||
<QuickFindDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RootLayout = () => (
|
||||
<MemoFilterProvider>
|
||||
<RootLayoutContent />
|
||||
<AppSidebarProvider>
|
||||
<GlobalMemoEditorProvider>
|
||||
<RootLayoutContent />
|
||||
</GlobalMemoEditorProvider>
|
||||
</AppSidebarProvider>
|
||||
</MemoFilterProvider>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@
|
|||
"uploading-images": "Uploading images {{completed}}/{{total}}",
|
||||
"upload-file": "Upload file"
|
||||
},
|
||||
"new-memo": "New memo",
|
||||
"no-changes-detected": "No changes detected",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@
|
|||
"link-prompt": "输入链接地址",
|
||||
"more": "更多格式"
|
||||
},
|
||||
"new-memo": "新建备忘录",
|
||||
"no-changes-detected": "未检测到更改",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@
|
|||
"uploading-images": "正在上傳圖片 {{completed}}/{{total}}",
|
||||
"upload-file": "上傳文件"
|
||||
},
|
||||
"new-memo": "新增備忘錄",
|
||||
"no-changes-detected": "未發現變更",
|
||||
"save": "儲存",
|
||||
"saving": "儲存中...",
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ vi.mock("@/contexts/InstanceContext", () => ({
|
|||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) =>
|
||||
(
|
||||
{
|
||||
({
|
||||
"common.version": "Version",
|
||||
"about.powered-by": "Powered by Memos",
|
||||
} as Record<string, string>
|
||||
}) as Record<string, string>
|
||||
)[key] ?? key,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render as testingLibraryRender, screen } from "@testing-library/react";
|
||||
import { fireEvent, screen, render as testingLibraryRender } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppSidebar, { MobileAppHeader } from "@/components/AppSidebar";
|
||||
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
|
||||
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
|
|
@ -11,6 +11,11 @@ const authState = vi.hoisted(() => ({
|
|||
}));
|
||||
const sidebarState = vi.hoisted(() => ({
|
||||
memoScope: "home" as "home" | "explore" | "archived",
|
||||
mobileOpen: false,
|
||||
}));
|
||||
const globalEditorState = vi.hoisted(() => ({
|
||||
canOpen: true,
|
||||
openEditor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemosLogo", () => ({
|
||||
|
|
@ -41,7 +46,7 @@ vi.mock("@/contexts/AppSidebarContext", () => ({
|
|||
setInboxFilter: vi.fn(),
|
||||
memoDetail: undefined,
|
||||
setMemoDetail: vi.fn(),
|
||||
mobileOpen: false,
|
||||
mobileOpen: sidebarState.mobileOpen,
|
||||
setMobileOpen: vi.fn(),
|
||||
quickFindOpen: false,
|
||||
setQuickFindOpen: vi.fn(),
|
||||
|
|
@ -54,6 +59,10 @@ vi.mock("@/contexts/AuthContext", () => ({
|
|||
useAuth: () => ({ isInitialized: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/GlobalMemoEditorContext", () => ({
|
||||
useGlobalMemoEditor: () => globalEditorState,
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/InstanceContext", () => ({
|
||||
useInstance: () => ({ isInitialized: true }),
|
||||
}));
|
||||
|
|
@ -123,23 +132,40 @@ describe("App sidebar logo", () => {
|
|||
authState.currentUser = { name: "users/test" };
|
||||
authState.memoViews = [];
|
||||
sidebarState.memoScope = "home";
|
||||
sidebarState.mobileOpen = false;
|
||||
globalEditorState.canOpen = true;
|
||||
globalEditorState.openEditor.mockClear();
|
||||
});
|
||||
|
||||
it("navigates home instead of opening a global editor", () => {
|
||||
it("keeps logo navigation unchanged and opens the global memo editor", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<MemoryRouter initialEntries={["/attachments"]}>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const logo = screen.getByRole("link", { name: "Memos logo" });
|
||||
expect(logo).toHaveAttribute("href", "/");
|
||||
expect(screen.queryByRole("button", { name: /create.*memos/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
|
||||
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByText("common.calendar")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Compose when the composer reports it is not available", () => {
|
||||
globalEditorState.canOpen = false;
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<AppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.search" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the compact public navigation for a guest", () => {
|
||||
authState.currentUser = undefined;
|
||||
globalEditorState.canOpen = false;
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/explore"]}>
|
||||
<AppSidebar />
|
||||
|
|
@ -149,6 +175,7 @@ describe("App sidebar logo", () => {
|
|||
expect(screen.getByRole("link", { name: "common.explore" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.about" })).toHaveAttribute("href", "/about");
|
||||
expect(screen.getByRole("link", { name: "common.sign-in-to-memos" }).closest("footer")).not.toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "common.home" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -303,15 +330,29 @@ describe("App sidebar logo", () => {
|
|||
expectActiveNavPill(await screen.findByRole("button", { name: label, current: "page" }), label);
|
||||
});
|
||||
|
||||
it("keeps the mobile brand beside navigation without a duplicate search action", () => {
|
||||
it("keeps the mobile header limited to navigation and brand", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<MemoryRouter initialEntries={["/about"]}>
|
||||
<MobileAppHeader />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Open navigation" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveAttribute("data-mobile-navigation-trigger");
|
||||
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/");
|
||||
expect(screen.queryByRole("button", { name: "common.search" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "editor.new-memo" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes Compose in the mobile navigation drawer", () => {
|
||||
sidebarState.mobileOpen = true;
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/about"]}>
|
||||
<MobileAppSidebar />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "common.search" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.new-memo" }));
|
||||
expect(globalEditorState.openEditor).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import { createElement, type PropsWithChildren } from "react";
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildAttachmentLibraryStats, useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
|
||||
import {
|
||||
type Attachment,
|
||||
AttachmentSchema,
|
||||
MotionMediaFamily,
|
||||
MotionMediaRole,
|
||||
MotionMediaSchema,
|
||||
type Attachment,
|
||||
} from "@/types/proto/api/v1/attachment_service_pb";
|
||||
|
||||
const clients = vi.hoisted(() => ({ listAttachments: vi.fn() }));
|
||||
|
|
|
|||
|
|
@ -57,9 +57,7 @@ describe("AuthProvider initialization", () => {
|
|||
it("resets full readiness while post-sign-in settings are pending", async () => {
|
||||
let resolveSettings!: (value: { settings: [] }) => void;
|
||||
clients.getCurrentUser.mockResolvedValue({ user: { name: "users/alice", username: "alice" } });
|
||||
clients.listUserSettings.mockImplementation(
|
||||
() => new Promise<{ settings: [] }>((resolve) => (resolveSettings = resolve)),
|
||||
);
|
||||
clients.listUserSettings.mockImplementation(() => new Promise<{ settings: [] }>((resolve) => (resolveSettings = resolve)));
|
||||
|
||||
render(<Probe />, { wrapper });
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { CalendarDayCell } from "@/components/ActivityCalendar/types";
|
|||
|
||||
const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => ({
|
||||
date: "2025-05-01",
|
||||
label: "1",
|
||||
label: 1,
|
||||
count: 0,
|
||||
isCurrentMonth: true,
|
||||
isToday: false,
|
||||
|
|
|
|||
|
|
@ -14,16 +14,7 @@ vi.mock("@/hooks/useUserQueries", () => ({
|
|||
|
||||
describe("Editor", () => {
|
||||
it("scopes tag autocomplete stats to the current user", () => {
|
||||
render(
|
||||
<Editor
|
||||
className="x"
|
||||
initialContent=""
|
||||
placeholder="memo"
|
||||
onContentChange={vi.fn()}
|
||||
onFiles={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
render(<Editor className="x" initialContent="" placeholder="memo" onContentChange={vi.fn()} onFiles={vi.fn()} onSubmit={vi.fn()} />);
|
||||
|
||||
expect(queries.useTagCounts).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ describe("useFilteredMemoStats", () => {
|
|||
vi.mocked(useAllUserStats).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useAllUserStats>);
|
||||
} as unknown as ReturnType<typeof useAllUserStats>);
|
||||
vi.mocked(useUserStats).mockReturnValue({
|
||||
data: {
|
||||
memoCreatedTimestamps: [ts(2026, 5, 1), ts(2026, 5, 1), ts(2026, 5, 2)],
|
||||
|
|
@ -46,7 +46,7 @@ describe("useFilteredMemoStats", () => {
|
|||
tagCount: {},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useUserStats>);
|
||||
} as unknown as ReturnType<typeof useUserStats>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -91,7 +91,7 @@ describe("useFilteredMemoStats", () => {
|
|||
tagCount: {},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useUserStats>);
|
||||
} as unknown as ReturnType<typeof useUserStats>);
|
||||
mockUseView.mockReturnValue({
|
||||
timeBasis: "update_time",
|
||||
orderByTimeAsc: false,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ beforeAll(() => {
|
|||
Element.prototype.releasePointerCapture = vi.fn();
|
||||
});
|
||||
|
||||
function makeController(opts: { active?: Partial<ActiveFormatState>; getSelectedText?: () => string } = {}) {
|
||||
function makeController(opts: { active?: Partial<ActiveFormatState> } = {}) {
|
||||
const run = vi.fn();
|
||||
const activeFormats: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS, ...opts.active };
|
||||
const controller: EditorController = {
|
||||
|
|
@ -31,10 +31,13 @@ function makeController(opts: { active?: Partial<ActiveFormatState>; getSelected
|
|||
setCursor: vi.fn(),
|
||||
scrollToCursor: () => {},
|
||||
selectAll: () => {},
|
||||
createUploadAnchor: vi.fn(),
|
||||
updateUploadAnchor: vi.fn(),
|
||||
resolveUploadAnchor: vi.fn(),
|
||||
cancelUploadAnchor: vi.fn(),
|
||||
formatting: {
|
||||
run,
|
||||
getActiveFormats: () => activeFormats,
|
||||
getSelectedText: opts.getSelectedText ?? (() => ""),
|
||||
subscribe: () => () => {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
255
web/tests/global-memo-editor.test.tsx
Normal file
255
web/tests/global-memo-editor.test.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MemoEditorProps } from "@/components/MemoEditor/types";
|
||||
import { GlobalMemoEditorProvider, useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
||||
|
||||
const DIALOG = { name: "editor.new-memo" };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
currentUser: { name: "users/test" } as { name: string } | undefined,
|
||||
isUserSettingsInitialized: true,
|
||||
editorProps: undefined as MemoEditorProps | undefined,
|
||||
loadMemoEditor: vi.fn(),
|
||||
setMobileOpen: vi.fn(),
|
||||
setQuickFindOpen: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/MemoEditor/loader", () => ({
|
||||
loadMemoEditor: mocks.loadMemoEditor,
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/AppSidebarContext", () => ({
|
||||
useAppSidebar: () => ({ setMobileOpen: mocks.setMobileOpen, setQuickFindOpen: mocks.setQuickFindOpen }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({ isUserSettingsInitialized: mocks.isUserSettingsInitialized }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
default: () => mocks.currentUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const MockMemoEditor = (props: MemoEditorProps) => {
|
||||
mocks.editorProps = props;
|
||||
return (
|
||||
<div data-testid="global-editor">
|
||||
<button type="button" onClick={() => props.onConfirm?.("memos/created")}>
|
||||
Save memo
|
||||
</button>
|
||||
<button type="button" onClick={props.onCancel}>
|
||||
Cancel editor
|
||||
</button>
|
||||
<button type="button" onClick={props.onFocusModeExit}>
|
||||
Exit focus mode
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onSavingChange?.(true)}>
|
||||
Start saving
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Trigger = () => {
|
||||
const { openEditor } = useGlobalMemoEditor();
|
||||
return (
|
||||
<button type="button" onClick={openEditor}>
|
||||
Open editor
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/** Stands in for the mobile drawer: its Compose button unmounts as the drawer closes. */
|
||||
const EphemeralTrigger = () => {
|
||||
const { openEditor } = useGlobalMemoEditor();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
return (
|
||||
<>
|
||||
{!dismissed && (
|
||||
<button
|
||||
type="button"
|
||||
data-new-memo-trigger
|
||||
onClick={() => {
|
||||
setDismissed(true);
|
||||
openEditor();
|
||||
}}
|
||||
>
|
||||
Open drawer editor
|
||||
</button>
|
||||
)}
|
||||
<button type="button" data-mobile-navigation-trigger>
|
||||
Open navigation
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProvider = (children = <Trigger />) => render(<GlobalMemoEditorProvider>{children}</GlobalMemoEditorProvider>);
|
||||
|
||||
/** Renders, focuses the trigger so focus-return has something to restore, and opens. */
|
||||
const openViaTrigger = async () => {
|
||||
renderProvider();
|
||||
const trigger = screen.getByRole("button", { name: "Open editor" });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
await screen.findByRole("dialog", DIALOG);
|
||||
return trigger;
|
||||
};
|
||||
|
||||
const expectClosed = () => waitFor(() => expect(screen.queryByRole("dialog", DIALOG)).not.toBeInTheDocument());
|
||||
|
||||
describe("GlobalMemoEditorProvider", () => {
|
||||
beforeEach(() => {
|
||||
mocks.currentUser = { name: "users/test" };
|
||||
mocks.isUserSettingsInitialized = true;
|
||||
mocks.editorProps = undefined;
|
||||
mocks.loadMemoEditor.mockResolvedValue({ default: MockMemoEditor });
|
||||
mocks.setMobileOpen.mockClear();
|
||||
mocks.setQuickFindOpen.mockClear();
|
||||
});
|
||||
|
||||
it("opens a modal focus-mode editor, closes the sidebar surfaces, and restores focus after Escape", async () => {
|
||||
const trigger = await openViaTrigger();
|
||||
const dialog = screen.getByRole("dialog", DIALOG);
|
||||
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(mocks.setMobileOpen).toHaveBeenCalledWith(false);
|
||||
expect(mocks.setQuickFindOpen).toHaveBeenCalledWith(false);
|
||||
expect(mocks.editorProps).toMatchObject({
|
||||
autoFocus: true,
|
||||
initialFocusMode: true,
|
||||
cacheKey: "global-memo-editor",
|
||||
});
|
||||
await waitFor(() => expect(dialog).toContainElement(document.activeElement as HTMLElement | null));
|
||||
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
|
||||
await expectClosed();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it.each(["Save memo", "Cancel editor", "Exit focus mode", "common.close"])("closes after %s", async (action) => {
|
||||
const trigger = await openViaTrigger();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: action }));
|
||||
|
||||
await expectClosed();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("prevents dialog and focus-mode dismissal while saving but lets save completion force close", async () => {
|
||||
await openViaTrigger();
|
||||
const dialog = screen.getByRole("dialog", DIALOG);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Start saving" }));
|
||||
fireEvent.keyDown(dialog, { key: "Escape" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Exit focus mode" }));
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.close" }));
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save memo" }));
|
||||
await expectClosed();
|
||||
});
|
||||
|
||||
it("makes the screen-reader close control visible when keyboard-focused", async () => {
|
||||
await openViaTrigger();
|
||||
const close = screen.getByRole("button", { name: "common.close" });
|
||||
|
||||
expect(close).toHaveClass("sr-only", "focus-visible:not-sr-only");
|
||||
close.focus();
|
||||
expect(close).toHaveFocus();
|
||||
});
|
||||
|
||||
it("returns focus to mobile navigation when the drawer's Compose trigger unmounts", async () => {
|
||||
renderProvider(<EphemeralTrigger />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open drawer editor" }));
|
||||
await screen.findByRole("dialog", DIALOG);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel editor" }));
|
||||
|
||||
await expectClosed();
|
||||
expect(screen.getByRole("button", { name: "Open navigation" })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("does not open or load the editor for a guest", () => {
|
||||
mocks.currentUser = undefined;
|
||||
renderProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
|
||||
|
||||
expect(mocks.loadMemoEditor).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invalidates a pending open request across sign-out and same-user sign-in", async () => {
|
||||
let resolveEditorLoad!: (module: { default: typeof MockMemoEditor }) => void;
|
||||
mocks.loadMemoEditor.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveEditorLoad = resolve;
|
||||
}),
|
||||
);
|
||||
const view = renderProvider();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
|
||||
expect(mocks.loadMemoEditor).toHaveBeenCalledOnce();
|
||||
|
||||
mocks.currentUser = undefined;
|
||||
view.rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Trigger />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
mocks.currentUser = { name: "users/test" };
|
||||
view.rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Trigger />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveEditorLoad({ default: MockMemoEditor });
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("dialog", DIALOG)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not resurrect an open composer after the same user signs out and back in", async () => {
|
||||
const view = renderProvider();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open editor" }));
|
||||
await screen.findByRole("dialog", DIALOG);
|
||||
|
||||
mocks.currentUser = undefined;
|
||||
view.rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Trigger />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
await expectClosed();
|
||||
|
||||
mocks.currentUser = { name: "users/test" };
|
||||
view.rerender(
|
||||
<GlobalMemoEditorProvider>
|
||||
<Trigger />
|
||||
</GlobalMemoEditorProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog", DIALOG)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not assign a global Compose shortcut", () => {
|
||||
renderProvider(<span />);
|
||||
|
||||
fireEvent.keyDown(window, { key: "m", shiftKey: true, metaKey: true });
|
||||
|
||||
expect(mocks.loadMemoEditor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pairAppleLivePhotoFiles } from "@/components/MemoEditor/hooks";
|
||||
import { splitInlineLocalFiles } from "@/components/MemoEditor/hooks/useInlineImageUpload";
|
||||
|
|
|
|||
|
|
@ -39,11 +39,7 @@ describe("managed attachment Markdown", () => {
|
|||
});
|
||||
|
||||
it("uses the first duplicate reference definition like Goldmark", () => {
|
||||
const content = [
|
||||
"![photo][asset]",
|
||||
"[asset]: /file/attachments/first-image",
|
||||
"[asset]: /file/attachments/second-image",
|
||||
].join("\n\n");
|
||||
const content = ["![photo][asset]", "[asset]: /file/attachments/first-image", "[asset]: /file/attachments/second-image"].join("\n\n");
|
||||
|
||||
expect(Array.from(extractManagedAttachmentUIDs(content))).toEqual(["first-image"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ describe("uncheckAllTasks", () => {
|
|||
it("unchecks every checked task while preserving source formatting", () => {
|
||||
const markdown = ["Intro", "- [x] first", "* [X] second", " + [ ] nested", "1. [x] ordered", "Outro"].join("\n");
|
||||
|
||||
expect(uncheckAllTasks(markdown)).toBe(["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"));
|
||||
expect(uncheckAllTasks(markdown)).toBe(
|
||||
["Intro", "- [ ] first", "* [ ] second", " + [ ] nested", "1. [ ] ordered", "Outro"].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the original string when no checkbox markers need changing", () => {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ describe("media loading", () => {
|
|||
|
||||
it("does not bind an audio source until playback is requested", async () => {
|
||||
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
const { container } = render(
|
||||
<AudioAttachmentItem filename="recording.mp3" sourceUrl="/recording.mp3" mimeType="audio/mpeg" />,
|
||||
);
|
||||
const { container } = render(<AudioAttachmentItem filename="recording.mp3" sourceUrl="/recording.mp3" mimeType="audio/mpeg" />);
|
||||
const audio = container.querySelector("audio");
|
||||
|
||||
expect(audio).not.toHaveAttribute("src");
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { estimateMemoCardHeight } from "@/components/PagedMemoList/memoCardHeight";
|
||||
import { type Attachment, AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import {
|
||||
type Memo,
|
||||
MemoRelation_MemoSchema,
|
||||
MemoRelation_Type,
|
||||
MemoRelationSchema,
|
||||
MemoSchema,
|
||||
} from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { MemoRelation_MemoSchema, MemoRelation_Type, MemoRelationSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const buildAttachment = (overrides: Partial<Attachment>) =>
|
||||
const buildAttachment = (overrides: MessageInitShape<typeof AttachmentSchema>) =>
|
||||
create(AttachmentSchema, {
|
||||
name: "attachments/test",
|
||||
filename: "test.bin",
|
||||
|
|
@ -25,7 +19,7 @@ const buildCommentRelation = (memoName: string, index: number) =>
|
|||
relatedMemo: create(MemoRelation_MemoSchema, { name: memoName }),
|
||||
});
|
||||
|
||||
const buildMemo = (overrides: Partial<Memo> = {}) =>
|
||||
const buildMemo = (overrides: MessageInitShape<typeof MemoSchema> = {}) =>
|
||||
create(MemoSchema, {
|
||||
name: "memos/main",
|
||||
content: "hello",
|
||||
|
|
|
|||
|
|
@ -36,10 +36,7 @@ describe("useInfiniteMemoComments", () => {
|
|||
const { result } = renderHook(() => useInfiniteMemoComments("memos/parent", { pageSize: 2 }), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.data?.map((memo) => memo.name)).toEqual(["memos/comment-1", "memos/comment-2"]));
|
||||
expect(listMemoComments).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "" }),
|
||||
);
|
||||
expect(listMemoComments).toHaveBeenNthCalledWith(1, expect.objectContaining({ name: "memos/parent", pageSize: 2, pageToken: "" }));
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -90,9 +90,7 @@ describe("memo content lazy renderers", () => {
|
|||
});
|
||||
|
||||
it("escapes plain code and highlights common languages", async () => {
|
||||
expect(await highlightCode('<script data-test="x">&</script>', "")).toBe(
|
||||
"<script data-test="x">&</script>",
|
||||
);
|
||||
expect(await highlightCode('<script data-test="x">&</script>', "")).toBe("<script data-test="x">&</script>");
|
||||
expect(await highlightCode("echo hello", "bash")).toContain("hljs-built_in");
|
||||
expect(await highlightCode("const value = 1;", "js")).toContain("hljs-keyword");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
||||
import { toBlob } from "html-to-image";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
|
@ -10,14 +10,14 @@ import {
|
|||
getMemoShareRenderWidth,
|
||||
} from "@/components/MemoActionMenu/memoShareImage";
|
||||
import { buildMemoShareImagePreviewModel } from "@/components/MemoActionMenu/memoShareImagePreviewModel";
|
||||
import { type Attachment, AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
vi.mock("html-to-image", () => ({
|
||||
toBlob: vi.fn(),
|
||||
}));
|
||||
|
||||
const buildMemo = (overrides: Partial<Memo> = {}) =>
|
||||
const buildMemo = (overrides: MessageInitShape<typeof MemoSchema> = {}) =>
|
||||
create(MemoSchema, {
|
||||
name: "memos/test",
|
||||
content: "hello",
|
||||
|
|
@ -26,7 +26,7 @@ const buildMemo = (overrides: Partial<Memo> = {}) =>
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const buildAttachment = (overrides: Partial<Attachment>) =>
|
||||
const buildAttachment = (overrides: MessageInitShape<typeof AttachmentSchema>) =>
|
||||
create(AttachmentSchema, {
|
||||
name: "attachments/test",
|
||||
filename: "test.bin",
|
||||
|
|
|
|||
|
|
@ -91,9 +91,7 @@ describe("memo views", () => {
|
|||
filters: [{ factor: "displayTime", value: "2026-08-02" }],
|
||||
includePinned: false,
|
||||
}),
|
||||
).toBe(
|
||||
`created_ts >= timestamp(${Math.floor(start.getTime() / 1000)}) && created_ts < timestamp(${Math.floor(end.getTime() / 1000)})`,
|
||||
);
|
||||
).toBe(`created_ts >= timestamp(${Math.floor(start.getTime() / 1000)}) && created_ts < timestamp(${Math.floor(end.getTime() / 1000)})`);
|
||||
});
|
||||
|
||||
it("ignores invalid display-time filter values", () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class IntersectionObserverMock implements IntersectionObserver {
|
|||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
readonly scrollMargin = "";
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
intersectionCallback = callback;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { TILE_SPRITES, pickTileSprite } from "@/components/Placeholder/tileSprites";
|
||||
import { DEFAULT_MESSAGES, type PlaceholderVariant } from "@/components/Placeholder/messages";
|
||||
import { pickTileSprite, TILE_SPRITES } from "@/components/Placeholder/tileSprites";
|
||||
|
||||
describe("TILE_SPRITES integrity", () => {
|
||||
it("registers 32px by 32px sprite strips with animation-specific frame counts", () => {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MentionResolutionProvider,
|
||||
useResolvedMentionUsernames,
|
||||
useResolvedUser,
|
||||
} from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { MentionResolutionProvider, useResolvedMentionUsernames, useResolvedUser } from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { useMemoViews, useUser, userKeys, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
import { useMemoViews, userKeys, useUser, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
|
|
|
|||
80
web/tests/root-layout-global-editor.test.tsx
Normal file
80
web/tests/root-layout-global-editor.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import RootLayout from "@/layouts/RootLayout";
|
||||
|
||||
vi.mock("@/components/AppSidebar", () => ({
|
||||
default: () => <aside data-testid="desktop-sidebar">Desktop sidebar</aside>,
|
||||
MobileAppHeader: () => <header data-testid="mobile-header">Mobile header</header>,
|
||||
MobileAppSidebar: () => <aside data-testid="mobile-sidebar">Mobile sidebar</aside>,
|
||||
QuickFindDialog: () => <div data-testid="quick-find">Quick Find</div>,
|
||||
SIDEBAR_WIDTH_VAR: "--app-sidebar-width",
|
||||
SidebarResizeHandle: () => <div data-testid="resize-handle" />,
|
||||
useSidebarWidth: () => ({ width: 256, minWidth: 192, maxWidth: 384, setWidth: vi.fn() }),
|
||||
}));
|
||||
|
||||
// GlobalMemoEditorContext is deliberately NOT mocked: the real provider calls
|
||||
// useAppSidebar(), which throws unless RootLayout nests it inside
|
||||
// AppSidebarProvider. That nesting is the only thing here that can break.
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({ isUserSettingsInitialized: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/InstanceContext", () => ({
|
||||
useInstance: () => ({ profile: { instanceUrl: "https://example.com", demo: true } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
default: () => ({ name: "users/test" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useMediaQuery", () => ({
|
||||
default: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const SHELL_TEST_IDS = ["desktop-sidebar", "mobile-sidebar", "mobile-header", "quick-find"];
|
||||
|
||||
const RouteState = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div>
|
||||
<output data-testid="route">{location.pathname}</output>
|
||||
<button type="button" onClick={() => navigate("/inbox")}>
|
||||
Open inbox
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("RootLayout global editor shell", () => {
|
||||
it("mounts the composer provider inside the sidebar provider and keeps the shell across routes", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/attachments"]}>
|
||||
<Routes>
|
||||
<Route element={<RootLayout />}>
|
||||
<Route path="attachments" element={<RouteState />} />
|
||||
<Route path="inbox" element={<RouteState />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
for (const testId of SHELL_TEST_IDS) {
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
}
|
||||
// The composer stays closed until something calls openEditor.
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open inbox" }));
|
||||
|
||||
expect(screen.getByTestId("route")).toHaveTextContent("/inbox");
|
||||
for (const testId of SHELL_TEST_IDS) {
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
import { isValidElement } from "react";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeConfig, ROUTES } from "@/router";
|
||||
import {
|
||||
RequireAuthRoute,
|
||||
RequireFullInitializationRoute,
|
||||
RequireGuestRoute,
|
||||
RequireInstanceInitializationRoute,
|
||||
} from "@/router/guards";
|
||||
import { ROUTES, routeConfig } from "@/router";
|
||||
import { RequireAuthRoute, RequireFullInitializationRoute, RequireGuestRoute, RequireInstanceInitializationRoute } from "@/router/guards";
|
||||
|
||||
// Walk the nested route config and find the first route with the given path,
|
||||
// starting from the provided roots. Returns undefined if nothing matches.
|
||||
|
|
@ -70,6 +65,10 @@ describe("router configuration", () => {
|
|||
expect(hasAncestorOfType(routeConfig, ROUTES.ABOUT, RequireInstanceInitializationRoute)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not register a dedicated new memo route", () => {
|
||||
expect(findByPath(routeConfig, "/new")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves memo feeds available for early queries", () => {
|
||||
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -50,9 +50,9 @@ describe("scroll restoration", () => {
|
|||
});
|
||||
|
||||
it("resets new routes and restores history entries", async () => {
|
||||
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation((xOrOptions, y) => {
|
||||
scrollY = typeof xOrOptions === "number" ? (y ?? 0) : (xOrOptions.top ?? 0);
|
||||
});
|
||||
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(((xOrOptions?: number | ScrollToOptions, y?: number) => {
|
||||
scrollY = typeof xOrOptions === "number" ? (y ?? 0) : (xOrOptions?.top ?? 0);
|
||||
}) as typeof window.scrollTo);
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { HashIcon } from "lucide-react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import SidebarRow, { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
|
|
@ -26,7 +27,7 @@ describe("sidebar row grammar", () => {
|
|||
});
|
||||
|
||||
it("gives a nav row the shared row box", () => {
|
||||
render(<SidebarRow icon={() => null} label="Tasks" />);
|
||||
render(<SidebarRow icon={HashIcon} label="Tasks" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Tasks" })).toHaveClass(...boxClasses);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findTagMetadata, mergeTagCounts } from "@/lib/tag";
|
||||
import {
|
||||
type UserSetting_TagMetadata,
|
||||
type UserSetting_TagsSetting,
|
||||
UserSetting_TagsSettingSchema,
|
||||
UserStatsSchema,
|
||||
} from "@/types/proto/api/v1/user_service_pb";
|
||||
import { UserSetting_TagMetadataSchema, UserSetting_TagsSettingSchema, UserStatsSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
describe("exact tag keys", () => {
|
||||
it("aggregates names that collide with Object prototype properties", () => {
|
||||
|
|
@ -51,13 +46,13 @@ describe("exact tag keys", () => {
|
|||
expect(Object.keys(created.tags).sort()).toEqual(["__proto__", "constructor", "normal"]);
|
||||
expect(Object.keys(decoded.tags).sort()).toEqual(["__proto__", "constructor", "normal"]);
|
||||
expect(decoded.tags.normal.blurContent).toBe(false);
|
||||
expect(decoded.tags.constructor.blurContent).toBe(true);
|
||||
expect(decoded.tags["constructor"].blurContent).toBe(true);
|
||||
expect(decoded.tags.__proto__.blurContent).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat inherited properties as exact metadata", () => {
|
||||
const metadata = { blurContent: true } as UserSetting_TagMetadata;
|
||||
const setting = { tags: { ".*": metadata } } as UserSetting_TagsSetting;
|
||||
const metadata = create(UserSetting_TagMetadataSchema, { blurContent: true });
|
||||
const setting = create(UserSetting_TagsSettingSchema, { tags: { ".*": metadata } });
|
||||
|
||||
expect(findTagMetadata("toString", setting)).toBe(metadata);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,11 +42,7 @@ describe("useMemoInit", () => {
|
|||
filename: "image.png",
|
||||
type: "image/png",
|
||||
});
|
||||
cacheService.saveNow(
|
||||
cacheService.key("users/steven", "restored-draft"),
|
||||
"",
|
||||
[attachment],
|
||||
);
|
||||
cacheService.saveNow(cacheService.key("users/steven", "restored-draft"), "", [attachment]);
|
||||
|
||||
render(
|
||||
<EditorProvider>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ describe("<VideoPoster>", () => {
|
|||
readonly root = null;
|
||||
readonly rootMargin = "200px 0px";
|
||||
readonly thresholds = [0];
|
||||
readonly scrollMargin = "";
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback;
|
||||
|
|
@ -84,13 +85,7 @@ describe("<VideoPoster>", () => {
|
|||
});
|
||||
|
||||
it("uses an available poster without loading the video fallback", () => {
|
||||
render(
|
||||
<VideoPoster
|
||||
sourceUrl="/file/attachments/video/video.mp4"
|
||||
posterUrl="/file/attachments/video/poster.webp"
|
||||
alt="clip.mp4"
|
||||
/>,
|
||||
);
|
||||
render(<VideoPoster sourceUrl="/file/attachments/video/video.mp4" posterUrl="/file/attachments/video/poster.webp" alt="clip.mp4" />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "clip.mp4" })).toHaveAttribute("src", "/file/attachments/video/poster.webp");
|
||||
expect(screen.queryByTestId("video-poster-fallback")).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@
|
|||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src"]
|
||||
"include": ["./src", "./tests"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue