From 3ec81ceba2f00f3255abcd51860ea3b0c8eebc78 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:05:58 +0100 Subject: [PATCH 01/15] feat(query-builder): condition builder for the where clause The query panel's where clause now edits as [field] [operator] [values] rows by default, with a Builder/Text switch back to the text editor. Rows map both ways to the same where-clause string (parse/print over the existing grammar), and every edit leaves as the existing whereClause text change, so previews and saving are unchanged. A clause the rows can't show stays in the text editor with a note. Fields come from the editor's autocomplete vocabulary: common fields, then resource and source attributes in a drill-down picker (new kit ComboboxPicker). Several values OR only on attribute fields, the only filters the query engine ORs; 'is not' AND-s any number. Adds /lab/condition-builder. --- apps/web/src/lab/condition-builder-lab.ts | 136 +++++++++ apps/web/src/lab/entries/condition-builder.ts | 6 + apps/web/src/lab/registry.ts | 8 + .../editor/editor-metrics.scene.test.ts | 12 +- .../widget/widget-presets.scene.test.ts | 23 -- .../query-builder/condition-builder-view.ts | 239 +++++++++++++++ .../condition-builder.scene.test.ts | 140 +++++++++ .../kit/query-builder/condition-fields.ts | 220 +++++++++++++ .../kit/query-builder/condition-rows.test.ts | 139 +++++++++ .../page/kit/query-builder/condition-rows.ts | 116 +++++++ .../page/kit/query-builder/conditions.test.ts | 122 ++++++++ .../src/page/kit/query-builder/conditions.ts | 289 ++++++++++++++++++ .../page/kit/query-builder/panel-message.ts | 23 ++ .../page/kit/query-builder/panel-select.ts | 39 +++ apps/web/src/page/kit/query-builder/panel.ts | 23 +- apps/web/src/page/kit/query-builder/parts.ts | 110 ++++--- packages/views/src/ui/INDEX.md | 10 +- packages/views/src/ui/combobox-picker.ts | 117 +++++++ 18 files changed, 1695 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/lab/condition-builder-lab.ts create mode 100644 apps/web/src/lab/entries/condition-builder.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-builder-view.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-fields.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-rows.test.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-rows.ts create mode 100644 apps/web/src/page/kit/query-builder/conditions.test.ts create mode 100644 apps/web/src/page/kit/query-builder/conditions.ts create mode 100644 apps/web/src/page/kit/query-builder/panel-select.ts create mode 100644 packages/views/src/ui/combobox-picker.ts diff --git a/apps/web/src/lab/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts new file mode 100644 index 0000000000..1df10709d5 --- /dev/null +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -0,0 +1,136 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { Option, Schema, Array as Arr } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { typography } from "@maple/views/ui/typography" +import { vstack } from "@maple/views/ui/stack" +import type { WhereClauseAutocompleteValues } from "../lib/query-builder/where-clause-autocomplete" +import type { Resources } from "../platform/resources" +import { sectionHeading } from "../page/kit/section-heading" +import { applyQueryChange } from "../page/kit/query-builder/change" +import { QueryDraft } from "../page/kit/query-builder/draft" +import * as QueryPanel from "../page/kit/query-builder/panel" +import { queryPanelView } from "../page/kit/query-builder/view" + +// `/lab/condition-builder`: the query panel's where clause as condition rows, over fixture +// vocabulary: empty, populated, a clause the rows can't show, and the text editor. + +export const LAB_VALUES: WhereClauseAutocompleteValues = { + services: ["checkout", "api", "payments", "frontend"], + spanNames: ["GET /api/cart", "POST /api/checkout", "db.query"], + environments: ["production", "staging"], + severities: ["ERROR", "WARN", "INFO", "DEBUG"], + httpStatusCodes: ["200", "404", "500", "503"], + attributeKeys: ["http.route", "http.request.method", "http.response.status_code", "db.system"], + resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region"], +} + +type Scenario = Readonly<{ title: string; hint: string; query: QueryDraft }> + +const draft = (id: string, name: string, dataSource: "traces" | "logs", whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id, name }, dataSource), + whereClause, +}) + +export const SCENARIOS: ReadonlyArray = [ + { title: "Empty", hint: "No conditions yet.", query: draft("empty", "A", "traces", "") }, + { + title: "Populated", + hint: "An attribute row with several values matches any of them; rows are AND-ed.", + query: draft( + "populated", + "B", + "traces", + 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists', + ), + }, + { + title: "Can't show as rows", + hint: "An OR across different fields stays in the text editor.", + query: draft( + "unparseable", + "C", + "traces", + '(service.name = "checkout" OR span.name = "GET /health")', + ), + }, + { + title: "Text mode", + hint: "Switched to Text by hand.", + query: draft("text", "D", "logs", 'service.name = "checkout" AND severity = "ERROR"'), + }, +] + +export const Model = Schema.Struct({ panel: QueryPanel.Model, queries: Schema.Array(QueryDraft) }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotPanelMessage: { message: QueryPanel.Message } }) +export type Message = typeof Message.Type + +export const init = (): Update.Return => { + const panel = QueryPanel.init("condition-lab") + return { + model: { + panel: { ...panel, whereModes: { text: "text" } }, + queries: Arr.map(SCENARIOS, (scenario) => scenario.query), + }, + } +} + +export const panelInputs = (queries: ReadonlyArray): QueryPanel.PanelInputs => ({ + queryOf: (queryId) => Option.getOrUndefined(Arr.findFirst(queries, (query) => query.id === queryId)), + baseValuesOf: () => LAB_VALUES, + orgId: "org_lab", + window: null, +}) + +/** The host's half: the panel's query edits land on the drafts. */ +export const update = (model: Model, message: Message): Update.Return => { + const next = QueryPanel.update(model.panel, message.message, panelInputs(model.queries)) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotPanelMessage({ message: inner }), + ) + const out = next.outMessage + const queries = + out?._tag === "ChangedQuery" + ? Arr.map(model.queries, (query) => + query.id === out.queryId ? applyQueryChange(query, out.change) : query, + ) + : model.queries + return { model: { panel: next.model, queries }, commands } +} + +export const view = (model: Model, h: HtmlBuilder): Html => + vstack(h, { gap: "8", className: "mx-auto w-full max-w-3xl p-6" }, [ + vstack(h, { gap: "1" }, [ + sectionHeading(h, { title: "Condition builder" }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "The where clause as rows. Every edit prints the same string the text editor writes.", + ]), + ]), + ...Arr.map(model.queries, (query, index) => { + const scenario = SCENARIOS[index] + return vstack(h, { gap: "2", as: "section", attributes: [h.Key(query.id)] }, [ + sectionHeading(h, { title: scenario?.title ?? query.name, hint: scenario?.hint, as: "h3" }), + h.submodel({ + slotId: `condition-lab-${query.id}`, + model: model.panel, + view: queryPanelView, + toParentMessage: (message) => Message.GotPanelMessage({ message }), + viewInputs: { + query, + index, + canRemove: false, + intervalPlaceholder: "Auto", + hasHeaderActions: false, + hasVisibilityToggle: false, + autocompleteValues: LAB_VALUES, + }, + }), + typography(h, { size: "2xs", tone: "subtle", mono: true, as: "p", className: "break-all" }, [ + query.whereClause === "" ? "(empty where clause)" : query.whereClause, + ]), + ]) + }), + ]) diff --git a/apps/web/src/lab/entries/condition-builder.ts b/apps/web/src/lab/entries/condition-builder.ts new file mode 100644 index 0000000000..9c64b93b1c --- /dev/null +++ b/apps/web/src/lab/entries/condition-builder.ts @@ -0,0 +1,6 @@ +import { defineLab } from "../define" +import { init, Model, update, view } from "../condition-builder-lab" + +// `/lab/condition-builder`: the query panel's condition rows over fixture vocabulary. + +export const lab = defineLab("Condition builder", { Model, init, update, view }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..02bc171ec3 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -435,6 +435,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/condition-builder", + title: "Condition builder", + description: + "The query panel's where clause as field, operator and value rows: empty, populated, a clause the rows can't show, and text mode.", + kind: "lab", + session: "none", + }, { path: "/lab/time-range", title: "Time range picker", diff --git a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts index 1e2a948987..4423cbc4d0 100644 --- a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts @@ -6,9 +6,6 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" -import { CaretMessage } from "../../kit/where-autocomplete" -import { WhereFieldMessage } from "../../kit/where-field" -import { MountWhereFieldCaret } from "../../kit/where-field-command" import type { PageContext } from "../../contract" import * as QueryPanel from "../../kit/query-builder/panel" import { NOW, ORG } from "../shared/test-rows" @@ -26,17 +23,10 @@ const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } const view = withViewInputs(pageView, { context })() const pageUpdate = (model: Model, message: Message) => update(model, message, context) const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" -/** The page's mounts: the theme, the closed templates dialog and the where editor's caret. */ +/** The page's mounts: the theme and the closed templates dialog (the where clause opens as rows, no caret). */ const mounted = () => [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), - // Resolved with the query panel's message: the page's submodel wraps it. - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ fieldId: DRAFT_ID, message: CaretMessage.BlurredEditor() }), - }), - ), ] const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) diff --git a/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts b/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts index 578dab66d2..8a3d104ce8 100644 --- a/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts @@ -15,9 +15,6 @@ import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import * as QueryPanel from "../../kit/query-builder/panel" import * as Combobox from "@maple/views/ui/combobox" -import { CaretMessage } from "../../kit/where-autocomplete" -import { WhereFieldMessage } from "../../kit/where-field" -import { MountWhereFieldCaret } from "../../kit/where-field-command" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" @@ -222,17 +219,6 @@ describe("preset widgets in the editor", () => { rect: { left: 0, right: 225, top: 4, bottom: 5, width: 112, height: 32 }, }), ), - // Resolved with the query panel's message: the page's submodel wraps it. - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ - fieldId: - builder.state?.queries[0]?.id ?? expect.unreachable("the Builder has query A"), - message: CaretMessage.BlurredEditor(), - }), - }), - ), expectView(text("+ Formula")).toExist(), expectView(text("This preset's query can't be edited")).not.toExist(), ) @@ -277,15 +263,6 @@ describe("preset widgets in the editor", () => { scene( { update: (model: Model, message: Message) => update(model, message, context), view }, ...mounted(pie), - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ - fieldId: pie.state?.queries[0]?.id ?? expect.unreachable("the pie has query A"), - message: CaretMessage.BlurredEditor(), - }), - }), - ), expectView( text("This visualization groups data into categories. Add a group-by field"), ).toExist(), diff --git a/apps/web/src/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts new file mode 100644 index 0000000000..cd375741d0 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -0,0 +1,239 @@ +import type { WhereClauseAutocompleteValues } from "../../../lib/query-builder/where-clause-autocomplete" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Combobox from "@maple/views/ui/combobox" +import { comboboxPicker } from "@maple/views/ui/combobox-picker" +import { chipsView } from "@maple/views/ui/combobox-view" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { + type ConditionField, + type FieldPickerItem, + fieldOf, + fieldPickerItems, + fieldsOf, + groupLabelOf, + OPERATOR_LABEL, + operatorsOf, + valueSuggestionsOf, +} from "./condition-fields" +import { type ConditionRow, isValueless } from "./condition-rows" +import { conditionPickerId, operatorSelectId } from "./conditions" +import type { QueryDraft } from "./draft" +import { Message, type Model } from "./panel-message" +import { panelSelect } from "./panel-select" +import { Array as Arr, Option, String as Str } from "effect" + +// `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each +// picked from the query's own vocabulary. + +type H = HtmlBuilder + +const GROUP_SHORT = { common: "", resource: "resource", attribute: "attr" } + +const fieldTrigger = (h: H, field: ConditionField): ReadonlyArray => + field.group === "common" + ? [field.label] + : [ + typography(h, { tone: "subtle", as: "span", mono: true }, [`${GROUP_SHORT[field.group]}.`]), + field.label, + ] + +const fieldItem = ( + h: HtmlBuilder, + item: FieldPickerItem, + dataSource: QueryDraft["dataSource"], + isSearching: boolean, +): ReadonlyArray => { + const row = (label: string, trailing: ReadonlyArray) => + hstack(h, { gap: "2", justify: "between", shrink: true, className: "w-full" }, [ + typography(h, { truncate: true, as: "span" }, [label]), + ...trailing, + ]) + const hint = (text: string) => + typography( + h, + { size: "2xs", tone: "subtle", mono: true, as: "span", truncate: true, className: "max-w-1/2" }, + [text], + ) + switch (item._tag) { + case "Field": + return [ + row(item.field.label, [ + ...(item.field.group === "common" + ? [hint(item.field.key)] + : isSearching + ? [hint(GROUP_SHORT[item.field.group])] + : []), + ]), + ] + case "Group": + return [ + row(groupLabelOf(item.group, dataSource), [ + hstack(h, { gap: "1", as: "span" }, [ + hint(String(item.count)), + icon(h, "ChevronRightIcon", { size: 12, className: "text-foreground-subtle" }), + ]), + ]), + ] + case "Back": + return [ + hstack(h, { gap: "1.5", as: "span" }, [ + icon(h, "ChevronLeftIcon", { size: 12, className: "text-foreground-subtle" }), + "All fields", + ]), + ] + case "Custom": + return [row(`Use "${item.key}"`, [])] + } +} + +const fieldPicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + values: WhereClauseAutocompleteValues, +): Html => { + const id = conditionPickerId(query.id, row.id, "field") + const picker = model.conditionPickers[id] ?? Combobox.init(id, "single") + const current = { ...picker, selected: row.key === "" ? [] : [row.key] } + const search = Combobox.queryOf(current) + const items = fieldPickerItems(fieldsOf(query.dataSource, values), model.fieldDrills[id] ?? "all", search) + const byValue = new Map(Arr.map(items, (item) => [item.value, item])) + const firstGroup = Option.getOrNull(Arr.findFirst(items, (item) => item._tag === "Group")) + return h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: current, + view: comboboxPicker, + toParentMessage: (message) => + Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), + viewInputs: { + items: Arr.map(items, (item) => item.value), + ariaLabel: "Field", + searchPlaceholder: "Search fields…", + emptyText: "No fields match", + trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), + isPlaceholder: row.key === "", + triggerClassName: "max-w-56", + renderItem: (itemH, value) => { + const item = byValue.get(value) + return item === undefined + ? [value] + : fieldItem(itemH, item, query.dataSource, Str.trim(search) !== "") + }, + itemClassName: (value) => + firstGroup !== null && value === firstGroup.value + ? "mt-1 border-t border-border-subtle pt-1" + : undefined, + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }) +} + +const valuePicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + values: WhereClauseAutocompleteValues, +): Html => { + const id = conditionPickerId(query.id, row.id, "values") + const picker = model.conditionPickers[id] ?? Combobox.init(id, "multiple") + const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) + const typed = Str.trim(picker.inputValue) + // What is typed is offered first when it is not already a suggestion, so a click adds it too. + const isTypedNew = typed !== "" && !suggestions.includes(typed) && !row.values.includes(typed) + return h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: { ...picker, selected: row.values }, + view: chipsView, + toParentMessage: (message) => + Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }), + viewInputs: { + items: isTypedNew ? [typed, ...suggestions] : suggestions, + ariaLabel: "Value", + placeholder: row.values.length === 0 ? "Value" : "", + emptyText: "Type a value and press Enter", + className: "text-xs", + renderItem: (_, value) => (isTypedNew && value === typed ? [`Use "${value}"`] : [value]), + toView: (parts) => h.div([h.Class("min-w-40 flex-1")], parts), + }, + }) +} + +const conditionRow = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, +): Html => { + const connector = typography(h, { size: "xs", tone: "muted", className: "w-10 shrink-0" }, [ + index === 0 ? "Where" : "and", + ]) + const remove = iconButton( + h, + { + size: "icon-xs", + label: "Remove condition", + hasTooltip: false, + className: "text-muted-foreground hover:text-foreground active:text-foreground", + attributes: [h.OnClick(Message.ClickedRemoveCondition({ queryId: query.id, rowId: row.id }))], + }, + [icon(h, "XmarkIcon")], + ) + const field = fieldPicker(h, model, query, row, values) + const layout = (parts: ReadonlyArray) => + hstack(h, { gap: "1.5", wrap: true, attributes: [h.DataAttribute("slot", "condition-row")] }, [ + connector, + field, + ...parts, + remove, + ]) + if (row.key === "") return layout([]) + const kind = fieldOf(row.key, query.dataSource).kind + return panelSelect( + h, + model, + operatorSelectId(query.id, row.id), + row.operator, + Arr.map(operatorsOf(kind, row.operator), (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + })), + { + size: "sm", + className: "w-36", + ariaLabel: "Operator", + toView: (parts) => + layout([ + ...parts, + ...(isValueless(row.operator) ? [] : [valuePicker(h, model, query, row, values)]), + ]), + }, + ) +} + +/** The rows, or nothing when there are none: the caller's "Add condition" starts the first. */ +export const conditionBuilder = ( + h: H, + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => + rows.length === 0 + ? [] + : [ + vstack( + h, + { + gap: "1.5", + attributes: [h.AriaLabel(`Conditions for query ${query.name}`), h.Role("group")], + }, + Arr.map(rows, (row, index) => conditionRow(h, model, query, row, index, values)), + ), + ] diff --git a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts new file mode 100644 index 0000000000..6d9e2665fd --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -0,0 +1,140 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { + all, + click, + Command, + expect as expectView, + given, + keydown, + label, + last, + Mount, + role, + scene, + text, + type as typeInto, +} from "foldkit/scene" +import { describe, test } from "vitest" +import * as Animation from "@foldkit/ui/animation" +import * as Combobox from "@maple/views/ui/combobox" +import { CaretMessage } from "../where-autocomplete" +import { WhereFieldMessage } from "../where-field" +import { MountWhereFieldCaret } from "../where-field-command" +import { type Model, update, view } from "../../../lab/condition-builder-lab" +import type { QueryDraft } from "./draft" +import * as QueryPanel from "./panel" + +// The where clause as condition rows: every pick prints the clause the text editor would hold. + +const traces = (whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), + whereClause, +}) + +const hosted = (whereClause: string): Model => ({ + panel: QueryPanel.init("test"), + queries: [traces(whereClause)], +}) + +const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) + +/** A picker's close animation, `generation` counting its open and close transitions. */ +const pickerClosed = (id: string, generation: number) => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve( + Combobox.WaitForComboboxExit({ id, generation }), + Animation.Message.EndedAnimation({ generation }), + ), + Mount.expectEnded(Combobox.MountCombobox), +] + +/** A picker's open animation. */ +const pickerOpened = (generation: number) => [ + comboMounted(), + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })), +] + +/** The text editor's caret, read when it mounts. */ +const caretMounted = () => + Mount.resolve( + MountWhereFieldCaret, + QueryPanel.Message.GotWhereField({ + message: WhereFieldMessage.GotCaret({ fieldId: "q", message: CaretMessage.BlurredEditor() }), + }), + ) + +/** The last row's value picker: the row just added. */ +const newValue = last(all.role("combobox", { name: "Value" })) + +describe("condition builder", () => { + test("a new row picks an attribute, then typed values, and prints an OR group", () => { + scene( + { update, view }, + given(hosted("")), + expectView(text("(empty where clause)")).toExist(), + click(role("button", { name: "Add condition" })), + comboMounted(), + click(role("option", { name: /^Span attributes/ })), + click(role("option", { name: /^http\.route/ })), + ...pickerClosed("cond-q-c0-field", 1), + typeInto(role("combobox", { name: "Value" }), "/cart"), + ...pickerOpened(1), + keydown(role("combobox", { name: "Value" }), "Enter"), + typeInto(role("combobox", { name: "Value" }), "/checkout"), + keydown(role("combobox", { name: "Value" }), "Enter"), + expectView(text('(attr.http.route = "/cart" OR attr.http.route = "/checkout")')).toExist(), + ) + }) + + test("the field picker drills into resource attributes", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + click(role("button", { name: "Add condition" })), + comboMounted(), + click(role("option", { name: /^Resource attributes/ })), + click(role("option", { name: /^host\.name/ })), + ...pickerClosed("cond-q-c1-field", 1), + typeInto(newValue, "web-1"), + ...pickerOpened(1), + keydown(newValue, "Enter"), + expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), + ) + }) + + test("removing a row prints the rest", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout" AND attr.http.route exists')), + click(label("Remove condition")), + expectView(text("attr.http.route exists")).toExist(), + ) + }) + + test("Text shows the editor over the same clause, and Builder brings the rows back", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + click(role("button", { name: "Text" })), + caretMounted(), + expectView(label("Where clause for query A")).toHaveValue('service.name = "checkout"'), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + click(role("button", { name: "Builder" })), + Mount.expectEnded(MountWhereFieldCaret), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + ) + }) + + test("a clause the rows can't show stays in the text editor and says so", () => { + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + expectView(text("This filter uses syntax the builder can't show, edit as text")).toExist(), + expectView(label("Where clause for query A")).toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.ts b/apps/web/src/page/kit/query-builder/condition-fields.ts new file mode 100644 index 0000000000..192ebecf18 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -0,0 +1,220 @@ +import type { Operator } from "@maple/domain/where-clause" +import { + getWhereClauseAutocomplete, + type WhereClauseAutocompleteValues, +} from "../../../lib/query-builder/where-clause-autocomplete" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { QueryDraft } from "./draft" + +// The condition builder's vocabulary: which fields a query offers (grouped like a path picker), +// the operators a field takes, and the values it suggests, all read from the where editor's +// autocomplete values. + +type DataSource = QueryDraft["dataSource"] + +export type FieldGroup = "common" | "resource" | "attribute" +export type FieldKind = "string" | "number" | "boolean" +export type ConditionField = Readonly<{ key: string; label: string; group: FieldGroup; kind: FieldKind }> + +const common = (key: string, label: string, kind: FieldKind = "string"): ConditionField => ({ + key, + label, + group: "common", + kind, +}) + +const COMMON_FIELDS = { + traces: [ + common("service.name", "Service"), + common("span.name", "Span name"), + common("deployment.environment", "Environment"), + common("attr.http.response.status_code", "HTTP status code", "number"), + common("has_error", "Has error", "boolean"), + ], + logs: [common("service.name", "Service"), common("severity", "Severity"), common("body", "Message")], + metrics: [common("service.name", "Service"), common("metric.type", "Metric type")], + product_events: [ + common("event.name", "Event name"), + common("host", "Host"), + common("page.path", "Page path"), + common("service.name", "Service"), + common("user.id", "User"), + ], +} satisfies Record> + +const ATTRIBUTE_GROUP_LABEL = { + traces: "Span attributes", + logs: "Log attributes", + metrics: "Metric attributes", + product_events: "Event properties", +} satisfies Record + +export const groupLabelOf = (group: FieldGroup, dataSource: DataSource): string => + group === "common" + ? "Common" + : group === "resource" + ? "Resource attributes" + : ATTRIBUTE_GROUP_LABEL[dataSource] + +const NUMERIC_KEY = /(status_code|_ms|duration|\.port|\.count|\.size|_bytes)$/ + +const kindOfKey = (key: string): FieldKind => (NUMERIC_KEY.test(key) ? "number" : "string") + +const RESOURCE_PREFIX = "resource." +const ATTRIBUTE_PREFIX = "attr." + +/** A key as a field: a common one by name, else its attribute group with the prefix dropped. */ +export const fieldOf = (key: string, dataSource: DataSource): ConditionField => { + const known = Option.getOrNull(Arr.findFirst(COMMON_FIELDS[dataSource], (field) => field.key === key)) + if (known !== null) return known + if (Str.startsWith(RESOURCE_PREFIX)(key)) + return { key, label: Str.slice(RESOURCE_PREFIX.length)(key), group: "resource", kind: kindOfKey(key) } + const label = Str.startsWith(ATTRIBUTE_PREFIX)(key) ? Str.slice(ATTRIBUTE_PREFIX.length)(key) : key + return { key, label, group: "attribute", kind: kindOfKey(key) } +} + +/** + * Whether the query engine can OR this field's values: only attribute filters lower an + * `(a OR b)` group; a named dimension (`service.name`) or a product-events query drops it. + */ +export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => + dataSource !== "product_events" && + field.kind !== "boolean" && + (field.group !== "common" || Str.startsWith(ATTRIBUTE_PREFIX)(field.key)) + +/** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ +export const fieldsOf = ( + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => + Arr.dedupeWith( + [ + ...COMMON_FIELDS[dataSource], + ...Arr.map(values.resourceAttributeKeys ?? [], (key) => + fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), + ), + ...Arr.map(values.attributeKeys ?? [], (key) => fieldOf(`${ATTRIBUTE_PREFIX}${key}`, dataSource)), + ], + (a, b) => a.key === b.key, + ) + +// The field picker's list: a drill-down over the groups, flat while searching. + +export const FieldDrill = Schema.Literals(["all", "resource", "attribute"]) +export type FieldDrill = typeof FieldDrill.Type + +export type FieldPickerItem = + | Readonly<{ _tag: "Field"; value: string; field: ConditionField }> + | Readonly<{ _tag: "Group"; value: string; group: "resource" | "attribute"; count: number }> + | Readonly<{ _tag: "Back"; value: string }> + | Readonly<{ _tag: "Custom"; value: string; key: string }> + +const GROUP_VALUE = "@group:" +const BACK_VALUE = "@back" +const CUSTOM_VALUE = "@custom:" +const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/ + +const asField = (field: ConditionField): FieldPickerItem => ({ _tag: "Field", value: field.key, field }) + +const matchesQuery = (field: ConditionField, query: string): boolean => { + const needle = Str.toLowerCase(query) + return ( + Str.includes(needle)(Str.toLowerCase(field.key)) || Str.includes(needle)(Str.toLowerCase(field.label)) + ) +} + +/** What a typed key means inside a group: the group's prefix unless it already has one. */ +const customKeyOf = (typed: string, drill: FieldDrill, fields: ReadonlyArray): string => { + if (Str.startsWith(RESOURCE_PREFIX)(typed) || Str.startsWith(ATTRIBUTE_PREFIX)(typed)) return typed + if (drill === "resource") return `${RESOURCE_PREFIX}${typed}` + if (drill === "all" && Arr.some(fields, (field) => field.group === "common" && field.key === typed)) + return typed + return `${ATTRIBUTE_PREFIX}${typed}` +} + +export const fieldPickerItems = ( + fields: ReadonlyArray, + drill: FieldDrill, + query: string, +): ReadonlyArray => { + const typed = Str.trim(query) + const inScope = drill === "all" ? fields : Arr.filter(fields, (field) => field.group === drill) + const back: ReadonlyArray = drill === "all" ? [] : [{ _tag: "Back", value: BACK_VALUE }] + if (typed !== "") { + const key = customKeyOf(typed, drill, fields) + const custom: ReadonlyArray = + KEY_PATTERN.test(typed) && !Arr.some(fields, (field) => field.key === key) + ? [{ _tag: "Custom", value: `${CUSTOM_VALUE}${key}`, key }] + : [] + return [ + ...Arr.map( + Arr.filter(inScope, (field) => matchesQuery(field, typed)), + asField, + ), + ...custom, + ] + } + if (drill !== "all") return [...back, ...Arr.map(inScope, asField)] + const groupNames: ReadonlyArray<"resource" | "attribute"> = ["resource", "attribute"] + const groups = Arr.flatMap(groupNames, (group): ReadonlyArray => { + const count = Arr.filter(fields, (field) => field.group === group).length + return count === 0 ? [] : [{ _tag: "Group", value: `${GROUP_VALUE}${group}`, group, count }] + }) + return [ + ...Arr.map( + Arr.filter(fields, (field) => field.group === "common"), + asField, + ), + ...groups, + ] +} + +// Operators and values. + +/** The operators a field takes, in menu order; a row's current operator always stays offered. */ +export const operatorsOf = (kind: FieldKind, current?: Operator): ReadonlyArray => { + const own: ReadonlyArray = + kind === "number" + ? ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"] + : kind === "boolean" + ? ["=", "!=", "exists", "!exists"] + : ["=", "!=", "contains", "!contains", "exists", "!exists"] + return current === undefined || own.includes(current) ? own : [...own, current] +} + +export const OPERATOR_LABEL = { + "=": "is", + "!=": "is not", + contains: "contains", + "!contains": "does not contain", + ">": "greater than", + ">=": "at least", + "<": "less than", + "<=": "at most", + exists: "is set", + "!exists": "is not set", +} satisfies Record + +/** Known values for a field: what the where editor would suggest after `key = `. */ +export const valueSuggestionsOf = ( + field: ConditionField, + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + if (field.kind === "boolean") return ["true", "false"] + if (Str.endsWith("status_code")(field.key)) return values.httpStatusCodes ?? [] + const expression = `${field.key} = ` + const { suggestions } = getWhereClauseAutocomplete({ + expression, + cursor: expression.length, + dataSource, + values, + maxSuggestions: 200, + }) + return Arr.dedupe( + Arr.map( + Arr.filter(suggestions, (suggestion) => suggestion.kind === "value"), + (suggestion) => suggestion.label, + ), + ) +} diff --git a/apps/web/src/page/kit/query-builder/condition-rows.test.ts b/apps/web/src/page/kit/query-builder/condition-rows.test.ts new file mode 100644 index 0000000000..9a15ec4df8 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -0,0 +1,139 @@ +import { parseWhereClause } from "@maple/domain/where-clause" +import { describe, expect, test } from "vitest" +import { type ConditionRow, nextRowId, parseConditionRows, printConditionRows } from "./condition-rows" +import { Array as Arr, Option } from "effect" + +const rowsOf = (whereClause: string) => + Option.map(parseConditionRows(whereClause), (rows) => + Arr.map(rows, ({ key, operator, values }) => ({ key, operator, values })), + ) + +const row = (key: string, operator: ConditionRow["operator"], values: ReadonlyArray, id = "c0") => ({ + id, + key, + operator, + values, +}) + +describe("parseConditionRows", () => { + test.each([ + ["", []], + ['service.name = "checkout"', [{ key: "service.name", operator: "=", values: ["checkout"] }]], + [ + '(service.name = "checkout" OR service.name = "api")', + [{ key: "service.name", operator: "=", values: ["checkout", "api"] }], + ], + ["attr.http.route exists", [{ key: "attr.http.route", operator: "exists", values: [] }]], + ["resource.host.name !exists", [{ key: "resource.host.name", operator: "!exists", values: [] }]], + [ + "attr.http.response.status_code >= 500", + [{ key: "attr.http.response.status_code", operator: ">=", values: ["500"] }], + ], + // Aliases read as their canonical key. + [ + "service = checkout AND env = prod", + [ + { key: "service.name", operator: "=", values: ["checkout"] }, + { key: "deployment.environment", operator: "=", values: ["prod"] }, + ], + ], + // Negations of one key fold into one row. + [ + 'severity != "DEBUG" AND severity != "TRACE"', + [{ key: "severity", operator: "!=", values: ["DEBUG", "TRACE"] }], + ], + // Attribute keys keep their case. + ["attr.userId = 'u_1'", [{ key: "attr.userId", operator: "=", values: ["u_1"] }]], + ['body contains "timed out"', [{ key: "body", operator: "contains", values: ["timed out"] }]], + ])("%s", (whereClause, expected) => { + expect(rowsOf(whereClause)).toEqual(Option.some(expected)) + }) + + test.each([ + // Not the grammar at all. + "service.name ~ checkout", + 'service.name = "unclosed', + // OR groups a row cannot show: mixed keys, mixed or negated operators, nesting. + '(service.name = "a" OR span.name = "b")', + '(service.name = "a" OR service.name contains "b")', + '(service.name != "a" OR service.name != "b")', + '(service.name = "a" OR (span.name = "b"))', + // A group without orGroups support in a row: AND inside parentheses. + '(service.name = "a" AND span.name = "b")', + ])("%s cannot be shown", (whereClause) => { + expect(Option.isNone(parseConditionRows(whereClause))).toBe(true) + }) +}) + +describe("printConditionRows", () => { + test.each([ + [[row("service.name", "=", ["checkout"])], 'service.name = "checkout"'], + [ + [row("service.name", "=", ["checkout", "api"])], + '(service.name = "checkout" OR service.name = "api")', + ], + [[row("severity", "!=", ["DEBUG", "TRACE"])], 'severity != "DEBUG" AND severity != "TRACE"'], + [[row("attr.http.route", "exists", [])], "attr.http.route exists"], + [[row("attr.http.response.status_code", ">", ["499"])], "attr.http.response.status_code > 499"], + [[row("root_only", "=", ["true"])], "root_only = true"], + // A value holding a double quote takes single quotes. + [[row("span.name", "contains", ['say "hi"'])], `span.name contains 'say "hi"'`], + [ + [row("service.name", "=", ["checkout"]), row("deployment.environment", "!exists", [], "c1")], + 'service.name = "checkout" AND deployment.environment !exists', + ], + ])("%j", (rows, expected) => { + expect(printConditionRows(rows)).toBe(expected) + }) + + test("unfinished rows print nothing", () => { + expect( + printConditionRows([ + row("", "=", []), + row("service.name", "=", [], "c1"), + row("span.name", "=", ["GET /"], "c2"), + ]), + ).toBe('span.name = "GET /"') + }) +}) + +describe("round trip", () => { + // What the grammar reads, order-free: the builder may reorder AND-ed clauses. + const meaning = (whereClause: string) => { + const parsed = parseWhereClause(whereClause, { orGroups: true }) + const keyed = (clause: { key: string; operator: string; value: string }) => + `${clause.key} ${clause.operator} ${clause.value}` + return { + clauses: Arr.sort(Arr.map(parsed.clauses, keyed), (a: string, b: string) => + a < b ? -1 : a > b ? 1 : 0, + ), + groups: Arr.map(parsed.groups, (group) => Arr.map(group, keyed)), + warnings: parsed.warnings.length, + } + } + + test.each([ + 'service.name = "checkout"', + 'service.name = checkout AND span.name contains "GET /api"', + '(service.name = "a" OR service.name = "b") AND has_error = true', + 'severity != "DEBUG" AND service.name = "x" AND severity != "TRACE"', + "attr.http.response.status_code >= 500 AND attr.http.response.status_code < 600", + "resource.k8s.pod.name exists AND attr.db.system !exists", + `attr.message contains 'it''s' AND span.name = 'say "hi"'`, + '(body contains "timeout" OR body contains "refused")', + ])("%s", (whereClause) => { + const rows = Option.getOrThrow(parseConditionRows(whereClause)) + const printed = printConditionRows(rows) + expect(meaning(printed)).toEqual(meaning(whereClause)) + expect(Option.map(parseConditionRows(printed), (again) => Arr.map(again, (r) => r.values))).toEqual( + Option.some(Arr.map(rows, (r) => r.values)), + ) + }) +}) + +describe("nextRowId", () => { + test("is past every id in use", () => { + expect(nextRowId([])).toBe("c0") + expect(nextRowId([row("a", "=", [], "c0"), row("b", "=", [], "c4")])).toBe("c5") + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts new file mode 100644 index 0000000000..c254f11f47 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -0,0 +1,116 @@ +import { + normalizeKey, + Operator, + type ParsedClause, + parseWhereClause, + quoteWhereValue, + splitWhereClause, +} from "@maple/domain/where-clause" +import { Array as Arr, Option, Schema, String as Str } from "effect" + +// The condition builder's rows and their two-way mapping to the where-clause string +// (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` +// and AND-ed for `!=`/`!contains`. + +/** One `[field] [operator] [values]` row; `key` is empty until a field is picked. */ +export const ConditionRow = Schema.Struct({ + id: Schema.String, + key: Schema.String, + operator: Operator, + values: Schema.Array(Schema.String), +}) +export type ConditionRow = typeof ConditionRow.Type + +/** Several values mean "any of them": printed as one `(k = a OR k = b)` group. */ +export const isAnyOf = (operator: Operator): boolean => operator === "=" || operator === "contains" + +/** Several values mean "none of them": printed as AND-ed clauses. */ +export const isNoneOf = (operator: Operator): boolean => operator === "!=" || operator === "!contains" + +export const isValueless = (operator: Operator): boolean => operator === "exists" || operator === "!exists" + +/** An alias (`service`) as its canonical key; any other key as typed, since attribute keys keep case. */ +const keyOf = (clause: ParsedClause): string => { + const canonical = normalizeKey(clause.key) + return canonical !== clause.key ? canonical : (clause.rawKey ?? clause.key) +} + +type Draft = Omit + +const draftOf = (clause: ParsedClause): Draft => ({ + key: keyOf(clause), + operator: clause.operator, + values: isValueless(clause.operator) ? [] : [clause.value], +}) + +/** One AND-ed part: a single clause, or an OR group whose members share key and an any-of operator. */ +const partOf = (part: string): Option.Option => { + const parsed = parseWhereClause(part, { orGroups: true }) + if (parsed.warnings.length > 0) return Option.none() + const [clause] = parsed.clauses + if (parsed.groups.length === 0 && parsed.clauses.length === 1 && clause !== undefined) + return Option.some(draftOf(clause)) + const [group] = parsed.groups + if (parsed.clauses.length > 0 || parsed.groups.length !== 1 || group === undefined) return Option.none() + const drafts = Arr.map(group, draftOf) + const [first] = drafts + if (first === undefined || !isAnyOf(first.operator)) return Option.none() + const isShared = Arr.every( + drafts, + (draft) => draft.key === first.key && draft.operator === first.operator, + ) + return isShared + ? Option.some({ ...first, values: Arr.flatMap(drafts, (draft) => draft.values) }) + : Option.none() +} + +/** `k != a AND k != b` reads as one "is not a, b" row. */ +const mergeNoneOf = (drafts: ReadonlyArray): ReadonlyArray => + Arr.reduce(drafts, Arr.empty(), (rows: ReadonlyArray, draft) => { + if (!isNoneOf(draft.operator)) return [...rows, draft] + const index = Arr.findFirstIndex( + rows, + (row) => row.key === draft.key && row.operator === draft.operator, + ) + return Option.match(index, { + onNone: () => [...rows, draft], + onSome: (at) => + Arr.map(rows, (row, i) => + i === at ? { ...row, values: Arr.dedupe([...row.values, ...draft.values]) } : row, + ), + }) + }) + +/** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ +export const parseConditionRows = (whereClause: string): Option.Option> => + Option.map(Option.all(Arr.map(splitWhereClause(Str.trim(whereClause)), partOf)), (drafts) => + Arr.map(mergeNoneOf(drafts), (draft, index) => ({ id: `c${index}`, ...draft })), + ) + +/** Numbers and booleans stay bare (`root_only = true` is what the span select reads); the rest is quoted. */ +const literalOf = (value: string): string => + /^-?\d+(\.\d+)?$/.test(value) || value === "true" || value === "false" ? value : quoteWhereValue(value) + +const clauseOf = (key: string, operator: Operator, value: string): string => + isValueless(operator) ? `${key} ${operator}` : `${key} ${operator} ${literalOf(value)}` + +/** A row the where clause can carry: a field, and a value unless the operator takes none. */ +export const isComplete = (row: ConditionRow): boolean => + row.key !== "" && (isValueless(row.operator) || row.values.length > 0) + +const printRow = (row: ConditionRow): string => { + if (isValueless(row.operator)) return clauseOf(row.key, row.operator, "") + const clauses = Arr.map(row.values, (value) => clauseOf(row.key, row.operator, value)) + if (clauses.length === 1) return Arr.join(clauses, "") + return isAnyOf(row.operator) ? `(${Arr.join(clauses, " OR ")})` : Arr.join(clauses, " AND ") +} + +/** The where clause for the complete rows; an unfinished row prints nothing. */ +export const printConditionRows = (rows: ReadonlyArray): string => + Arr.join(Arr.map(Arr.filter(rows, isComplete), printRow), " AND ") + +/** A new row's id, past every id the rows use. */ +export const nextRowId = (rows: ReadonlyArray): string => { + const used = Arr.map(rows, (row) => Number(Str.slice(1)(row.id))) + return `c${Arr.reduce(used, -1, (max, id) => (Number.isFinite(id) && id > max ? id : max)) + 1}` +} diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts new file mode 100644 index 0000000000..537a799573 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -0,0 +1,122 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { describe, expect, test } from "vitest" +import type { QueryDraft } from "./draft" +import { init, Message, type Model, operatorSelectId, type PanelInputs, update } from "./panel" + +// The builder's operator and value steps, as the where-clause edit they emit. + +const traces = (whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), + whereClause, +}) + +const inputsFor = (query: QueryDraft): PanelInputs => ({ + queryOf: (queryId) => (queryId === query.id ? query : undefined), + baseValuesOf: () => ({ services: ["checkout", "api"] }), + orgId: "org_test", + window: null, +}) + +/** The where clause a message leaves as, or null when it leaves none. */ +const emitted = (whereClause: string, message: Message, model: Model = init("test")): string | null => { + const next = update(model, message, inputsFor(traces(whereClause))) + const change = next.outMessage?._tag === "ChangedQuery" ? next.outMessage.change : null + return change?._tag === "SetText" ? change.value : null +} + +const pickOperator = (rowId: string, value: string) => + Message.GotSelectMessage({ + id: operatorSelectId("q", rowId), + message: { _tag: "SelectedItem", index: 0, value }, + }) + +describe("operator", () => { + test("is set drops the values", () => { + expect( + emitted('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "exists")), + ).toBe("service.name exists") + }) + + test("is not keeps every value, as AND-ed negations", () => { + expect(emitted('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "!="))).toBe( + 'service.name != "checkout" AND service.name != "api"', + ) + }) + + test("a comparison keeps one value", () => { + expect( + emitted( + "(attr.http.response.status_code = 500 OR attr.http.response.status_code = 503)", + pickOperator("c0", ">="), + ), + ).toBe("attr.http.response.status_code >= 500") + }) +}) + +describe("values", () => { + // The query engine ORs attribute filters only; a named field's "is" holds one value. + test("a named field's new value replaces the old one", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "api" }, + }) + expect(emitted('service.name = "checkout"', message)).toBe('service.name = "api"') + }) + + test("an attribute's new value joins the OR group", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "POST" }, + }) + expect(emitted('attr.http.request.method = "GET"', message)).toBe( + '(attr.http.request.method = "GET" OR attr.http.request.method = "POST")', + ) + }) + + test("a named field's is not keeps every value", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "api" }, + }) + expect(emitted('service.name != "checkout"', message)).toBe( + 'service.name != "checkout" AND service.name != "api"', + ) + }) + + test("a comparison's new value replaces the old one", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "404" }, + }) + expect(emitted("attr.http.response.status_code > 499", message)).toBe( + "attr.http.response.status_code > 404", + ) + }) + + test("removing a chip removes the value", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "RemovedChip", value: "api" }, + }) + expect(emitted('(service.name = "checkout" OR service.name = "api")', message)).toBe( + 'service.name = "checkout"', + ) + }) +}) + +describe("mode", () => { + test("switching editors is draft state, not an edit", () => { + const next = update( + init("test"), + Message.ChangedWhereMode({ queryId: "q", mode: "text" }), + inputsFor(traces("")), + ) + expect(next.outMessage).toBeUndefined() + expect(next.model.whereModes).toEqual({ q: "text" }) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts new file mode 100644 index 0000000000..252a1c1012 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -0,0 +1,289 @@ +import { Operator } from "@maple/domain/where-clause" +import { Command, type Update } from "foldkit" +import * as Combobox from "@maple/views/ui/combobox" +import * as Select from "@maple/views/ui/select" +import type { Resources } from "../../../platform/resources" +import { QueryChange } from "./change" +import { canMatchAnyOf, fieldOf, fieldPickerItems, fieldsOf, operatorsOf } from "./condition-fields" +import { + type ConditionRow, + isValueless, + nextRowId, + parseConditionRows, + printConditionRows, + isAnyOf, + isNoneOf, +} from "./condition-rows" +import type { QueryDraft } from "./draft" +import { Message, type Model, OutMessage, type WhereMode } from "./panel-message" +import type { PanelInputs } from "./where" +import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" + +// The condition builder's steps: every edit prints the rows and leaves as the query's +// where-clause edit, so previews and saving see only the string. + +type Return = Update.ReturnWithOutMessage + +export type PickerPart = "field" | "values" + +export const conditionPickerId = (queryId: string, rowId: string, part: PickerPart): string => + `cond-${queryId}-${rowId}-${part}` + +const OPERATOR_SELECT = "whereOp" + +export const operatorSelectId = (queryId: string, rowId: string): string => + `${OPERATOR_SELECT}:${queryId}:${rowId}` + +export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERATOR_SELECT}:`)(id) + +/** The rows a query shows: the kept ones while they still print its where clause, else a fresh parse. */ +export const conditionRowsOf = ( + model: Model, + query: QueryDraft, +): Option.Option> => { + const kept = model.conditions[query.id] + return kept !== undefined && printConditionRows(kept) === query.whereClause + ? Option.some(kept) + : parseConditionRows(query.whereClause) +} + +/** Whether a query edits as rows: chosen (the default) and representable. */ +export const whereModeOf = (model: Model, query: QueryDraft): WhereMode => + model.whereModes[query.id] ?? "builder" + +const emitted = ( + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + commands?: Return["commands"], +): Return => ({ + model: { ...model, conditions: { ...model.conditions, [query.id]: rows } }, + ...(commands === undefined ? undefined : { commands }), + outMessage: OutMessage.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ field: "whereClause", value: printConditionRows(rows) }), + }), +}) + +type RowContext = Readonly<{ query: QueryDraft; rows: ReadonlyArray }> + +const withRows = ( + model: Model, + queryId: string, + inputs: PanelInputs | undefined, + step: (context: RowContext) => Return, +): Return => { + const query = inputs?.queryOf(queryId) + if (query === undefined) return { model } + return Option.match(conditionRowsOf(model, query), { + onNone: () => ({ model }), + onSome: (rows) => step({ query, rows }), + }) +} + +const withRow = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, + step: (context: RowContext & Readonly<{ row: ConditionRow }>) => Return, +): Return => + withRows(model, queryId, inputs, (context) => + Option.match( + Arr.findFirst(context.rows, (row) => row.id === rowId), + { onNone: () => ({ model }), onSome: (row) => step({ ...context, row }) }, + ), + ) + +const replaced = (rows: ReadonlyArray, next: ConditionRow): ReadonlyArray => + Arr.map(rows, (row) => (row.id === next.id ? next : row)) + +export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ + model: { + ...model, + whereModes: { ...model.whereModes, [queryId]: mode }, + conditions: Rec.remove(model.conditions, queryId), + }, +}) + +/** A new row opens its field picker, as the first thing to choose. */ +export const addedCondition = (model: Model, queryId: string, inputs: PanelInputs | undefined): Return => + withRows(model, queryId, inputs, ({ query, rows }) => { + const id = nextRowId(rows) + const pickerId = conditionPickerId(queryId, id, "field") + const opened: Model = { + ...model, + conditionPickers: { + ...model.conditionPickers, + [pickerId]: Combobox.init(pickerId, "single", { isOpen: true }), + }, + } + return emitted(opened, query, [...rows, { id, key: "", operator: "=", values: [] }]) + }) + +export const removedCondition = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, +): Return => + withRows(model, queryId, inputs, ({ query, rows }) => { + const pickerIds = [ + conditionPickerId(queryId, rowId, "field"), + conditionPickerId(queryId, rowId, "values"), + ] + const pickers = Rec.filter(model.conditionPickers, (_, id) => !pickerIds.includes(id)) + return emitted( + { + ...model, + conditionPickers: pickers, + selects: Rec.remove(model.selects, operatorSelectId(queryId, rowId)), + }, + query, + Arr.filter(rows, (row) => row.id !== rowId), + ) + }) + +/** The value an Enter or a click picks from the list the view drew. */ +const pickedValue = (current: Combobox.Model, message: Combobox.Message): string | null => { + if (message._tag === "ClickedItem") return message.value + if (message._tag === "PressedInputKey" && message.key === "Enter" && current.highlighted !== null) + return message.items[current.highlighted] ?? null + return null +} + +const pickerOf = (model: Model, id: string, mode: Combobox.Mode): Combobox.Model => + model.conditionPickers[id] ?? Combobox.init(id, mode) + +/** The field picker: a group or "All fields" drills without closing; a field sets the row's key. */ +export const fieldStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "field") + const current = { ...pickerOf(model, id, "single"), selected: row.key === "" ? [] : [row.key] } + const drill = model.fieldDrills[id] ?? "all" + const fields = fieldsOf(query.dataSource, inputs?.baseValuesOf(query) ?? {}) + const items = fieldPickerItems(fields, drill, Combobox.queryOf(current)) + const picked = Option.flatMap(Option.fromNullishOr(pickedValue(current, message)), (value) => + Arr.findFirst(items, (item) => item.value === value), + ) + const navigated = Option.flatMap(picked, (item) => + item._tag === "Group" + ? Option.some(item.group) + : item._tag === "Back" + ? Option.some("all" as const) + : Option.none(), + ) + if (Option.isSome(navigated)) + return { + model: { + ...model, + conditionPickers: { + ...model.conditionPickers, + [id]: { ...current, inputValue: "", highlighted: 0 }, + }, + fieldDrills: { ...model.fieldDrills, [id]: navigated.value }, + }, + } + const next = Combobox.update(current, message) + const lifted: Model = { + ...model, + conditionPickers: { ...model.conditionPickers, [id]: next.model }, + fieldDrills: next.model.isOpen ? model.fieldDrills : Rec.remove(model.fieldDrills, id), + } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionField({ queryId, rowId, message: inner }), + ) + const key = Option.getOrNull( + Option.flatMap(picked, (item) => + item._tag === "Field" + ? Option.some(item.field.key) + : item._tag === "Custom" + ? Option.some(item.key) + : Option.none(), + ), + ) + if (next.outMessage?._tag !== "ChangedValue" || key === null || key === row.key) + return { model: lifted, commands } + const operators = operatorsOf(fieldOf(key, query.dataSource).kind) + const operator = operators.includes(row.operator) ? row.operator : "=" + return emitted(lifted, query, replaced(rows, { ...row, key, operator, values: [] }), commands) + }) + +/** Whether a row holds several values: "is not" always (AND-ed excludes), "is" only where the engine ORs. */ +export const holdsManyValues = (row: Pick, query: QueryDraft): boolean => + isNoneOf(row.operator) || + (isAnyOf(row.operator) && canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource)) + +const withValue = (row: ConditionRow, query: QueryDraft, value: string): ReadonlyArray => + holdsManyValues(row, query) ? Arr.dedupe([...row.values, value]) : [value] + +/** The value picker: suggestions or anything typed (Enter adds it); one value unless the row holds many. */ +export const valuesStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "values") + const current = { ...pickerOf(model, id, "multiple"), selected: row.values } + const typed = Str.trim(current.inputValue) + if ( + message._tag === "PressedInputKey" && + message.key === "Enter" && + current.highlighted === null && + typed !== "" + ) + return emitted( + { + ...model, + conditionPickers: { ...model.conditionPickers, [id]: { ...current, inputValue: "" } }, + }, + query, + replaced(rows, { ...row, values: withValue(row, query, typed) }), + ) + const next = Combobox.update(current, message) + const lifted: Model = { ...model, conditionPickers: { ...model.conditionPickers, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionValues({ queryId, rowId, message: inner }), + ) + if (next.outMessage?._tag !== "ChangedSelection") return { model: lifted, commands } + const selected = next.outMessage.selected + const values = holdsManyValues(row, query) ? selected : Arr.takeRight(selected, 1) + return emitted(lifted, query, replaced(rows, { ...row, values }), commands) + }) + +const isOperator = Schema.is(Operator) + +/** The operator select (`whereOp::`); a valueless operator drops the values. */ +export const operatorStep = ( + model: Model, + id: string, + message: Select.Message, + inputs: PanelInputs | undefined, +): Return => { + const current = model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }) + const next = Select.update(current, message) + const lifted: Model = { ...model, selects: { ...model.selects, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotSelectMessage({ id, message: inner }), + ) + const [, queryId = "", rowId = ""] = Str.split(":")(id) + const operator = next.outMessage?.value + if (operator === undefined || !isOperator(operator)) return { model: lifted, commands } + return withRow(lifted, queryId, rowId, inputs, ({ query, rows, row }) => { + const values = isValueless(operator) + ? [] + : holdsManyValues({ key: row.key, operator }, query) + ? row.values + : Arr.take(row.values, 1) + return emitted(lifted, query, replaced(rows, { ...row, operator, values }), commands) + }) +} diff --git a/apps/web/src/page/kit/query-builder/panel-message.ts b/apps/web/src/page/kit/query-builder/panel-message.ts index 521b1a73fe..5dbd66695d 100644 --- a/apps/web/src/page/kit/query-builder/panel-message.ts +++ b/apps/web/src/page/kit/query-builder/panel-message.ts @@ -4,6 +4,12 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Select from "@maple/views/ui/select" import { initWhereFields, WhereFieldMessage, WhereFields } from "../where-field" import { AddOnKey, QueryChange, QueryTextField } from "./change" +import { FieldDrill } from "./condition-fields" +import { ConditionRow } from "./condition-rows" + +/** How a query's where clause is edited: condition rows, or the text editor. */ +export const WhereMode = Schema.Literals(["builder", "text"]) +export type WhereMode = typeof WhereMode.Type // `QueryPanel`'s own state: the // selects and group-by chips of every panel, and which panels are collapsed. The drafts stay with @@ -19,6 +25,14 @@ export const Model = Schema.Struct({ collapsed: Schema.Array(Schema.String), /** Each where editor's caret, list and active key values, by query id. */ whereFields: WhereFields, + /** Draft UI state, never saved: the editor each query shows (builder when absent). */ + whereModes: Schema.Record(Schema.String, WhereMode), + /** The builder's rows by query id, kept while they still print to the draft's where clause. */ + conditions: Schema.Record(Schema.String, Schema.Array(ConditionRow)), + /** Each row's field and value pickers, by picker id (`conditionPickerId`). */ + conditionPickers: Schema.Record(Schema.String, Combobox.Model), + /** Which group a field picker has drilled into, by picker id. */ + fieldDrills: Schema.Record(Schema.String, FieldDrill), }) export type Model = typeof Model.Type @@ -29,6 +43,10 @@ export const init = (slotPrefix: string): Model => ({ metricPickers: {}, collapsed: [], whereFields: initWhereFields(), + whereModes: {}, + conditions: {}, + conditionPickers: {}, + fieldDrills: {}, }) export const Message = defineMessageUnion({ @@ -44,6 +62,11 @@ export const Message = defineMessageUnion({ ClickedRemove: { queryId: Schema.String }, /** The where editors' suggestions; a field id is its query's id. */ GotWhereField: { message: WhereFieldMessage }, + ChangedWhereMode: { queryId: Schema.String, mode: WhereMode }, + ClickedAddCondition: { queryId: Schema.String }, + ClickedRemoveCondition: { queryId: Schema.String, rowId: Schema.String }, + GotConditionField: { queryId: Schema.String, rowId: Schema.String, message: Combobox.Message }, + GotConditionValues: { queryId: Schema.String, rowId: Schema.String, message: Combobox.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/kit/query-builder/panel-select.ts b/apps/web/src/page/kit/query-builder/panel-select.ts new file mode 100644 index 0000000000..058f46eb4d --- /dev/null +++ b/apps/web/src/page/kit/query-builder/panel-select.ts @@ -0,0 +1,39 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Select from "@maple/views/ui/select" +import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" +import { Message, type Model } from "./panel-message" +import { Array as Arr, Option } from "effect" + +type H = HtmlBuilder + +/** One of the panel's selects, kept by id; its value is always the draft's. */ +export const panelSelect = ( + h: H, + model: Model, + id: string, + value: string, + options: ReadonlyArray, + inputs: Readonly<{ + size?: "sm" | "default" + className?: string + ariaLabel?: string + /** Places the trigger and its siblings (hidden input, portal) in the caller's layout. */ + toView: (parts: ReadonlyArray) => Html + }>, +): Html => { + const index = Option.getOrNull(Arr.findFirstIndex(options, (option) => option.value === value)) + const current = model.selects[id] ?? Select.init(id, { value, selectedIndex: index }) + return h.submodel({ + slotId: `${model.slotPrefix}-select-${id}`, + model: { ...current, value }, + view: selectView, + toParentMessage: (message) => Message.GotSelectMessage({ id, message }), + viewInputs: { + entries: Arr.map(options, (item) => ({ _tag: "Item" as const, item })), + size: inputs.size, + className: inputs.className, + ariaLabel: inputs.ariaLabel, + toView: inputs.toView, + }, + }) +} diff --git a/apps/web/src/page/kit/query-builder/panel.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7074687bf4..7329cab460 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -8,6 +8,16 @@ import type { Resources } from "../../../platform/resources" import * as Combobox from "@maple/views/ui/combobox" import * as Select from "@maple/views/ui/select" import { QueryChange } from "./change" +import { + addedCondition, + changedWhereMode, + fieldStep, + isOperatorSelect, + operatorSelectId, + operatorStep, + removedCondition, + valuesStep, +} from "./conditions" import { groupBySelectionOf } from "./draft" import { Message, type Model, OutMessage } from "./panel-message" import { type PanelInputs, whereStep } from "./where" @@ -15,6 +25,7 @@ import { Array as Arr, Option, String as Str } from "effect" export * from "./panel-message" export type { PanelInputs } from "./where" +export { operatorSelectId } type Return = Update.ReturnWithOutMessage @@ -123,10 +134,11 @@ const metricStep = (model: Model, queryId: string, value: string, message: Combo : { model: lifted, commands } } -/** Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. */ +/** Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. */ export const update = (model: Model, message: Message, inputs?: PanelInputs): Return => Message.match(message, { - GotSelectMessage: ({ id, message: inner }) => selectStep(model, id, inner), + GotSelectMessage: ({ id, message: inner }) => + isOperatorSelect(id) ? operatorStep(model, id, inner, inputs) : selectStep(model, id, inner), GotGroupByMessage: ({ queryId, message: inner }) => groupByStep(model, queryId, inner, inputs), GotMetricMessage: ({ queryId, value, message: inner }) => metricStep(model, queryId, value, inner), ToggledCollapsed: ({ queryId }) => ({ @@ -140,4 +152,11 @@ export const update = (model: Model, message: Message, inputs?: PanelInputs): Re ClickedRemove: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedRemove({ queryId }) }), GotWhereField: ({ message: inner }) => inputs === undefined ? { model } : whereStep(model, inner, inputs), + ChangedWhereMode: ({ queryId, mode }) => changedWhereMode(model, queryId, mode), + ClickedAddCondition: ({ queryId }) => addedCondition(model, queryId, inputs), + ClickedRemoveCondition: ({ queryId, rowId }) => removedCondition(model, queryId, rowId, inputs), + GotConditionField: ({ queryId, rowId, message: inner }) => + fieldStep(model, queryId, rowId, inner, inputs), + GotConditionValues: ({ queryId, rowId, message: inner }) => + valuesStep(model, queryId, rowId, inner, inputs), }) diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index e12dc31548..a29d43baba 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -5,13 +5,22 @@ import * as Combobox from "@maple/views/ui/combobox" import { chipsView } from "@maple/views/ui/combobox-view" import { filterChip } from "@maple/views/ui/filter-chip" import { input } from "@maple/views/ui/input" -import * as Select from "@maple/views/ui/select" -import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" import type { AddOnKey } from "./change" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, type Model } from "./panel" import { whereEditor } from "./where-editor" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { conditionBuilder } from "./condition-builder-view" +import { conditionRowsOf, whereModeOf } from "./conditions" +import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" +import { panelSelect } from "./panel-select" + +export { panelSelect } import { Array as Arr, Record as Rec, Option } from "effect" // The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. @@ -26,38 +35,6 @@ export const ADD_ON_KEYS: ReadonlyArray<{ key: AddOnKey; label: string }> = [ { key: "legend", label: "Legend" }, ] -/** One of the panel's selects, kept by id; its value is always the draft's. */ -export const panelSelect = ( - h: H, - model: Model, - id: string, - value: string, - options: ReadonlyArray, - inputs: Readonly<{ - size?: "sm" | "default" - className?: string - ariaLabel?: string - /** Places the trigger and its siblings (hidden input, portal) in the caller's layout. */ - toView: (parts: ReadonlyArray) => Html - }>, -): Html => { - const index = Option.getOrNull(Arr.findFirstIndex(options, (option) => option.value === value)) - const current = model.selects[id] ?? Select.init(id, { value, selectedIndex: index }) - return h.submodel({ - slotId: `${model.slotPrefix}-select-${id}`, - model: { ...current, value }, - view: selectView, - toParentMessage: (message) => Message.GotSelectMessage({ id, message }), - viewInputs: { - entries: Arr.map(options, (item) => ({ _tag: "Item" as const, item })), - size: inputs.size, - className: inputs.className, - ariaLabel: inputs.ariaLabel, - toView: inputs.toView, - }, - }) -} - const WHERE_PLACEHOLDER = { traces: 'service.name = "checkout" AND status.code = "Error"', logs: 'service.name = "checkout" AND severity = "ERROR"', @@ -67,21 +44,21 @@ const WHERE_PLACEHOLDER = { const WHERE_TEXTAREA_CLASS = "min-h-[32px] resize-y text-xs" -/** `WhereClauseEditor`: the textarea under its highlight overlay, and its suggestion list. */ -export const whereClauseEditor = ( +/** The text editor: the textarea under its highlight overlay, and its suggestion list. */ +const whereTextEditor = ( h: H, model: Model, query: QueryDraft, values: WhereClauseAutocompleteValues, - options: Readonly<{ placeholder?: string; className?: string }> = {}, + placeholder: string | undefined, ): Html => whereEditor(h, { id: whereTextareaId(query.id), value: query.whereClause, ariaLabel: `Where clause for query ${query.name}`, - placeholder: options.placeholder ?? WHERE_PLACEHOLDER[query.dataSource], + placeholder: placeholder ?? WHERE_PLACEHOLDER[query.dataSource], rows: 1, - className: options.className ?? "flex-1", + className: "w-full", textareaClassName: WHERE_TEXTAREA_CLASS, onInput: (value) => Message.ChangedText({ queryId: query.id, field: "whereClause", value }), autocomplete: { @@ -93,6 +70,61 @@ export const whereClauseEditor = ( }, }) +const UNPARSEABLE_NOTE = "This filter uses syntax the builder can't show, edit as text" + +/** + * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text + * editor; a Builder/Text switch under it. Both edit the same where-clause string. + */ +export const whereClauseEditor = ( + h: H, + model: Model, + query: QueryDraft, + values: WhereClauseAutocompleteValues, + options: Readonly<{ placeholder?: string; className?: string }> = {}, +): Html => { + const rows = conditionRowsOf(model, query) + const preferred = whereModeOf(model, query) + const isBuilder = preferred === "builder" && Option.isSome(rows) + const leading = isBuilder + ? button( + h, + { + variant: "ghost", + size: "xs", + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ) + : preferred === "builder" + ? typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE]) + : h.empty + const toggle = segmentedSelect(h, { + options: [ + { value: "builder", label: "Builder", isDisabled: Option.isNone(rows) }, + { value: "text", label: "Text" }, + ], + value: isBuilder ? "builder" : "text", + onChange: (mode: WhereMode) => Message.ChangedWhereMode({ queryId: query.id, mode }), + size: "xs", + ariaLabel: `Where clause editor for query ${query.name}`, + className: "ml-auto shrink-0", + }) + return vstack(h, { gap: "1.5", className: options.className ?? "flex-1 min-w-0" }, [ + ...(isBuilder + ? conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ) + : [whereTextEditor(h, model, query, values, options.placeholder)]), + hstack(h, { gap: "2", wrap: true }, [leading, toggle]), + ]) +} + /** A controlled `` that reports each keystroke. */ export const textInput = ( h: HtmlBuilder, diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..7b1311900d 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -35,6 +35,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/collapsible.ts` `collapsible`, `collapsibleTrigger`: `Collapsible` whose open state lives in the owner's Model: the trigger button and, while open, its panel (closed, Base UI renders no panel). - `packages/views/src/ui/color-input.ts` `colorInput`: `ColorInput`: a native colour picker drawn as a small bordered swatch, reporting the picked hex colour. - `packages/views/src/ui/column.ts` `columnClass`, `column`: `Column`: a cell's width in a row of flex cells (a div table's head and rows): a flexible column that takes the free width over a floor (`min`), or a fixed lane (`width`). +- `packages/views/src/ui/combobox-picker.ts` `triggerIdOf`, `comboboxPicker`: `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search input on top (a `` with ``). - `packages/views/src/ui/combobox-view.ts` `dismissButton`, `inputAttributes`, `chipsInput`, `hiddenInput`, `comboboxCheckIcon`, `listPopup`, `chipsView`, `inputField`, `view`: Base UI's visually hidden "Dismiss" button for screen readers, beside the input and the list. - `packages/views/src/ui/combobox.ts` `inputId`, `listId`, `itemId`, `filterItems`, `queryOf`, `visibleItems` (stateful: Model, init, update): Base UI `Combobox` (multiple, with chips) and `Autocomplete` (`@maple/ui/combobox`, `@maple/ui/autocomplete`): an input that filters a listbox. - `packages/views/src/ui/command.ts` `commandTitles`, `commandInput`, `commandList`, `commandGroup`, `commandFooter`, `commandHiddenInput`, `commandOption`, `commandListbox`, `commandEmpty`: `@maple/ui/command`: the command palette's body, an inline Autocomplete (always open, first match highlighted) on the Combobox Model, inside a `command-dialog` Dialog. @@ -209,11 +210,16 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. +- `apps/web/src/page/kit/query-builder/condition-builder-view.ts` `conditionBuilder`: `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each picked from the query's own vocabulary. +- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `fieldsOf`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... +- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `parseConditionRows`, `isComplete`, `printConditionRows`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... +- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `whereModeOf`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuesStep`, `operatorStep`: The condition builder's steps: every edit prints the rows and leaves as the query's where-clause edit, so previews and saving see only the string. - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. - `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields. -- `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. -- `apps/web/src/page/kit/query-builder/parts.ts` `panelSelect`, `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. +- `apps/web/src/page/kit/query-builder/panel-select.ts` `panelSelect`: One of the panel's selects, kept by id; its value is always the draft's. +- `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. +- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text editor; a Builder/Text switch under it. - `apps/web/src/page/kit/query-builder/view.ts` `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. - `apps/web/src/page/kit/query-builder/where-editor.ts` `whereEditor`, `highlightOverlay`: The suggestions of a field kept in the owner's `WhereFields` (`page/kit/where-field.ts`). - `apps/web/src/page/kit/query-builder/where.ts` `whereTextareaId`, `whereSpecOf`, `whereStep`: `WhereClauseEditor` inside a `QueryPanel`: one `WhereFields` field per query, keyed by query id. diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts new file mode 100644 index 0000000000..9988a4d954 --- /dev/null +++ b/packages/views/src/ui/combobox-picker.ts @@ -0,0 +1,117 @@ +import { cn } from "@maple/ui/lib/utils" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "./button" +import { Message, type Model, queryOf } from "./combobox" +import { chipsInput, inputAttributes, listPopup } from "./combobox-view" +import { icon } from "./icon" +import { isMounted } from "./overlay/transition" + +/** + * `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search + * input on top (a `` with ``). The caller matches `items` to the + * search itself (`queryOf`), so a list can drill into groups or offer a typed value. + */ +export type ComboboxPickerInputs = Readonly<{ + /** The list for the current search, in order. */ + items: ReadonlyArray + ariaLabel: string + searchPlaceholder: string + emptyText: string + /** The trigger's label: the pick, or a prompt drawn muted with `isPlaceholder`. */ + trigger: ReadonlyArray + isPlaceholder?: boolean + renderItem?: (h: HtmlBuilder, value: string) => ReadonlyArray + itemClassName?: (value: string) => string | undefined + triggerClassName?: string + /** Places the trigger and, while open, the list portal. */ + toView: (parts: ReadonlyArray) => Html +}> + +export const triggerIdOf = (id: string): string => `${id}-trigger` + +export const comboboxPicker = Submodel.defineView( + (model, inputs, h) => { + const triggerId = triggerIdOf(model.id) + const isShown = isMounted(model.isOpen, model.animation) + const trigger = button( + h, + { + variant: "outline", + size: "sm", + className: cn("min-w-0 max-w-full justify-between font-normal", inputs.triggerClassName), + slot: "combobox-trigger", + attributes: [ + h.Id(triggerId), + h.AriaLabel(inputs.ariaLabel), + h.AriaExpanded(model.isOpen), + h.AriaHasPopup("dialog"), + h.Role("combobox"), + ...(isShown ? [h.AriaControls(`${model.id}-popup`)] : []), + ...(model.isOpen ? [h.DataAttribute("popup-open", "")] : []), + h.OnClick(Message.ClickedTriggerOf({ items: inputs.items })), + ], + }, + [ + h.span( + [ + h.Class( + cn("min-w-0 truncate", inputs.isPlaceholder === true && "text-muted-foreground"), + ), + ], + inputs.trigger, + ), + icon(h, "ChevronDownIcon", { size: 12, className: "shrink-0 text-muted-foreground" }), + ], + ) + const header = h.div( + [h.Class("flex items-center gap-2 border-b border-border-subtle px-3")], + [ + icon(h, "MagnifierIcon", { className: "size-3.5 shrink-0 text-foreground-subtle" }), + chipsInput(h, { + size: "sm", + className: "h-9 w-full ps-0 text-xs", + attributes: [ + h.Attribute("autofocus", ""), + ...inputAttributes( + h, + { ...model, inputValue: queryOf(model) }, + { + slot: "combobox-chips-input", + ariaLabel: `Search ${inputs.ariaLabel}`, + placeholder: inputs.searchPlaceholder, + items: inputs.items, + isUnfiltered: true, + }, + (message) => message, + ), + ], + }), + ], + ) + return inputs.toView([ + trigger, + ...(isShown + ? [ + listPopup(h, model, { + slot: "combobox", + items: inputs.items, + isPrefiltered: true, + emptyText: inputs.emptyText, + anchorSelector: `#${triggerId}`, + className: "w-72 max-w-[calc(100vw-2rem)] overflow-hidden", + positionerClassName: "z-60", + listClassName: "max-h-72 overflow-y-auto p-1", + itemClassName: (value) => + cn( + "min-h-7 grid-cols-[1rem_minmax(0,1fr)] text-xs", + inputs.itemClassName?.(value), + ), + renderItem: (value) => inputs.renderItem?.(h, value) ?? [value], + trigger: { returnFocusId: triggerId, header }, + }), + ] + : []), + ]) + }, +) From 2b0962623c029d7d5c2ca4cf800babb2c20aff70 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:29:31 +0100 Subject: [PATCH 02/15] fix(query-builder): condition builder review fixes Operators come from one engine table (where-key-rules, held to the clause handlers by test), so the builder offers only clauses the query applies. Text mode is explicit state once typed in. Edits rewrite only their own clause and Add condition emits nothing. Single-value rows use a single picker; product-events excludes and user/group ids take one value; values with both quote kinds are refused. Traces intrinsics are named fields and root_only is left to the span select. Rows lay out on a grid where only the values cell wraps, controls carry row-specific labels, and builder state clears when rows re-parse. --- apps/web/src/lab/condition-builder-lab.ts | 10 +- .../query-builder/condition-builder-view.ts | 170 ++++++++++---- .../condition-builder.scene.test.ts | 54 +++-- .../query-builder/condition-fields.test.ts | 60 +++++ .../kit/query-builder/condition-fields.ts | 52 +++-- .../kit/query-builder/condition-rows.test.ts | 54 ++++- .../page/kit/query-builder/condition-rows.ts | 80 ++++++- .../page/kit/query-builder/conditions.test.ts | 67 +++++- .../src/page/kit/query-builder/conditions.ts | 221 +++++++++++------- .../page/kit/query-builder/metrics-body.ts | 3 +- apps/web/src/page/kit/query-builder/panel.ts | 17 +- apps/web/src/page/kit/query-builder/parts.ts | 51 ++-- apps/web/src/page/kit/query-builder/view.ts | 3 +- .../query-engine/src/query-builder/model.ts | 2 + .../src/query-builder/where-key-rules.test.ts | 98 ++++++++ .../src/query-builder/where-key-rules.ts | 137 +++++++++++ packages/views/src/ui/INDEX.md | 8 +- 17 files changed, 879 insertions(+), 208 deletions(-) create mode 100644 apps/web/src/page/kit/query-builder/condition-fields.test.ts create mode 100644 packages/query-engine/src/query-builder/where-key-rules.test.ts create mode 100644 packages/query-engine/src/query-builder/where-key-rules.ts diff --git a/apps/web/src/lab/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts index 1df10709d5..77d689dd65 100644 --- a/apps/web/src/lab/condition-builder-lab.ts +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -22,7 +22,13 @@ export const LAB_VALUES: WhereClauseAutocompleteValues = { environments: ["production", "staging"], severities: ["ERROR", "WARN", "INFO", "DEBUG"], httpStatusCodes: ["200", "404", "500", "503"], - attributeKeys: ["http.route", "http.request.method", "http.response.status_code", "db.system"], + attributeKeys: [ + "http.route", + "http.request.method", + "http.response.status_code", + "db.system", + "messaging.destination.partition.id", + ], resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region"], } @@ -42,7 +48,7 @@ export const SCENARIOS: ReadonlyArray = [ "populated", "B", "traces", - 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists', + 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists AND attr.messaging.destination.partition.id = "12"', ), }, { diff --git a/apps/web/src/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts index cd375741d0..0c9b61487e 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -1,8 +1,9 @@ import type { WhereClauseAutocompleteValues } from "../../../lib/query-builder/where-clause-autocomplete" +import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import * as Combobox from "@maple/views/ui/combobox" import { comboboxPicker } from "@maple/views/ui/combobox-picker" -import { chipsView } from "@maple/views/ui/combobox-view" +import { chipsView, view as comboboxView } from "@maple/views/ui/combobox-view" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { hstack, vstack } from "@maple/views/ui/stack" @@ -14,12 +15,13 @@ import { fieldPickerItems, fieldsOf, groupLabelOf, + isOwnedElsewhere, OPERATOR_LABEL, operatorsOf, valueSuggestionsOf, } from "./condition-fields" -import { type ConditionRow, isValueless } from "./condition-rows" -import { conditionPickerId, operatorSelectId } from "./conditions" +import { type ConditionRow, isUnspellable, isValueless } from "./condition-rows" +import { conditionPickerId, operatorSelectId, valuePickerOf } from "./conditions" import type { QueryDraft } from "./draft" import { Message, type Model } from "./panel-message" import { panelSelect } from "./panel-select" @@ -94,6 +96,7 @@ const fieldPicker = ( model: Model, query: QueryDraft, row: ConditionRow, + index: number, values: WhereClauseAutocompleteValues, ): Html => { const id = conditionPickerId(query.id, row.id, "field") @@ -111,12 +114,12 @@ const fieldPicker = ( Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), viewInputs: { items: Arr.map(items, (item) => item.value), - ariaLabel: "Field", + ariaLabel: `Field for condition ${index + 1}`, searchPlaceholder: "Search fields…", emptyText: "No fields match", trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), isPlaceholder: row.key === "", - triggerClassName: "max-w-56", + triggerClassName: "w-full", renderItem: (itemH, value) => { const item = byValue.get(value) return item === undefined @@ -127,42 +130,100 @@ const fieldPicker = ( firstGroup !== null && value === firstGroup.value ? "mt-1 border-t border-border-subtle pt-1" : undefined, - toView: (parts) => h.div([h.Class("contents")], parts), + toView: (parts) => h.div([h.Class("col-start-2 row-start-1 min-w-0")], parts), }, }) } +/** A row's field in words, for the labels of its controls. */ +const rowFieldLabel = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" ? "new condition" : fieldOf(row.key, dataSource).label + +/** "Service is checkout, api": what a row's remove button removes. */ +const rowSummary = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" + ? "new condition" + : Arr.join( + [ + rowFieldLabel(row, dataSource), + OPERATOR_LABEL[row.operator], + ...(row.values.length === 0 ? [] : [Arr.join(row.values, ", ")]), + ], + " ", + ) + +const UNSPELLABLE_NOTE = `A value can't hold both ' and "` + const valuePicker = ( h: H, model: Model, query: QueryDraft, row: ConditionRow, values: WhereClauseAutocompleteValues, -): Html => { +): ReadonlyArray => { const id = conditionPickerId(query.id, row.id, "values") - const picker = model.conditionPickers[id] ?? Combobox.init(id, "multiple") + const picker = valuePickerOf(model, query, row) const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) - const typed = Str.trim(picker.inputValue) + const typed = Str.trim(Combobox.queryOf(picker)) + const isUnspellableTyped = isUnspellable(typed) // What is typed is offered first when it is not already a suggestion, so a click adds it too. - const isTypedNew = typed !== "" && !suggestions.includes(typed) && !row.values.includes(typed) - return h.submodel({ - slotId: `${model.slotPrefix}-${id}`, - model: { ...picker, selected: row.values }, - view: chipsView, - toParentMessage: (message) => - Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }), - viewInputs: { - items: isTypedNew ? [typed, ...suggestions] : suggestions, - ariaLabel: "Value", - placeholder: row.values.length === 0 ? "Value" : "", - emptyText: "Type a value and press Enter", - className: "text-xs", - renderItem: (_, value) => (isTypedNew && value === typed ? [`Use "${value}"`] : [value]), - toView: (parts) => h.div([h.Class("min-w-40 flex-1")], parts), - }, - }) + const isTypedNew = + typed !== "" && !isUnspellableTyped && !suggestions.includes(typed) && !row.values.includes(typed) + const items = isTypedNew ? [typed, ...suggestions] : suggestions + const ariaLabel = `Value for ${rowFieldLabel(row, query.dataSource)}` + const toMessage = (message: Combobox.Message) => + Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }) + const renderItem = (value: string): ReadonlyArray => + isTypedNew && value === typed ? [`Use "${value}"`] : [value] + const note = isUnspellableTyped + ? [typography(h, { size: "2xs", tone: "warning", as: "p" }, [UNSPELLABLE_NOTE])] + : [] + const field = + picker.mode === "multiple" + ? h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: picker, + view: chipsView, + toParentMessage: toMessage, + viewInputs: { + items, + ariaLabel, + placeholder: row.values.length === 0 ? "Value" : "", + emptyText: "Type a value and press Enter", + className: "text-xs", + renderItem: (_, value) => renderItem(value), + toView: (parts) => h.div([h.Class("min-w-0")], parts), + }, + }) + : h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: picker, + view: comboboxView, + toParentMessage: toMessage, + viewInputs: { + items, + ariaLabel, + placeholder: "Value", + emptyText: "Type a value and press Enter", + size: "sm", + showTrigger: false, + highlightsSelected: true, + className: "text-xs", + renderItem, + toView: ({ field: input, rest }) => h.div([h.Class("min-w-0")], [input, ...rest]), + }, + }) + return [field, ...note] } +// Wide: one line of [connector] [field] [operator] [values] [remove]. Narrow: the values cell +// wraps under the field and operator, the rest stays on the first line. +const ROW_GRID = + "grid grid-cols-[2.5rem_minmax(0,1fr)_7rem_auto] items-center gap-1.5 @lg:grid-cols-[2.5rem_minmax(0,14rem)_9rem_minmax(10rem,1fr)_auto]" +const VALUES_CELL = + "col-start-2 col-span-2 row-start-2 min-w-0 @lg:col-start-4 @lg:col-span-1 @lg:row-start-1" +const REMOVE_CELL = "col-start-4 row-start-1 @lg:col-start-5" + const conditionRow = ( h: H, model: Model, @@ -171,69 +232,82 @@ const conditionRow = ( index: number, values: WhereClauseAutocompleteValues, ): Html => { - const connector = typography(h, { size: "xs", tone: "muted", className: "w-10 shrink-0" }, [ + const connector = typography(h, { size: "xs", tone: "muted", className: "col-start-1 row-start-1" }, [ index === 0 ? "Where" : "and", ]) const remove = iconButton( h, { size: "icon-xs", - label: "Remove condition", + label: `Remove condition: ${rowSummary(row, query.dataSource)}`, hasTooltip: false, - className: "text-muted-foreground hover:text-foreground active:text-foreground", + className: cn(REMOVE_CELL, "text-muted-foreground hover:text-foreground active:text-foreground"), attributes: [h.OnClick(Message.ClickedRemoveCondition({ queryId: query.id, rowId: row.id }))], }, [icon(h, "XmarkIcon")], ) - const field = fieldPicker(h, model, query, row, values) + const field = fieldPicker(h, model, query, row, index, values) const layout = (parts: ReadonlyArray) => - hstack(h, { gap: "1.5", wrap: true, attributes: [h.DataAttribute("slot", "condition-row")] }, [ - connector, - field, - ...parts, - remove, - ]) + h.div( + [h.Class(ROW_GRID), h.DataAttribute("slot", "condition-row")], + [connector, field, ...parts, remove], + ) if (row.key === "") return layout([]) - const kind = fieldOf(row.key, query.dataSource).kind + const fieldLabel = rowFieldLabel(row, query.dataSource) return panelSelect( h, model, operatorSelectId(query.id, row.id), row.operator, - Arr.map(operatorsOf(kind, row.operator), (operator) => ({ - value: operator, - label: OPERATOR_LABEL[operator], - })), + Arr.map( + operatorsOf(fieldOf(row.key, query.dataSource), query.dataSource, row.operator), + (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + }), + ), { size: "sm", - className: "w-36", - ariaLabel: "Operator", + className: "col-start-3 row-start-1 w-full", + ariaLabel: `Operator for ${fieldLabel}`, toView: (parts) => layout([ ...parts, - ...(isValueless(row.operator) ? [] : [valuePicker(h, model, query, row, values)]), + ...(isValueless(row.operator) + ? [] + : [ + vstack( + h, + { gap: "1", className: VALUES_CELL }, + valuePicker(h, model, query, row, values), + ), + ]), ]), }, ) } -/** The rows, or nothing when there are none: the caller's "Add condition" starts the first. */ +/** The rows a query shows, or nothing: the caller's "Add condition" starts the first. A row another + * control owns (traces `root_only`) stays in the clause but is not drawn. */ export const conditionBuilder = ( h: H, model: Model, query: QueryDraft, rows: ReadonlyArray, values: WhereClauseAutocompleteValues, -): ReadonlyArray => - rows.length === 0 +): ReadonlyArray => { + const shown = Arr.filter(rows, (row) => !isOwnedElsewhere(row.key, query.dataSource)) + return shown.length === 0 ? [] : [ vstack( h, { gap: "1.5", + className: "@container", attributes: [h.AriaLabel(`Conditions for query ${query.name}`), h.Role("group")], }, - Arr.map(rows, (row, index) => conditionRow(h, model, query, row, index, values)), + Arr.map(shown, (row, index) => conditionRow(h, model, query, row, index, values)), ), ] +} diff --git a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts index 6d9e2665fd..22ca447025 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -1,13 +1,11 @@ import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" import { - all, click, Command, expect as expectView, given, keydown, label, - last, Mount, role, scene, @@ -64,11 +62,9 @@ const caretMounted = () => }), ) -/** The last row's value picker: the row just added. */ -const newValue = last(all.role("combobox", { name: "Value" })) - describe("condition builder", () => { test("a new row picks an attribute, then typed values, and prints an OR group", () => { + const value = role("combobox", { name: "Value for http.route" }) scene( { update, view }, given(hosted("")), @@ -78,16 +74,19 @@ describe("condition builder", () => { click(role("option", { name: /^Span attributes/ })), click(role("option", { name: /^http\.route/ })), ...pickerClosed("cond-q-c0-field", 1), - typeInto(role("combobox", { name: "Value" }), "/cart"), + // Adding a row and picking its field leave the clause alone until a value completes it. + expectView(text("(empty where clause)")).toExist(), + typeInto(value, "/cart"), ...pickerOpened(1), - keydown(role("combobox", { name: "Value" }), "Enter"), - typeInto(role("combobox", { name: "Value" }), "/checkout"), - keydown(role("combobox", { name: "Value" }), "Enter"), + keydown(value, "Enter"), + typeInto(value, "/checkout"), + keydown(value, "Enter"), expectView(text('(attr.http.route = "/cart" OR attr.http.route = "/checkout")')).toExist(), ) }) test("the field picker drills into resource attributes", () => { + const value = role("combobox", { name: "Value for host.name" }) scene( { update, view }, given(hosted('service.name = "checkout"')), @@ -96,19 +95,31 @@ describe("condition builder", () => { click(role("option", { name: /^Resource attributes/ })), click(role("option", { name: /^host\.name/ })), ...pickerClosed("cond-q-c1-field", 1), - typeInto(newValue, "web-1"), + typeInto(value, "web-1"), ...pickerOpened(1), - keydown(newValue, "Enter"), + keydown(value, "Enter"), expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), ) }) - test("removing a row prints the rest", () => { + test("a named field holds one value: a pick replaces it and closes the list", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + click(role("combobox", { name: "Value for Service" })), + ...pickerOpened(1), + click(role("option", { name: "api" })), + ...pickerClosed("cond-q-c0-values", 2), + expectView(text('service.name = "api"')).toExist(), + ) + }) + + test("removing a row keeps the other clauses as the user wrote them", () => { scene( { update, view }, - given(hosted('service.name = "checkout" AND attr.http.route exists')), - click(label("Remove condition")), - expectView(text("attr.http.route exists")).toExist(), + given(hosted("service = checkout AND attr.http.route exists")), + click(label("Remove condition: http.route is set")), + expectView(text("service = checkout")).toExist(), ) }) @@ -137,4 +148,17 @@ describe("condition builder", () => { expectView(role("button", { name: "Builder" })).toBeDisabled(), ) }) + + test("typing in the text editor keeps it, even once the text reads as rows", () => { + const editor = label("Where clause for query A") + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + typeInto(editor, 'service.name = "a"'), + expectView(editor).toExist(), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).not.toBeDisabled(), + ) + }) }) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.test.ts b/apps/web/src/page/kit/query-builder/condition-fields.test.ts new file mode 100644 index 0000000000..74b4ae8cc3 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest" +import { canMatchAnyOf, fieldOf, fieldsOf, operatorsOf } from "./condition-fields" +import { Array as Arr } from "effect" + +// The builder offers what the source's clause handler applies (the engine's key rules). + +describe("operatorsOf", () => { + test.each([ + ["logs", "service.name", ["=", "!="]], + ["logs", "severity", ["=", "!="]], + ["logs", "body", ["=", "contains"]], + ["metrics", "service.name", ["="]], + ["metrics", "deployment.environment", ["="]], + ["metrics", "metric.type", ["="]], + ["traces", "has_error", ["="]], + ["traces", "min_duration_ms", ["="]], + ["traces", "service.name", ["=", "!=", "contains"]], + ["product_events", "event.name", ["=", "!="]], + ["product_events", "user.id", ["="]], + ] as const)("%s %s", (dataSource, key, expected) => { + expect(operatorsOf(fieldOf(key, dataSource), dataSource)).toEqual(expected) + }) + + test("a map attribute takes the operators of its kind", () => { + expect(operatorsOf(fieldOf("attr.http.route", "traces"), "traces")).toEqual([ + "=", + "!=", + "contains", + "!contains", + "exists", + "!exists", + ]) + expect(operatorsOf(fieldOf("attr.http.response.status_code", "logs"), "logs")).toContain(">=") + }) + + test("a row keeps its current operator on offer", () => { + expect(operatorsOf(fieldOf("severity", "logs"), "logs", "contains")).toEqual(["=", "!=", "contains"]) + }) +}) + +describe("canMatchAnyOf", () => { + test("only attribute filters on sources that lower OR groups", () => { + expect(canMatchAnyOf(fieldOf("attr.http.route", "logs"), "logs")).toBe(true) + expect(canMatchAnyOf(fieldOf("db.system", "traces"), "traces")).toBe(true) + expect(canMatchAnyOf(fieldOf("db.system", "logs"), "logs")).toBe(false) + expect(canMatchAnyOf(fieldOf("service.name", "traces"), "traces")).toBe(false) + expect(canMatchAnyOf(fieldOf("attr.plan", "product_events"), "product_events")).toBe(false) + }) +}) + +describe("fieldsOf", () => { + test("traces intrinsics are named fields, and root_only is left to the span select", () => { + const keys = Arr.map(fieldsOf("traces", {}), (field) => field.key) + expect(keys).toEqual( + expect.arrayContaining(["min_duration_ms", "max_duration_ms", "vcs.ref.head.revision"]), + ) + expect(keys).not.toContain("root_only") + expect(fieldOf("root_only", "traces")).toMatchObject({ group: "common", kind: "boolean" }) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.ts b/apps/web/src/page/kit/query-builder/condition-fields.ts index 192ebecf18..88f5161be0 100644 --- a/apps/web/src/page/kit/query-builder/condition-fields.ts +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -1,4 +1,5 @@ import type { Operator } from "@maple/domain/where-clause" +import { whereKeyMatchesAnyOf, whereKeyRule, whereOperatorsOf } from "@maple/query-engine/query-builder" import { getWhereClauseAutocomplete, type WhereClauseAutocompleteValues, @@ -30,9 +31,17 @@ const COMMON_FIELDS = { common("deployment.environment", "Environment"), common("attr.http.response.status_code", "HTTP status code", "number"), common("has_error", "Has error", "boolean"), + common("min_duration_ms", "Min duration (ms)", "number"), + common("max_duration_ms", "Max duration (ms)", "number"), + common("vcs.ref.head.revision", "Commit"), + common("root_only", "Root spans only", "boolean"), ], logs: [common("service.name", "Service"), common("severity", "Severity"), common("body", "Message")], - metrics: [common("service.name", "Service"), common("metric.type", "Metric type")], + metrics: [ + common("service.name", "Service"), + common("deployment.environment", "Environment"), + common("metric.type", "Metric type"), + ], product_events: [ common("event.name", "Event name"), common("host", "Host"), @@ -73,14 +82,13 @@ export const fieldOf = (key: string, dataSource: DataSource): ConditionField => return { key, label, group: "attribute", kind: kindOfKey(key) } } -/** - * Whether the query engine can OR this field's values: only attribute filters lower an - * `(a OR b)` group; a named dimension (`service.name`) or a product-events query drops it. - */ +/** Whether the query engine ORs this field's values (`whereKeyMatchesAnyOf`: attribute filters only). */ export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => - dataSource !== "product_events" && - field.kind !== "boolean" && - (field.group !== "common" || Str.startsWith(ATTRIBUTE_PREFIX)(field.key)) + field.kind !== "boolean" && whereKeyMatchesAnyOf(dataSource, field.key) + +/** A key another control owns: the traces span select writes `root_only`. */ +export const isOwnedElsewhere = (key: string, dataSource: DataSource): boolean => + dataSource === "traces" && key === "root_only" /** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ export const fieldsOf = ( @@ -89,7 +97,7 @@ export const fieldsOf = ( ): ReadonlyArray => Arr.dedupeWith( [ - ...COMMON_FIELDS[dataSource], + ...Arr.filter(COMMON_FIELDS[dataSource], (field) => !isOwnedElsewhere(field.key, dataSource)), ...Arr.map(values.resourceAttributeKeys ?? [], (key) => fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), ), @@ -171,14 +179,26 @@ export const fieldPickerItems = ( // Operators and values. -/** The operators a field takes, in menu order; a row's current operator always stays offered. */ -export const operatorsOf = (kind: FieldKind, current?: Operator): ReadonlyArray => { +const KIND_OPERATORS = { + number: ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"], + boolean: ["=", "!=", "exists", "!exists"], + string: ["=", "!=", "contains", "!contains", "exists", "!exists"], +} satisfies Record> + +/** + * The operators a field takes on its source (the engine's `whereOperatorsOf`); a map attribute + * takes every operator, narrowed to its kind. A row's current operator always stays offered. + */ +export const operatorsOf = ( + field: ConditionField, + dataSource: DataSource, + current?: Operator, +): ReadonlyArray => { + const accepted = whereOperatorsOf(dataSource, field.key) const own: ReadonlyArray = - kind === "number" - ? ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"] - : kind === "boolean" - ? ["=", "!=", "exists", "!exists"] - : ["=", "!=", "contains", "!contains", "exists", "!exists"] + whereKeyRule(dataSource, field.key)._tag === "Attribute" + ? Arr.filter(KIND_OPERATORS[field.kind], (operator) => accepted.includes(operator)) + : accepted return current === undefined || own.includes(current) ? own : [...own, current] } diff --git a/apps/web/src/page/kit/query-builder/condition-rows.test.ts b/apps/web/src/page/kit/query-builder/condition-rows.test.ts index 9a15ec4df8..ce7e69025f 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -1,6 +1,14 @@ import { parseWhereClause } from "@maple/domain/where-clause" import { describe, expect, test } from "vitest" -import { type ConditionRow, nextRowId, parseConditionRows, printConditionRows } from "./condition-rows" +import { + type ConditionRow, + isUnspellable, + nextRowId, + parseConditionRows, + printConditionRows, + printConditionRowsOver, + sameConditions, +} from "./condition-rows" import { Array as Arr, Option } from "effect" const rowsOf = (whereClause: string) => @@ -137,3 +145,47 @@ describe("nextRowId", () => { expect(nextRowId([row("a", "=", [], "c0"), row("b", "=", [], "c4")])).toBe("c5") }) }) + +describe("printConditionRowsOver", () => { + const original = + "service = checkout AND attr.http.route exists AND severity != DEBUG AND severity != TRACE" + const rows = Option.getOrThrow(parseConditionRows(original)) + + test("untouched rows keep their text, a changed row is printed afresh", () => { + const edited = Arr.map(rows, (row) => + row.key === "attr.http.route" ? { ...row, operator: "!exists" as const } : row, + ) + expect(printConditionRowsOver(edited, original)).toBe( + "service = checkout AND attr.http.route !exists AND severity != DEBUG AND severity != TRACE", + ) + }) + + test("a removed row drops only its text", () => { + expect( + printConditionRowsOver( + Arr.filter(rows, (row) => row.key !== "attr.http.route"), + original, + ), + ).toBe("service = checkout AND severity != DEBUG AND severity != TRACE") + }) +}) + +describe("sameConditions", () => { + test("ignores ids and unfinished rows, not order or values", () => { + const a = [row("service.name", "=", ["a"], "c0"), row("", "=", [], "c1")] + expect(sameConditions(a, [row("service.name", "=", ["a"], "c9")])).toBe(true) + expect(sameConditions(a, [row("service.name", "=", ["b"])])).toBe(false) + expect( + sameConditions( + [row("a", "exists", []), row("b", "exists", [], "c1")], + [row("b", "exists", []), row("a", "exists", [], "c1")], + ), + ).toBe(false) + }) +}) + +describe("isUnspellable", () => { + test("only a value with both quote kinds", () => { + expect(Arr.map([`it's`, `say "hi"`, `it's "on"`], isUnspellable)).toEqual([false, false, true]) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts index c254f11f47..9ec40a1c9e 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -64,29 +64,72 @@ const partOf = (part: string): Option.Option => { : Option.none() } +/** A row with the clause text it was read from, so an untouched row keeps the user's spelling. */ +type Spelled = Readonly<{ draft: Draft; text: string }> + /** `k != a AND k != b` reads as one "is not a, b" row. */ -const mergeNoneOf = (drafts: ReadonlyArray): ReadonlyArray => - Arr.reduce(drafts, Arr.empty(), (rows: ReadonlyArray, draft) => { - if (!isNoneOf(draft.operator)) return [...rows, draft] +const mergeNoneOf = (parts: ReadonlyArray): ReadonlyArray => + Arr.reduce(parts, Arr.empty(), (rows: ReadonlyArray, part) => { + if (!isNoneOf(part.draft.operator)) return [...rows, part] const index = Arr.findFirstIndex( rows, - (row) => row.key === draft.key && row.operator === draft.operator, + (row) => row.draft.key === part.draft.key && row.draft.operator === part.draft.operator, ) return Option.match(index, { - onNone: () => [...rows, draft], + onNone: () => [...rows, part], onSome: (at) => Arr.map(rows, (row, i) => - i === at ? { ...row, values: Arr.dedupe([...row.values, ...draft.values]) } : row, + i === at + ? { + draft: { + ...row.draft, + values: Arr.dedupe([...row.draft.values, ...part.draft.values]), + }, + text: `${row.text} AND ${part.text}`, + } + : row, ), }) }) +const spelledRows = (whereClause: string): Option.Option> => + Option.map( + Option.all( + Arr.map(splitWhereClause(Str.trim(whereClause)), (text) => + Option.map(partOf(text), (draft): Spelled => ({ draft, text })), + ), + ), + mergeNoneOf, + ) + /** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ export const parseConditionRows = (whereClause: string): Option.Option> => - Option.map(Option.all(Arr.map(splitWhereClause(Str.trim(whereClause)), partOf)), (drafts) => - Arr.map(mergeNoneOf(drafts), (draft, index) => ({ id: `c${index}`, ...draft })), + Option.map(spelledRows(whereClause), (parts) => + Arr.map(parts, ({ draft }, index) => ({ id: `c${index}`, ...draft })), ) +const sameDraft = (a: Draft, b: Draft): boolean => + a.key === b.key && + a.operator === b.operator && + a.values.length === b.values.length && + Arr.every(a.values, (value, index) => b.values[index] === value) + +/** The same complete conditions in the same order, ids aside. */ +export const sameConditions = (a: ReadonlyArray, b: ReadonlyArray): boolean => { + const left = Arr.filter(a, isComplete) + const right = Arr.filter(b, isComplete) + return ( + left.length === right.length && + Arr.every(left, (row, index) => { + const other = right[index] + return other !== undefined && sameDraft(row, other) + }) + ) +} + +/** A value the grammar cannot spell: it has no escape, so a value may not hold both quotes. */ +export const isUnspellable = (value: string): boolean => Str.includes('"')(value) && Str.includes("'")(value) + /** Numbers and booleans stay bare (`root_only = true` is what the span select reads); the rest is quoted. */ const literalOf = (value: string): string => /^-?\d+(\.\d+)?$/.test(value) || value === "true" || value === "false" ? value : quoteWhereValue(value) @@ -109,6 +152,27 @@ const printRow = (row: ConditionRow): string => { export const printConditionRows = (rows: ReadonlyArray): string => Arr.join(Arr.map(Arr.filter(rows, isComplete), printRow), " AND ") +/** + * `printConditionRows`, but a row `original` already holds keeps its text there, so an edit + * rewrites only the clauses it changed. + */ +export const printConditionRowsOver = (rows: ReadonlyArray, original: string): string => { + const spelled = Option.getOrElse(spelledRows(original), () => Arr.empty()) + const [, texts] = Arr.mapAccum( + Arr.filter(rows, isComplete), + spelled, + (unused: ReadonlyArray, row): [ReadonlyArray, string] => + Option.match( + Arr.findFirstIndex(unused, (part) => sameDraft(part.draft, row)), + { + onNone: () => [unused, printRow(row)], + onSome: (at) => [Arr.remove(unused, at), unused[at]?.text ?? printRow(row)], + }, + ), + ) + return Arr.join(texts, " AND ") +} + /** A new row's id, past every id the rows use. */ export const nextRowId = (rows: ReadonlyArray): string => { const used = Arr.map(rows, (row) => Number(Str.slice(1)(row.id))) diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts index 537a799573..5321b475f5 100644 --- a/apps/web/src/page/kit/query-builder/conditions.test.ts +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -5,11 +5,13 @@ import { init, Message, type Model, operatorSelectId, type PanelInputs, update } // The builder's operator and value steps, as the where-clause edit they emit. -const traces = (whereClause: string): QueryDraft => ({ - ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), +const draftOf = (dataSource: QueryDraft["dataSource"], whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, dataSource), whereClause, }) +const traces = (whereClause: string): QueryDraft => draftOf("traces", whereClause) + const inputsFor = (query: QueryDraft): PanelInputs => ({ queryOf: (queryId) => (queryId === query.id ? query : undefined), baseValuesOf: () => ({ services: ["checkout", "api"] }), @@ -18,12 +20,18 @@ const inputsFor = (query: QueryDraft): PanelInputs => ({ }) /** The where clause a message leaves as, or null when it leaves none. */ -const emitted = (whereClause: string, message: Message, model: Model = init("test")): string | null => { - const next = update(model, message, inputsFor(traces(whereClause))) +const emittedOn = (query: QueryDraft, message: Message, model: Model = init("test")): string | null => { + const next = update(model, message, inputsFor(query)) const change = next.outMessage?._tag === "ChangedQuery" ? next.outMessage.change : null return change?._tag === "SetText" ? change.value : null } +const emitted = (whereClause: string, message: Message, model?: Model): string | null => + emittedOn(traces(whereClause), message, model) + +const values = (rowId: string, message: Parameters[0]["message"]) => + Message.GotConditionValues({ queryId: "q", rowId, message }) + const pickOperator = (rowId: string, value: string) => Message.GotSelectMessage({ id: operatorSelectId("q", rowId), @@ -120,3 +128,54 @@ describe("mode", () => { expect(next.model.whereModes).toEqual({ q: "text" }) }) }) + +describe("rewrites", () => { + test("adding a row leaves the clause alone until the row is complete", () => { + expect(emitted('service.name = "checkout"', Message.ClickedAddCondition({ queryId: "q" }))).toBeNull() + }) + + test("an edit rewrites only its own clause", () => { + expect(emitted('service = checkout AND attr.http.route = "/a"', pickOperator("c1", "exists"))).toBe( + "service = checkout AND attr.http.route exists", + ) + }) + + test("the span select's root_only clause survives a builder edit", () => { + expect(emitted('root_only = true AND service.name = "a"', pickOperator("c1", "!="))).toBe( + 'root_only = true AND service.name != "a"', + ) + }) + + test("clicking a single value's current value changes nothing", () => { + expect( + emitted('service.name = "checkout"', values("c0", { _tag: "ClickedItem", value: "checkout" })), + ).toBeNull() + }) +}) + +describe("refusals", () => { + test("a typed value holding both quotes is not added", () => { + const query = traces('attr.note = "x"') + const typed = update( + init("test"), + values("c0", { _tag: "ChangedInput", value: `it's "on"` }), + inputsFor(query), + ) + expect(typed.outMessage).toBeUndefined() + const entered = update( + typed.model, + values("c0", { _tag: "PressedInputKey", key: "Enter", items: [] }), + inputsFor(query), + ) + expect(entered.outMessage).toBeUndefined() + }) + + test("a product-events exclude holds one value, since a second would replace the first", () => { + expect( + emittedOn( + draftOf("product_events", 'event.name != "signup"'), + values("c0", { _tag: "ClickedItem", value: "login" }), + ), + ).toBe('event.name != "login"') + }) +}) diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts index 252a1c1012..dc069b6bfa 100644 --- a/apps/web/src/page/kit/query-builder/conditions.ts +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -7,20 +7,22 @@ import { QueryChange } from "./change" import { canMatchAnyOf, fieldOf, fieldPickerItems, fieldsOf, operatorsOf } from "./condition-fields" import { type ConditionRow, + isAnyOf, + isNoneOf, + isUnspellable, isValueless, nextRowId, parseConditionRows, - printConditionRows, - isAnyOf, - isNoneOf, + printConditionRowsOver, + sameConditions, } from "./condition-rows" import type { QueryDraft } from "./draft" import { Message, type Model, OutMessage, type WhereMode } from "./panel-message" import type { PanelInputs } from "./where" import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" -// The condition builder's steps: every edit prints the rows and leaves as the query's -// where-clause edit, so previews and saving see only the string. +// The condition builder's steps: an edit that changes the conditions leaves as the query's +// where-clause edit, rewriting only the clauses it touched, so previews and saving see only the string. type Return = Update.ReturnWithOutMessage @@ -36,36 +38,68 @@ export const operatorSelectId = (queryId: string, rowId: string): string => export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERATOR_SELECT}:`)(id) -/** The rows a query shows: the kept ones while they still print its where clause, else a fresh parse. */ +/** The kept rows while they still mean the query's where clause, else none. */ +const keptRowsOf = (model: Model, query: QueryDraft): Option.Option> => + Option.flatMap(Option.fromNullishOr(model.conditions[query.id]), (kept) => + Option.filter(parseConditionRows(query.whereClause), (parsed) => sameConditions(parsed, kept)).pipe( + Option.map(() => kept), + ), + ) + +/** The rows a query shows: the kept ones while they mean its where clause, else a fresh parse. */ export const conditionRowsOf = ( model: Model, query: QueryDraft, -): Option.Option> => { - const kept = model.conditions[query.id] - return kept !== undefined && printConditionRows(kept) === query.whereClause - ? Option.some(kept) - : parseConditionRows(query.whereClause) +): Option.Option> => + Option.orElse(keptRowsOf(model, query), () => parseConditionRows(query.whereClause)) + +/** A fresh parse renumbers the rows: drop the query's rows, pickers, drills and operator selects. */ +export const withoutBuilderState = (model: Model, queryId: string): Model => { + const pickerPrefix = `cond-${queryId}-` + return { + ...model, + conditions: Rec.remove(model.conditions, queryId), + conditionPickers: Rec.filter(model.conditionPickers, (_, id) => !Str.startsWith(pickerPrefix)(id)), + fieldDrills: Rec.filter(model.fieldDrills, (_, id) => !Str.startsWith(pickerPrefix)(id)), + selects: Rec.filter(model.selects, (_, id) => !Str.startsWith(`${OPERATOR_SELECT}:${queryId}:`)(id)), + } } -/** Whether a query edits as rows: chosen (the default) and representable. */ +/** The editor a query shows; only a Builder click leaves Text once the text editor was used. */ export const whereModeOf = (model: Model, query: QueryDraft): WhereMode => model.whereModes[query.id] ?? "builder" +/** The text editor was typed in: it stays the editor, and the rows read afresh later. */ +export const typedInTextEditor = (model: Model, queryId: string): Model => ({ + ...withoutBuilderState(model, queryId), + whereModes: { ...model.whereModes, [queryId]: "text" }, +}) + +/** Keeps the rows; the where clause changes only when the complete conditions do. */ const emitted = ( model: Model, query: QueryDraft, + before: ReadonlyArray, rows: ReadonlyArray, commands?: Return["commands"], -): Return => ({ - model: { ...model, conditions: { ...model.conditions, [query.id]: rows } }, - ...(commands === undefined ? undefined : { commands }), - outMessage: OutMessage.ChangedQuery({ - queryId: query.id, - change: QueryChange.SetText({ field: "whereClause", value: printConditionRows(rows) }), - }), -}) +): Return => { + const kept: Model = { ...model, conditions: { ...model.conditions, [query.id]: rows } } + const withCommands = commands === undefined ? undefined : { commands } + if (sameConditions(before, rows)) return { model: kept, ...withCommands } + return { + model: kept, + ...withCommands, + outMessage: OutMessage.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ + field: "whereClause", + value: printConditionRowsOver(rows, query.whereClause), + }), + }), + } +} -type RowContext = Readonly<{ query: QueryDraft; rows: ReadonlyArray }> +type RowContext = Readonly<{ model: Model; query: QueryDraft; rows: ReadonlyArray }> const withRows = ( model: Model, @@ -75,9 +109,22 @@ const withRows = ( ): Return => { const query = inputs?.queryOf(queryId) if (query === undefined) return { model } - return Option.match(conditionRowsOf(model, query), { - onNone: () => ({ model }), - onSome: (rows) => step({ query, rows }), + return Option.match(keptRowsOf(model, query), { + onSome: (rows) => step({ model, query, rows }), + // Stale kept rows mean the ids renumber: their pickers go. The fresh rows are kept either way. + onNone: () => + Option.match(parseConditionRows(query.whereClause), { + onNone: () => ({ model }), + onSome: (rows) => { + const fresh = + model.conditions[queryId] === undefined ? model : withoutBuilderState(model, queryId) + return step({ + model: { ...fresh, conditions: { ...fresh.conditions, [queryId]: rows } }, + query, + rows, + }) + }, + }), }) } @@ -91,7 +138,7 @@ const withRow = ( withRows(model, queryId, inputs, (context) => Option.match( Arr.findFirst(context.rows, (row) => row.id === rowId), - { onNone: () => ({ model }), onSome: (row) => step({ ...context, row }) }, + { onNone: () => ({ model: context.model }), onSome: (row) => step({ ...context, row }) }, ), ) @@ -99,26 +146,22 @@ const replaced = (rows: ReadonlyArray, next: ConditionRow): Readon Arr.map(rows, (row) => (row.id === next.id ? next : row)) export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ - model: { - ...model, - whereModes: { ...model.whereModes, [queryId]: mode }, - conditions: Rec.remove(model.conditions, queryId), - }, + model: { ...withoutBuilderState(model, queryId), whereModes: { ...model.whereModes, [queryId]: mode } }, }) -/** A new row opens its field picker, as the first thing to choose. */ +/** A new row opens its field picker, as the first thing to choose; the clause is unchanged. */ export const addedCondition = (model: Model, queryId: string, inputs: PanelInputs | undefined): Return => - withRows(model, queryId, inputs, ({ query, rows }) => { + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { const id = nextRowId(rows) const pickerId = conditionPickerId(queryId, id, "field") const opened: Model = { - ...model, + ...current, conditionPickers: { - ...model.conditionPickers, + ...current.conditionPickers, [pickerId]: Combobox.init(pickerId, "single", { isOpen: true }), }, } - return emitted(opened, query, [...rows, { id, key: "", operator: "=", values: [] }]) + return emitted(opened, query, rows, [...rows, { id, key: "", operator: "=", values: [] }]) }) export const removedCondition = ( @@ -127,19 +170,19 @@ export const removedCondition = ( rowId: string, inputs: PanelInputs | undefined, ): Return => - withRows(model, queryId, inputs, ({ query, rows }) => { + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { const pickerIds = [ conditionPickerId(queryId, rowId, "field"), conditionPickerId(queryId, rowId, "values"), ] - const pickers = Rec.filter(model.conditionPickers, (_, id) => !pickerIds.includes(id)) return emitted( { - ...model, - conditionPickers: pickers, - selects: Rec.remove(model.selects, operatorSelectId(queryId, rowId)), + ...current, + conditionPickers: Rec.filter(current.conditionPickers, (_, id) => !pickerIds.includes(id)), + selects: Rec.remove(current.selects, operatorSelectId(queryId, rowId)), }, query, + rows, Arr.filter(rows, (row) => row.id !== rowId), ) }) @@ -152,9 +195,6 @@ const pickedValue = (current: Combobox.Model, message: Combobox.Message): string return null } -const pickerOf = (model: Model, id: string, mode: Combobox.Mode): Combobox.Model => - model.conditionPickers[id] ?? Combobox.init(id, mode) - /** The field picker: a group or "All fields" drills without closing; a field sets the row's key. */ export const fieldStep = ( model: Model, @@ -163,10 +203,11 @@ export const fieldStep = ( message: Combobox.Message, inputs: PanelInputs | undefined, ): Return => - withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const id = conditionPickerId(queryId, rowId, "field") - const current = { ...pickerOf(model, id, "single"), selected: row.key === "" ? [] : [row.key] } - const drill = model.fieldDrills[id] ?? "all" + const stored = base.conditionPickers[id] ?? Combobox.init(id, "single") + const current = { ...stored, selected: row.key === "" ? [] : [row.key] } + const drill = base.fieldDrills[id] ?? "all" const fields = fieldsOf(query.dataSource, inputs?.baseValuesOf(query) ?? {}) const items = fieldPickerItems(fields, drill, Combobox.queryOf(current)) const picked = Option.flatMap(Option.fromNullishOr(pickedValue(current, message)), (value) => @@ -182,19 +223,19 @@ export const fieldStep = ( if (Option.isSome(navigated)) return { model: { - ...model, + ...base, conditionPickers: { - ...model.conditionPickers, + ...base.conditionPickers, [id]: { ...current, inputValue: "", highlighted: 0 }, }, - fieldDrills: { ...model.fieldDrills, [id]: navigated.value }, + fieldDrills: { ...base.fieldDrills, [id]: navigated.value }, }, } const next = Combobox.update(current, message) const lifted: Model = { - ...model, - conditionPickers: { ...model.conditionPickers, [id]: next.model }, - fieldDrills: next.model.isOpen ? model.fieldDrills : Rec.remove(model.fieldDrills, id), + ...base, + conditionPickers: { ...base.conditionPickers, [id]: next.model }, + fieldDrills: next.model.isOpen ? base.fieldDrills : Rec.remove(base.fieldDrills, id), } const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotConditionField({ queryId, rowId, message: inner }), @@ -210,20 +251,34 @@ export const fieldStep = ( ) if (next.outMessage?._tag !== "ChangedValue" || key === null || key === row.key) return { model: lifted, commands } - const operators = operatorsOf(fieldOf(key, query.dataSource).kind) - const operator = operators.includes(row.operator) ? row.operator : "=" - return emitted(lifted, query, replaced(rows, { ...row, key, operator, values: [] }), commands) + const operators = operatorsOf(fieldOf(key, query.dataSource), query.dataSource) + const operator = operators.includes(row.operator) ? row.operator : (operators[0] ?? "=") + return emitted(lifted, query, rows, replaced(rows, { ...row, key, operator, values: [] }), commands) }) -/** Whether a row holds several values: "is not" always (AND-ed excludes), "is" only where the engine ORs. */ +/** + * Whether a row holds several values: "is" only where the engine ORs (attribute filters), + * "is not" as AND-ed excludes except on product events, whose excludes replace each other. + */ export const holdsManyValues = (row: Pick, query: QueryDraft): boolean => - isNoneOf(row.operator) || - (isAnyOf(row.operator) && canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource)) + isAnyOf(row.operator) + ? canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource) + : isNoneOf(row.operator) && query.dataSource !== "product_events" -const withValue = (row: ConditionRow, query: QueryDraft, value: string): ReadonlyArray => - holdsManyValues(row, query) ? Arr.dedupe([...row.values, value]) : [value] +/** The value picker's Model for the row: a multiple picker with chips, or a single one holding the value. */ +export const valuePickerOf = (model: Model, query: QueryDraft, row: ConditionRow): Combobox.Model => { + const id = conditionPickerId(query.id, row.id, "values") + const mode = holdsManyValues(row, query) ? "multiple" : "single" + const stored = model.conditionPickers[id] + const picker = stored !== undefined && stored.mode === mode ? stored : Combobox.init(id, mode) + return { + ...picker, + selected: row.values, + inputValue: mode === "single" && !picker.isOpen ? (row.values[0] ?? "") : picker.inputValue, + } +} -/** The value picker: suggestions or anything typed (Enter adds it); one value unless the row holds many. */ +/** The value picker: a suggestion or anything typed (Enter adds it); a single picker closes on a pick. */ export const valuesStep = ( model: Model, queryId: string, @@ -231,33 +286,43 @@ export const valuesStep = ( message: Combobox.Message, inputs: PanelInputs | undefined, ): Return => - withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const id = conditionPickerId(queryId, rowId, "values") - const current = { ...pickerOf(model, id, "multiple"), selected: row.values } + const current = valuePickerOf(base, query, row) + const isMany = current.mode === "multiple" + const lift = (next: Combobox.Model): Model => ({ + ...base, + conditionPickers: { ...base.conditionPickers, [id]: next }, + }) const typed = Str.trim(current.inputValue) - if ( + const isTypedEnter = message._tag === "PressedInputKey" && message.key === "Enter" && current.highlighted === null && typed !== "" - ) + // The grammar cannot spell it; the view says so beside the input. + if (isTypedEnter && isUnspellable(typed)) return { model: lift(current) } + if (isTypedEnter && isMany) return emitted( - { - ...model, - conditionPickers: { ...model.conditionPickers, [id]: { ...current, inputValue: "" } }, - }, + lift({ ...current, inputValue: "" }), query, - replaced(rows, { ...row, values: withValue(row, query, typed) }), + rows, + replaced(rows, { ...row, values: Arr.dedupe([...row.values, typed]) }), ) - const next = Combobox.update(current, message) - const lifted: Model = { ...model, conditionPickers: { ...model.conditionPickers, [id]: next.model } } + const pick = isTypedEnter ? Combobox.Message.PressedEscape() : message + const next = Combobox.update(isTypedEnter ? { ...current, selected: [typed] } : current, pick) const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotConditionValues({ queryId, rowId, message: inner }), ) - if (next.outMessage?._tag !== "ChangedSelection") return { model: lifted, commands } - const selected = next.outMessage.selected - const values = holdsManyValues(row, query) ? selected : Arr.takeRight(selected, 1) - return emitted(lifted, query, replaced(rows, { ...row, values }), commands) + const values = isTypedEnter + ? [typed] + : next.outMessage?._tag === "ChangedSelection" + ? next.outMessage.selected + : next.outMessage?._tag === "ChangedValue" + ? [next.outMessage.value] + : null + if (values === null || Arr.some(values, isUnspellable)) return { model: lift(next.model), commands } + return emitted(lift(next.model), query, rows, replaced(rows, { ...row, values }), commands) }) const isOperator = Schema.is(Operator) @@ -278,12 +343,12 @@ export const operatorStep = ( const [, queryId = "", rowId = ""] = Str.split(":")(id) const operator = next.outMessage?.value if (operator === undefined || !isOperator(operator)) return { model: lifted, commands } - return withRow(lifted, queryId, rowId, inputs, ({ query, rows, row }) => { + return withRow(lifted, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const values = isValueless(operator) ? [] : holdsManyValues({ key: row.key, operator }, query) ? row.values : Arr.take(row.values, 1) - return emitted(lifted, query, replaced(rows, { ...row, operator, values }), commands) + return emitted(base, query, rows, replaced(rows, { ...row, operator, values }), commands) }) } diff --git a/apps/web/src/page/kit/query-builder/metrics-body.ts b/apps/web/src/page/kit/query-builder/metrics-body.ts index 5d57b60350..8bf41ce7b0 100644 --- a/apps/web/src/page/kit/query-builder/metrics-body.ts +++ b/apps/web/src/page/kit/query-builder/metrics-body.ts @@ -14,7 +14,8 @@ import { QUERY_BUILDER_SPACE_AGGREGATIONS } from "@maple/query-model" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, metricPickerIdOf, type Model } from "./panel" import { type MetricOption, metricValueOf } from "./metric-options" -import { panelSelect, textInput, whereClauseEditor } from "./parts" +import { panelSelect } from "./panel-select" +import { textInput, whereClauseEditor } from "./parts" import { Array as Arr, Schema, String as Str, Option } from "effect" // `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: diff --git a/apps/web/src/page/kit/query-builder/panel.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7329cab460..af231d5039 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -16,6 +16,7 @@ import { operatorSelectId, operatorStep, removedCondition, + typedInTextEditor, valuesStep, } from "./conditions" import { groupBySelectionOf } from "./draft" @@ -145,13 +146,23 @@ export const update = (model: Model, message: Message, inputs?: PanelInputs): Re model: { ...model, collapsed: toggled(model.collapsed, queryId) }, }), ToggledVisible: ({ queryId }) => changed(model, queryId, QueryChange.ToggledHidden()), + // Typing in the text editor keeps it the editor, even once the text reads as rows. ChangedText: ({ queryId, field, value }) => - changed(model, queryId, QueryChange.SetText({ field, value })), + changed( + field === "whereClause" ? typedInTextEditor(model, queryId) : model, + queryId, + QueryChange.SetText({ field, value }), + ), ToggledAddOn: ({ queryId, key }) => changed(model, queryId, QueryChange.ToggledAddOn({ key })), ClickedClone: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedClone({ queryId }) }), ClickedRemove: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedRemove({ queryId }) }), - GotWhereField: ({ message: inner }) => - inputs === undefined ? { model } : whereStep(model, inner, inputs), + GotWhereField: ({ message: inner }) => { + if (inputs === undefined) return { model } + const next = whereStep(model, inner, inputs) + return next.outMessage === undefined + ? next + : { ...next, model: typedInTextEditor(next.model, next.outMessage.queryId) } + }, ChangedWhereMode: ({ queryId, mode }) => changedWhereMode(model, queryId, mode), ClickedAddCondition: ({ queryId }) => addedCondition(model, queryId, inputs), ClickedRemoveCondition: ({ queryId, rowId }) => removedCondition(model, queryId, rowId, inputs), diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index a29d43baba..62ed3c3119 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -19,8 +19,6 @@ import { conditionRowsOf, whereModeOf } from "./conditions" import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" import { panelSelect } from "./panel-select" - -export { panelSelect } import { Array as Arr, Record as Rec, Option } from "effect" // The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. @@ -74,7 +72,7 @@ const UNPARSEABLE_NOTE = "This filter uses syntax the builder can't show, edit a /** * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text - * editor; a Builder/Text switch under it. Both edit the same where-clause string. + * editor, under a Builder/Text switch. Both edit the same where-clause string. */ export const whereClauseEditor = ( h: H, @@ -84,22 +82,7 @@ export const whereClauseEditor = ( options: Readonly<{ placeholder?: string; className?: string }> = {}, ): Html => { const rows = conditionRowsOf(model, query) - const preferred = whereModeOf(model, query) - const isBuilder = preferred === "builder" && Option.isSome(rows) - const leading = isBuilder - ? button( - h, - { - variant: "ghost", - size: "xs", - className: "text-muted-foreground", - attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], - }, - [icon(h, "PlusIcon"), "Add condition"], - ) - : preferred === "builder" - ? typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE]) - : h.empty + const isBuilder = whereModeOf(model, query) === "builder" && Option.isSome(rows) const toggle = segmentedSelect(h, { options: [ { value: "builder", label: "Builder", isDisabled: Option.isNone(rows) }, @@ -111,17 +94,31 @@ export const whereClauseEditor = ( ariaLabel: `Where clause editor for query ${query.name}`, className: "ml-auto shrink-0", }) + const note = Option.isNone(rows) ? [typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE])] : [] + const addCondition = button( + h, + { + variant: "ghost", + size: "xs", + className: "self-start text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ) return vstack(h, { gap: "1.5", className: options.className ?? "flex-1 min-w-0" }, [ + hstack(h, { gap: "2" }, [...note, toggle]), ...(isBuilder - ? conditionBuilder( - h, - model, - query, - Option.getOrElse(rows, () => []), - values, - ) + ? [ + ...conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ), + addCondition, + ] : [whereTextEditor(h, model, query, values, options.placeholder)]), - hstack(h, { gap: "2", wrap: true }, [leading, toggle]), ]) } diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index cd6a5e0753..749461fab9 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -21,7 +21,8 @@ import type { QueryDraft } from "./draft" import { Message, type Model } from "./panel" import type { MetricOption } from "./metric-options" import { type MetricKeys, metricsBody } from "./metrics-body" -import { addOnSections, ADD_ON_KEYS, addOnToggles, panelSelect, textInput, whereClauseEditor } from "./parts" +import { panelSelect } from "./panel-select" +import { addOnSections, ADD_ON_KEYS, addOnToggles, textInput, whereClauseEditor } from "./parts" import { Array as Arr, String as Str } from "effect" // `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. diff --git a/packages/query-engine/src/query-builder/model.ts b/packages/query-engine/src/query-builder/model.ts index 79c7bea8de..a8d24c93f3 100644 --- a/packages/query-engine/src/query-builder/model.ts +++ b/packages/query-engine/src/query-builder/model.ts @@ -10,6 +10,8 @@ import { } from "@maple/domain/where-clause" import { Match, Array as Arr, Record as Rec, String as Str, Option, Result } from "effect" +export * from "./where-key-rules" + export type { QueryBuilderDataSource, QueryBuilderMetricType, diff --git a/packages/query-engine/src/query-builder/where-key-rules.test.ts b/packages/query-engine/src/query-builder/where-key-rules.test.ts new file mode 100644 index 0000000000..aec3551a62 --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.test.ts @@ -0,0 +1,98 @@ +import type { QueryBuilderQueryDraftPayload } from "@maple/domain/http" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { describe, expect, it } from "vitest" +import { buildTimeseriesQuerySpec, WHERE_DIMENSIONS, WHERE_OPERATORS, whereOperatorsOf } from "./model" +import { Array as Arr, Option, Record as Rec } from "effect" + +// The key rules against the clause handlers: every allowlisted operator is applied without a +// warning, and every other operator on a dimension warns, so the table cannot drift. + +const draft = (dataSource: QueryBuilderDataSource, whereClause: string): QueryBuilderQueryDraftPayload => ({ + id: "q1", + name: "A", + enabled: true, + hidden: false, + dataSource, + aggregation: dataSource === "metrics" ? "avg" : "count", + whereClause, + stepInterval: "", + orderByDirection: "desc", + addOns: { groupBy: false, having: false, orderBy: false, limit: false, legend: false }, + groupBy: [], + having: "", + orderBy: "", + limit: "", + legend: "", + ...(dataSource === "metrics" + ? { + metricName: "system.cpu.utilization", + metricType: "gauge", + isMonotonic: false, + signalSource: "default", + } + : undefined), +}) + +/** A value each key's handler reads as valid, so only the operator decides. */ +const VALID_VALUES: ReadonlyArray = [ + ["root_only", "true"], + ["has_error", "true"], + ["min_duration_ms", "5"], + ["max_duration_ms", "5"], + ["metric.type", "gauge"], + ["visitor.type", "new"], + ["visitor_type", "new"], +] + +const validValueOf = (key: string): string => + Option.match( + Arr.findFirst(VALID_VALUES, ([candidate]) => candidate === key), + { onNone: () => "x", onSome: ([, value]) => value }, + ) + +const clauseOf = (key: string, operator: string): string => + operator === "exists" || operator === "!exists" + ? `${key} ${operator}` + : `${key} ${operator} "${validValueOf(key)}"` + +const warningsOf = (dataSource: QueryBuilderDataSource, whereClause: string) => + buildTimeseriesQuerySpec(draft(dataSource, whereClause)).warnings + +const sources: ReadonlyArray = ["traces", "logs", "metrics", "product_events"] + +describe("where key rules", () => { + const dimensionCases = Arr.flatMap(sources, (dataSource) => + Arr.flatMap(Rec.keys(WHERE_DIMENSIONS[dataSource]), (key) => + Arr.map(WHERE_OPERATORS, (operator) => ({ + dataSource, + key, + operator, + isAllowed: whereOperatorsOf(dataSource, key).includes(operator), + })), + ), + ) + + it.each(dimensionCases)( + "$dataSource $key $operator: allowed=$isAllowed", + ({ dataSource, key, operator, isAllowed }) => { + expect(warningsOf(dataSource, clauseOf(key, operator)).length === 0).toBe(isAllowed) + }, + ) + + const attributeCases = Arr.flatMap(sources, (dataSource) => + Arr.map(WHERE_OPERATORS, (operator) => ({ dataSource, operator })), + ) + + it.each(attributeCases)("$dataSource attr.* takes $operator", ({ dataSource, operator }) => { + expect(whereOperatorsOf(dataSource, "attr.http.route")).toContain(operator) + expect(warningsOf(dataSource, clauseOf("attr.http.route", operator))).toEqual([]) + }) + + it("a bare unknown key is an attribute on traces and dropped on logs and metrics", () => { + expect(whereOperatorsOf("traces", "db.system")).toEqual(WHERE_OPERATORS) + expect(whereOperatorsOf("logs", "db.system")).toEqual([]) + expect(warningsOf("logs", 'db.system = "x"').length).toBeGreaterThan(0) + expect(whereOperatorsOf("metrics", "db.system")).toEqual([]) + expect(warningsOf("metrics", 'db.system = "x"').length).toBeGreaterThan(0) + }) +}) diff --git a/packages/query-engine/src/query-builder/where-key-rules.ts b/packages/query-engine/src/query-builder/where-key-rules.ts new file mode 100644 index 0000000000..148cfa506a --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.ts @@ -0,0 +1,137 @@ +import { normalizeKey, type Operator } from "@maple/domain/where-clause" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { Option, Record as Rec, String as Str } from "effect" + +// Which where-clause operators each source's clause handler (`apply*Clause` in `model.ts`) +// accepts per key. Editors read it so they never offer a clause the query drops with a +// warning; `where-key-rules.test.ts` holds it to the handlers in both directions. + +export const WHERE_OPERATORS: ReadonlyArray = [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "contains", + "!contains", + "exists", + "!exists", +] + +/** + * A key's rule: a column-backed `Dimension` takes its listed operators; an `Attribute` + * (a map key) takes every operator; an `Unsupported` key is dropped by the source. + */ +export type WhereKeyRule = + | Readonly<{ _tag: "Dimension"; operators: ReadonlyArray }> + | Readonly<{ _tag: "Attribute" }> + | Readonly<{ _tag: "Unsupported" }> + +const EQUALS: ReadonlyArray = ["="] +const EQUALS_OR_NOT: ReadonlyArray = ["=", "!="] +const TRACES_DIMENSION: ReadonlyArray = ["=", "!=", "contains"] +const LOGS_BODY: ReadonlyArray = ["=", "contains"] + +const PRODUCT_EVENT_LIST_KEYS = [ + "event.name", + "event", + "event_name", + "event.kind", + "kind", + "source", + "host", + "page.path", + "page_path", + "path", + "service.name", + "service", +] + +const PRODUCT_EVENT_EQUALS_KEYS = [ + "user.id", + "group.id", + "referrer.host", + "referrer_host", + "referrer", + "country", + "device.type", + "device_type", + "device", + "browser", + "browser.name", + "os", + "os.name", + "language", + "utm.source", + "utm_source", + "utm.medium", + "utm_medium", + "utm.campaign", + "utm_campaign", + "visitor.type", + "visitor_type", +] + +/** Column-backed keys by source, after `normalizeKey`. */ +export const WHERE_DIMENSIONS = { + traces: { + "service.name": TRACES_DIMENSION, + "span.name": TRACES_DIMENSION, + "deployment.environment": TRACES_DIMENSION, + "vcs.ref.head.revision": EQUALS_OR_NOT, + root_only: EQUALS, + has_error: EQUALS, + min_duration_ms: EQUALS, + max_duration_ms: EQUALS, + }, + logs: { + "service.name": EQUALS_OR_NOT, + severity: EQUALS_OR_NOT, + body: LOGS_BODY, + message: LOGS_BODY, + search: LOGS_BODY, + }, + metrics: { + "service.name": EQUALS, + "deployment.environment": EQUALS, + "metric.type": EQUALS, + }, + product_events: { + ...Rec.fromIterableWith(PRODUCT_EVENT_LIST_KEYS, (key) => [key, EQUALS_OR_NOT]), + ...Rec.fromIterableWith(PRODUCT_EVENT_EQUALS_KEYS, (key) => [key, EQUALS]), + }, +} satisfies Record>> + +/** One source's dimensions, read by any key. */ +const dimensionsOf = ( + dataSource: QueryBuilderDataSource, +): Readonly>> => WHERE_DIMENSIONS[dataSource] + +/** The rule for a key as written (aliases resolve), on one source. */ +export const whereKeyRule = (dataSource: QueryBuilderDataSource, key: string): WhereKeyRule => { + const canonical = normalizeKey(key) + if (Str.startsWith("attr.")(canonical) || Str.startsWith("resource.")(canonical)) + return { _tag: "Attribute" } + return Option.match(Rec.get(dimensionsOf(dataSource), canonical), { + onSome: (operators): WhereKeyRule => ({ _tag: "Dimension", operators }), + // Traces read a bare key as a span attribute and product events as a `track()` prop. + onNone: (): WhereKeyRule => + dataSource === "traces" || dataSource === "product_events" + ? { _tag: "Attribute" } + : { _tag: "Unsupported" }, + }) +} + +/** The operators a key takes on a source: none when the source drops it. */ +export const whereOperatorsOf = ( + dataSource: QueryBuilderDataSource, + key: string, +): ReadonlyArray => { + const rule = whereKeyRule(dataSource, key) + return rule._tag === "Dimension" ? rule.operators : rule._tag === "Attribute" ? WHERE_OPERATORS : [] +} + +/** Whether `(k = a OR k = b)` on this key is lowered: attribute filters only, on sources that lower groups. */ +export const whereKeyMatchesAnyOf = (dataSource: QueryBuilderDataSource, key: string): boolean => + dataSource !== "product_events" && whereKeyRule(dataSource, key)._tag === "Attribute" diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 7b1311900d..aaeb175bff 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -211,15 +211,15 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. - `apps/web/src/page/kit/query-builder/condition-builder-view.ts` `conditionBuilder`: `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each picked from the query's own vocabulary. -- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `fieldsOf`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... -- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `parseConditionRows`, `isComplete`, `printConditionRows`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... -- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `whereModeOf`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuesStep`, `operatorStep`: The condition builder's steps: every edit prints the rows and leaves as the query's where-clause edit, so previews and saving see only the string. +- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `isOwnedElsewhere`, `fieldsOf`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... +- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `parseConditionRows`, `sameConditions`, `isUnspellable`, `isComplete`, `printConditionRows`, `printConditionRowsOver`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... +- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `withoutBuilderState`, `whereModeOf`, `typedInTextEditor`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuePickerOf`, `valuesStep`, `operatorStep`: The condition builder's steps: an edit that changes the conditions leaves as the query's where-clause edit, rewriting only the clauses it touched, so previews and saving see onl... - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. - `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields. - `apps/web/src/page/kit/query-builder/panel-select.ts` `panelSelect`: One of the panel's selects, kept by id; its value is always the draft's. - `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. -- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text editor; a Builder/Text switch under it. +- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. - `apps/web/src/page/kit/query-builder/view.ts` `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. - `apps/web/src/page/kit/query-builder/where-editor.ts` `whereEditor`, `highlightOverlay`: The suggestions of a field kept in the owner's `WhereFields` (`page/kit/where-field.ts`). - `apps/web/src/page/kit/query-builder/where.ts` `whereTextareaId`, `whereSpecOf`, `whereStep`: `WhereClauseEditor` inside a `QueryPanel`: one `WhereFields` field per query, keyed by query id. From 2de1f25e4fa58a208a8a19ce48f1e4baf03041bd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:11:02 +0100 Subject: [PATCH 03/15] fix(query-builder): field picker's accessible name includes the picked field --- .../src/page/kit/query-builder/condition-builder-view.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts index 0c9b61487e..16402480b6 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -114,7 +114,11 @@ const fieldPicker = ( Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), viewInputs: { items: Arr.map(items, (item) => item.value), - ariaLabel: `Field for condition ${index + 1}`, + // The label replaces the trigger's text for screen readers, so it carries the pick. + ariaLabel: + row.key === "" + ? `Field for condition ${index + 1}` + : `Field for condition ${index + 1}: ${row.key}`, searchPlaceholder: "Search fields…", emptyText: "No fields match", trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), From dba6fcf7eb6eeb61a2cfdea34d997fe94cd73540 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:20:42 +0100 Subject: [PATCH 04/15] feat(views): pickers that keep the keyboard, a select that opens below Select takes alignItemWithTrigger={false} to open below its trigger. The chips field takes a size. ComboboxPicker's list is 360px wide and its trigger takes a tooltip. A popup with its own search input keeps focus there when an option is pressed, so typing, Enter and Escape go on working after a mouse pick that leaves the list open. --- packages/views/src/ui/combobox-picker.ts | 9 ++++++++- packages/views/src/ui/combobox-view.ts | 13 +++++++++++-- packages/views/src/ui/combobox.ts | 18 ++++++++++++++++++ packages/views/src/ui/select-view.ts | 20 +++++++++++++------- packages/views/src/ui/select.ts | 10 +++++++--- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts index 9988a4d954..1b0d3d7b4f 100644 --- a/packages/views/src/ui/combobox-picker.ts +++ b/packages/views/src/ui/combobox-picker.ts @@ -6,6 +6,7 @@ import { Message, type Model, queryOf } from "./combobox" import { chipsInput, inputAttributes, listPopup } from "./combobox-view" import { icon } from "./icon" import { isMounted } from "./overlay/transition" +import { tooltipTrigger } from "./tooltip-host" /** * `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search @@ -21,6 +22,8 @@ export type ComboboxPickerInputs = Readonly<{ /** The trigger's label: the pick, or a prompt drawn muted with `isPlaceholder`. */ trigger: ReadonlyArray isPlaceholder?: boolean + /** The trigger's tooltip, for a pick the trigger shows shortened. */ + tooltip?: string renderItem?: (h: HtmlBuilder, value: string) => ReadonlyArray itemClassName?: (value: string) => string | undefined triggerClassName?: string @@ -50,6 +53,10 @@ export const comboboxPicker = Submodel.defineView