mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
ee92183319
commit
7dc5b73f93
14 changed files with 358 additions and 78 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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" }] }),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue