feat(search): add expression mode to Quick Find (#6276)
This commit is contained in:
parent
60b89736a2
commit
bf1f742321
18 changed files with 743 additions and 179 deletions
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
70
web/src/components/PagedMemoList/MemoListError.tsx
Normal file
70
web/src/components/PagedMemoList/MemoListError.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]}>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
70
web/tests/memo-search-queries.test.tsx
Normal file
70
web/tests/memo-search-queries.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue