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..ae60de661d --- /dev/null +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -0,0 +1,155 @@ +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, clauses 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", + "messaging.destination.partition.id", + "service.version", + ], + // `service.version` is in both groups: its trigger names the group. + resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region", "service.version"], +} + +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.service.version exists AND attr.messaging.destination.partition.id = "12"', + ), + }, + { + title: "Can't show as rows", + hint: "An OR across different fields stays in the text editor.", + query: draft( + "unparseable", + "C", + "traces", + '(attr.http.route = "/cart" OR attr.rpc.method = "Checkout")', + ), + }, + { + title: "OR group the query drops", + hint: "A named field can't be OR-ed: the filter stays in Text, and the warning names the group the query drops.", + query: draft( + "dropped", + "E", + "traces", + '(service.name = "checkout" OR service.name = "worker") AND attr.http.route exists', + ), + }, + { + 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 => + // As wide as the alert editor gives the panel, so rows lay out as they do there. + vstack(h, { gap: "8", className: "mx-auto w-full max-w-5xl p-4 sm: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..10d5534662 --- /dev/null +++ b/apps/web/src/lab/entries/condition-builder.ts @@ -0,0 +1,18 @@ +import { Effect, Schema } from "effect" +import { init, Message, Model, update, view } from "../condition-builder-lab" +import { defineShellLab } from "../shell-lab" + +// `/lab/condition-builder`: the query panel's condition rows over fixture vocabulary, inside the +// real shell so the kit tooltips show. + +export const lab = defineShellLab("Condition builder", { + Model, + Message, + Flags: Schema.Struct({}), + flags: Effect.succeed({}), + init, + update, + view, + currentPath: "/lab/condition-builder", + breadcrumbs: [{ label: "Lab" }, { label: "Condition builder" }], +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 278d123e6c..c96ebd899b 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -451,6 +451,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..8e9ade04c6 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -0,0 +1,348 @@ +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, 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" +import { typography } from "@maple/views/ui/typography" +import { + type ConditionField, + type FieldPickerItem, + fieldOf, + fieldPickerItems, + fieldsOf, + groupLabelOf, + isOwnedElsewhere, + OPERATOR_LABEL, + operatorsOf, + sharesLabel, + valueSuggestionsOf, +} from "./condition-fields" +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" +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" } + +/** The pick as the list shows it: no `attr.` / `resource.` prefix, the group named only to tell two fields apart. */ +const fieldTrigger = (h: H, field: ConditionField, isLabelShared: boolean): ReadonlyArray => + isLabelShared && field.group !== "common" + ? [ + field.label, + typography(h, { size: "2xs", tone: "subtle", as: "span", className: "ml-1.5" }, [ + 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, + index: number, + values: WhereClauseAutocompleteValues, + rowFields: ReadonlyArray, +): 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 fields = fieldsOf(query.dataSource, values) + const field = fieldOf(row.key, query.dataSource) + const items = fieldPickerItems(fields, model.fieldDrills[id] ?? "all", search, query.dataSource) + 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), + // 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, field, sharesLabel(field, [...fields, ...rowFields])), + isPlaceholder: row.key === "", + ...(row.key === "" ? undefined : { tooltip: row.key }), + triggerClassName: "w-full max-w-80", + 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(FIELD_CELL)], 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", or just "Service" before a value is picked: what a row's remove button removes. */ +const rowSummary = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" + ? "new condition" + : Arr.join( + [ + rowFieldLabel(row, dataSource), + ...(isValueless(row.operator) || row.values.length > 0 + ? [OPERATOR_LABEL[row.operator]] + : []), + ...(row.values.length === 0 ? [] : [Arr.join(row.values, ", ")]), + ], + " ", + ) + +const UNSPELLABLE_NOTE = `A value can't contain both ' and "` +const VALUE_HINT = "Type a value and press Enter" + +const valuePicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + const id = conditionPickerId(query.id, row.id, "values") + const picker = valuePickerOf(model, query, row) + const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) + 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 !== "" && !isUnspellableTyped && !suggestions.includes(typed) && !row.values.includes(typed) + // A value the clause can't spell offers nothing: the open list says why, where the note under + // the field is covered by it. + const items = isUnspellableTyped ? [] : isTypedNew ? [typed, ...suggestions] : suggestions + const emptyText = isUnspellableTyped ? UNSPELLABLE_NOTE : VALUE_HINT + // Numbered like the field picker: two rows on one field are still two names. + const ariaLabel = `Value for condition ${index + 1}: ${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, + size: "sm", + 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, + size: "sm", + highlightsSelected: true, + renderItem, + toView: ({ field: input, rest }) => h.div([h.Class("min-w-0")], [input, ...rest]), + }, + }) + return [field, ...note] +} + +// Wide (the where area, `@container/where`): one line of [lead] [field] [operator] [values] +// [remove], the field and operator as wide as their text. Narrow: the lead, field and remove on +// one line, then the operator and values under the field. `min-w` is what a row needs beside the +// editor's floated controls; with less it drops under them. +const ROW_GRID = + "grid min-w-52 grid-cols-[2.5rem_max-content_minmax(0,1fr)_auto] items-center gap-1.5 @xl/where:min-w-108 @xl/where:grid-cols-[2.5rem_minmax(8rem,max-content)_max-content_minmax(10rem,1fr)_auto]" +const FIELD_CELL = "col-span-2 col-start-2 row-start-1 min-w-32 @xl/where:col-span-1" +const OPERATOR_CELL = "col-start-2 row-start-2 @xl/where:col-start-3 @xl/where:row-start-1" +const VALUES_CELL = + "col-span-2 col-start-3 row-start-2 min-w-0 @xl/where:col-span-1 @xl/where:col-start-4 @xl/where:row-start-1" +const REMOVE_CELL = "col-start-4 row-start-1 @xl/where:col-start-5" + +const conditionRow = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, + rowFields: ReadonlyArray, +): Html => { + 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 ${index + 1}: ${rowSummary(row, query.dataSource)}`, + hasTooltip: false, + 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, index, values, rowFields) + const layout = (parts: ReadonlyArray) => + h.div( + [h.Class(ROW_GRID), h.DataAttribute("slot", "condition-row")], + [connector, field, ...parts, remove], + ) + if (row.key === "") return layout([]) + const fieldLabel = rowFieldLabel(row, query.dataSource) + return panelSelect( + h, + model, + operatorSelectId(query.id, row.id), + row.operator, + Arr.map( + operatorsOf(fieldOf(row.key, query.dataSource), query.dataSource, row.operator), + (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + }), + ), + { + size: "sm", + // As wide as the operator's words, not the select's default 9rem. + className: cn(OPERATOR_CELL, "min-w-16"), + // The label replaces the trigger's text for screen readers, so it carries the pick. + ariaLabel: `Operator for condition ${index + 1}: ${fieldLabel} ${OPERATOR_LABEL[row.operator]}`, + alignItemWithTrigger: false, + toView: (parts) => + layout([ + ...parts, + ...(isValueless(row.operator) + ? [] + : [ + vstack( + h, + { gap: "1", className: VALUES_CELL }, + valuePicker(h, model, query, row, index, values), + ), + ]), + ]), + }, + ) +} + +/** 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 => { + const shown = Arr.filter(rows, (row) => !isOwnedElsewhere(row.key, query.dataSource)) + const rowFields = Arr.map( + Arr.filter(shown, (row) => row.key !== ""), + (row) => fieldOf(row.key, query.dataSource), + ) + // A plain block, not a stack: each row lays out against the editor's floated controls itself. + return shown.length === 0 + ? [] + : [ + h.div( + [ + h.AriaLabel(`Conditions for query ${query.name}`), + h.Class("space-y-1.5"), + h.Role("group"), + ], + Arr.map(shown, (row, index) => + conditionRow(h, model, query, row, index, values, rowFields), + ), + ), + ] +} 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..1632d3bb76 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -0,0 +1,305 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { + click, + Command, + expect as expectView, + given, + keydown, + label, + 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() }), + }), + ) + +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 condition 1: http.route" }) + 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), + // 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(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 condition 2: host.name" }) + 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(value, "web-1"), + ...pickerOpened(1), + keydown(value, "Enter"), + expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), + ) + }) + + 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 condition 1: Service" })), + ...pickerOpened(1), + click(role("option", { name: "api" })), + ...pickerClosed("cond-q-c0-values", 2), + expectView(text('service.name = "api"')).toExist(), + ) + }) + + test("a value with both quote kinds is refused in the open list, and Enter keeps the clause", () => { + const value = role("combobox", { name: "Value for condition 1: http.route" }) + scene( + { update, view }, + given(hosted('attr.http.route = "/cart"')), + typeInto(value, `it's "x"`), + ...pickerOpened(1), + expectView(role("status")).toHaveText(`A value can't contain both ' and "`), + expectView(role("option", { name: /^Use/ })).not.toExist(), + keydown(value, "Enter"), + expectView(role("status")).toHaveText(`A value can't contain both ' and "`), + expectView(text('attr.http.route = "/cart"')).toExist(), + ) + }) + + test("either quote alone is offered as a value", () => { + const value = role("combobox", { name: "Value for condition 1: http.route" }) + scene( + { update, view }, + given(hosted('attr.http.route = "/cart"')), + typeInto(value, `it's`), + ...pickerOpened(1), + expectView(role("option", { name: `Use "it's"` })).toExist(), + keydown(value, "Enter"), + expectView(text(`(attr.http.route = "/cart" OR attr.http.route = "it's")`)).toExist(), + ) + }) + + test("two rows on one field name their controls apart, by condition number", () => { + scene( + { update, view }, + given(hosted('attr.http.route = "/a" AND attr.http.route = "/b"')), + expectView(role("combobox", { name: "Value for condition 1: http.route" })).toExist(), + expectView(role("combobox", { name: "Value for condition 2: http.route" })).toExist(), + expectView(label("Remove condition 1: http.route is /a")).toExist(), + expectView(label("Remove condition 2: http.route is /b")).toExist(), + expectView(label("Operator for condition 1: http.route is")).toExist(), + expectView(label("Operator for condition 2: http.route is")).toExist(), + ) + }) + + test("removing a row keeps the other clauses as the user wrote them", () => { + scene( + { update, view }, + given(hosted("service = checkout AND attr.http.route exists")), + click(label("Remove condition 2: http.route is set")), + expectView(text("service = checkout")).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("typing in the field search and pressing Enter picks the highlighted field", () => { + const search = role("combobox", { name: "Search Field for condition 1" }) + scene( + { update, view }, + given(hosted("")), + click(role("button", { name: "Add condition" })), + comboMounted(), + typeInto(search, "http.ro"), + keydown(search, "Enter"), + ...pickerClosed("cond-q-c0-field", 1), + expectView(role("combobox", { name: "Field for condition 1: attr.http.route" })).toExist(), + ) + }) + + test("Enter on a freshly opened field list picks its first field", () => { + scene( + { update, view }, + given(hosted("")), + click(role("button", { name: "Add condition" })), + comboMounted(), + keydown(role("combobox", { name: "Search Field for condition 1" }), "Enter"), + ...pickerClosed("cond-q-c0-field", 1), + expectView(role("combobox", { name: "Field for condition 1: service.name" })).toExist(), + ) + }) + + test("a clause the rows can't show stays in the text editor and says why, once", () => { + scene( + { update, view }, + given(hosted('(attr.http.route = "/cart" OR attr.rpc.method = "Checkout")')), + caretMounted(), + expectView(text("This filter mixes fields in an OR, so it stays in Text.")).toExist(), + // The query applies an OR of attribute filters, so nothing warns. + expectView(text(/This OR group is not applied/)).not.toExist(), + expectView(label("Where clause for query A")).toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) + + test("a doubled operator stays in Text with its reason instead of a row whose value is =", () => { + scene( + { update, view }, + given(hosted("service.name ==")), + caretMounted(), + expectView( + text("This filter uses syntax the builder can't show, so it stays in Text."), + ).toExist(), + expectView(label("Where clause for query A")).toHaveValue("service.name =="), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) + + test("an OR group the query drops also says which group is not applied", () => { + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + expectView(text("This filter mixes fields in an OR, so it stays in Text.")).toExist(), + expectView( + text( + "This OR group is not applied: (service.name = a OR span.name = b). Only attribute filters can be OR-ed.", + ), + ).toExist(), + ) + }) + + test("an OR group on a named field stays in Text, untouched, with its reason and the dropped group", () => { + const clause = '(service.name = "checkout" OR service.name = "worker")' + scene( + { update, view }, + given(hosted(clause)), + caretMounted(), + expectView(label("Where clause for query A")).toHaveValue(clause), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + expectView( + text("This OR group is on a field that can't be OR-ed, so it stays in Text."), + ).toExist(), + // The note already says why, so the warning only names the group. + expectView( + text("This OR group is not applied: (service.name = checkout OR service.name = worker)."), + ).toExist(), + expectView(text(clause)).toExist(), + ) + }) + + test("two dropped OR groups give two lines, one per group", () => { + scene( + { update, view }, + given( + hosted('(service.name = "a" OR service.name = "b") AND (span.name = "x" OR span.name = "y")'), + ), + caretMounted(), + expectView( + text("This OR group is not applied: (service.name = a OR service.name = b)."), + ).toExist(), + expectView(text("This OR group is not applied: (span.name = x OR span.name = y).")).toExist(), + ) + }) + + test("the same OR group on an attribute key reads as one row with both values", () => { + scene( + { update, view }, + given(hosted('(attr.http.route = "/cart" OR attr.http.route = "/pay")')), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + expectView(text(/stays in Text/)).not.toExist(), + expectView(text(/This OR group is not applied/)).not.toExist(), + ) + }) + + 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..411d8a3b42 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest" +import { + canMatchAnyOf, + fieldOf, + fieldPickerItems, + fieldsOf, + isPickable, + operatorsOf, + sharesLabel, +} 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" }) + }) +}) + +describe("sharesLabel", () => { + const fields = fieldsOf("traces", { + attributeKeys: ["service.version", "http.route"], + resourceAttributeKeys: ["service.version", "host.name"], + }) + + test("only a key that two groups hold needs its group named", () => { + expect(sharesLabel(fieldOf("resource.service.version", "traces"), fields)).toBe(true) + expect(sharesLabel(fieldOf("attr.service.version", "traces"), fields)).toBe(true) + expect(sharesLabel(fieldOf("attr.http.route", "traces"), fields)).toBe(false) + expect(sharesLabel(fieldOf("service.name", "traces"), fields)).toBe(false) + }) +}) + +// What the picker offers is what the source applies: a key it drops is not listed and cannot be typed in. +describe("fields the source drops", () => { + const vocabulary = { attributeKeys: ["plan", "http.route"], resourceAttributeKeys: ["host.name"] } + const offered = ( + dataSource: "traces" | "logs" | "metrics" | "product_events", + drill: "all" | "resource" | "attribute", + search: string, + ) => fieldPickerItems(fieldsOf(dataSource, vocabulary), drill, search, dataSource) + const keysOf = (items: ReturnType) => + Arr.flatMap(items, (item) => + item._tag === "Field" ? [item.field.key] : item._tag === "Custom" ? [item.key] : [], + ) + + test("product events list no resource attributes", () => { + expect( + Arr.filter(fieldsOf("product_events", vocabulary), (field) => field.group === "resource"), + ).toEqual([]) + expect(Arr.map(offered("product_events", "all", ""), (item) => item.value)).not.toContain( + "@group:resource", + ) + expect(Arr.map(offered("traces", "all", ""), (item) => item.value)).toContain("@group:resource") + }) + + test("product events take no typed resource.* key, in the list or inside a stale resource drill", () => { + expect(offered("product_events", "all", "resource.host.name")).toEqual([]) + expect(offered("product_events", "all", "host.name")).toEqual([ + { _tag: "Custom", value: "@custom:attr.host.name", key: "attr.host.name" }, + ]) + expect(keysOf(offered("product_events", "resource", "region"))).toEqual([]) + expect(keysOf(offered("traces", "all", "resource.cloud.region"))).toEqual(["resource.cloud.region"]) + }) + + test.each(["logs", "metrics"] as const)( + "%s never offer a bare unknown key: what is typed becomes an attribute", + (dataSource) => { + expect(isPickable("db.system", dataSource)).toBe(false) + expect(keysOf(offered(dataSource, "all", "db.system"))).toEqual(["attr.db.system"]) + expect(keysOf(offered(dataSource, "attribute", "db.system"))).toEqual(["attr.db.system"]) + expect(keysOf(offered(dataSource, "resource", "db.system"))).toEqual(["resource.db.system"]) + }, + ) +}) 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..acab2a4056 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -0,0 +1,263 @@ +import type { Operator } from "@maple/domain/where-clause" +import { whereKeyMatchesAnyOf, whereKeyRule, whereOperatorsOf } from "@maple/query-engine/query-builder" +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"), + 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("deployment.environment", "Environment"), + 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 ORs this field's values (`whereKeyMatchesAnyOf`: attribute filters only). */ +export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => + 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" + +/** + * Whether the source applies a filter on this key at all (the engine's `whereOperatorsOf`). A key + * it drops (`resource.*` on product events) is never a new pick; a saved row on one stays as it is. + */ +export const isPickable = (key: string, dataSource: DataSource): boolean => + whereOperatorsOf(dataSource, key).length > 0 + +/** + * Every field the query offers: the common ones, then resource and own attributes as the editor + * writes them, less the keys the source drops. + */ +export const fieldsOf = ( + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + const known: ReadonlyArray = [ + ...Arr.filter(COMMON_FIELDS[dataSource], (field) => !isOwnedElsewhere(field.key, dataSource)), + ...Arr.map(values.resourceAttributeKeys ?? [], (key) => + fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), + ), + ...Arr.map(values.attributeKeys ?? [], (key) => fieldOf(`${ATTRIBUTE_PREFIX}${key}`, dataSource)), + ] + return Arr.filter( + Arr.dedupeWith(known, (a, b) => a.key === b.key), + (field) => isPickable(field.key, dataSource), + ) +} + +/** Whether another known field reads the same in a trigger, so this one needs its group named. */ +export const sharesLabel = (field: ConditionField, known: ReadonlyArray): boolean => + Arr.some(known, (other) => other.key !== field.key && other.label === field.label) + +// 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. A bare key + * is never free entry, so a source that drops unknown bare keys (logs, metrics) gets `attr.`. + */ +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}` +} + +/** The list for a search: the matching fields, then the typed key itself when the source takes it. */ +export const fieldPickerItems = ( + fields: ReadonlyArray, + drill: FieldDrill, + query: string, + dataSource: DataSource, +): 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) && + isPickable(key, dataSource) && + !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. + +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 = + 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] +} + +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..1bc150c278 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -0,0 +1,279 @@ +import { parseWhereClause } from "@maple/domain/where-clause" +import { describe, expect, test } from "vitest" +import { + type ConditionRow, + isUnspellable, + nextRowId, + parseConditionRows, + printConditionRows, + printConditionRowsOver, + sameConditions, + unshowableReasonOf, +} from "./condition-rows" +import { Array as Arr, Option } from "effect" + +type DataSource = Parameters[1] + +const rowsOf = (whereClause: string, dataSource: DataSource = "traces") => + Option.map(parseConditionRows(whereClause, dataSource), (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"] }]], + [ + '(attr.http.route = "/cart" OR attr.http.route = "/pay")', + [{ key: "attr.http.route", operator: "=", values: ["/cart", "/pay"] }], + ], + // A group of one is a plain clause, on any field. + ['(service.name = "checkout")', [{ key: "service.name", operator: "=", values: ["checkout"] }]], + // AND-ed excludes are one row on any field: every source applies them. + [ + 'service.name != "a" AND service.name != "b"', + [{ key: "service.name", operator: "!=", values: ["a", "b"] }], + ], + ["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, a named field, nesting. + '(service.name = "a" OR span.name = "b")', + '(service.name = "a" OR service.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, "traces"))).toBe(true) + }) +}) + +describe("printConditionRows", () => { + test.each([ + [[row("service.name", "=", ["checkout"])], 'service.name = "checkout"'], + [ + [row("attr.http.route", "=", ["/cart", "/pay"])], + '(attr.http.route = "/cart" OR attr.http.route = "/pay")', + ], + [[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"', + '(attr.http.route = "/a" OR attr.http.route = "/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"'`, + '(attr.error.message contains "timeout" OR attr.error.message contains "refused")', + ])("%s", (whereClause) => { + const rows = Option.getOrThrow(parseConditionRows(whereClause, "traces")) + const printed = printConditionRows(rows) + expect(meaning(printed)).toEqual(meaning(whereClause)) + expect( + Option.map(parseConditionRows(printed, "traces"), (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") + }) +}) + +describe("printConditionRowsOver", () => { + const original = + "service = checkout AND attr.http.route exists AND severity != DEBUG AND severity != TRACE" + const rows = Option.getOrThrow(parseConditionRows(original, "logs")) + + 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, "logs")).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, + "logs", + ), + ).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]) + }) +}) + +describe("unshowableReasonOf", () => { + test.each([ + ['service.name = "a" AND attr.b exists', Option.none()], + ['(attr.a = "1" OR attr.a = "2")', Option.none()], + ['(service.name = "a" OR span.name = "b")', Option.some("mixedFields")], + ['(service.name = "a" OR service.name contains "b")', Option.some("orOperators")], + ['(service.name != "a" OR service.name != "b")', Option.some("orOperators")], + ['(service.name = "checkout" OR service.name = "worker")', Option.some("orNotApplied")], + ["attr.b exists AND (has_error = true OR has_error = false)", Option.some("orNotApplied")], + ["service.name ~ checkout", Option.some("syntax")], + // A missing value or a doubled operator: the grammar reads the stray `=` as the value. + ["service.name =", Option.some("syntax")], + ["service.name ==", Option.some("syntax")], + ["service.name >=", Option.some("syntax")], + ["service.name ==checkout", Option.some("syntax")], + ['service.name = "a" AND attr.b !==', Option.some("syntax")], + ['(attr.a = "1" OR attr.a ==)', Option.some("syntax")], + // A sibling's quoted `=` does not vouch for the bare one beside it. + ['(attr.a = "=" OR attr.a ==)', Option.some("syntax")], + // Quoted, the same characters are a value the user meant. + ['attr.a = "="', Option.none()], + ['(attr.a = "=" OR attr.a = "=")', Option.none()], + ["attr.a = '=x'", Option.none()], + ["attr.duration >= 5", Option.none()], + ['service.name = "a" AND (service.name = "b" AND span.name = "c")', Option.some("syntax")], + ])("%s", (whereClause, expected) => { + expect(unshowableReasonOf(whereClause, "traces")).toEqual(expected) + }) +}) + +// An OR group reads as a multi-value row only where the source applies it (the engine's +// `whereKeyMatchesAnyOf`); anywhere else a row would hold one value and a pick would drop the rest. +describe("OR groups by source", () => { + const twoValues = (key: string) => `(${key} = "a" OR ${key} = "b")` + + test.each([ + ["traces", "attr.http.route"], + ["traces", "resource.host.name"], + // Traces read a bare unknown key as a span attribute. + ["traces", "db.system"], + ["logs", "attr.http.route"], + ["logs", "resource.host.name"], + ["metrics", "attr.state"], + ] as const)("%s ORs %s: one row, two values", (dataSource, key) => { + expect(rowsOf(twoValues(key), dataSource)).toEqual( + Option.some([{ key, operator: "=", values: ["a", "b"] }]), + ) + expect(unshowableReasonOf(twoValues(key), dataSource)).toEqual(Option.none()) + }) + + test.each([ + ["traces", "service.name"], + ["traces", "span.name"], + ["traces", "deployment.environment"], + ["logs", "service.name"], + ["logs", "severity"], + // Logs and metrics drop a bare unknown key. + ["logs", "db.system"], + ["metrics", "service.name"], + ["metrics", "db.system"], + // Product events lower no OR group at all. + ["product_events", "event.name"], + ["product_events", "attr.plan"], + ["product_events", "plan"], + ] as const)("%s does not OR %s: it stays in Text", (dataSource, key) => { + expect(rowsOf(twoValues(key), dataSource)).toEqual(Option.none()) + expect(unshowableReasonOf(twoValues(key), dataSource)).toEqual(Option.some("orNotApplied")) + }) + + test("a product-events OR across fields still reads as mixed fields", () => { + expect(unshowableReasonOf('(event.name = "a" OR host = "b")', "product_events")).toEqual( + Option.some("mixedFields"), + ) + }) +}) 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..9f82ab9436 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -0,0 +1,232 @@ +import { + normalizeKey, + Operator, + type ParsedClause, + parseWhereClause, + quoteWhereValue, + splitWhereClause, +} from "@maple/domain/where-clause" +import { type QueryBuilderDataSource, whereKeyMatchesAnyOf } from "@maple/query-engine/query-builder" +import { Array as Arr, Option, Result, 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`. Reading is per data source: what one source ORs another drops. + +/** 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], +}) + +/** + * Why a clause has no rows: an OR across fields, an OR a row's operators can't hold, an OR on a + * key the source does not OR, or other syntax. + */ +export type UnshowableReason = "mixedFields" | "orOperators" | "orNotApplied" | "syntax" + +type Part = Result.Result + +/** + * A bare value that is the rest of a mistyped operator: `key ==` reads as `key = "="` and + * `key >=` as `key > "="`. Quoted (`key = "="`), the same value is one the user meant. + */ +const looksLikeOperator = (value: string): boolean => /^=|^[=!<>]+$/.test(value) + +const timesIn = (text: string, needle: string): number => Str.split(needle)(text).length - 1 + +/** Each clause with such a value needs its own quoted spelling in `part`: a sibling's does not vouch for it. */ +const hasStrayOperator = (clauses: ReadonlyArray, part: string): boolean => + Arr.some(Arr.dedupe(Arr.map(clauses, (clause) => clause.value)), (value) => { + if (!looksLikeOperator(value)) return false + const quoted = timesIn(part, `"${value}"`) + timesIn(part, `'${value}'`) + return Arr.filter(clauses, (clause) => clause.value === value).length > quoted + }) + +/** + * One AND-ed part: a single clause, or an OR group whose members share key and an any-of + * operator, on a key the source ORs (the engine's `whereKeyMatchesAnyOf`). + */ +const readPart = (part: string, dataSource: QueryBuilderDataSource): Part => { + const parsed = parseWhereClause(part, { orGroups: true }) + if (parsed.warnings.length > 0) return Result.fail("syntax") + const clauses = [...parsed.clauses, ...Arr.flatten(parsed.groups)] + if (hasStrayOperator(clauses, part)) return Result.fail("syntax") + const [clause] = parsed.clauses + if (parsed.groups.length === 0 && parsed.clauses.length === 1 && clause !== undefined) + return Result.succeed(draftOf(clause)) + const [group] = parsed.groups + if (parsed.clauses.length > 0 || parsed.groups.length !== 1 || group === undefined) + return Result.fail("syntax") + const drafts = Arr.map(group, draftOf) + const [first] = drafts + if (first === undefined) return Result.fail("syntax") + if (!Arr.every(drafts, (draft) => draft.key === first.key)) return Result.fail("mixedFields") + if (!isAnyOf(first.operator) || !Arr.every(drafts, (draft) => draft.operator === first.operator)) + return Result.fail("orOperators") + // Such a row holds one value: shown as a row, the next pick would drop the rest of a saved group. + if (drafts.length > 1 && !whereKeyMatchesAnyOf(dataSource, first.key)) return Result.fail("orNotApplied") + return Result.succeed({ ...first, values: Arr.flatMap(drafts, (draft) => draft.values) }) +} + +/** Why the builder can't show a clause on this source, or none when it reads as rows. */ +export const unshowableReasonOf = ( + whereClause: string, + dataSource: QueryBuilderDataSource, +): Option.Option => + Arr.findFirst(splitWhereClause(Str.trim(whereClause)), (part) => + Result.getFailure(readPart(part, dataSource)), + ) + +/** 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 = (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.draft.key === part.draft.key && row.draft.operator === part.draft.operator, + ) + return Option.match(index, { + onNone: () => [...rows, part], + onSome: (at) => + Arr.map(rows, (row, i) => + 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, + dataSource: QueryBuilderDataSource, +): Option.Option> => + Option.map( + Option.all( + Arr.map(splitWhereClause(Str.trim(whereClause)), (text) => + Option.map(Result.getSuccess(readPart(text, dataSource)), (draft): Spelled => ({ + draft, + text, + })), + ), + ), + mergeNoneOf, + ) + +/** The rows a where clause reads as on this source, or none when a row cannot show it. */ +export const parseConditionRows = ( + whereClause: string, + dataSource: QueryBuilderDataSource, +): Option.Option> => + Option.map(spelledRows(whereClause, dataSource), (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) + +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 ") + +/** + * `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, + dataSource: QueryBuilderDataSource, +): string => { + const spelled = Option.getOrElse(spelledRows(original, dataSource), () => 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))) + 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..98e483b89d --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -0,0 +1,356 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { describe, expect, test } from "vitest" +import type { QueryDraft } from "./draft" +import { fieldPickerItems, fieldsOf } from "./condition-fields" +import { conditionRowsOf } from "./conditions" +import { init, Message, type Model, operatorSelectId, type PanelInputs, update } from "./panel" +import { Array as Arr, Option } from "effect" + +// The builder's operator and value steps, as the where-clause edit they emit. + +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"] }), + orgId: "org_test", + window: null, +}) + +/** The where clause a message leaves as, or null when it leaves none. */ +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), + message: { _tag: "SelectedItem", index: 0, value }, + }) + +describe("operator", () => { + test("is set drops the values", () => { + expect( + emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', pickOperator("c0", "exists")), + ).toBe("attr.http.route exists") + }) + + test("is not keeps every value, as AND-ed negations", () => { + expect( + emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', pickOperator("c0", "!=")), + ).toBe('attr.http.route != "/cart" AND attr.http.route != "/pay"') + }) + + 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: "/pay" }, + }) + expect(emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', message)).toBe( + 'attr.http.route = "/cart"', + ) + }) +}) + +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" }) + }) + + test("a row in progress is still there after a look at Text and back", () => { + const inputs = inputsFor(traces('service.name = "checkout"')) + // A new row has no field yet, so the clause does not carry it: only the panel holds it. + const added = update(init("test"), Message.ClickedAddCondition({ queryId: "q" }), inputs).model + const rowCount = added.conditions["q"]?.length + const back = Arr.reduce( + [ + Message.ChangedWhereMode({ queryId: "q", mode: "text" }), + Message.ChangedWhereMode({ queryId: "q", mode: "builder" }), + ], + added, + (model, message) => update(model, message, inputs).model, + ) + expect(rowCount).toBe(2) + expect(back.conditions["q"]?.length).toBe(2) + }) +}) + +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"') + }) +}) + +describe("field picker highlight", () => { + const vocabulary = { attributeKeys: ["http.route", "db.system"], resourceAttributeKeys: ["host.name"] } + const query = traces('attr.db.system = "postgres"') + const inputs: PanelInputs = { ...inputsFor(query), baseValuesOf: () => vocabulary } + const fields = fieldsOf("traces", vocabulary) + const valuesOf = (drill: "all" | "resource" | "attribute", search = "") => + Arr.map(fieldPickerItems(fields, drill, search, "traces"), (item) => item.value) + const field = (message: Parameters[0]["message"]) => + Message.GotConditionField({ queryId: "q", rowId: "c0", message }) + /** The model after the messages, and the value its field list highlights. */ + const after = (messages: ReadonlyArray) => { + const model = Arr.reduce( + messages, + init("test"), + (current, message) => update(current, message, inputs).model, + ) + return { model, highlighted: model.conditionPickers["cond-q-c0-field"]?.highlighted ?? null } + } + const opened = field({ _tag: "ClickedTriggerOf", items: valuesOf("all") }) + const drilled = (group: "resource" | "attribute") => + field({ _tag: "ClickedItem", value: `@group:${group}` }) + + test("opening highlights the first field when the picked one is inside a group", () => { + expect(after([opened]).highlighted).toBe(0) + }) + + test("drilling into the picked field's group highlights it, not the back row", () => { + const { highlighted } = after([opened, drilled("attribute")]) + expect(valuesOf("attribute")[highlighted ?? -1]).toBe("attr.db.system") + }) + + test("drilling into another group highlights its first field", () => { + const { highlighted } = after([opened, drilled("resource")]) + expect(valuesOf("resource")[highlighted ?? -1]).toBe("resource.host.name") + }) + + test("going back highlights the group that was open", () => { + const { highlighted } = after([ + opened, + drilled("resource"), + field({ _tag: "ClickedItem", value: "@back" }), + ]) + expect(valuesOf("all")[highlighted ?? -1]).toBe("@group:resource") + }) + + test("Enter after a search picks the first match", () => { + const { model } = after([ + opened, + field({ _tag: "ChangedInput", value: "http.ro" }), + field({ + _tag: "PressedInputKey", + key: "Enter", + items: valuesOf("all", "http.ro"), + isUnfiltered: true, + }), + ]) + expect(model.conditions["q"]?.[0]?.key).toBe("attr.http.route") + }) +}) + +// A saved OR group on a field the source does not OR has no rows: no builder message can reach it. +describe("an OR group the source does not apply", () => { + const clause = '(service.name = "checkout" OR service.name = "worker") AND attr.http.route exists' + + test("it has no rows", () => { + expect(conditionRowsOf(init("test"), traces(clause))).toEqual(Option.none()) + }) + + test.each([ + ["adding a condition", Message.ClickedAddCondition({ queryId: "q" })], + ["picking a value", values("c0", { _tag: "ClickedItem", value: "api" })], + ["picking an operator", pickOperator("c0", "!=")], + ["removing a condition", Message.ClickedRemoveCondition({ queryId: "q", rowId: "c0" })], + ])("%s leaves the clause as written", (_, message) => { + const next = update(init("test"), message, inputsFor(traces(clause))) + expect(next.outMessage).toBeUndefined() + expect(next.model.conditions).toEqual({}) + }) + + test("the same group on an attribute key is a row that takes a third value", () => { + expect( + emitted( + '(attr.http.route = "/cart" OR attr.http.route = "/pay")', + values("c0", { _tag: "ClickedItem", value: "/ship" }), + ), + ).toBe('(attr.http.route = "/cart" OR attr.http.route = "/pay" OR attr.http.route = "/ship")') + }) +}) + +// A key the source drops (the engine's `whereOperatorsOf` is empty) is never a new pick. +describe("field picks the source drops", () => { + const field = (message: Parameters[0]["message"]) => + Message.GotConditionField({ queryId: "q", rowId: "c0", message }) + /** A new row, the search typed into its field picker, then a click on a list value. */ + const afterPick = (dataSource: QueryDraft["dataSource"], typed: string, value: string) => { + const inputs: PanelInputs = { + ...inputsFor(draftOf(dataSource, "")), + // The host hands resource keys to every source; product events must not list them. + baseValuesOf: () => ({ attributeKeys: ["plan"], resourceAttributeKeys: ["host.name"] }), + } + const model = Arr.reduce( + [ + Message.ClickedAddCondition({ queryId: "q" }), + ...(typed === "" ? [] : [field({ _tag: "ChangedInput", value: typed })]), + ], + init("test"), + (current, message) => update(current, message, inputs).model, + ) + const picked = update(model, field({ _tag: "ClickedItem", value }), inputs) + return { + outMessage: picked.outMessage, + keys: Arr.map(picked.model.conditions["q"] ?? [], (row) => row.key), + } + } + + test.each([ + // The known resource key, and the same key typed in. + ["product_events", "host", "resource.host.name"], + ["product_events", "resource.host.name", "resource.host.name"], + ["product_events", "resource.host.name", "@custom:resource.host.name"], + ["product_events", "resource.cloud.region", "@custom:resource.cloud.region"], + // A bare unknown key, which logs and metrics drop. + ["logs", "db.system", "db.system"], + ["logs", "db.system", "@custom:db.system"], + ["metrics", "db.system", "db.system"], + ["metrics", "db.system", "@custom:db.system"], + ] as const)("%s, search %j: a pick of %s is ignored", (dataSource, typed, value) => { + expect(afterPick(dataSource, typed, value)).toEqual({ outMessage: undefined, keys: [""] }) + }) + + test.each([ + ["product_events", "", "event.name", "event.name"], + ["product_events", "plan", "attr.plan", "attr.plan"], + ["logs", "", "severity", "severity"], + ["logs", "host", "resource.host.name", "resource.host.name"], + // What logs and metrics offer for a typed bare key: the attribute of that name. + ["logs", "db.system", "@custom:attr.db.system", "attr.db.system"], + ["metrics", "db.system", "@custom:attr.db.system", "attr.db.system"], + ["traces", "resource.cloud.region", "@custom:resource.cloud.region", "resource.cloud.region"], + ] as const)("%s, search %j: a pick of %s lands as %s", (dataSource, typed, value, key) => { + expect(afterPick(dataSource, typed, value).keys).toEqual([key]) + }) + + test("a saved product-events row on resource.* stays, and an edit beside it leaves its text alone", () => { + const query = draftOf("product_events", 'resource.host.name contains web AND event.name = "signup"') + expect(Option.map(conditionRowsOf(init("test"), query), (rows) => rows[0])).toEqual( + Option.some({ id: "c0", key: "resource.host.name", operator: "contains", values: ["web"] }), + ) + expect(emittedOn(query, Message.ClickedAddCondition({ queryId: "q" }))).toBeNull() + expect(emittedOn(query, pickOperator("c1", "!="))).toBe( + 'resource.host.name contains web AND event.name != "signup"', + ) + }) +}) 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..a9278d6cfa --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -0,0 +1,409 @@ +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, + type FieldDrill, + fieldOf, + fieldPickerItems, + type FieldPickerItem, + fieldsOf, + operatorsOf, +} from "./condition-fields" +import { + type ConditionRow, + isAnyOf, + isNoneOf, + isUnspellable, + isValueless, + nextRowId, + parseConditionRows, + 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: 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 + +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 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, query.dataSource), (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> => + Option.orElse(keptRowsOf(model, query), () => parseConditionRows(query.whereClause, query.dataSource)) + +/** 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)), + } +} + +/** 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 => { + 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, query.dataSource), + }), + }), + } +} + +type RowContext = Readonly<{ model: Model; 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(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, query.dataSource), { + 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, + }) + }, + }), + }) +} + +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: context.model }), onSome: (row) => step({ ...context, row }) }, + ), + ) + +const replaced = (rows: ReadonlyArray, next: ConditionRow): ReadonlyArray => + Arr.map(rows, (row) => (row.id === next.id ? next : row)) + +/** Switching editors keeps the rows in progress: a half-filled row is still there on the way back. */ +export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ + model: { ...model, whereModes: { ...model.whereModes, [queryId]: mode } }, +}) + +/** 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, ({ model: current, query, rows }) => { + const id = nextRowId(rows) + const pickerId = conditionPickerId(queryId, id, "field") + const opened: Model = { + ...current, + conditionPickers: { + ...current.conditionPickers, + [pickerId]: { ...Combobox.init(pickerId, "single", { isOpen: true }), highlighted: 0 }, + }, + } + return emitted(opened, query, rows, [...rows, { id, key: "", operator: "=", values: [] }]) + }) + +export const removedCondition = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, +): Return => + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { + const pickerIds = [ + conditionPickerId(queryId, rowId, "field"), + conditionPickerId(queryId, rowId, "values"), + ] + return emitted( + { + ...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), + ) + }) + +/** 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 +} + +/** The option an open field list highlights: the picked field when listed, else the first that is not "All fields". */ +const fieldHighlightOf = ( + items: ReadonlyArray, + key: string, + from?: FieldDrill, +): number | null => + Option.getOrNull( + Option.orElse( + Arr.findFirstIndex(items, (item) => item.value === key), + () => + Option.orElse( + // Back out of a group: its own row, where the highlight was before drilling in. + Arr.findFirstIndex(items, (item) => item._tag === "Group" && item.group === from), + () => + Option.orElse( + Arr.findFirstIndex(items, (item) => item._tag !== "Back"), + () => (items.length === 0 ? Option.none() : Option.some(0)), + ), + ), + ), + ) + +/** + * The field picker: a group or "All fields" drills without closing; a field sets the row's key. + * An open list always has a highlight, so Enter picks without an arrow key first. + */ +export const fieldStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "field") + 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), query.dataSource) + // A pick is one of the offered items, so a key the source drops (never offered) cannot be picked. + 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: { + ...base, + conditionPickers: { + ...base.conditionPickers, + [id]: { + ...current, + inputValue: "", + highlighted: fieldHighlightOf( + fieldPickerItems(fields, navigated.value, "", query.dataSource), + row.key, + drill, + ), + }, + }, + fieldDrills: { ...base.fieldDrills, [id]: navigated.value }, + }, + } + const next = Combobox.update(current, message) + // Opening, and typing a search, start from the default highlight. + const needsHighlight = + next.model.isOpen && (next.model.highlighted === null || message._tag === "ChangedInput") + const picker = needsHighlight + ? { + ...next.model, + highlighted: fieldHighlightOf( + fieldPickerItems(fields, drill, Combobox.queryOf(next.model), query.dataSource), + row.key, + ), + } + : next.model + const lifted: Model = { + ...base, + conditionPickers: { ...base.conditionPickers, [id]: picker }, + fieldDrills: picker.isOpen ? base.fieldDrills : Rec.remove(base.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), 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" 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 => + isAnyOf(row.operator) + ? canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource) + : isNoneOf(row.operator) && query.dataSource !== "product_events" + +/** 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: a suggestion or anything typed (Enter adds it); a single picker closes on a pick. */ +export const valuesStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "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) + 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( + lift({ ...current, inputValue: "" }), + query, + rows, + replaced(rows, { ...row, values: Arr.dedupe([...row.values, typed]) }), + ) + 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 }), + ) + 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) + +/** 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, ({ model: base, query, rows, row }) => { + const values = isValueless(operator) + ? [] + : holdsManyValues({ key: row.key, operator }, query) + ? row.values + : Arr.take(row.values, 1) + 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-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..fcca5ee2f8 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/panel-select.ts @@ -0,0 +1,42 @@ +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 + /** `false`: the list opens below the trigger instead of over it. */ + alignItemWithTrigger?: boolean + /** 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, + alignItemWithTrigger: inputs.alignItemWithTrigger, + 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..af231d5039 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -8,6 +8,17 @@ 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, + typedInTextEditor, + valuesStep, +} from "./conditions" import { groupBySelectionOf } from "./draft" import { Message, type Model, OutMessage } from "./panel-message" import { type PanelInputs, whereStep } from "./where" @@ -15,6 +26,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,21 +135,39 @@ 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 }) => ({ 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), + 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..655b486b14 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 { cn } from "@maple/ui/lib/utils" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { conditionBuilder } from "./condition-builder-view" +import { type UnshowableReason, unshowableReasonOf } from "./condition-rows" +import { conditionRowsOf, whereModeOf } from "./conditions" +import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" +import { panelSelect } from "./panel-select" 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,22 @@ 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, + className: string, ): 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, textareaClassName: WHERE_TEXTAREA_CLASS, onInput: (value) => Message.ChangedText({ queryId: query.id, field: "whereClause", value }), autocomplete: { @@ -93,6 +71,104 @@ export const whereClauseEditor = ( }, }) +/** Why the clause stays in the text editor, in one line. */ +const UNSHOWABLE_NOTE = { + mixedFields: "This filter mixes fields in an OR, so it stays in Text.", + orOperators: "This filter uses an OR the builder can't show, so it stays in Text.", + orNotApplied: "This OR group is on a field that can't be OR-ed, so it stays in Text.", + syntax: "This filter uses syntax the builder can't show, so it stays in Text.", +} satisfies Record + +// What sits beside the floated controls takes this much room, or drops under them. +const BESIDE_CONTROLS = "flow-root min-w-52" + +/** + * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text + * editor. The Builder/Text switch and `trailing` (the span select) float top right, level with + * the first row; a narrow editor puts them on their own line. Both edit the same string. + */ +export const whereClauseEditor = ( + h: H, + model: Model, + query: QueryDraft, + values: WhereClauseAutocompleteValues, + options: Readonly<{ placeholder?: string; className?: string; trailing?: ReadonlyArray }> = {}, +): Html => { + const rows = conditionRowsOf(model, query) + const isBuilder = whereModeOf(model, query) === "builder" && Option.isSome(rows) + 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: "sm", + ariaLabel: `Where clause editor for query ${query.name}`, + className: "shrink-0", + }) + // Capped at the editor's width and wrapping, so a narrow panel stacks the two instead of overflowing. + const controls = hstack( + h, + { gap: "2", wrap: true, justify: "end", className: "float-right mb-1 ml-2 max-w-full" }, + [toggle, ...(options.trailing ?? [])], + ) + const note = Option.match(unshowableReasonOf(query.whereClause, query.dataSource), { + onNone: () => [], + onSome: (reason) => [ + // One line beside the controls where the editor is wide, else its own lines under them. + hstack(h, { className: cn(BESIDE_CONTROLS, "min-h-7 @xl/where:min-w-100") }, [ + typography(h, { size: "xs", tone: "muted" }, [UNSHOWABLE_NOTE[reason]]), + ]), + ], + }) + const conditions = isBuilder + ? conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ) + : [] + const addCondition = hstack( + h, + { className: cn(BESIDE_CONTROLS, "min-h-7", conditions.length > 0 && "mt-1") }, + [ + button( + h, + { + variant: "ghost", + size: "xs", + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ), + ], + ) + return h.div( + [h.Class(cn("@container/where flow-root", options.className ?? "min-w-0 flex-1"))], + [ + controls, + ...note, + ...(isBuilder + ? [...conditions, addCondition] + : [ + whereTextEditor( + h, + model, + query, + values, + options.placeholder, + // Under the note when there is one, else beside the controls. + cn(BESIDE_CONTROLS, note.length > 0 && "clear-both"), + ), + ]), + ], + ) +} + /** A controlled `` that reports each keystroke. */ export const textInput = ( h: HtmlBuilder, diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index cd6a5e0753..21067cef25 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -21,8 +21,10 @@ 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 { Array as Arr, String as Str } from "effect" +import { unshowableReasonOf } from "./condition-rows" +import { panelSelect } from "./panel-select" +import { addOnSections, ADD_ON_KEYS, addOnToggles, textInput, whereClauseEditor } from "./parts" +import { Array as Arr, Option, String as Str } from "effect" // `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. @@ -148,11 +150,12 @@ const body = (h: H, model: Model, inputs: QueryPanelInputs): ReadonlyArray intervalPlaceholder, autocompleteValues: inputs.autocompleteValues ?? {}, }) + // The span select rides with the editor's Builder/Text switch, top right of the where area. const whereRow = (parts: ReadonlyArray) => - h.div( - [h.Class("flex items-start gap-2")], - [whereClauseEditor(h, model, query, inputs.autocompleteValues ?? {}), ...parts], - ) + whereClauseEditor(h, model, query, inputs.autocompleteValues ?? {}, { + className: "min-w-0", + trailing: parts, + }) return [ query.dataSource === "traces" ? panelSelect( @@ -189,6 +192,35 @@ const body = (h: H, model: Model, inputs: QueryPanelInputs): ReadonlyArray ] } +// The engine's warning for a dropped OR group ends in the group's text: which filter is missing. +const OR_GROUP_DROPPED = /OR group ignored: only attribute filters can be OR-ed: (.+)$/s +const OR_RULE = "Only attribute filters can be OR-ed." + +/** + * The engine's warnings in the panel's words: a dropped OR group names its group, one line per + * distinct group. `isRuleStated`: the editor's note already says the field can't be OR-ed. + */ +export const panelWarningsOf = ( + warnings: ReadonlyArray, + options: Readonly<{ isRuleStated?: boolean }> = {}, +): ReadonlyArray => + Arr.dedupe( + Arr.map(warnings, (warning) => + Option.match( + Option.flatMap(Str.match(OR_GROUP_DROPPED)(warning), (match) => + Option.fromNullishOr(match[1]), + ), + { + onNone: () => warning, + onSome: (group) => + options.isRuleStated === true + ? `This OR group is not applied: ${group}.` + : `This OR group is not applied: ${group}. ${OR_RULE}`, + }, + ), + ), + ) + /** `WarningList`: clauses the builder could not apply as written. */ const warningList = (h: H, warnings: ReadonlyArray): ReadonlyArray => warnings.length === 0 @@ -215,7 +247,15 @@ export const queryPanelView = Submodel.defineView [ ...body(h, model, inputs), // TODO(query-builder): the product-events setup hint (`SignalEmptyState`). - ...warningList(h, buildTimeseriesQuerySpec(query).warnings), + ...warningList( + h, + panelWarningsOf(buildTimeseriesQuerySpec(query).warnings, { + isRuleStated: Option.contains( + unshowableReasonOf(query.whereClause, query.dataSource), + "orNotApplied", + ), + }), + ), // A metrics query groups from its "across" row; a Group by toggle would only hide that choice. addOnToggles( h, 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..dcbaaad02c --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.test.ts @@ -0,0 +1,138 @@ +import type { QueryBuilderQueryDraftPayload } from "@maple/domain/http" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { describe, expect, it } from "vitest" +import { Operator } from "@maple/domain/where-clause" +import { + buildTimeseriesQuerySpec, + WHERE_DIMENSIONS, + WHERE_OPERATORS, + whereKeyMatchesAnyOf, + whereOperatorsOf, +} from "./model" +import { Array as Arr, Option, Order, 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([]) + }) + + const resourceSources: ReadonlyArray = ["traces", "logs", "metrics"] + const resourceCases = Arr.flatMap(resourceSources, (dataSource) => + Arr.map(WHERE_OPERATORS, (operator) => ({ dataSource, operator })), + ) + + it.each(resourceCases)("$dataSource resource.* takes $operator", ({ dataSource, operator }) => { + expect(whereOperatorsOf(dataSource, "resource.host.name")).toContain(operator) + expect(warningsOf(dataSource, clauseOf("resource.host.name", operator))).toEqual([]) + }) + + it("product events offer no operator on resource.*, which they would read as a prop of that name", () => { + expect(whereOperatorsOf("product_events", "resource.host.name")).toEqual([]) + }) + + it("the operator list is the grammar's: an operator added there is one an attribute key takes", () => { + expect(Arr.sort(WHERE_OPERATORS, Order.String)).toEqual(Arr.sort(Operator.literals, Order.String)) + }) + + const anyOfSources: ReadonlyArray = [ + "traces", + "logs", + "metrics", + "product_events", + ] + const anyOfCases = Arr.flatMap(anyOfSources, (dataSource) => + Arr.map(["attr.http.route", "resource.host.name", "service.name"], (key) => ({ dataSource, key })), + ) + + it.each(anyOfCases)("$dataSource applies an OR group on $key exactly when the rule says so", (each) => { + const warnings = warningsOf(each.dataSource, `(${each.key} = "a" OR ${each.key} = "b")`) + expect(warnings.length === 0).toBe(whereKeyMatchesAnyOf(each.dataSource, each.key)) + }) + + 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..e9396bc6ca --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.ts @@ -0,0 +1,140 @@ +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. + +/** Every grammar operator, in the order editors list them; the test holds it to the domain's literals. */ +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)) return { _tag: "Attribute" } + // Product events have no resource attributes: their handler would read the key as a prop of that literal name. + if (Str.startsWith("resource.")(canonical)) + return dataSource === "product_events" ? { _tag: "Unsupported" } : { _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 f01bff1962..0f351163c2 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -35,8 +35,9 @@ 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/combobox.ts` `inputId`, `listId`, `itemId`, `filterItems`, `queryOf`, `visibleItems`, `focusLeaves` (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. - `packages/views/src/ui/compact-search-input.ts` `compactSearchInput`: `CompactSearchInput`: a 24px mono filter field (magnifier, search input, clear button) for a panel toolbar. - `packages/views/src/ui/confirm-dialog.ts` `confirmDialog`: Port of `ConfirmDialog` (packages/ui/src/components/ui/confirm-dialog.tsx) over the alert-dialog Submodel. @@ -117,7 +118,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/overlay/anchored.ts` `outsidePresses`, `anchoredPopup`, `strayEscapes`: The Mount body every anchored popup shares (popover, hover card, menus, select, combobox): portal to ``, Base UI's outside marking, optional scroll lock and focus moves, t... - `packages/views/src/ui/overlay/auto-resize.ts` `fitPositionerToPopup`: Base UI's `usePopupAutoResize`: the popup is measured static inside a `max-content` positioner (a long label near the viewport edge must not wrap), the positioner takes that fra... - `packages/views/src/ui/overlay/popup-state.ts` `popupId`, `triggerId`, `portalId`, `initPopup`, `makeTransition`: Open state shared by every anchored popup's Model: open flag, Base UI transition status and the side and alignment the positioner rendered on (after any flip). -- `packages/views/src/ui/overlay/portal.ts` `portalToBody`, `markOutside`, `lockBodyScroll`, `containEscape`, `tabbables`, `trapFocus`, `manageFocus`: DOM side of Base UI's floating layer, used by overlay Mounts: the portal to ``, marking what lies outside a popup, the scroll lock, and moving focus in and back out. +- `packages/views/src/ui/overlay/portal.ts` `portalToBody`, `markOutside`, `lockBodyScroll`, `containEscape`, `keepFocusOnPress`, `tabbables`, `trapFocus`, `manageFocus`: DOM side of Base UI's floating layer, used by overlay Mounts: the portal to ``, marking what lies outside a popup, the scroll lock, and moving focus in and back out. - `packages/views/src/ui/overlay/position.ts` `positionDefaults`, `onScreenOr`, `anchorPosition`: Base UI's `useAnchorPositioning` (internals/useAnchorPositioning.mjs, @base-ui/react 1.8) on `@floating-ui/dom`: the same middleware order, collision padding bias, CSS variables... - `packages/views/src/ui/overlay/roving.ts` `isRovingKey`, `nextIndex`: Base UI's list navigation for menus and listboxes: arrows move the highlight and wrap (`loop`), Home/End jump, disabled entries are skipped. - `packages/views/src/ui/overlay/select-align.ts` `alignItemWithTrigger`: Base UI Select's `alignItemWithTrigger` (select/popup/SelectPopup.mjs, @base-ui/react 1.8): the popup opens over the trigger so the selected item's text sits on the trigger's va... @@ -210,12 +211,17 @@ 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`, `isOwnedElsewhere`, `isPickable`, `fieldsOf`, `sharesLabel`, `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`, `unshowableReasonOf`, `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.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/view.ts` `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. +- `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`: 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` `panelWarningsOf`, `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. - `apps/web/src/page/kit/query-builder-legend.ts` `queryBuilderLegend`: `QueryBuilderLegend` (packages/ui/src/components/charts/_shared/query-builder-legend.tsx), bottom layout, for hosts outside a chart. diff --git a/packages/views/src/ui/combobox-focus.test.ts b/packages/views/src/ui/combobox-focus.test.ts new file mode 100644 index 0000000000..ff908ec88b --- /dev/null +++ b/packages/views/src/ui/combobox-focus.test.ts @@ -0,0 +1,60 @@ +import { Effect, Fiber, Stream } from "effect" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { focusLeaves } from "./combobox" + +/** An element stand-in: `closest` answers whether it sits in the field (the anchor). */ +class FakeElement extends EventTarget { + constructor(private readonly isInField: boolean) { + super() + } + closest(): FakeElement | null { + return this.isInField ? this : null + } +} + +const original = globalThis.document +const originalElement = globalThis.Element +beforeEach(() => { + Object.defineProperty(globalThis, "document", { value: new EventTarget(), configurable: true }) + Object.defineProperty(globalThis, "Element", { value: FakeElement, configurable: true }) +}) +afterEach(() => { + Object.defineProperty(globalThis, "document", { value: original, configurable: true }) + Object.defineProperty(globalThis, "Element", { value: originalElement, configurable: true }) +}) + +/** How many times the list is told to close while focus lands on each of `targets` in turn. */ +const closesFor = (inList: FakeElement, targets: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const seen: Array = [] + const root = { contains: (node: unknown) => node === inList } + const fiber = yield* Effect.forkChild( + Stream.runForEach(focusLeaves(root, "[data-field]"), (message) => + Effect.sync(() => seen.push(message._tag)), + ), + ) + yield* Effect.sleep(10) + yield* Effect.forEach(targets, (target) => + Effect.sync(() => + globalThis.document.dispatchEvent( + Object.defineProperty(new Event("focusin"), "target", { value: target }), + ), + ), + ) + yield* Effect.sleep(10) + yield* Fiber.interrupt(fiber) + return seen + }), + ) + +describe("focusLeaves", () => { + test("focus moving within the field or its list keeps the list open", async () => { + const inList = new FakeElement(false) + expect(await closesFor(inList, [new FakeElement(true), inList])).toEqual([]) + }) + + test("Tab to a control outside the field and its list closes the list", async () => { + expect(await closesFor(new FakeElement(false), [new FakeElement(false)])).toEqual(["PressedOutside"]) + }) +}) diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts new file mode 100644 index 0000000000..1b0d3d7b4f --- /dev/null +++ b/packages/views/src/ui/combobox-picker.ts @@ -0,0 +1,124 @@ +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" +import { tooltipTrigger } from "./tooltip-host" + +/** + * `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 + /** 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 + /** 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 })), + // Hidden while its own list is open, which would cover it. + ...(inputs.tooltip === undefined || model.isOpen + ? [] + : tooltipTrigger(h, inputs.tooltip)), + ], + }, + [ + 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-90 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 }, + }), + ] + : []), + ]) + }, +) diff --git a/packages/views/src/ui/combobox-view.ts b/packages/views/src/ui/combobox-view.ts index 28819cdbea..85778b98ac 100644 --- a/packages/views/src/ui/combobox-view.ts +++ b/packages/views/src/ui/combobox-view.ts @@ -392,6 +392,8 @@ export type ChipsViewInputs = Readonly<{ placeholder: string emptyText: string className?: string + /** ``: the field is as tall as a control of that size. */ + size?: "sm" | "default" /** * `MultiSelectCombobox disabled`: the field stays mounted, dimmed and inert, so cards holding * several conditionally disabled pickers never reflow. @@ -485,8 +487,15 @@ export const chipsView = Submodel.defineView((m }), ...(isOpen ? [dismissButton(h)] : []), h.input([ - h.Class(cn(C.comboboxChipsInputStyles, C.comboboxChipsInputDefaultStyles)), - h.DataAttribute("size", "default"), + h.Class( + cn( + C.comboboxChipsInputStyles, + inputs.size === "sm" + ? C.comboboxChipsInputSmStyles + : C.comboboxChipsInputDefaultStyles, + ), + ), + h.DataAttribute("size", inputs.size ?? "default"), ...(isDisabled ? [h.Disabled(true)] : []), ...inputAttributes( h, diff --git a/packages/views/src/ui/combobox.ts b/packages/views/src/ui/combobox.ts index a5a3f407ab..23f07e608a 100644 --- a/packages/views/src/ui/combobox.ts +++ b/packages/views/src/ui/combobox.ts @@ -1,9 +1,9 @@ import * as Animation from "@foldkit/ui/animation" -import { Array, Effect, Option, Schema, Stream, String as Str } from "effect" +import { Array, Effect, Option, Queue, Schema, Stream, String as Str } from "effect" import { Mount, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { anchoredPopup } from "./overlay/anchored" -import { containEscape } from "./overlay/portal" +import { containEscape, keepFocusOnPress } from "./overlay/portal" import { initPopup, makeTransition, PopupState } from "./overlay/popup-state" import { Align, positionDefaults, Side } from "./overlay/position" import { isRovingKey, nextIndex } from "./overlay/roving" @@ -243,9 +243,50 @@ export const MountCombobox = Mount.defineStream("MountCombobox", { } }), Stream.merge(escapeContained(anchorSelector)), + // A press on the list leaves focus in the input that takes the keys: the field's own, or + // the popup's search input. Left on the listbox, Escape and Enter reached nothing. + Stream.merge(pressesKeepFocus(element)), + // A popup with its own search input: Escape there is stopped at the portal, which the + // anchor does not enclose. A field's list instead closes once focus leaves the field. + Stream.merge( + returnFocusId === undefined + ? focusLeaves(element, anchorSelector) + : escapeContainedIn(element), + ), ), }) +type PressedOutside = Extract + +/** Base UI keeps focus in the input while the list is pressed, so typing, Enter and Escape go on working. */ +const pressesKeepFocus = (root: Element): Stream.Stream => + Stream.callback(() => keepFocusOnPress(root).pipe(Effect.andThen(Effect.never))) + +/** Focus moved (Tab) to something outside the field and its list: the list closes, as on a press outside. */ +export const focusLeaves = ( + root: Pick, + anchorSelector: string, +): Stream.Stream => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onFocusIn = (event: Event) => { + const target = event.target + if (!(target instanceof Element)) return + if (root.contains(target) || target.closest(anchorSelector) !== null) return + Queue.offerUnsafe(queue, Message.PressedOutside()) + } + document.addEventListener("focusin", onFocusIn) + return onFocusIn + }), + (onFocusIn) => Effect.sync(() => document.removeEventListener("focusin", onFocusIn)), + ).pipe(Effect.andThen(Effect.never)), + ) + +/** Escape typed in the popup's own input stops at the portal, short of an enclosing dialog or page. */ +const escapeContainedIn = (root: Element): Stream.Stream => + Stream.callback(() => containEscape(root).pipe(Effect.andThen(Effect.never))) + /** While the list is open, Escape in the input closes it and stops short of an enclosing dialog. */ const escapeContained = (anchorSelector: string): Stream.Stream => Stream.callback(() => diff --git a/packages/views/src/ui/overlay/portal.test.ts b/packages/views/src/ui/overlay/portal.test.ts index 05a415fbdc..885d76e375 100644 --- a/packages/views/src/ui/overlay/portal.test.ts +++ b/packages/views/src/ui/overlay/portal.test.ts @@ -1,16 +1,29 @@ import { Effect, Exit, Scope } from "effect" import { afterEach, beforeEach, describe, expect, test } from "vitest" -import { containEscape, lockBodyScroll } from "./portal" +import { containEscape, keepFocusOnPress, lockBodyScroll } from "./portal" /** A body stand-in: the scroll lock only reads `style.overflow` and `dataset`. */ const fakeBody = () => ({ style: { overflow: "" }, dataset: {} }) +/** An element stand-in that is its own press target: `closest` finds a control only when it is one. */ +class FakeElement extends EventTarget { + constructor(private readonly isControl: boolean) { + super() + } + closest(): FakeElement | null { + return this.isControl ? this : null + } +} + const original = globalThis.document +const originalElement = globalThis.Element beforeEach(() => { Object.defineProperty(globalThis, "document", { value: { body: fakeBody() }, configurable: true }) + Object.defineProperty(globalThis, "Element", { value: FakeElement, configurable: true }) }) afterEach(() => { Object.defineProperty(globalThis, "document", { value: original, configurable: true }) + Object.defineProperty(globalThis, "Element", { value: originalElement, configurable: true }) }) const overflow = () => globalThis.document.body.style.overflow @@ -51,3 +64,25 @@ describe("containEscape", () => { ), )) }) + +describe("keepFocusOnPress", () => { + /** Whether a mouse press on `target` still moves focus (its default) with the popup holding it. */ + const pressMovesFocus = (target: FakeElement) => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* keepFocusOnPress(target) + const press = new Event("mousedown", { cancelable: true }) + target.dispatchEvent(press) + return !press.defaultPrevented + }), + ), + ) + + test("a press on a list option leaves focus in the input; a press on a control still takes it", async () => { + expect({ + option: await pressMovesFocus(new FakeElement(false)), + control: await pressMovesFocus(new FakeElement(true)), + }).toEqual({ option: false, control: true }) + }) +}) diff --git a/packages/views/src/ui/overlay/portal.ts b/packages/views/src/ui/overlay/portal.ts index 7a167e9c7b..e074ec0bc4 100644 --- a/packages/views/src/ui/overlay/portal.ts +++ b/packages/views/src/ui/overlay/portal.ts @@ -138,6 +138,26 @@ export const containEscape = (container: EventTarget): Effect.Effect Effect.sync(() => container.removeEventListener("keydown", onKeyDown)), ).pipe(Effect.asVoid) +const OWN_FOCUS = "input, textarea, select, button, a[href]" + +/** + * A press in `container` that is not on one of its controls leaves focus where it is: a list's + * options are not focus targets, so the input that handles the keys keeps them (Base UI's combobox). + */ +export const keepFocusOnPress = (container: EventTarget): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => { + const onMouseDown = (event: Event) => { + const target = event.target + if (typeof Element === "undefined" || !(target instanceof Element)) return + if (target.closest(OWN_FOCUS) === null) event.preventDefault() + } + container.addEventListener("mousedown", onMouseDown) + return onMouseDown + }), + (onMouseDown) => Effect.sync(() => container.removeEventListener("mousedown", onMouseDown)), + ).pipe(Effect.asVoid) + const TABBABLE = "a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]" diff --git a/packages/views/src/ui/select-view.ts b/packages/views/src/ui/select-view.ts index 7dc9c6d2c0..f69fa64702 100644 --- a/packages/views/src/ui/select-view.ts +++ b/packages/views/src/ui/select-view.ts @@ -64,8 +64,14 @@ export type ViewInputs = Readonly<{ leading?: ReadonlyArray /** `