feat(search): add expression mode to Quick Find (#6276)

This commit is contained in:
Johnny 2026-09-05 09:14:08 +08:00 committed by GitHub
parent 60b89736a2
commit bf1f742321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 743 additions and 179 deletions

View file

@ -35,7 +35,7 @@ import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/conte
import { useAuth } from "@/contexts/AuthContext";
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
import { useInstance } from "@/contexts/InstanceContext";
import { stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { getFilterSearch, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useSpaceContext } from "@/contexts/SpaceContext";
import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
import useCurrentUser from "@/hooks/useCurrentUser";
@ -376,9 +376,8 @@ const GlobalNavigation = () => {
const ActiveScopeIcon = activeScopeItem.icon;
const navigateToScope = (scope: PrimaryMemoScope) => {
const filterQuery = stringifyFilters(filters);
setMemoScope(scope);
navigate({ pathname: getMemoScopePath(scope), search: filterQuery ? `?filter=${filterQuery}` : "" });
navigate({ pathname: getMemoScopePath(scope), search: getFilterSearch(filters) });
setMobileOpen(false);
};

View file

@ -1,23 +1,51 @@
import { CornerDownLeftIcon, SearchIcon } from "lucide-react";
import { FormEvent, useEffect, useState } from "react";
import { type ChangeEvent, type FormEvent, type KeyboardEvent, useEffect, useId, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { tabsTriggerVariants } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { type MemoFilter, replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { getFilterSearch, isSearchFilter, type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useSpaceContext } from "@/contexts/SpaceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoCollectionRoute } from "@/lib/memo-views";
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getRouteActionPolicy, getSidebarRouteKind } from "./routes";
export const buildQuickFindFilters = (query: string, currentFilters: MemoFilter[], preserveCurrentScope: boolean): MemoFilter[] => {
const words = Array.from(new Set(query.trim().split(/\s+/).filter(Boolean)));
const contentFilters: MemoFilter[] = words.map((value) => ({ factor: "contentSearch", value }));
return preserveCurrentScope ? replaceFiltersByFactor(currentFilters, "contentSearch", contentFilters) : contentFilters;
export type QuickFindMode = "text" | "cel";
const buildSearchFilters = (query: string, mode: QuickFindMode): MemoFilter[] => {
const trimmed = query.trim();
if (mode === "cel") return trimmed ? [{ factor: "celSearch", value: trimmed }] : [];
return Array.from(new Set(trimmed.split(/\s+/).filter(Boolean))).map((value) => ({ factor: "contentSearch", value }));
};
export const buildQuickFindFilters = (
query: string,
currentFilters: MemoFilter[],
preserveCurrentScope: boolean,
mode: QuickFindMode,
): MemoFilter[] => {
const scopeFilters = preserveCurrentScope ? currentFilters.filter((filter) => !isSearchFilter(filter)) : [];
return [...scopeFilters, ...buildSearchFilters(query, mode)];
};
/** The inverse of buildQuickFindFilters: the query and mode that the active filters were submitted with. */
export const readQuickFindQuery = (filters: MemoFilter[]): { query: string; mode: QuickFindMode } => {
const celSearch = filters.find((filter) => filter.factor === "celSearch");
if (celSearch) return { query: celSearch.value, mode: "cel" };
return {
query: filters
.filter((filter) => filter.factor === "contentSearch")
.map((filter) => filter.value)
.join(" "),
mode: "text",
};
};
export interface QuickFindSubmission {
@ -26,17 +54,17 @@ export interface QuickFindSubmission {
switchToAll: boolean;
}
export const resolveQuickFindSubmission = (pathname: string, query: string, currentFilters: MemoFilter[]): QuickFindSubmission => {
export const resolveQuickFindSubmission = (
pathname: string,
query: string,
currentFilters: MemoFilter[],
mode: QuickFindMode,
): QuickFindSubmission => {
const routePolicy = getRouteActionPolicy(pathname);
const filters = buildQuickFindFilters(query, currentFilters, routePolicy.searchScope !== "all");
const filterQuery = stringifyFilters(filters);
const filters = buildQuickFindFilters(query, currentFilters, routePolicy.searchScope !== "all", mode);
return {
filters,
destination: routePolicy.searchDestination
? filterQuery
? `${routePolicy.searchDestination}?filter=${filterQuery}`
: routePolicy.searchDestination
: undefined,
destination: routePolicy.searchDestination ? `${routePolicy.searchDestination}${getFilterSearch(filters)}` : undefined,
switchToAll: routePolicy.searchScope === "all",
};
};
@ -59,6 +87,8 @@ const QuickFindDialog = () => {
const { clearSelectedSpace, duplicateSpaceTitles, selectedSpace, selectedSpaceName } = useSpaceContext();
const { quickFindOpen, setQuickFindOpen } = useAppSidebar();
const [query, setQuery] = useState("");
const [mode, setMode] = useState<QuickFindMode>("text");
const hintId = useId();
const viewApplies = isMemoCollectionRoute(location.pathname);
const selectedMemoView = viewApplies ? memoViews.find((item) => getMemoViewId(item.name) === memoView) : undefined;
const lensLabel =
@ -80,16 +110,13 @@ const QuickFindDialog = () => {
useEffect(() => {
if (!quickFindOpen) return;
setQuery(
filters
.filter((filter) => filter.factor === "contentSearch")
.map((filter) => filter.value)
.join(" "),
);
const active = readQuickFindQuery(filters);
setMode(active.mode);
setQuery(active.query);
}, [filters, quickFindOpen]);
const submitQuery = () => {
const submission = resolveQuickFindSubmission(location.pathname, query, filters);
const submission = resolveQuickFindSubmission(location.pathname, query, filters, mode);
if (submission.switchToAll) {
// This is an explicit cross-Space action, so switch the collection state
@ -112,6 +139,22 @@ const QuickFindDialog = () => {
submitQuery();
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// Shift+Enter inserts a newline in an expression; every other Enter submits.
if (event.key !== "Enter" || (mode === "cel" && event.shiftKey)) return;
// Enter that commits an IME composition must not also submit the search.
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
event.preventDefault();
submitQuery();
};
const fieldProps = {
autoFocus: true,
value: query,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setQuery(event.target.value),
onKeyDown: handleKeyDown,
};
return (
<Dialog open={quickFindOpen} onOpenChange={setQuickFindOpen}>
<DialogContent
@ -123,30 +166,73 @@ const QuickFindDialog = () => {
<DialogDescription className="sr-only">
{t("common.search")} {scopeLabel}
</DialogDescription>
<form onSubmit={handleSubmit} className="flex h-[52px] shrink-0 items-center gap-2.5 px-4">
<SearchIcon className="size-[17px] shrink-0 text-muted-foreground" strokeWidth={1.8} />
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
submitQuery();
}}
className="h-10 border-0 bg-transparent px-0 !text-[14px] shadow-none focus-visible:ring-0"
placeholder={`${t("common.search")} ${compactScopeLabel}`}
aria-label={`${t("common.search")} ${scopeLabel}`}
/>
<Button
type="submit"
variant="ghost"
size="icon-sm"
className="size-7 shrink-0 rounded-md text-muted-foreground"
aria-label={t("common.search")}
>
<CornerDownLeftIcon className="size-3.5" />
</Button>
<form onSubmit={handleSubmit}>
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-4 py-2">
<span className="min-w-0 truncate text-xs text-muted-foreground" title={scopeLabel}>
{compactScopeLabel}
</span>
<div role="tablist" aria-label={t("search.mode")} className="flex shrink-0 items-center gap-0.5 rounded-md bg-muted/60 p-0.5">
{(["text", "cel"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={mode === value}
onClick={() => setMode(value)}
className={cn(tabsTriggerVariants({ variant: "segmented", active: mode === value }), "h-6 px-2 py-0 text-xs")}
>
{value === "cel" ? t("search.expression-mode") : t("search.text")}
</button>
))}
</div>
</div>
{mode === "cel" ? (
<div className="space-y-3 p-4">
<Textarea
{...fieldProps}
rows={3}
className="field-sizing-fixed max-h-[40dvh] resize-y font-mono text-sm leading-6"
placeholder={'"work" in tags && !content.contains("GitHub")'}
aria-label={t("search.expression")}
aria-describedby={hintId}
/>
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
<p id={hintId}>{t("search.keyboard-hint")}</p>
<a
href="https://usememos.com/docs/usage/shortcuts#filter-expression-syntax"
target="_blank"
rel="noopener noreferrer"
className="inline-block underline underline-offset-2 hover:text-foreground"
>
{t("search.syntax-help")}
</a>
</div>
<Button type="submit" size="sm" className="shrink-0">
{t("common.search")}
</Button>
</div>
</div>
) : (
<div className="flex h-[52px] shrink-0 items-center gap-2.5 px-4">
<SearchIcon className="size-[17px] shrink-0 text-muted-foreground" strokeWidth={1.8} />
<Input
{...fieldProps}
className="h-10 border-0 bg-transparent px-0 !text-[14px] shadow-none focus-visible:ring-0"
placeholder={`${t("common.search")} ${compactScopeLabel}`}
aria-label={`${t("common.search")} ${scopeLabel}`}
/>
<Button
type="submit"
variant="ghost"
size="icon-sm"
className="size-7 shrink-0 rounded-md text-muted-foreground"
aria-label={t("common.search")}
>
<CornerDownLeftIcon className="size-3.5" />
</Button>
</div>
)}
</form>
</DialogContent>
</Dialog>

View file

@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { type FilterFactor, getMemoFilterKey, type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
@ -29,6 +30,8 @@ const DATE_FILTER_FORMAT = "MMM D, YYYY";
interface FilterConfig {
icon: LucideIcon;
getLabel: (value: string, t: ReturnType<typeof useTranslate>) => string;
/** The chip shows a typed expression: monospace, wider, and clicking it reopens Quick Find to edit. */
editableQuery?: boolean;
}
const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
@ -44,6 +47,11 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
icon: SearchIcon,
getLabel: (value) => value,
},
celSearch: {
icon: SearchIcon,
getLabel: (value) => value,
editableQuery: true,
},
displayTime: {
icon: CalendarIcon,
getLabel: (value) => {
@ -77,26 +85,53 @@ interface FilterChipProps {
icon?: LucideIcon;
label: string;
onRemove: () => void;
/** When set, the label is a button that edits the filter; the label is also shown in full as a tooltip. */
onEdit?: { label: string; onClick: () => void };
}
/** One chip for anything narrowing the list, so a view, a tag and a day are announced the same way. */
const FilterChip = ({ icon: Icon, label, onRemove }: FilterChipProps) => (
<div className="group inline-flex items-center gap-1.5 h-7 px-2.5 bg-accent/50 hover:bg-accent border border-border/50 rounded-full text-sm transition-all duration-200 hover:shadow-sm">
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="text-foreground/80 font-medium max-w-32 truncate">{label}</span>
<span className="ml-0.5 -mr-1">
<Button variant="ghost" size="icon-sm" onClick={onRemove} aria-label="Remove filter">
<XIcon className="w-3 h-3" />
</Button>
</span>
</div>
);
const FilterChip = ({ icon: Icon, label, onRemove, onEdit }: FilterChipProps) => {
const body = (
<>
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className={cn("text-foreground/80 truncate", onEdit ? "font-mono text-xs max-w-64" : "font-medium max-w-32")}>{label}</span>
</>
);
return (
<div
className={cn(
"group inline-flex min-w-0 max-w-full items-center gap-1.5 h-7 px-2.5",
"bg-accent/50 hover:bg-accent border border-border/50 rounded-full text-sm transition-all duration-200 hover:shadow-sm",
)}
>
{onEdit ? (
<button
type="button"
onClick={onEdit.onClick}
title={label}
aria-label={onEdit.label}
className="flex min-w-0 items-center gap-1.5"
>
{body}
</button>
) : (
body
)}
<span className="ml-0.5 -mr-1">
<Button variant="ghost" size="icon-sm" onClick={onRemove} aria-label="Remove filter">
<XIcon className="w-3 h-3" />
</Button>
</span>
</div>
);
};
const MemoFilters = ({ className }: { className?: string }) => {
const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser();
const { filters, memoView, removeFilter, setMemoView } = useMemoFilterContext();
const { setQuickFindOpen } = useAppSidebar();
// A remembered view only narrows the collection routes; elsewhere it is dormant and must not be echoed.
const viewApplies = memoView !== undefined && isMemoCollectionRoute(location.pathname);
const { data: memoViews = [] } = useMemoViews(viewApplies ? currentUser?.name : undefined);
@ -133,6 +168,11 @@ const MemoFilters = ({ className }: { className?: string }) => {
icon={FILTER_CONFIGS[filter.factor]?.icon}
label={getFilterDisplayText(filter)}
onRemove={() => handleRemoveFilter(filter)}
onEdit={
FILTER_CONFIGS[filter.factor]?.editableQuery
? { label: t("search.edit-query"), onClick: () => setQuickFindOpen(true) }
: undefined
}
/>
))}
</div>

View file

@ -0,0 +1,70 @@
import { Code } from "@connectrpc/connect";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { getErrorMessage, hasConnectCode } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
interface Props {
error: unknown;
onRetry: () => Promise<unknown>;
/** Present when the failing list carries a user query that can be edited or cleared. */
onEditQuery?: () => void;
onClearQuery?: () => void;
}
const MESSAGE_KEYS = {
invalid: "search.invalid-expression",
denied: "search.access-error",
load: "search.load-error",
} as const;
const classify = (error: unknown): keyof typeof MESSAGE_KEYS => {
if (hasConnectCode(error, Code.InvalidArgument)) return "invalid";
if (hasConnectCode(error, Code.PermissionDenied, Code.Unauthenticated)) return "denied";
return "load";
};
const MemoListError = ({ error, onRetry, onEditQuery, onClearQuery }: Props) => {
const t = useTranslate();
const [retrying, setRetrying] = useState(false);
const kind = classify(error);
const handleRetry = async () => {
setRetrying(true);
try {
await onRetry();
} finally {
setRetrying(false);
}
};
return (
<div role="alert" className="w-full min-w-0 space-y-3 rounded-lg border border-border px-4 py-4">
<p className="text-sm font-medium">{t(MESSAGE_KEYS[kind])}</p>
{kind === "invalid" && (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">
{getErrorMessage(error)}
</pre>
)}
<div className="flex flex-wrap items-center gap-2">
{kind === "load" && (
<Button variant="outline" size="sm" disabled={retrying} onClick={handleRetry}>
{t("search.retry")}
</Button>
)}
{kind !== "load" && onEditQuery && (
<Button variant="outline" size="sm" onClick={onEditQuery}>
{t("search.edit-query")}
</Button>
)}
{kind !== "load" && onClearQuery && (
<Button variant="ghost" size="sm" onClick={onClearQuery}>
{t("search.clear-query")}
</Button>
)}
</div>
</div>
);
};
export default MemoListError;

View file

@ -2,8 +2,9 @@ import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { Button } from "@/components/ui/button";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { isSearchFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
@ -18,6 +19,7 @@ import { useTranslate } from "@/utils/i18n";
import ColumnGrid, { columnCountForWidth, GRID_GAP } from "../ColumnGrid";
import MemoFilters from "../MemoFilters";
import Placeholder from "../Placeholder";
import MemoListError from "./MemoListError";
import { estimateMemoCardHeight } from "./memoCardHeight";
// Memo identity for React keys and grid planning. The pages use it for their renderer keys too,
@ -120,7 +122,8 @@ function useAutoFetchWhenNotScrollable({
const PagedMemoList = (props: Props) => {
const t = useTranslate();
const { isUserSettingsInitialized } = useAuth();
const { filters, memoView } = useMemoFilterContext();
const { filters, memoView, removeFilter } = useMemoFilterContext();
const { setQuickFindOpen } = useAppSidebar();
const { maxColumns, compactMode } = useView();
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
// column renders in normal document flow; anything wider becomes the packed grid.
@ -148,19 +151,24 @@ const PagedMemoList = (props: Props) => {
// pages don't each repeat the policy.
const effectiveCompact = compactMode || useGrid;
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteMemos(
{
state: props.state || State.NORMAL,
orderBy: props.orderBy || "create_time desc",
filter: combineCELFilters(props.contextFilter, props.filter),
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
},
{ enabled: props.enabled ?? true },
);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error, isFetchNextPageError, refetch } =
useInfiniteMemos(
{
state: props.state || State.NORMAL,
orderBy: props.orderBy || "create_time desc",
filter: combineCELFilters(props.contextFilter, props.filter),
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
},
{ enabled: props.enabled ?? true },
);
// Tag settings decide whether sensitive memo content must be blurred. Keep that
// privacy boundary, but do not wait for unrelated memo views or instance settings.
const isDisplayPending = isLoading || !isUserSettingsInitialized;
// A failed first page leaves nothing to show; a failed later page keeps what already
// loaded. Either way, automatic pagination pauses until the user retries.
const isInitialError = isError && !isFetchNextPageError;
const canPaginate = !isDisplayPending && !isError;
const showLoader = useDelayedFlag(isDisplayPending, LOADING_INDICATOR_DELAY_MS);
// Flatten pages into a single array of memos
@ -176,7 +184,7 @@ const PagedMemoList = (props: Props) => {
// Auto-fetch hook: fetches more content when page isn't scrollable
useAutoFetchWhenNotScrollable({
enabled: !isDisplayPending,
enabled: canPaginate,
hasNextPage,
isFetchingNextPage,
memoCount: sortedMemoList.length,
@ -185,7 +193,7 @@ const PagedMemoList = (props: Props) => {
// Infinite scroll: fetch more when user scrolls near bottom
useEffect(() => {
if (isDisplayPending || !hasNextPage) return;
if (!canPaginate || !hasNextPage) return;
const handleScroll = () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
@ -196,14 +204,14 @@ const PagedMemoList = (props: Props) => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [isDisplayPending, hasNextPage, isFetchingNextPage, fetchNextPage]);
}, [canPaginate, hasNextPage, isFetchingNextPage, fetchNextPage]);
const leadingContent = props.renderLeading?.({ useGrid });
const headerContent = props.renderHeader?.({ useGrid });
// A freshly created memo is hoisted to the front; pin it to the top of column one so it
// appears right under the composer instead of dropping into a random (shortest) column.
const displayMemoList = isDisplayPending ? [] : sortedMemoList;
const displayMemoList = isDisplayPending || isInitialError ? [] : sortedMemoList;
const firstMemo = displayMemoList[0];
const priorityKey = newMemoName && firstMemo?.name === newMemoName ? getMemoKey(firstMemo) : undefined;
@ -223,10 +231,24 @@ const PagedMemoList = (props: Props) => {
);
const emptyPlaceholder =
!isDisplayPending && !isFetchingNextPage && !hasNextPage && displayMemoList.length === 0 ? (
!isDisplayPending && !isError && !isFetchingNextPage && !hasNextPage && displayMemoList.length === 0 ? (
<Placeholder variant="empty" message={props.emptyMessage ?? t("message.no-data")} className="w-full" />
) : null;
const initialLoader = isDisplayPending && showLoader ? <Loader /> : null;
// Only a query the user typed can be edited or cleared from the error; facet and scope
// filters are fixed by the route.
const hasSearch = filters.some(isSearchFilter);
const errorNotice =
!isDisplayPending && isError ? (
<MemoListError
error={error}
onRetry={isFetchNextPageError ? fetchNextPage : refetch}
onEditQuery={hasSearch ? () => setQuickFindOpen(true) : undefined}
onClearQuery={hasSearch ? () => removeFilter(isSearchFilter) : undefined}
/>
) : null;
const initialError = isInitialError ? errorNotice : null;
const pageError = isFetchNextPageError ? errorNotice : null;
// Column one is the action column: the composer and any active filters head it, and the
// empty state follows them. The newest memo also lands directly beneath them (priorityKey
@ -234,11 +256,12 @@ const PagedMemoList = (props: Props) => {
// grid's x-spacing exactly.
const hasFilters = filters.length > 0 || memoView !== undefined;
const gridLeading =
leadingContent || hasFilters || initialLoader || emptyPlaceholder ? (
leadingContent || hasFilters || initialLoader || emptyPlaceholder || initialError ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>
{leadingContent}
<MemoFilters />
{initialLoader}
{initialError}
{emptyPlaceholder}
</div>
) : undefined;
@ -246,6 +269,7 @@ const PagedMemoList = (props: Props) => {
// Pagination controls are identical across both layouts.
const footer = (
<>
{pageError}
{isFetchingNextPage && <Loader />}
{!isFetchingNextPage && (hasNextPage || displayMemoList.length > 0) && (
<div className="w-full opacity-70 flex flex-row justify-center items-center my-4">
@ -280,6 +304,7 @@ const PagedMemoList = (props: Props) => {
{leadingContent}
<MemoFilters className="mb-2" />
{initialLoader}
{initialError}
{displayMemoList.map((memo) => props.renderer(memo, { compact: effectiveCompact }))}
{emptyPlaceholder}
{!isDisplayPending && footer}

View file

@ -6,6 +6,7 @@ export type FilterFactor =
| "tagSearch"
| "visibility"
| "contentSearch"
| "celSearch"
| "displayTime"
| "pinned"
| "property.hasLink"
@ -41,6 +42,15 @@ export const stringifyFilters = (filters: MemoFilter[]): string => {
return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(",");
};
/** The `?filter=` search string that carries these filters in a URL, or "" when there are none. */
export const getFilterSearch = (filters: MemoFilter[]): string => {
const filterQuery = stringifyFilters(filters);
return filterQuery ? `?${new URLSearchParams({ filter: filterQuery })}` : "";
};
/** Search filters carry the user's query itself (plain words or a CEL expression), as opposed to facets. */
export const isSearchFilter = (filter: MemoFilter): boolean => filter.factor === "contentSearch" || filter.factor === "celSearch";
export const replaceFiltersByFactor = (filters: MemoFilter[], factor: FilterFactor, replacements: MemoFilter[]): MemoFilter[] => [
...filters.filter((filter) => filter.factor !== factor),
...replacements,

View file

@ -2,6 +2,7 @@ import { useMemo } from "react";
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
import { combineCELFilters } from "@/lib/cel-filter";
import { BUILTIN_TASKS_VIEW_FILTER, BUILTIN_TASKS_VIEW_ID, getMemoViewId } from "@/lib/memo-views";
import { buildMemoCreatorFilter, getVisibilityName } from "@/lib/resource-names";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
@ -68,6 +69,8 @@ export const buildMemoFilter = ({
for (const filter of filters) {
if (filter.factor === "contentSearch") {
conditions.push(`content.contains(${escapeFilterValue(filter.value)})`);
} else if (filter.factor === "celSearch") {
conditions.push(filter.value);
} else if (filter.factor === "tagSearch") {
conditions.push(`tag in [${escapeFilterValue(filter.value)}]`);
} else if (filter.factor === "pinned") {
@ -95,7 +98,7 @@ export const buildMemoFilter = ({
conditions.push(`visibility in [${visibilityValues}]`);
}
return conditions.length > 0 ? conditions.join(" && ") : undefined;
return combineCELFilters(...conditions);
};
export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => {

View file

@ -1,4 +1,8 @@
import { ConnectError } from "@connectrpc/connect";
import { type Code, ConnectError } from "@connectrpc/connect";
export function hasConnectCode(error: unknown, ...codes: Code[]): error is ConnectError {
return error instanceof ConnectError && codes.includes(error.code);
}
export function getErrorMessage(error: unknown, fallback = "Unknown error"): string {
if (error instanceof ConnectError) {

View file

@ -1,13 +1,16 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { Code } from "@connectrpc/connect";
import { QueryClient } from "@tanstack/react-query";
import { hasConnectCode } from "@/lib/error";
// Don't retry requests that failed due to authentication errors.
// The auth interceptor in connect.ts already handles token refresh and request retry.
// If the interceptor still throws Unauthenticated, the session is truly gone and the
// user will be redirected to /auth. A React Query retry would only fire a second
// failed refresh attempt and a second redirect call while navigation is already in progress.
const shouldRetry = (failureCount: number, error: unknown): boolean => {
if (error instanceof ConnectError && error.code === Code.Unauthenticated) return false;
// Don't retry requests whose outcome is deterministic: a rejected request (bad filter
// expression) or a denied one fails identically on every attempt.
// Unauthenticated is also terminal here: the auth interceptor in connect.ts already handles
// token refresh and request retry. If the interceptor still throws Unauthenticated, the
// session is truly gone and the user will be redirected to /auth. A React Query retry would
// only fire a second failed refresh attempt and a second redirect call while navigation is
// already in progress.
export const shouldRetry = (failureCount: number, error: unknown): boolean => {
if (hasConnectCode(error, Code.InvalidArgument, Code.PermissionDenied, Code.Unauthenticated)) return false;
return failureCount < 1;
};

View file

@ -519,6 +519,20 @@
"back-to-top": "Back to Top",
"go-to-home": "Go to Home"
},
"search": {
"access-error": "You don’t have access to these memos.",
"clear-query": "Clear query",
"edit-query": "Edit query",
"expression": "Search expression",
"expression-mode": "Expression",
"invalid-expression": "Search expression couldn’t be applied",
"keyboard-hint": "Enter to search · Shift+Enter for a new line",
"load-error": "Couldn’t load memos",
"mode": "Search mode",
"retry": "Retry",
"syntax-help": "Search syntax",
"text": "Text"
},
"setting": {
"access-token": {
"access-token-copied-to-clipboard": "Access token copied to clipboard",

View file

@ -1,9 +1,10 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, screen, render as testingLibraryRender, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
import { type MemoFilter, parseFilterQuery } from "@/contexts/MemoFilterContext";
const authState = vi.hoisted(() => ({
currentUser: { name: "users/test" } as { name: string } | undefined,
@ -30,6 +31,7 @@ const spaceState = vi.hoisted(() => ({
selectSpace: vi.fn(),
}));
const filteredStatsHook = vi.hoisted(() => vi.fn());
const filterState = vi.hoisted(() => ({ filters: [] as MemoFilter[] }));
const tagsSectionHook = vi.hoisted(() => vi.fn());
vi.mock("@/components/MemosLogo", () => ({
@ -92,10 +94,10 @@ vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ isInitialized: true }),
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
stringifyFilters: () => "",
vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => ({
...(await importOriginal<typeof import("@/contexts/MemoFilterContext")>()),
useMemoFilterContext: () => ({
filters: [],
filters: filterState.filters,
memoView: undefined,
setMemoView: vi.fn(),
}),
@ -204,9 +206,32 @@ describe("App sidebar logo", () => {
spaceState.selectMemos.mockClear();
spaceState.selectSpace.mockClear();
filteredStatsHook.mockClear();
filterState.filters = [];
tagsSectionHook.mockClear();
});
it("preserves a CEL expression when switching sidebar scopes", async () => {
const expression = 'tags.exists(t, t.contains("50%, café & C++"))\n || pinned';
filterState.filters = [{ factor: "celSearch", value: expression }];
const LocationProbe = () => {
const location = useLocation();
return (
<output data-testid="scope-location">
{JSON.stringify({ path: location.pathname, filters: parseFilterQuery(new URLSearchParams(location.search).get("filter")) })}
</output>
);
};
render(
<MemoryRouter initialEntries={["/"]}>
<AppSidebar />
<LocationProbe />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "common.home" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "common.explore" }));
expect(screen.getByTestId("scope-location")).toHaveTextContent(JSON.stringify({ path: "/explore", filters: filterState.filters }));
});
it("shows the context switcher and opens the global memo editor", () => {
render(
<MemoryRouter initialEntries={["/attachments"]}>

View file

@ -1,15 +1,28 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, MemoryRouter, RouterProvider, useLocation } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { MemoFilterProvider, parseFilterQuery, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import {
type MemoFilter,
MemoFilterProvider,
parseFilterQuery,
stringifyFilters,
useMemoFilterContext,
} from "@/contexts/MemoFilterContext";
import { BUILTIN_TASKS_VIEW_ID } from "@/lib/memo-views";
const expression = 'tags.exists(t, t.contains("50%, café & C++"))\n || space == null';
const Harness = () => {
const { filters, setFilters, setMemoView, memoView } = useMemoFilterContext();
const location = useLocation();
return (
<div>
<output data-testid="filters">{JSON.stringify(filters)}</output>
<output data-testid="memoView">{memoView}</output>
<output data-testid="url">{location.search}</output>
<button type="button" onClick={() => setFilters([{ factor: "celSearch", value: expression }])}>
Search CEL
</button>
<button type="button" onClick={() => setFilters([{ factor: "contentSearch", value: "plan" }])}>
Search plan
</button>
@ -21,6 +34,36 @@ const Harness = () => {
};
describe("MemoFilterProvider", () => {
it("restores CEL after URL synchronization, navigation history, and remount", async () => {
const routes = [
{
path: "*",
element: (
<MemoFilterProvider>
<Harness />
</MemoFilterProvider>
),
},
];
const router = createMemoryRouter(routes, { initialEntries: ["/inbox", "/explore"], initialIndex: 1 });
const rendered = render(<RouterProvider router={router} />);
fireEvent.click(screen.getByRole("button", { name: "Search CEL" }));
const expected: MemoFilter[] = [{ factor: "celSearch", value: expression }];
await waitFor(() => expect(parseFilterQuery(new URLSearchParams(router.state.location.search).get("filter"))).toEqual(expected));
const savedUrl = `/explore${router.state.location.search}`;
await act(async () => {
await router.navigate(-1);
});
await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent("[]"));
await act(async () => {
await router.navigate(1);
});
await waitFor(() => expect(screen.getByTestId("filters")).toHaveTextContent(JSON.stringify(expected)));
rendered.unmount();
render(<RouterProvider router={createMemoryRouter(routes, { initialEntries: [savedUrl] })} />);
expect(screen.getByTestId("filters")).toHaveTextContent(JSON.stringify(expected));
expect(new URLSearchParams(router.state.location.search).get("filter")).toBe(stringifyFilters(expected));
});
it("keeps encoded values containing colons intact", () => {
expect(parseFilterQuery("contentSearch:https://example.com:8080/path")).toEqual([
{ factor: "contentSearch", value: "https://example.com:8080/path" },

View file

@ -0,0 +1,70 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildQuickFindFilters } from "@/components/AppSidebar/QuickFindDialog";
import { buildMemoFilter } from "@/hooks/useMemoFilters";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { shouldRetry } from "@/lib/query-client";
const listMemos = vi.hoisted(() => vi.fn());
vi.mock("@/connect", () => ({ memoServiceClient: { listMemos }, userServiceClient: {}, memoViewServiceClient: {} }));
const createWrapper = () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry, retryDelay: 0 } } });
return ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
describe("memo search requests", () => {
beforeEach(() => {
listMemos.mockReset();
});
it.each([
"content.contains('urgent')",
'tags.exists(t, t.startsWith("work/"))',
'content.matches("(?i)urgent")',
"space == null",
])("sends server-supported CEL without interpreting it: %s", async (expression) => {
listMemos.mockResolvedValue({ memos: [], nextPageToken: "" });
const filter = buildMemoFilter({ filters: buildQuickFindFilters(expression, [], true, "cel"), includePinned: false });
const { result } = renderHook(() => useInfiniteMemos({ filter }), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(listMemos).toHaveBeenCalledWith(expect.objectContaining({ filter: `(${expression})` }));
expect(result.current.data?.pages[0].memos).toEqual([]);
});
it.each([
Code.InvalidArgument,
Code.PermissionDenied,
Code.Unauthenticated,
])("does not retry terminal search errors (%i)", async (code) => {
listMemos.mockRejectedValue(new ConnectError("rejected", code));
const { result } = renderHook(() => useInfiniteMemos({ filter: "invalid" }), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(listMemos).toHaveBeenCalledOnce();
});
it("recovers when the invalid query is edited", async () => {
listMemos
.mockRejectedValueOnce(new ConnectError("invalid expression", Code.InvalidArgument))
.mockResolvedValue({ memos: [], nextPageToken: "" });
const { result, rerender } = renderHook(({ filter }) => useInfiniteMemos({ filter }), {
wrapper: createWrapper(),
initialProps: { filter: "invalid" },
});
await waitFor(() => expect(result.current.isError).toBe(true));
rerender({ filter: "pinned" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.error).toBeNull();
expect(listMemos).toHaveBeenCalledTimes(2);
});
it("retains the existing single retry for transient failures", async () => {
listMemos.mockRejectedValueOnce(new ConnectError("offline", Code.Unavailable)).mockResolvedValue({ memos: [], nextPageToken: "" });
const { result } = renderHook(() => useInfiniteMemos({ filter: "pinned" }), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(listMemos).toHaveBeenCalledTimes(2);
});
});

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildMemoFilter } from "@/hooks/useMemoFilters";
import { combineCELFilters } from "@/lib/cel-filter";
import {
BUILTIN_TASKS_VIEW_FILTER,
BUILTIN_TASKS_VIEW_ID,
@ -46,6 +47,21 @@ describe("memo scopes", () => {
});
describe("memo views", () => {
it("keeps OR expressions inside the author, Space, view, and facet constraints", () => {
const query = 'content.contains("plan") || pinned';
const filter = buildMemoFilter({
creatorName: "users/steven",
selectedMemoViewFilter: "has_link || has_code",
filters: [
{ factor: "celSearch", value: query },
{ factor: "tagSearch", value: "work" },
],
includePinned: true,
});
expect(combineCELFilters('space == "spaces/product"', filter)).toBe(
'(space == "spaces/product") && ((creator == "users/steven") && (has_link || has_code) && (content.contains("plan") || pinned) && (tag in ["work"]))',
);
});
it("uses a collision-safe built-in Tasks view", () => {
expect(BUILTIN_TASKS_VIEW_ID).not.toBe("tasks");
expect(BUILTIN_TASKS_VIEW_FILTER).toBe("has_task_list && has_incomplete_tasks");
@ -65,7 +81,7 @@ describe("memo views", () => {
visibilities: [Visibility.PUBLIC],
}),
).toBe(
'creator == "users/steven" && has_task_list && has_incomplete_tasks && content.contains("plan") && tag in ["work"] && visibility in ["PUBLIC"]',
'(creator == "users/steven") && (has_task_list && has_incomplete_tasks) && (content.contains("plan")) && (tag in ["work"]) && (visibility in ["PUBLIC"])',
);
});
@ -76,7 +92,7 @@ describe("memo views", () => {
includePinned: false,
visibilities: [Visibility.PUBLIC, Visibility.PROTECTED, Visibility.SPACE],
}),
).toBe('visibility in ["PUBLIC", "PROTECTED", "SPACE"]');
).toBe('(visibility in ["PUBLIC", "PROTECTED", "SPACE"])');
});
it("maps property filter factors to their CEL flags", () => {
@ -90,7 +106,7 @@ describe("memo views", () => {
],
includePinned: false,
}),
).toBe("has_link && has_task_list && has_code && has_location");
).toBe("(has_link) && (has_task_list) && (has_code) && (has_location)");
});
it("uses a custom memo view filter when Tasks is not selected", () => {
@ -101,7 +117,7 @@ describe("memo views", () => {
includePinned: false,
selectedMemoViewFilter: 'tag in ["work"]',
}),
).toBe('tag in ["work"]');
).toBe('(tag in ["work"])');
});
it("builds display-time filters from valid local calendar-day boundaries", () => {
@ -114,7 +130,9 @@ 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", () => {

View file

@ -1,5 +1,6 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PagedMemoList from "@/components/PagedMemoList";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@ -10,7 +11,12 @@ const feed = vi.hoisted(() => ({
hasNextPage: false,
isLoading: false,
fetchNextPage: vi.fn(async () => undefined),
refetch: vi.fn(),
error: null as ConnectError | null,
isFetchNextPageError: false,
}));
const sidebar = vi.hoisted(() => ({ setQuickFindOpen: vi.fn() }));
const filterContext = vi.hoisted(() => ({ removeFilter: vi.fn() }));
const readiness = vi.hoisted(() => ({ userSettings: true }));
const memoQuery = vi.hoisted(() => ({ request: undefined as Record<string, unknown> | undefined }));
@ -23,14 +29,22 @@ vi.mock("@/hooks/useMemoQueries", () => ({
hasNextPage: feed.hasNextPage,
isFetchingNextPage: false,
isLoading: feed.isLoading,
error: feed.error,
isError: !!feed.error,
isFetchNextPageError: feed.isFetchNextPageError,
refetch: feed.refetch,
isFetching: false,
};
},
}));
vi.mock("@/contexts/MemoFilterContext", () => ({
useMemoFilterContext: () => ({ filters: [] }),
vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => ({
...(await importOriginal<typeof import("@/contexts/MemoFilterContext")>()),
useMemoFilterContext: () => ({ filters: [{ factor: "celSearch", value: "pinned" }], removeFilter: filterContext.removeFilter }),
}));
vi.mock("@/contexts/AppSidebarContext", () => ({ useAppSidebar: () => sidebar }));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({ isUserSettingsInitialized: readiness.userSettings }),
}));
@ -71,10 +85,77 @@ describe("<PagedMemoList>", () => {
feed.hasNextPage = false;
feed.isLoading = false;
feed.fetchNextPage.mockClear();
feed.refetch.mockClear();
feed.error = null;
feed.isFetchNextPageError = false;
sidebar.setQuickFindOpen.mockClear();
filterContext.removeFilter.mockClear();
readiness.userSettings = true;
memoQuery.request = undefined;
});
it.each([1, 0] as const)("shows recoverable validation errors in layout %i instead of an empty state", (columns) => {
view.maxColumns = columns;
const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200);
try {
feed.error = new ConnectError("unknown identifier <script>bad</script>", Code.InvalidArgument);
renderList();
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent("search.invalid-expression");
expect(alert).toHaveTextContent("unknown identifier <script>bad</script>");
expect(alert.querySelector("script")).toBeNull();
expect(screen.queryByText("No data found.")).not.toBeInTheDocument();
expect(screen.getByTestId("memo-filters")).toBeInTheDocument();
if (columns === 0) expect(alert.closest(".absolute")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "search.edit-query" }));
expect(sidebar.setQuickFindOpen).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole("button", { name: "search.clear-query" }));
const remove = filterContext.removeFilter.mock.calls[0][0];
expect(remove({ factor: "celSearch" })).toBe(true);
expect(remove({ factor: "contentSearch" })).toBe(true);
expect(remove({ factor: "tagSearch" })).toBe(false);
expect(screen.queryByRole("button", { name: "search.retry" })).not.toBeInTheDocument();
} finally {
widthSpy.mockRestore();
}
});
it.each([Code.PermissionDenied, Code.Unauthenticated])("shows access errors separately (%i)", (code) => {
feed.error = new ConnectError("denied", code);
renderList();
expect(screen.getByRole("alert")).toHaveTextContent("search.access-error");
expect(screen.queryByText("search.invalid-expression")).not.toBeInTheDocument();
});
it("retries initial network errors", () => {
feed.error = new ConnectError("offline", Code.Unavailable);
renderList();
expect(screen.getByRole("alert")).toHaveTextContent("search.load-error");
fireEvent.click(screen.getByRole("button", { name: "search.retry" }));
expect(feed.refetch).toHaveBeenCalledOnce();
expect(feed.fetchNextPage).not.toHaveBeenCalled();
});
it("keeps prior pages but stops automatic pagination after a failed page until Retry", async () => {
vi.useFakeTimers();
try {
feed.memos = [memo];
feed.hasNextPage = true;
feed.error = new ConnectError("offline", Code.Unavailable);
feed.isFetchNextPageError = true;
renderList((memo) => <div>{memo.content}</div>);
expect(screen.getByText("hello")).toBeInTheDocument();
await act(async () => vi.advanceTimersByTimeAsync(1000));
fireEvent.scroll(window);
expect(feed.fetchNextPage).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "search.retry" }));
expect(feed.fetchNextPage).toHaveBeenCalledOnce();
expect(feed.refetch).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("keeps fetched memo content hidden until privacy settings settle", () => {
feed.memos = [memo];
readiness.userSettings = false;

View file

@ -1,16 +1,18 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, RouterProvider, useLocation } from "react-router-dom";
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";
const state = vi.hoisted(() => ({
currentUser: { name: "users/alice" } as { name: string } | undefined,
spaces: [{ name: "spaces/product", title: "Product", description: "" }],
filters: [] as Array<{ factor: "contentSearch"; value: string }>,
filters: [] as Array<{ factor: "contentSearch" | "celSearch" | "tagSearch"; value: string }>,
setFilters: vi.fn(),
setMemoView: vi.fn(),
removeFilter: vi.fn(),
}));
vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => {
@ -22,6 +24,7 @@ vi.mock("@/contexts/MemoFilterContext", async (importOriginal) => {
memoView: undefined,
setFilters: state.setFilters,
setMemoView: state.setMemoView,
removeFilter: state.removeFilter,
}),
};
});
@ -55,6 +58,7 @@ const Harness = () => {
Open Quick Find
</button>
<QuickFindDialog />
<MemoFilters />
</>
);
};
@ -69,10 +73,7 @@ describe("Quick Find navigation", () => {
state.setMemoView.mockClear();
});
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");
const renderSearch = (initialEntry = "/explore") => {
const router = createMemoryRouter(
[
{
@ -86,17 +87,95 @@ describe("Quick Find navigation", () => {
),
},
],
{ initialEntries: ["/inbox"] },
{ initialEntries: [initialEntry] },
);
render(<RouterProvider router={router} />);
return router;
};
const openQuickFind = () => fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
it("preserves the draft when changing modes and submits CEL with Enter", async () => {
renderSearch();
openQuickFind();
fireEvent.change(await screen.findByRole("textbox"), { target: { value: "pinned || has_link" } });
fireEvent.click(screen.getByRole("tab", { name: "search.expression-mode" }));
const expression = screen.getByRole("textbox");
expect(expression.tagName).toBe("TEXTAREA");
expect(expression).toHaveValue("pinned || has_link");
expect(expression).toHaveFocus();
fireEvent.keyDown(expression, { key: "Enter", shiftKey: true });
expect(state.setFilters).not.toHaveBeenCalled();
fireEvent.keyDown(expression, { key: "Enter" });
expect(state.setFilters).toHaveBeenCalledWith([{ factor: "celSearch", value: "pinned || has_link" }]);
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("restores the expression from its chip and discards cancelled changes", async () => {
state.filters = [
{ factor: "celSearch", value: "pinned\n || has_code" },
{ factor: "tagSearch", value: "work" },
];
renderSearch();
openQuickFind();
const expression = await screen.findByRole("textbox");
expect(expression).toHaveValue("pinned\n || has_code");
expect(screen.getByRole("tab", { name: "search.expression-mode" })).toHaveAttribute("aria-selected", "true");
fireEvent.change(expression, { target: { value: "has_link" } });
fireEvent.keyDown(expression, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(state.setFilters).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "search.edit-query" }));
expect(await screen.findByRole("textbox")).toHaveValue("pinned\n || has_code");
fireEvent.click(screen.getByRole("tab", { name: "search.text" }));
expect(screen.getByRole("textbox")).toHaveValue("pinned || has_code");
fireEvent.click(screen.getByRole("tab", { name: "search.expression-mode" }));
expect(screen.getByRole("textbox")).toHaveValue("pinned\n || has_code");
});
it.each(["text", "cel"])("ignores Enter during IME composition in %s mode", async (mode) => {
renderSearch();
openQuickFind();
if (mode === "cel") fireEvent.click(screen.getByRole("tab", { name: "search.expression-mode" }));
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "pinned" } });
const composingEnter = createEvent.keyDown(input, { key: "Enter", isComposing: true });
const legacyComposingEnter = createEvent.keyDown(input, { key: "Enter", keyCode: 229 });
fireEvent(input, composingEnter);
fireEvent(input, legacyComposingEnter);
expect(composingEnter.defaultPrevented).toBe(false);
expect(legacyComposingEnter.defaultPrevented).toBe(false);
expect(state.setFilters).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it("removes only CEL using the chip remove button", async () => {
state.filters = [
{ factor: "celSearch", value: "pinned" },
{ factor: "tagSearch", value: "work" },
];
renderSearch();
openQuickFind();
fireEvent.keyDown(await screen.findByRole("textbox"), { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
const [removeCel] = screen.getAllByRole("button", { name: "Remove filter" });
fireEvent.click(removeCel);
const predicate = state.removeFilter.mock.calls[0][0];
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");
const router = renderSearch("/inbox");
expect(screen.getByTestId("scope")).toHaveTextContent("spaces/product");
fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
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:roadmap"));
await waitFor(() => expect(screen.getByTestId("path")).toHaveTextContent("/?filter=contentSearch%3Aroadmap"));
expect(state.setFilters).toHaveBeenCalledWith([{ factor: "contentSearch", value: "roadmap" }]);
expect(screen.getByTestId("scope")).toHaveTextContent("all");
expect(sessionStorage.getItem(storageKey)).toBeNull();
@ -111,24 +190,9 @@ describe("Quick Find navigation", () => {
it("keeps a unique selected Space title compact in remembered-collection search", async () => {
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), "spaces/product");
const router = createMemoryRouter(
[
{
path: "*",
element: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
renderSearch("/");
fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
openQuickFind();
const input = await screen.findByRole("textbox");
expect(input).toHaveAttribute("placeholder", "common.search Product · common.memos");
expect(input).toHaveAttribute("aria-label", "common.search Product · common.memos");
@ -142,24 +206,9 @@ describe("Quick Find navigation", () => {
];
sessionStorage.setItem(getSelectedSpaceStorageKey("users/alice"), `spaces/${uuid}`);
const router = createMemoryRouter(
[
{
path: "*",
element: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
renderSearch("/");
fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
openQuickFind();
const input = await screen.findByRole("textbox");
expect(input).toHaveAttribute("placeholder", "common.search Product (123e4567…) · common.memos");
expect(input).toHaveAttribute("aria-label", `common.search Product (${uuid}) · common.memos`);
@ -168,29 +217,14 @@ describe("Quick Find navigation", () => {
it("preserves an anonymous global page in history", async () => {
state.currentUser = undefined;
const router = createMemoryRouter(
[
{
path: "*",
element: (
<SpaceProvider>
<AppSidebarProvider>
<Harness />
</AppSidebarProvider>
</SpaceProvider>
),
},
],
{ initialEntries: ["/about"] },
);
render(<RouterProvider router={router} />);
const router = renderSearch("/about");
fireEvent.click(screen.getByRole("button", { name: "Open Quick Find" }));
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:roadmap"));
await waitFor(() => expect(screen.getByTestId("path")).toHaveTextContent("/?filter=contentSearch%3Aroadmap"));
await act(async () => {
await router.navigate(-1);

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildQuickFindFilters, resolveQuickFindSubmission } from "@/components/AppSidebar/QuickFindDialog";
import { type MemoFilter, replaceFiltersByFactor } from "@/contexts/MemoFilterContext";
import { type MemoFilter, parseFilterQuery, replaceFiltersByFactor } from "@/contexts/MemoFilterContext";
describe("Quick Find", () => {
const scopedFilters: MemoFilter[] = [
@ -9,8 +9,44 @@ describe("Quick Find", () => {
{ factor: "contentSearch", value: "old" },
];
it.each([
"content.contains('urgent')",
'tags.exists(t, t.startsWith("work/"))',
'content.matches("(?i)urgent")',
"space == null",
'content.contains("50%, café & C++")\n || pinned',
])("retains CEL verbatim through a navigation URL: %s", (expression) => {
const submission = resolveQuickFindSubmission("/attachments", expression, scopedFilters, "cel");
const decoded = parseFilterQuery(new URL(submission.destination!, "https://memos.test").searchParams.get("filter"));
expect(decoded).toEqual([
...scopedFilters.filter((filter) => filter.factor !== "contentSearch"),
{ factor: "celSearch", value: expression },
]);
expect(submission.switchToAll).toBe(false);
});
it("replaces both kinds of search without dropping facets", () => {
const filters: MemoFilter[] = [...scopedFilters, { factor: "celSearch", value: "pinned" }];
expect(buildQuickFindFilters("has_link", filters, true, "cel")).toEqual([
...scopedFilters.slice(0, 2),
{ factor: "celSearch", value: "has_link" },
]);
expect(buildQuickFindFilters("new new", filters, true, "text")).toEqual([
...scopedFilters.slice(0, 2),
{ factor: "contentSearch", value: "new" },
]);
expect(buildQuickFindFilters(" \n ", filters, true, "cel")).toEqual(scopedFilters.slice(0, 2));
expect(buildQuickFindFilters(" ", filters, true, "text")).toEqual(scopedFilters.slice(0, 2));
});
it("does not recognize expressions in Text mode", () => {
expect(buildQuickFindFilters('content.contains("urgent")', [], false, "text")).toEqual([
{ factor: "contentSearch", value: 'content.contains("urgent")' },
]);
});
it("replaces search terms while preserving date and tag facets in a collection", () => {
expect(buildQuickFindFilters("project plan project", scopedFilters, true)).toEqual([
expect(buildQuickFindFilters("project plan project", scopedFilters, true, "text")).toEqual([
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
{ factor: "contentSearch", value: "project" },
@ -19,11 +55,11 @@ describe("Quick Find", () => {
});
it("starts a clean All search outside collection routes", () => {
expect(buildQuickFindFilters("project", scopedFilters, false)).toEqual([{ factor: "contentSearch", value: "project" }]);
expect(buildQuickFindFilters("project", scopedFilters, false, "text")).toEqual([{ factor: "contentSearch", value: "project" }]);
});
it.each(["/", "/explore"])("keeps scoped filters and stays on %s", (pathname) => {
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
@ -35,7 +71,7 @@ describe("Quick Find", () => {
});
it("searches Archived as a user collection without clearing the remembered Space", () => {
expect(resolveQuickFindSubmission("/archived", "project", scopedFilters)).toEqual({
expect(resolveQuickFindSubmission("/archived", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
@ -47,37 +83,37 @@ describe("Quick Find", () => {
});
it("keeps the remembered collection filters when searching from Attachments", () => {
expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters)).toEqual({
expect(resolveQuickFindSubmission("/attachments", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
{ factor: "contentSearch", value: "project" },
],
destination: "/?filter=tagSearch:work,displayTime:2026-08-03,contentSearch:project",
destination: "/?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject",
switchToAll: false,
});
});
it("keeps the remembered collection on a normalized Attachments route", () => {
expect(resolveQuickFindSubmission("/Attachments/", "project", scopedFilters)).toEqual({
expect(resolveQuickFindSubmission("/Attachments/", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
{ factor: "contentSearch", value: "project" },
],
destination: "/?filter=tagSearch:work,displayTime:2026-08-03,contentSearch:project",
destination: "/?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject",
switchToAll: false,
});
});
it("keeps Profile search on the Profile and returns its map tab to the memo list", () => {
expect(resolveQuickFindSubmission("/u/steven", "project", scopedFilters)).toEqual({
expect(resolveQuickFindSubmission("/u/steven", "project", scopedFilters, "text")).toEqual({
filters: [
{ factor: "tagSearch", value: "work" },
{ factor: "displayTime", value: "2026-08-03" },
{ factor: "contentSearch", value: "project" },
],
destination: "/u/steven?filter=tagSearch:work,displayTime:2026-08-03,contentSearch:project",
destination: "/u/steven?filter=tagSearch%3Awork%2CdisplayTime%3A2026-08-03%2CcontentSearch%3Aproject",
switchToAll: false,
});
});
@ -91,9 +127,9 @@ describe("Quick Find", () => {
"/memos/shares/token",
"/404",
])("starts a clean All search from %s", (pathname) => {
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters)).toEqual({
expect(resolveQuickFindSubmission(pathname, "project", scopedFilters, "text")).toEqual({
filters: [{ factor: "contentSearch", value: "project" }],
destination: "/?filter=contentSearch:project",
destination: "/?filter=contentSearch%3Aproject",
switchToAll: true,
});
});

View file

@ -8,6 +8,7 @@ import SidebarRow, { sidebarRowStateClasses } from "@/components/AppSidebar/Side
import { SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES } from "@/components/AppSidebar/SidebarSection";
import TagsSection from "@/components/AppSidebar/TagsSection";
import MemoFilters from "@/components/MemoFilters";
import { AppSidebarProvider } from "@/contexts/AppSidebarContext";
import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { BUILTIN_TASKS_VIEW_ID } from "@/lib/memo-views";
@ -74,10 +75,12 @@ const renderChips = (path: string, viewId?: string) =>
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MemoFilterProvider>
{viewId && <SelectView id={viewId} />}
<MemoFilters />
</MemoFilterProvider>
<AppSidebarProvider>
<MemoFilterProvider>
{viewId && <SelectView id={viewId} />}
<MemoFilters />
</MemoFilterProvider>
</AppSidebarProvider>
</MemoryRouter>
</QueryClientProvider>,
);