feat(ui): declare shared search operators and flush queries on blur (#44665)

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-05 15:00:34 -07:00 • committed by GitHub
parent ee92183319
commit 7dc5b73f93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 358 additions and 78 deletions

View file

@ -1,7 +1,7 @@
import { Bot, CalendarClock, CircleDashed, SquareChevronRight } from "lucide-react";
import { type ClientIndex, filterItems } from "@/components/shared/search/evaluate";
import type { FieldSpec, QueryLanguage } from "@/components/shared/search/language";
import { ALL_OPERATORS, type FieldSpec, type QueryLanguage } from "@/components/shared/search/language";
import { scopeLabel } from "../model/format";
import type { Lens } from "../model/types";
@ -14,7 +14,10 @@ const INVESTIGATION_FIELDS = {
export type InvestigationField = keyof typeof INVESTIGATION_FIELDS;
export const INVESTIGATION_QUERY: QueryLanguage<InvestigationField> = { fields: INVESTIGATION_FIELDS };
export const INVESTIGATION_QUERY: QueryLanguage<InvestigationField> = {
fields: INVESTIGATION_FIELDS,
ops: ALL_OPERATORS,
};
export const INVESTIGATION_INDEX: ClientIndex<Lens, InvestigationField> = {
read: {

View file

@ -4,7 +4,7 @@ import type { TraceSummary } from "../../types";
import { previewText, traceAgentNames } from "../../utils";
import { type ClientIndex, filterItems } from "@/components/shared/search/evaluate";
import type { FieldSpec, QueryLanguage } from "@/components/shared/search/language";
import { ALL_OPERATORS, type FieldSpec, type QueryLanguage } from "@/components/shared/search/language";
const RUN_FIELDS = {
name: { group: "Run attributes", icon: SquareChevronRight, suggestValues: true },
@ -17,7 +17,7 @@ const RUN_FIELDS = {
export type RunField = keyof typeof RUN_FIELDS;
export const RUN_QUERY: QueryLanguage<RunField> = { fields: RUN_FIELDS };
export const RUN_QUERY: QueryLanguage<RunField> = { fields: RUN_FIELDS, ops: ALL_OPERATORS };
/** Reads the run fields off a loaded page; free text searches trace id, input and name. */
export const RUN_INDEX: ClientIndex<TraceSummary, RunField> = {

View file

@ -2,7 +2,7 @@ import { getProxyBaseUrl } from "@/components/networking";
import type { TimeWindow } from "@/components/shared/timeline/Timeline";
import type { TraceQueryBody } from "../../types";
import { valueMatcher } from "@/components/shared/search/language";
import { isNegatedOp, valueMatcher } from "@/components/shared/search/language";
import type { SearchFilter, SearchQuery } from "@/components/shared/search/searchQuery";
import type { RunField } from "./runQuery";
@ -48,7 +48,7 @@ const textPredicate = (term: string): string => {
function filterPredicate(filter: SearchFilter<RunField>): string {
const predicate = FIELD_PREDICATES[filter.field](filter.value);
return filter.op === "neq" || filter.op === "nglob" ? `NOT (${predicate})` : predicate;
return isNegatedOp(filter.op) ? `NOT (${predicate})` : predicate;
}
const timeBound = (range: TimeWindow | undefined): string =>

View file

@ -1,9 +1,18 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useEffect, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes";
import {
EXACT_NOTE_QUERY,
NEGATION_NOTE_QUERY,
NOTE_INDEX,
type NoteField,
NOTE_QUERY,
notes,
WILDCARD_NOTE_QUERY,
} from "./__fixtures__/notes";
import type { QueryLanguage } from "./language";
import { SearchBox } from "./SearchBox";
import type { SearchQuery } from "./searchQuery";
import { itemValues, type ValueSource } from "./valueSource";
@ -35,13 +44,15 @@ function NoteSearch({
value,
onChange,
values = itemValues(NOTE_INDEX, notes),
language = NOTE_QUERY,
}: {
value: string;
onChange: (value: string) => void;
values?: ValueSource<NoteField>;
language?: QueryLanguage<NoteField>;
}) {
return (
<SearchBox.Root language={NOTE_QUERY} values={values} value={value} onValueChange={onChange} label="Search notes">
<SearchBox.Root language={language} values={values} value={value} onValueChange={onChange} label="Search notes">
<SearchBox.Input placeholder="Search notes" />
<SearchBox.Suggestions />
</SearchBox.Root>
@ -84,6 +95,26 @@ function LaggingHarness() {
const box = () => screen.getByRole("combobox", { name: "Search notes" });
const undelivered = () => screen.getByRole("status", { name: "undelivered" });
const listbox = () => screen.getByRole("listbox", { name: "Search suggestions" });
const operatorLabels = () =>
within(listbox())
.queryAllByText(/^(equals|not equals|wildcard match|does not contain)$/)
.map((label) => label.textContent);
/**
* Timers only move when the test says so, so a slow machine cannot fire the debounce mid-typing.
* Testing Library flushes its own zero-delay timer through the `jest` global, which Vitest lacks.
*/
const pausedClockUser = () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.stubGlobal("jest", { advanceTimersByTime: vi.advanceTimersByTime });
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
};
const pauseTyping = () => act(() => vi.advanceTimersByTime(150));
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("SearchBox", () => {
it("builds a filter from the keyboard: field, then value", async () => {
@ -116,6 +147,33 @@ describe("SearchBox", () => {
expect(box()).toHaveAttribute("aria-expanded", "false");
});
it.each([
["negation and wildcards", NOTE_QUERY, ["equals", "not equals", "wildcard match", "does not contain"]],
["negation only", NEGATION_NOTE_QUERY, ["equals", "not equals"]],
["wildcards only", WILDCARD_NOTE_QUERY, ["equals", "wildcard match"]],
])("explains exactly the operators a language with %s honors", async (_, language, expected) => {
const user = userEvent.setup();
render(<NoteSearch value="" onChange={vi.fn()} language={language} />);
await user.click(box());
await user.keyboard("tag:");
expect(within(listbox()).getByText("Comparison operators")).toBeVisible();
expect(operatorLabels()).toEqual(expected);
});
it("offers values without operator help or negated keys for an equality-only language", async () => {
const user = userEvent.setup();
render(<NoteSearch value="" onChange={vi.fn()} language={EXACT_NOTE_QUERY} />);
await user.click(box());
await user.keyboard("tag:");
expect(within(listbox()).getByRole("option", { name: "cron" })).toBeVisible();
expect(within(listbox()).queryByText("Comparison operators")).not.toBeInTheDocument();
expect(operatorLabels()).toEqual([]);
await user.keyboard("{Escape}{ArrowDown}");
expect(listbox()).toBeVisible();
await user.keyboard(" -ta");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("wraps arrow navigation and picks with Tab", async () => {
const user = userEvent.setup();
render(<Harness />);
@ -157,35 +215,77 @@ describe("SearchBox", () => {
});
it("emits the query once typing pauses, not per keystroke", async () => {
const user = userEvent.setup();
const user = pausedClockUser();
const onChange = vi.fn();
render(<NoteSearch value="" onChange={onChange} />);
await user.click(box());
await user.keyboard("ref");
act(() => vi.advanceTimersByTime(149));
await user.keyboard("und");
act(() => vi.advanceTimersByTime(149));
expect(onChange).not.toHaveBeenCalled();
pauseTyping();
expect(onChange).toHaveBeenCalledExactlyOnceWith("refund");
});
it("keeps typing intact while the value it emitted arrives late", async () => {
const user = pausedClockUser();
render(<LaggingHarness />);
const deliver = () => user.click(screen.getByRole("button", { name: "Deliver next" }));
await user.click(box());
await user.keyboard("ab");
pauseTyping();
expect(undelivered()).toHaveTextContent(/^ab$/, { normalizeWhitespace: false });
await user.keyboard("c");
await deliver();
await user.click(box());
await user.keyboard("d");
pauseTyping();
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
expect(undelivered()).toHaveTextContent(/^abc\|abcd$/, { normalizeWhitespace: false });
await deliver();
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
await deliver();
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
expect(undelivered()).toBeEmptyDOMElement();
});
it("hands over what was typed as soon as focus leaves, without waiting for the pause", async () => {
const user = pausedClockUser();
render(<LaggingHarness />);
await user.click(box());
await user.keyboard("tag:cron");
expect(undelivered()).toBeEmptyDOMElement();
await user.click(document.body);
expect(undelivered()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false });
pauseTyping();
expect(undelivered()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false });
});
it("delivers nothing more on blur once the query was already delivered", async () => {
const user = pausedClockUser();
const onChange = vi.fn();
render(<NoteSearch value="" onChange={onChange} />);
await user.click(box());
await user.keyboard("refund");
await waitFor(() => expect(onChange).toHaveBeenCalledWith("refund"));
pauseTyping();
expect(onChange).toHaveBeenCalledExactlyOnceWith("refund");
await user.click(document.body);
await user.click(box());
await user.click(document.body);
pauseTyping();
expect(onChange).toHaveBeenCalledOnce();
});
it("keeps typing intact while the value it emitted arrives late", async () => {
const user = userEvent.setup();
render(<LaggingHarness />);
it("lets a query set from outside replace text that blur just handed over", async () => {
const user = pausedClockUser();
render(<Harness />);
await user.click(box());
await user.keyboard("ab");
await waitFor(() =>
expect(undelivered()).toHaveTextContent(/^ab$/, {
normalizeWhitespace: false,
}),
);
await user.keyboard("c");
await user.click(screen.getByRole("button", { name: "Deliver next" }));
await user.click(box());
await user.keyboard("d");
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
await waitFor(() =>
expect(undelivered()).toHaveTextContent(/^abcd$/, {
normalizeWhitespace: false,
}),
);
await user.keyboard("refund");
await user.click(screen.getByRole("button", { name: "Load saved query" }));
expect(box()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false });
pauseTyping();
expect(box()).toHaveTextContent(/^tag:cron$/, { normalizeWhitespace: false });
});
it("shows a query set from outside, such as the URL", async () => {

View file

@ -2,7 +2,7 @@
import "prosemirror-view/style/prosemirror.css";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import { Check, Copy, CornerDownLeft, type LucideIcon, Search } from "lucide-react";
import { Schema } from "prosemirror-model";
@ -13,7 +13,7 @@ import { type ComponentProps, createContext, type ReactNode, useContext, useId,
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { parseQuery, type QueryClause, type QueryLanguage } from "./language";
import { type FilterOp, languageOps, parseQuery, type QueryClause, type QueryLanguage } from "./language";
import { type SearchQuery, toSearchQuery } from "./searchQuery";
import { completingField, completingPrefix, suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
import { NO_VALUES, type ValueSource } from "./valueSource";
@ -23,11 +23,11 @@ const schema = new Schema({ nodes: { doc: { content: "text*" }, text: {} } });
const EMIT_WAIT_MS = 150;
const KEEP_MENU_CLOSED = "searchBoxKeepMenuClosed";
const OPERATORS = [
{ label: "equals", example: "foo:bar" },
{ label: "not equals", example: "-foo:bar" },
{ label: "wildcard match", example: "foo:*bar*" },
{ label: "does not contain", example: "-foo:*bar*" },
const OPERATORS: readonly { readonly op: FilterOp; readonly label: string; readonly example: string }[] = [
{ op: "eq", label: "equals", example: "foo:bar" },
{ op: "neq", label: "not equals", example: "-foo:bar" },
{ op: "glob", label: "wildcard match", example: "foo:*bar*" },
{ op: "nglob", label: "does not contain", example: "-foo:*bar*" },
];
/** Colours each `key:` so the filters stand out from free text. */
@ -75,6 +75,8 @@ interface SearchBoxState {
readonly menu: SuggestionMenu<string> | null;
readonly activeId: string | undefined;
readonly icon: (field: string) => LucideIcon;
/** The ops the language can express, so the help only lists forms the surface honors. */
readonly ops: readonly FilterOp[];
}
const SearchBoxContext = createContext<SearchBoxState | null>(null);
@ -146,11 +148,12 @@ function Root<F extends string>({
menu: shownMenu,
activeId: activeItem?.id,
icon: (field) => language.fields[field as F].icon,
ops: languageOps(language),
}),
[listId, text, clauses, shownMenu, activeItem, language],
);
const emit = useDebouncedCallback(
const emitter = useDebouncer(
(next: string) => {
setUnechoed((current) => [...current, next]);
onValueChange(next);
@ -163,7 +166,7 @@ function Root<F extends string>({
if (tr.selectionSet || tr.docChanged) setActive(0);
if (!tr.docChanged) return;
setMenuOpen(!tr.getMeta(KEEP_MENU_CLOSED));
emit(tr.doc.textContent);
emitter.maybeExecute(tr.doc.textContent);
};
const handleKeyDown = (view: EditorView, event: KeyboardEvent): boolean => {
@ -200,6 +203,7 @@ function Root<F extends string>({
},
blur: () => {
setFocused(false);
emitter.flush();
return false;
},
}}
@ -262,7 +266,7 @@ export type SearchBoxSuggestionsProps = ComponentProps<"div">;
* `children` render in the footer beside the key hints, e.g. a `CopyCommand`.
*/
function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProps) {
const { listId, menu, activeId, icon } = useSearchBox();
const { listId, menu, activeId, icon, ops } = useSearchBox();
const pick = useEditorEventCallback((view, item: Suggestion<string>) => applySuggestion(view, item));
if (!menu) return null;
return (
@ -302,7 +306,7 @@ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProp
Loading values…
</div>
)}
{menu.showOperators && <OperatorHints />}
{menu.showOperators && <OperatorHints ops={ops} />}
</div>
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
@ -364,11 +368,11 @@ function CopyCommand<F extends string>({ title, command }: SearchBoxCopyCommandP
);
}
function OperatorHints() {
function OperatorHints({ ops }: { ops: readonly FilterOp[] }) {
return (
<div className="pb-1">
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">Comparison operators</div>
{OPERATORS.map((op) => (
{OPERATORS.filter((op) => ops.includes(op.op)).map((op) => (
<div key={op.label} className="flex items-center justify-between px-2 py-1 font-mono text-xs">
<span className="text-warning">{op.label}</span>
<code className="rounded bg-muted px-1.5 text-muted-foreground">{op.example}</code>

View file

@ -1,7 +1,7 @@
import { Braces, Hash, Tag, Type } from "lucide-react";
import type { ClientIndex } from "../evaluate";
import type { FieldSpec, QueryLanguage } from "../language";
import { ALL_OPERATORS, EQUALITY_ONLY, type FieldSpec, type QueryLanguage } from "../language";
export interface Note {
readonly id: string;
@ -19,7 +19,17 @@ const NOTE_FIELDS = {
export type NoteField = keyof typeof NOTE_FIELDS;
export const NOTE_QUERY: QueryLanguage<NoteField> = { fields: NOTE_FIELDS };
export const NOTE_QUERY: QueryLanguage<NoteField> = { fields: NOTE_FIELDS, ops: ALL_OPERATORS };
/** The same vocabulary over a backend that only filters by equality. */
export const EXACT_NOTE_QUERY: QueryLanguage<NoteField> = { fields: NOTE_FIELDS, ops: EQUALITY_ONLY };
export const NEGATION_NOTE_QUERY: QueryLanguage<NoteField> = {
fields: NOTE_FIELDS,
ops: { negation: true, wildcard: false },
};
export const WILDCARD_NOTE_QUERY: QueryLanguage<NoteField> = {
fields: NOTE_FIELDS,
ops: { negation: false, wildcard: true },
};
export const NOTE_INDEX: ClientIndex<Note, NoteField> = {
read: {

View file

@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest";
import { note, NOTE_INDEX, NOTE_QUERY, notes } from "./__fixtures__/notes";
import {
EXACT_NOTE_QUERY,
NEGATION_NOTE_QUERY,
note,
NOTE_INDEX,
NOTE_QUERY,
notes,
WILDCARD_NOTE_QUERY,
} from "./__fixtures__/notes";
import { evaluate, fieldValues, filterItems } from "./evaluate";
const ids = (text: string) => filterItems(NOTE_QUERY, NOTE_INDEX, notes, text).map((n) => n.id);
@ -20,6 +28,28 @@ describe("evaluate", () => {
expect(by("nglob", "*search*")).toEqual(["aaa111", "ccc333"]);
});
it("treats a star as a literal under eq and neq, so an equality-only query never globs", () => {
const starred = [note({ id: "star", title: "a*b" }), note({ id: "x", title: "axb" })];
const by = (op: "eq" | "neq") =>
evaluate(NOTE_INDEX, starred, { text: [], filters: [{ field: "title", op, value: "a*b" }] }).map((n) => n.id);
expect(by("eq")).toEqual(["star"]);
expect(by("neq")).toEqual(["x"]);
expect(filterItems(EXACT_NOTE_QUERY, NOTE_INDEX, starred, "title:a*b").map((n) => n.id)).toEqual(["star"]);
});
it("follows the language's capabilities from the typed text to the matched items", () => {
const tagged = [...notes, note({ id: "star", tags: ["research*"] })];
const hits = (language: typeof NOTE_QUERY, text: string) =>
filterItems(language, NOTE_INDEX, tagged, text).map((n) => n.id);
expect(hits(NOTE_QUERY, "-tag:research*")).toEqual(["aaa111", "ccc333"]);
expect(hits(EXACT_NOTE_QUERY, "tag:research*")).toEqual(["star"]);
expect(hits(EXACT_NOTE_QUERY, "-tag:researcher")).toEqual([]);
expect(hits(NEGATION_NOTE_QUERY, "-tag:researcher")).toEqual(["aaa111", "ccc333", "star"]);
expect(hits(NEGATION_NOTE_QUERY, "-tag:research*")).toEqual(["aaa111", "bbb222", "ccc333"]);
expect(hits(WILDCARD_NOTE_QUERY, "tag:research*")).toEqual(["bbb222", "star"]);
expect(hits(WILDCARD_NOTE_QUERY, "-tag:researcher")).toEqual([]);
});
it("requires every term and every filter", () => {
expect(
evaluate(NOTE_INDEX, notes, { text: ["refund"], filters: [{ field: "tag", op: "eq", value: "billing" }] }),

View file

@ -1,4 +1,4 @@
import { parseQuery, type QueryLanguage, valueMatcher } from "./language";
import { exactMatcher, isGlobOp, isNegatedOp, parseQuery, type QueryLanguage, valueMatcher } from "./language";
import { type SearchFilter, type SearchQuery, toSearchQuery } from "./searchQuery";
/** How to read a language's fields off items already in memory. */
@ -9,8 +9,9 @@ export interface ClientIndex<T, F extends string> {
}
function matchesFilter<T, F extends string>(index: ClientIndex<T, F>, item: T, filter: SearchFilter<F>): boolean {
const matches = index.read[filter.field](item).some(valueMatcher(filter.value));
return filter.op === "neq" || filter.op === "nglob" ? !matches : matches;
const matcher = isGlobOp(filter.op) ? valueMatcher(filter.value) : exactMatcher(filter.value);
const matches = index.read[filter.field](item).some(matcher);
return isNegatedOp(filter.op) ? !matches : matches;
}
function matchesText<T, F extends string>(index: ClientIndex<T, F>, item: T, term: string): boolean {

View file

@ -1,17 +1,67 @@
import { describe, expect, it } from "vitest";
import { NOTE_QUERY } from "./__fixtures__/notes";
import { parseQuery, valueMatcher } from "./language";
import { EXACT_NOTE_QUERY, NEGATION_NOTE_QUERY, NOTE_QUERY, WILDCARD_NOTE_QUERY } from "./__fixtures__/notes";
import { exactMatcher, languageOps, parseQuery, valueMatcher } from "./language";
describe("parseQuery", () => {
it("splits clauses on whitespace, keeps quoted stretches, and only promotes known keys to fields", () => {
expect(parseQuery(NOTE_QUERY, 'refund -Tag:"a b" foo:bar tag:')).toEqual([
{ kind: "text", value: "refund", from: 0, to: 6 },
{ kind: "field", field: "tag", negated: true, keyTo: 11, value: "a b", from: 7, to: 17 },
{ kind: "field", field: "tag", op: "neq", keyTo: 11, value: "a b", from: 7, to: 17 },
{ kind: "text", value: "foo:bar", from: 18, to: 25 },
{ kind: "field", field: "tag", negated: false, keyTo: 29, value: "", from: 26, to: 30 },
{ kind: "field", field: "tag", op: "eq", keyTo: 29, value: "", from: 26, to: 30 },
]);
});
it("picks the op from the dash and the wildcard when the language honors them", () => {
const ops = (text: string) => parseQuery(NOTE_QUERY, text).map((c) => (c.kind === "field" ? c.op : c.kind));
expect(ops("tag:a -tag:b tag:*c -tag:d*")).toEqual(["eq", "neq", "glob", "nglob"]);
});
it("keeps a dash as text and a star as a literal for an equality-only language", () => {
expect(parseQuery(EXACT_NOTE_QUERY, "-tag:a tag:b*")).toEqual([
{ kind: "text", value: "-tag:a", from: 0, to: 6 },
{ kind: "field", field: "tag", op: "eq", keyTo: 10, value: "b*", from: 7, to: 13 },
]);
});
it.each([
[NOTE_QUERY, ["nglob", "neq", "glob", "eq"]],
[EXACT_NOTE_QUERY, ["text", "text", "eq", "eq"]],
[NEGATION_NOTE_QUERY, ["neq", "neq", "eq", "eq"]],
[WILDCARD_NOTE_QUERY, ["text", "text", "glob", "eq"]],
])("reads each operator form the way the language's capabilities allow (%#)", (language, expected) => {
const ops = parseQuery(language, "-tag:research* -tag:research tag:research* tag:research").map((c) =>
c.kind === "field" ? c.op : c.kind,
);
expect(ops).toEqual(expected);
});
it("keeps the star in the value and the dash in the text whatever the capabilities", () => {
expect(parseQuery(NEGATION_NOTE_QUERY, "-Tag:research*")).toEqual([
{ kind: "field", field: "tag", op: "neq", keyTo: 4, value: "research*", from: 0, to: 14 },
]);
expect(parseQuery(WILDCARD_NOTE_QUERY, '-tag:"a b" tag:"a *"')).toEqual([
{ kind: "text", value: '-tag:"a b"', from: 0, to: 10 },
{ kind: "field", field: "tag", op: "glob", keyTo: 14, value: "a *", from: 11, to: 20 },
]);
});
});
describe("languageOps", () => {
it("lists only the ops a language can express", () => {
expect(languageOps(NOTE_QUERY)).toEqual(["eq", "neq", "glob", "nglob"]);
expect(languageOps(EXACT_NOTE_QUERY)).toEqual(["eq"]);
expect(languageOps({ ...NOTE_QUERY, ops: { negation: true, wildcard: false } })).toEqual(["eq", "neq"]);
expect(languageOps({ ...NOTE_QUERY, ops: { negation: false, wildcard: true } })).toEqual(["eq", "glob"]);
});
});
describe("exactMatcher", () => {
it("compares the whole value ignoring case and treats a star as a character", () => {
expect(exactMatcher("A*b")("a*B")).toBe(true);
expect(exactMatcher("a*b")("axb")).toBe(false);
});
});
describe("valueMatcher", () => {

View file

@ -7,11 +7,35 @@ export interface FieldSpec {
readonly suggestValues: boolean;
}
export type FilterOp = "eq" | "neq" | "glob" | "nglob";
/** Which `key:value` forms beyond equality the surface behind the box can honor. */
export interface QueryOperators {
/** `-key:value` excludes; otherwise the dash is ordinary text. */
readonly negation: boolean;
/** `*` inside a value is a wildcard; otherwise it is a literal character. */
readonly wildcard: boolean;
}
export const ALL_OPERATORS: QueryOperators = { negation: true, wildcard: true };
export const EQUALITY_ONLY: QueryOperators = { negation: false, wildcard: false };
/** The `key:value` vocabulary of one search box. Where the data lives is the evaluator's concern, not the language's. */
export interface QueryLanguage<F extends string> {
readonly fields: Readonly<Record<F, FieldSpec>>;
readonly ops: QueryOperators;
}
export const languageOps = <F extends string>({ ops }: QueryLanguage<F>): readonly FilterOp[] => [
"eq",
...(ops.negation ? (["neq"] as const) : []),
...(ops.wildcard ? (["glob"] as const) : []),
...(ops.negation && ops.wildcard ? (["nglob"] as const) : []),
];
export const isNegatedOp = (op: FilterOp): boolean => op === "neq" || op === "nglob";
export const isGlobOp = (op: FilterOp): boolean => op === "glob" || op === "nglob";
interface Span {
readonly from: number;
readonly to: number;
@ -22,7 +46,7 @@ export type QueryClause<F extends string> =
| (Span & {
readonly kind: "field";
readonly field: F;
readonly negated: boolean;
readonly op: FilterOp;
readonly keyTo: number;
readonly value: string;
});
@ -43,19 +67,29 @@ function tokenize(text: string): (Span & { raw: string })[] {
}));
}
const filterOp = (negated: boolean, glob: boolean): FilterOp => {
if (glob) return negated ? "nglob" : "glob";
return negated ? "neq" : "eq";
};
function parseToken<F extends string>(
language: QueryLanguage<F>,
{ raw, from, to }: Span & { raw: string },
): QueryClause<F> {
const match = /^(-?)([A-Za-z_]+):(.*)$/.exec(raw);
const key = match?.[2].toLowerCase() ?? "";
if (!match || !Object.hasOwn(language.fields, key)) return { kind: "text", value: unquote(raw), from, to };
const text: QueryClause<F> = { kind: "text", value: unquote(raw), from, to };
if (!match) return text;
const key = match[2].toLowerCase();
const negated = match[1] === "-";
if (!Object.hasOwn(language.fields, key)) return text;
if (negated && !language.ops.negation) return text;
const value = unquote(match[3]);
return {
kind: "field",
field: key as F,
negated: match[1] === "-",
op: filterOp(negated, language.ops.wildcard && value.includes("*")),
keyTo: from + match[1].length + match[2].length,
value: unquote(match[3]),
value,
from,
to,
};
@ -64,10 +98,16 @@ function parseToken<F extends string>(
export const parseQuery = <F extends string>(language: QueryLanguage<F>, text: string): QueryClause<F>[] =>
tokenize(text).map((token) => parseToken(language, token));
/** Whole-value match, ignoring case; `*` is a literal. */
export function exactMatcher(pattern: string): (value: string) => boolean {
const needle = pattern.toLowerCase();
return (value) => value.toLowerCase() === needle;
}
/** `bar` matches the whole value, `*bar*` is a glob; both ignore case. */
export function valueMatcher(pattern: string): (value: string) => boolean {
const needle = pattern.toLowerCase();
if (!needle.includes("*")) return (value) => value.toLowerCase() === needle;
if (!needle.includes("*")) return exactMatcher(needle);
const segments = needle.split("*");
const first = segments[0];
const last = segments.at(-1);

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { NOTE_QUERY } from "./__fixtures__/notes";
import { EXACT_NOTE_QUERY, NOTE_QUERY } from "./__fixtures__/notes";
import { parseQuery } from "./language";
import { toSearchQuery } from "./searchQuery";
@ -24,6 +24,13 @@ describe("toSearchQuery", () => {
expect(query("refund tag:")).toEqual({ text: ["refund"], filters: [] });
});
it("only ever emits eq for an equality-only language", () => {
expect(toSearchQuery(parseQuery(EXACT_NOTE_QUERY, "-tag:cron tag:*bill*"))).toEqual({
text: ["-tag:cron"],
filters: [{ field: "tag", op: "eq", value: "*bill*" }],
});
});
it("keeps an unknown key as free text", () => {
expect(query("foo:bar")).toEqual({ text: ["foo:bar"], filters: [] });
});

View file

@ -1,6 +1,4 @@
import type { QueryClause } from "./language";
export type FilterOp = "eq" | "neq" | "glob" | "nglob";
import type { FilterOp, QueryClause } from "./language";
export interface SearchFilter<F extends string> {
readonly field: F;
@ -18,18 +16,11 @@ export interface SearchQuery<F extends string> {
readonly filters: readonly SearchFilter<F>[];
}
const filterOp = (negated: boolean, value: string): FilterOp => {
if (value.includes("*")) return negated ? "nglob" : "glob";
return negated ? "neq" : "eq";
};
/** A key typed without a value yet narrows nothing, so the list does not blank out mid-typing. */
export function toSearchQuery<F extends string>(clauses: readonly QueryClause<F>[]): SearchQuery<F> {
const text = clauses.flatMap((clause) => (clause.kind === "text" && clause.value ? [clause.value] : []));
const filters = clauses.flatMap((clause) =>
clause.kind === "field" && clause.value
? [{ field: clause.field, op: filterOp(clause.negated, clause.value), value: clause.value }]
: [],
clause.kind === "field" && clause.value ? [{ field: clause.field, op: clause.op, value: clause.value }] : [],
);
return { text, filters };
}

View file

@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { type Note, note, NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes";
import {
EXACT_NOTE_QUERY,
NEGATION_NOTE_QUERY,
type Note,
note,
NOTE_INDEX,
type NoteField,
NOTE_QUERY,
notes,
WILDCARD_NOTE_QUERY,
} from "./__fixtures__/notes";
import { fieldValues } from "./evaluate";
import { completingField, completingPrefix, suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
import type { FieldValues } from "./valueSource";
@ -86,6 +96,35 @@ describe("suggest", () => {
expect(atEnd("body:x")).toBeNull();
});
it("skips the operator help and the negated key for an equality-only language", () => {
const exact = (text: string) => suggest(EXACT_NOTE_QUERY, text, text.length, lookupIn(notes));
expect(exact("tag:")?.showOperators).toBe(false);
expect(labels(exact("tag:"))).toEqual(["billing", "cron", "researcher", "triage"]);
expect(exact("-ta")).toBeNull();
expect(completingPrefix(EXACT_NOTE_QUERY, "tag:a*", 6)).toBe("a*");
expect(completingPrefix(NOTE_QUERY, "tag:a*", 6)).toBe("a");
expect(labels(exact("tag:*"))).toEqual([]);
});
it("keeps a literal star in the value prefix unless the language has wildcards", () => {
const starred = [...notes, note({ tags: ["a*b", "ab"] })];
const menu = (language: typeof NOTE_QUERY) => suggest(language, "tag:a*", 6, lookupIn(starred));
expect(labels(menu(NEGATION_NOTE_QUERY))).toEqual(["a*b"]);
expect(labels(menu(EXACT_NOTE_QUERY))).toEqual(["a*b"]);
expect(labels(menu(WILDCARD_NOTE_QUERY))).toEqual(["a*b", "ab", "researcher", "triage"]);
expect(completingPrefix(NEGATION_NOTE_QUERY, "tag:a*", 6)).toBe("a*");
expect(completingPrefix(WILDCARD_NOTE_QUERY, "tag:a*", 6)).toBe("a");
});
it("offers a negated key only when the language can negate", () => {
const fields = (language: typeof NOTE_QUERY) => suggest(language, "-ta", 3, lookupIn(notes));
expect(apply("-ta", items(fields(NEGATION_NOTE_QUERY))[0])).toBe("-tag:");
expect(fields(WILDCARD_NOTE_QUERY)).toBeNull();
expect(suggest(WILDCARD_NOTE_QUERY, "-tag:", 5, lookupIn(notes))).toBeNull();
expect(suggest(NEGATION_NOTE_QUERY, "tag:", 4, lookupIn(notes))?.showOperators).toBe(true);
expect(suggest(WILDCARD_NOTE_QUERY, "tag:", 4, lookupIn(notes))?.showOperators).toBe(true);
});
it("offers fields in the gap between tokens, but nothing mid-token", () => {
const text = "refund tag:cron";
expect(apply(text, items(at(text, 7))[1])).toBe("refund tag: tag:cron");

View file

@ -1,4 +1,4 @@
import { type FieldClause, languageFields, parseQuery, type QueryLanguage } from "./language";
import { type FieldClause, languageFields, languageOps, parseQuery, type QueryLanguage } from "./language";
import type { FieldValues } from "./valueSource";
export interface Suggestion<F extends string> {
@ -47,7 +47,7 @@ function target<F extends string>(language: QueryLanguage<F>, text: string, curs
}
if (clause.kind === "field") return { kind: "value", clause };
const raw = text.slice(clause.from, clause.to);
const negated = raw.startsWith("-");
const negated = language.ops.negation && raw.startsWith("-");
const prefix = negated ? raw.slice(1) : raw;
if (/[:"]/.test(prefix)) return null;
return { kind: "field", prefix, negated, from: clause.from, to: clause.to };
@ -59,10 +59,13 @@ export function completingField<F extends string>(language: QueryLanguage<F>, te
return found?.kind === "value" && language.fields[found.clause.field].suggestValues ? found.clause.field : null;
}
const literalPart = <F extends string>(language: QueryLanguage<F>, value: string): string =>
language.ops.wildcard ? value.replaceAll("*", "") : value;
/** The substring typed so far for the value at the cursor, wildcards stripped. */
export function completingPrefix<F extends string>(language: QueryLanguage<F>, text: string, cursor: number): string {
const found = target(language, text, cursor);
return found?.kind === "value" ? found.clause.value.replaceAll("*", "") : "";
return found?.kind === "value" ? literalPart(language, found.clause.value) : "";
}
function fieldMenu<F extends string>(
@ -89,12 +92,13 @@ function fieldMenu<F extends string>(
}
function valueMenu<F extends string>(
language: QueryLanguage<F>,
clause: FieldClause<F>,
text: string,
{ values, loading }: FieldValues,
): SuggestionMenu<F> | null {
const from = clause.keyTo + 1;
const needle = clause.value.replaceAll("*", "").toLowerCase();
const needle = literalPart(language, clause.value).toLowerCase();
const trailing = clause.to === text.length ? " " : "";
const suggestions = values
.filter((value) => value.toLowerCase().includes(needle))
@ -108,7 +112,7 @@ function valueMenu<F extends string>(
insert: quoteIfNeeded(value) + trailing,
completesClause: true,
}));
const showOperators = clause.value === "";
const showOperators = clause.value === "" && languageOps(language).length > 1;
if (suggestions.length === 0 && !showOperators && !loading) return null;
return { groups: suggestions.length ? [{ heading: clause.field, items: suggestions }] : [], showOperators, loading };
}
@ -125,6 +129,7 @@ export function suggest<F extends string>(
if (found.kind === "field") return fieldMenu(language, found);
const { field } = found.clause;
return valueMenu(
language,
found.clause,
text,
language.fields[field].suggestValues ? lookup(field) : { values: [], loading: false },