refactor(ui): declare the operators a search language honors

The box always advertised negation and wildcards and the parser always
emitted them, so a surface whose API only filters by equality would
promise forms it cannot honor. Each QueryLanguage now declares which
operators it supports; the parser, evaluator, suggestions and operator
help follow that declaration, and a star is a literal under eq.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-04 16:26:43 -07:00
parent 35f638b567
commit 8208449908
14 changed files with 165 additions and 51 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

@ -1,7 +1,7 @@
import { getProxyBaseUrl } from "@/components/networking";
import type { TimeWindow } from "../TracesTimeline";
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";
@ -47,7 +47,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

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { NOTE_INDEX, type NoteField, NOTE_QUERY, notes } 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 +36,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>
@ -116,6 +119,18 @@ describe("SearchBox", () => {
expect(box()).toHaveAttribute("aria-expanded", "false");
});
it("explains only the operators the language honors", async () => {
const user = userEvent.setup();
const negationOnly = { ...NOTE_QUERY, ops: { negation: true, wildcard: false } };
render(<NoteSearch value="" onChange={vi.fn()} language={negationOnly} />);
await user.click(box());
await user.keyboard("tag:");
expect(within(listbox()).getByText("Comparison operators")).toBeVisible();
expect(within(listbox()).getByText("not equals")).toBeVisible();
expect(within(listbox()).queryByText("wildcard match")).not.toBeInTheDocument();
expect(within(listbox()).queryByText("does not contain")).not.toBeInTheDocument();
});
it("wraps arrow navigation and picks with Tab", async () => {
const user = userEvent.setup();
render(<Harness />);

View file

@ -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,6 +148,7 @@ 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],
);
@ -262,7 +265,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 +305,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 +367,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,9 @@ 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 NOTE_INDEX: ClientIndex<Note, NoteField> = {
read: {

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { note, NOTE_INDEX, NOTE_QUERY, notes } from "./__fixtures__/notes";
import { EXACT_NOTE_QUERY, note, NOTE_INDEX, NOTE_QUERY, notes } 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 +20,15 @@ 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("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,45 @@
import { describe, expect, it } from "vitest";
import { NOTE_QUERY } from "./__fixtures__/notes";
import { parseQuery, valueMatcher } from "./language";
import { EXACT_NOTE_QUERY, 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 },
]);
});
});
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,6 @@
import { describe, expect, it } from "vitest";
import { type Note, note, NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes";
import { EXACT_NOTE_QUERY, type Note, note, NOTE_INDEX, type NoteField, NOTE_QUERY, notes } from "./__fixtures__/notes";
import { fieldValues } from "./evaluate";
import { completingField, completingPrefix, suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
import type { FieldValues } from "./valueSource";
@ -86,6 +86,16 @@ 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("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 },