From 3ec81ceba2f00f3255abcd51860ea3b0c8eebc78 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:05:58 +0100 Subject: [PATCH 01/27] feat(query-builder): condition builder for the where clause The query panel's where clause now edits as [field] [operator] [values] rows by default, with a Builder/Text switch back to the text editor. Rows map both ways to the same where-clause string (parse/print over the existing grammar), and every edit leaves as the existing whereClause text change, so previews and saving are unchanged. A clause the rows can't show stays in the text editor with a note. Fields come from the editor's autocomplete vocabulary: common fields, then resource and source attributes in a drill-down picker (new kit ComboboxPicker). Several values OR only on attribute fields, the only filters the query engine ORs; 'is not' AND-s any number. Adds /lab/condition-builder. --- apps/web/src/lab/condition-builder-lab.ts | 136 +++++++++ apps/web/src/lab/entries/condition-builder.ts | 6 + apps/web/src/lab/registry.ts | 8 + .../editor/editor-metrics.scene.test.ts | 12 +- .../widget/widget-presets.scene.test.ts | 23 -- .../query-builder/condition-builder-view.ts | 239 +++++++++++++++ .../condition-builder.scene.test.ts | 140 +++++++++ .../kit/query-builder/condition-fields.ts | 220 +++++++++++++ .../kit/query-builder/condition-rows.test.ts | 139 +++++++++ .../page/kit/query-builder/condition-rows.ts | 116 +++++++ .../page/kit/query-builder/conditions.test.ts | 122 ++++++++ .../src/page/kit/query-builder/conditions.ts | 289 ++++++++++++++++++ .../page/kit/query-builder/panel-message.ts | 23 ++ .../page/kit/query-builder/panel-select.ts | 39 +++ apps/web/src/page/kit/query-builder/panel.ts | 23 +- apps/web/src/page/kit/query-builder/parts.ts | 110 ++++--- packages/views/src/ui/INDEX.md | 10 +- packages/views/src/ui/combobox-picker.ts | 117 +++++++ 18 files changed, 1695 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/lab/condition-builder-lab.ts create mode 100644 apps/web/src/lab/entries/condition-builder.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-builder-view.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-fields.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-rows.test.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-rows.ts create mode 100644 apps/web/src/page/kit/query-builder/conditions.test.ts create mode 100644 apps/web/src/page/kit/query-builder/conditions.ts create mode 100644 apps/web/src/page/kit/query-builder/panel-select.ts create mode 100644 packages/views/src/ui/combobox-picker.ts diff --git a/apps/web/src/lab/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts new file mode 100644 index 0000000000..1df10709d5 --- /dev/null +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -0,0 +1,136 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { Option, Schema, Array as Arr } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { typography } from "@maple/views/ui/typography" +import { vstack } from "@maple/views/ui/stack" +import type { WhereClauseAutocompleteValues } from "../lib/query-builder/where-clause-autocomplete" +import type { Resources } from "../platform/resources" +import { sectionHeading } from "../page/kit/section-heading" +import { applyQueryChange } from "../page/kit/query-builder/change" +import { QueryDraft } from "../page/kit/query-builder/draft" +import * as QueryPanel from "../page/kit/query-builder/panel" +import { queryPanelView } from "../page/kit/query-builder/view" + +// `/lab/condition-builder`: the query panel's where clause as condition rows, over fixture +// vocabulary: empty, populated, a clause the rows can't show, and the text editor. + +export const LAB_VALUES: WhereClauseAutocompleteValues = { + services: ["checkout", "api", "payments", "frontend"], + spanNames: ["GET /api/cart", "POST /api/checkout", "db.query"], + environments: ["production", "staging"], + severities: ["ERROR", "WARN", "INFO", "DEBUG"], + httpStatusCodes: ["200", "404", "500", "503"], + attributeKeys: ["http.route", "http.request.method", "http.response.status_code", "db.system"], + resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region"], +} + +type Scenario = Readonly<{ title: string; hint: string; query: QueryDraft }> + +const draft = (id: string, name: string, dataSource: "traces" | "logs", whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id, name }, dataSource), + whereClause, +}) + +export const SCENARIOS: ReadonlyArray = [ + { title: "Empty", hint: "No conditions yet.", query: draft("empty", "A", "traces", "") }, + { + title: "Populated", + hint: "An attribute row with several values matches any of them; rows are AND-ed.", + query: draft( + "populated", + "B", + "traces", + 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists', + ), + }, + { + title: "Can't show as rows", + hint: "An OR across different fields stays in the text editor.", + query: draft( + "unparseable", + "C", + "traces", + '(service.name = "checkout" OR span.name = "GET /health")', + ), + }, + { + title: "Text mode", + hint: "Switched to Text by hand.", + query: draft("text", "D", "logs", 'service.name = "checkout" AND severity = "ERROR"'), + }, +] + +export const Model = Schema.Struct({ panel: QueryPanel.Model, queries: Schema.Array(QueryDraft) }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotPanelMessage: { message: QueryPanel.Message } }) +export type Message = typeof Message.Type + +export const init = (): Update.Return => { + const panel = QueryPanel.init("condition-lab") + return { + model: { + panel: { ...panel, whereModes: { text: "text" } }, + queries: Arr.map(SCENARIOS, (scenario) => scenario.query), + }, + } +} + +export const panelInputs = (queries: ReadonlyArray): QueryPanel.PanelInputs => ({ + queryOf: (queryId) => Option.getOrUndefined(Arr.findFirst(queries, (query) => query.id === queryId)), + baseValuesOf: () => LAB_VALUES, + orgId: "org_lab", + window: null, +}) + +/** The host's half: the panel's query edits land on the drafts. */ +export const update = (model: Model, message: Message): Update.Return => { + const next = QueryPanel.update(model.panel, message.message, panelInputs(model.queries)) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotPanelMessage({ message: inner }), + ) + const out = next.outMessage + const queries = + out?._tag === "ChangedQuery" + ? Arr.map(model.queries, (query) => + query.id === out.queryId ? applyQueryChange(query, out.change) : query, + ) + : model.queries + return { model: { panel: next.model, queries }, commands } +} + +export const view = (model: Model, h: HtmlBuilder): Html => + vstack(h, { gap: "8", className: "mx-auto w-full max-w-3xl p-6" }, [ + vstack(h, { gap: "1" }, [ + sectionHeading(h, { title: "Condition builder" }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "The where clause as rows. Every edit prints the same string the text editor writes.", + ]), + ]), + ...Arr.map(model.queries, (query, index) => { + const scenario = SCENARIOS[index] + return vstack(h, { gap: "2", as: "section", attributes: [h.Key(query.id)] }, [ + sectionHeading(h, { title: scenario?.title ?? query.name, hint: scenario?.hint, as: "h3" }), + h.submodel({ + slotId: `condition-lab-${query.id}`, + model: model.panel, + view: queryPanelView, + toParentMessage: (message) => Message.GotPanelMessage({ message }), + viewInputs: { + query, + index, + canRemove: false, + intervalPlaceholder: "Auto", + hasHeaderActions: false, + hasVisibilityToggle: false, + autocompleteValues: LAB_VALUES, + }, + }), + typography(h, { size: "2xs", tone: "subtle", mono: true, as: "p", className: "break-all" }, [ + query.whereClause === "" ? "(empty where clause)" : query.whereClause, + ]), + ]) + }), + ]) diff --git a/apps/web/src/lab/entries/condition-builder.ts b/apps/web/src/lab/entries/condition-builder.ts new file mode 100644 index 0000000000..9c64b93b1c --- /dev/null +++ b/apps/web/src/lab/entries/condition-builder.ts @@ -0,0 +1,6 @@ +import { defineLab } from "../define" +import { init, Model, update, view } from "../condition-builder-lab" + +// `/lab/condition-builder`: the query panel's condition rows over fixture vocabulary. + +export const lab = defineLab("Condition builder", { Model, init, update, view }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..02bc171ec3 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -435,6 +435,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/condition-builder", + title: "Condition builder", + description: + "The query panel's where clause as field, operator and value rows: empty, populated, a clause the rows can't show, and text mode.", + kind: "lab", + session: "none", + }, { path: "/lab/time-range", title: "Time range picker", diff --git a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts index 1e2a948987..4423cbc4d0 100644 --- a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts @@ -6,9 +6,6 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" -import { CaretMessage } from "../../kit/where-autocomplete" -import { WhereFieldMessage } from "../../kit/where-field" -import { MountWhereFieldCaret } from "../../kit/where-field-command" import type { PageContext } from "../../contract" import * as QueryPanel from "../../kit/query-builder/panel" import { NOW, ORG } from "../shared/test-rows" @@ -26,17 +23,10 @@ const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } const view = withViewInputs(pageView, { context })() const pageUpdate = (model: Model, message: Message) => update(model, message, context) const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" -/** The page's mounts: the theme, the closed templates dialog and the where editor's caret. */ +/** The page's mounts: the theme and the closed templates dialog (the where clause opens as rows, no caret). */ const mounted = () => [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), - // Resolved with the query panel's message: the page's submodel wraps it. - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ fieldId: DRAFT_ID, message: CaretMessage.BlurredEditor() }), - }), - ), ] const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) diff --git a/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts b/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts index 578dab66d2..8a3d104ce8 100644 --- a/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts @@ -15,9 +15,6 @@ import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import * as QueryPanel from "../../kit/query-builder/panel" import * as Combobox from "@maple/views/ui/combobox" -import { CaretMessage } from "../../kit/where-autocomplete" -import { WhereFieldMessage } from "../../kit/where-field" -import { MountWhereFieldCaret } from "../../kit/where-field-command" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" @@ -222,17 +219,6 @@ describe("preset widgets in the editor", () => { rect: { left: 0, right: 225, top: 4, bottom: 5, width: 112, height: 32 }, }), ), - // Resolved with the query panel's message: the page's submodel wraps it. - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ - fieldId: - builder.state?.queries[0]?.id ?? expect.unreachable("the Builder has query A"), - message: CaretMessage.BlurredEditor(), - }), - }), - ), expectView(text("+ Formula")).toExist(), expectView(text("This preset's query can't be edited")).not.toExist(), ) @@ -277,15 +263,6 @@ describe("preset widgets in the editor", () => { scene( { update: (model: Model, message: Message) => update(model, message, context), view }, ...mounted(pie), - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ - fieldId: pie.state?.queries[0]?.id ?? expect.unreachable("the pie has query A"), - message: CaretMessage.BlurredEditor(), - }), - }), - ), expectView( text("This visualization groups data into categories. Add a group-by field"), ).toExist(), diff --git a/apps/web/src/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts new file mode 100644 index 0000000000..cd375741d0 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -0,0 +1,239 @@ +import type { WhereClauseAutocompleteValues } from "../../../lib/query-builder/where-clause-autocomplete" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Combobox from "@maple/views/ui/combobox" +import { comboboxPicker } from "@maple/views/ui/combobox-picker" +import { chipsView } from "@maple/views/ui/combobox-view" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { + type ConditionField, + type FieldPickerItem, + fieldOf, + fieldPickerItems, + fieldsOf, + groupLabelOf, + OPERATOR_LABEL, + operatorsOf, + valueSuggestionsOf, +} from "./condition-fields" +import { type ConditionRow, isValueless } from "./condition-rows" +import { conditionPickerId, operatorSelectId } from "./conditions" +import type { QueryDraft } from "./draft" +import { Message, type Model } from "./panel-message" +import { panelSelect } from "./panel-select" +import { Array as Arr, Option, String as Str } from "effect" + +// `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each +// picked from the query's own vocabulary. + +type H = HtmlBuilder + +const GROUP_SHORT = { common: "", resource: "resource", attribute: "attr" } + +const fieldTrigger = (h: H, field: ConditionField): ReadonlyArray => + field.group === "common" + ? [field.label] + : [ + typography(h, { tone: "subtle", as: "span", mono: true }, [`${GROUP_SHORT[field.group]}.`]), + field.label, + ] + +const fieldItem = ( + h: HtmlBuilder, + item: FieldPickerItem, + dataSource: QueryDraft["dataSource"], + isSearching: boolean, +): ReadonlyArray => { + const row = (label: string, trailing: ReadonlyArray) => + hstack(h, { gap: "2", justify: "between", shrink: true, className: "w-full" }, [ + typography(h, { truncate: true, as: "span" }, [label]), + ...trailing, + ]) + const hint = (text: string) => + typography( + h, + { size: "2xs", tone: "subtle", mono: true, as: "span", truncate: true, className: "max-w-1/2" }, + [text], + ) + switch (item._tag) { + case "Field": + return [ + row(item.field.label, [ + ...(item.field.group === "common" + ? [hint(item.field.key)] + : isSearching + ? [hint(GROUP_SHORT[item.field.group])] + : []), + ]), + ] + case "Group": + return [ + row(groupLabelOf(item.group, dataSource), [ + hstack(h, { gap: "1", as: "span" }, [ + hint(String(item.count)), + icon(h, "ChevronRightIcon", { size: 12, className: "text-foreground-subtle" }), + ]), + ]), + ] + case "Back": + return [ + hstack(h, { gap: "1.5", as: "span" }, [ + icon(h, "ChevronLeftIcon", { size: 12, className: "text-foreground-subtle" }), + "All fields", + ]), + ] + case "Custom": + return [row(`Use "${item.key}"`, [])] + } +} + +const fieldPicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + values: WhereClauseAutocompleteValues, +): Html => { + const id = conditionPickerId(query.id, row.id, "field") + const picker = model.conditionPickers[id] ?? Combobox.init(id, "single") + const current = { ...picker, selected: row.key === "" ? [] : [row.key] } + const search = Combobox.queryOf(current) + const items = fieldPickerItems(fieldsOf(query.dataSource, values), model.fieldDrills[id] ?? "all", search) + const byValue = new Map(Arr.map(items, (item) => [item.value, item])) + const firstGroup = Option.getOrNull(Arr.findFirst(items, (item) => item._tag === "Group")) + return h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: current, + view: comboboxPicker, + toParentMessage: (message) => + Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), + viewInputs: { + items: Arr.map(items, (item) => item.value), + ariaLabel: "Field", + searchPlaceholder: "Search fields…", + emptyText: "No fields match", + trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), + isPlaceholder: row.key === "", + triggerClassName: "max-w-56", + renderItem: (itemH, value) => { + const item = byValue.get(value) + return item === undefined + ? [value] + : fieldItem(itemH, item, query.dataSource, Str.trim(search) !== "") + }, + itemClassName: (value) => + firstGroup !== null && value === firstGroup.value + ? "mt-1 border-t border-border-subtle pt-1" + : undefined, + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }) +} + +const valuePicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + values: WhereClauseAutocompleteValues, +): Html => { + const id = conditionPickerId(query.id, row.id, "values") + const picker = model.conditionPickers[id] ?? Combobox.init(id, "multiple") + const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) + const typed = Str.trim(picker.inputValue) + // What is typed is offered first when it is not already a suggestion, so a click adds it too. + const isTypedNew = typed !== "" && !suggestions.includes(typed) && !row.values.includes(typed) + return h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: { ...picker, selected: row.values }, + view: chipsView, + toParentMessage: (message) => + Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }), + viewInputs: { + items: isTypedNew ? [typed, ...suggestions] : suggestions, + ariaLabel: "Value", + placeholder: row.values.length === 0 ? "Value" : "", + emptyText: "Type a value and press Enter", + className: "text-xs", + renderItem: (_, value) => (isTypedNew && value === typed ? [`Use "${value}"`] : [value]), + toView: (parts) => h.div([h.Class("min-w-40 flex-1")], parts), + }, + }) +} + +const conditionRow = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, +): Html => { + const connector = typography(h, { size: "xs", tone: "muted", className: "w-10 shrink-0" }, [ + index === 0 ? "Where" : "and", + ]) + const remove = iconButton( + h, + { + size: "icon-xs", + label: "Remove condition", + hasTooltip: false, + className: "text-muted-foreground hover:text-foreground active:text-foreground", + attributes: [h.OnClick(Message.ClickedRemoveCondition({ queryId: query.id, rowId: row.id }))], + }, + [icon(h, "XmarkIcon")], + ) + const field = fieldPicker(h, model, query, row, values) + const layout = (parts: ReadonlyArray) => + hstack(h, { gap: "1.5", wrap: true, attributes: [h.DataAttribute("slot", "condition-row")] }, [ + connector, + field, + ...parts, + remove, + ]) + if (row.key === "") return layout([]) + const kind = fieldOf(row.key, query.dataSource).kind + return panelSelect( + h, + model, + operatorSelectId(query.id, row.id), + row.operator, + Arr.map(operatorsOf(kind, row.operator), (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + })), + { + size: "sm", + className: "w-36", + ariaLabel: "Operator", + toView: (parts) => + layout([ + ...parts, + ...(isValueless(row.operator) ? [] : [valuePicker(h, model, query, row, values)]), + ]), + }, + ) +} + +/** The rows, or nothing when there are none: the caller's "Add condition" starts the first. */ +export const conditionBuilder = ( + h: H, + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => + rows.length === 0 + ? [] + : [ + vstack( + h, + { + gap: "1.5", + attributes: [h.AriaLabel(`Conditions for query ${query.name}`), h.Role("group")], + }, + Arr.map(rows, (row, index) => conditionRow(h, model, query, row, index, values)), + ), + ] diff --git a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts new file mode 100644 index 0000000000..6d9e2665fd --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -0,0 +1,140 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { + all, + click, + Command, + expect as expectView, + given, + keydown, + label, + last, + Mount, + role, + scene, + text, + type as typeInto, +} from "foldkit/scene" +import { describe, test } from "vitest" +import * as Animation from "@foldkit/ui/animation" +import * as Combobox from "@maple/views/ui/combobox" +import { CaretMessage } from "../where-autocomplete" +import { WhereFieldMessage } from "../where-field" +import { MountWhereFieldCaret } from "../where-field-command" +import { type Model, update, view } from "../../../lab/condition-builder-lab" +import type { QueryDraft } from "./draft" +import * as QueryPanel from "./panel" + +// The where clause as condition rows: every pick prints the clause the text editor would hold. + +const traces = (whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), + whereClause, +}) + +const hosted = (whereClause: string): Model => ({ + panel: QueryPanel.init("test"), + queries: [traces(whereClause)], +}) + +const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) + +/** A picker's close animation, `generation` counting its open and close transitions. */ +const pickerClosed = (id: string, generation: number) => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve( + Combobox.WaitForComboboxExit({ id, generation }), + Animation.Message.EndedAnimation({ generation }), + ), + Mount.expectEnded(Combobox.MountCombobox), +] + +/** A picker's open animation. */ +const pickerOpened = (generation: number) => [ + comboMounted(), + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })), +] + +/** The text editor's caret, read when it mounts. */ +const caretMounted = () => + Mount.resolve( + MountWhereFieldCaret, + QueryPanel.Message.GotWhereField({ + message: WhereFieldMessage.GotCaret({ fieldId: "q", message: CaretMessage.BlurredEditor() }), + }), + ) + +/** The last row's value picker: the row just added. */ +const newValue = last(all.role("combobox", { name: "Value" })) + +describe("condition builder", () => { + test("a new row picks an attribute, then typed values, and prints an OR group", () => { + scene( + { update, view }, + given(hosted("")), + expectView(text("(empty where clause)")).toExist(), + click(role("button", { name: "Add condition" })), + comboMounted(), + click(role("option", { name: /^Span attributes/ })), + click(role("option", { name: /^http\.route/ })), + ...pickerClosed("cond-q-c0-field", 1), + typeInto(role("combobox", { name: "Value" }), "/cart"), + ...pickerOpened(1), + keydown(role("combobox", { name: "Value" }), "Enter"), + typeInto(role("combobox", { name: "Value" }), "/checkout"), + keydown(role("combobox", { name: "Value" }), "Enter"), + expectView(text('(attr.http.route = "/cart" OR attr.http.route = "/checkout")')).toExist(), + ) + }) + + test("the field picker drills into resource attributes", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + click(role("button", { name: "Add condition" })), + comboMounted(), + click(role("option", { name: /^Resource attributes/ })), + click(role("option", { name: /^host\.name/ })), + ...pickerClosed("cond-q-c1-field", 1), + typeInto(newValue, "web-1"), + ...pickerOpened(1), + keydown(newValue, "Enter"), + expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), + ) + }) + + test("removing a row prints the rest", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout" AND attr.http.route exists')), + click(label("Remove condition")), + expectView(text("attr.http.route exists")).toExist(), + ) + }) + + test("Text shows the editor over the same clause, and Builder brings the rows back", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + click(role("button", { name: "Text" })), + caretMounted(), + expectView(label("Where clause for query A")).toHaveValue('service.name = "checkout"'), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + click(role("button", { name: "Builder" })), + Mount.expectEnded(MountWhereFieldCaret), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + ) + }) + + test("a clause the rows can't show stays in the text editor and says so", () => { + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + expectView(text("This filter uses syntax the builder can't show, edit as text")).toExist(), + expectView(label("Where clause for query A")).toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.ts b/apps/web/src/page/kit/query-builder/condition-fields.ts new file mode 100644 index 0000000000..192ebecf18 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -0,0 +1,220 @@ +import type { Operator } from "@maple/domain/where-clause" +import { + getWhereClauseAutocomplete, + type WhereClauseAutocompleteValues, +} from "../../../lib/query-builder/where-clause-autocomplete" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { QueryDraft } from "./draft" + +// The condition builder's vocabulary: which fields a query offers (grouped like a path picker), +// the operators a field takes, and the values it suggests, all read from the where editor's +// autocomplete values. + +type DataSource = QueryDraft["dataSource"] + +export type FieldGroup = "common" | "resource" | "attribute" +export type FieldKind = "string" | "number" | "boolean" +export type ConditionField = Readonly<{ key: string; label: string; group: FieldGroup; kind: FieldKind }> + +const common = (key: string, label: string, kind: FieldKind = "string"): ConditionField => ({ + key, + label, + group: "common", + kind, +}) + +const COMMON_FIELDS = { + traces: [ + common("service.name", "Service"), + common("span.name", "Span name"), + common("deployment.environment", "Environment"), + common("attr.http.response.status_code", "HTTP status code", "number"), + common("has_error", "Has error", "boolean"), + ], + logs: [common("service.name", "Service"), common("severity", "Severity"), common("body", "Message")], + metrics: [common("service.name", "Service"), common("metric.type", "Metric type")], + product_events: [ + common("event.name", "Event name"), + common("host", "Host"), + common("page.path", "Page path"), + common("service.name", "Service"), + common("user.id", "User"), + ], +} satisfies Record> + +const ATTRIBUTE_GROUP_LABEL = { + traces: "Span attributes", + logs: "Log attributes", + metrics: "Metric attributes", + product_events: "Event properties", +} satisfies Record + +export const groupLabelOf = (group: FieldGroup, dataSource: DataSource): string => + group === "common" + ? "Common" + : group === "resource" + ? "Resource attributes" + : ATTRIBUTE_GROUP_LABEL[dataSource] + +const NUMERIC_KEY = /(status_code|_ms|duration|\.port|\.count|\.size|_bytes)$/ + +const kindOfKey = (key: string): FieldKind => (NUMERIC_KEY.test(key) ? "number" : "string") + +const RESOURCE_PREFIX = "resource." +const ATTRIBUTE_PREFIX = "attr." + +/** A key as a field: a common one by name, else its attribute group with the prefix dropped. */ +export const fieldOf = (key: string, dataSource: DataSource): ConditionField => { + const known = Option.getOrNull(Arr.findFirst(COMMON_FIELDS[dataSource], (field) => field.key === key)) + if (known !== null) return known + if (Str.startsWith(RESOURCE_PREFIX)(key)) + return { key, label: Str.slice(RESOURCE_PREFIX.length)(key), group: "resource", kind: kindOfKey(key) } + const label = Str.startsWith(ATTRIBUTE_PREFIX)(key) ? Str.slice(ATTRIBUTE_PREFIX.length)(key) : key + return { key, label, group: "attribute", kind: kindOfKey(key) } +} + +/** + * Whether the query engine can OR this field's values: only attribute filters lower an + * `(a OR b)` group; a named dimension (`service.name`) or a product-events query drops it. + */ +export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => + dataSource !== "product_events" && + field.kind !== "boolean" && + (field.group !== "common" || Str.startsWith(ATTRIBUTE_PREFIX)(field.key)) + +/** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ +export const fieldsOf = ( + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => + Arr.dedupeWith( + [ + ...COMMON_FIELDS[dataSource], + ...Arr.map(values.resourceAttributeKeys ?? [], (key) => + fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), + ), + ...Arr.map(values.attributeKeys ?? [], (key) => fieldOf(`${ATTRIBUTE_PREFIX}${key}`, dataSource)), + ], + (a, b) => a.key === b.key, + ) + +// The field picker's list: a drill-down over the groups, flat while searching. + +export const FieldDrill = Schema.Literals(["all", "resource", "attribute"]) +export type FieldDrill = typeof FieldDrill.Type + +export type FieldPickerItem = + | Readonly<{ _tag: "Field"; value: string; field: ConditionField }> + | Readonly<{ _tag: "Group"; value: string; group: "resource" | "attribute"; count: number }> + | Readonly<{ _tag: "Back"; value: string }> + | Readonly<{ _tag: "Custom"; value: string; key: string }> + +const GROUP_VALUE = "@group:" +const BACK_VALUE = "@back" +const CUSTOM_VALUE = "@custom:" +const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/ + +const asField = (field: ConditionField): FieldPickerItem => ({ _tag: "Field", value: field.key, field }) + +const matchesQuery = (field: ConditionField, query: string): boolean => { + const needle = Str.toLowerCase(query) + return ( + Str.includes(needle)(Str.toLowerCase(field.key)) || Str.includes(needle)(Str.toLowerCase(field.label)) + ) +} + +/** What a typed key means inside a group: the group's prefix unless it already has one. */ +const customKeyOf = (typed: string, drill: FieldDrill, fields: ReadonlyArray): string => { + if (Str.startsWith(RESOURCE_PREFIX)(typed) || Str.startsWith(ATTRIBUTE_PREFIX)(typed)) return typed + if (drill === "resource") return `${RESOURCE_PREFIX}${typed}` + if (drill === "all" && Arr.some(fields, (field) => field.group === "common" && field.key === typed)) + return typed + return `${ATTRIBUTE_PREFIX}${typed}` +} + +export const fieldPickerItems = ( + fields: ReadonlyArray, + drill: FieldDrill, + query: string, +): ReadonlyArray => { + const typed = Str.trim(query) + const inScope = drill === "all" ? fields : Arr.filter(fields, (field) => field.group === drill) + const back: ReadonlyArray = drill === "all" ? [] : [{ _tag: "Back", value: BACK_VALUE }] + if (typed !== "") { + const key = customKeyOf(typed, drill, fields) + const custom: ReadonlyArray = + KEY_PATTERN.test(typed) && !Arr.some(fields, (field) => field.key === key) + ? [{ _tag: "Custom", value: `${CUSTOM_VALUE}${key}`, key }] + : [] + return [ + ...Arr.map( + Arr.filter(inScope, (field) => matchesQuery(field, typed)), + asField, + ), + ...custom, + ] + } + if (drill !== "all") return [...back, ...Arr.map(inScope, asField)] + const groupNames: ReadonlyArray<"resource" | "attribute"> = ["resource", "attribute"] + const groups = Arr.flatMap(groupNames, (group): ReadonlyArray => { + const count = Arr.filter(fields, (field) => field.group === group).length + return count === 0 ? [] : [{ _tag: "Group", value: `${GROUP_VALUE}${group}`, group, count }] + }) + return [ + ...Arr.map( + Arr.filter(fields, (field) => field.group === "common"), + asField, + ), + ...groups, + ] +} + +// Operators and values. + +/** The operators a field takes, in menu order; a row's current operator always stays offered. */ +export const operatorsOf = (kind: FieldKind, current?: Operator): ReadonlyArray => { + const own: ReadonlyArray = + kind === "number" + ? ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"] + : kind === "boolean" + ? ["=", "!=", "exists", "!exists"] + : ["=", "!=", "contains", "!contains", "exists", "!exists"] + return current === undefined || own.includes(current) ? own : [...own, current] +} + +export const OPERATOR_LABEL = { + "=": "is", + "!=": "is not", + contains: "contains", + "!contains": "does not contain", + ">": "greater than", + ">=": "at least", + "<": "less than", + "<=": "at most", + exists: "is set", + "!exists": "is not set", +} satisfies Record + +/** Known values for a field: what the where editor would suggest after `key = `. */ +export const valueSuggestionsOf = ( + field: ConditionField, + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + if (field.kind === "boolean") return ["true", "false"] + if (Str.endsWith("status_code")(field.key)) return values.httpStatusCodes ?? [] + const expression = `${field.key} = ` + const { suggestions } = getWhereClauseAutocomplete({ + expression, + cursor: expression.length, + dataSource, + values, + maxSuggestions: 200, + }) + return Arr.dedupe( + Arr.map( + Arr.filter(suggestions, (suggestion) => suggestion.kind === "value"), + (suggestion) => suggestion.label, + ), + ) +} diff --git a/apps/web/src/page/kit/query-builder/condition-rows.test.ts b/apps/web/src/page/kit/query-builder/condition-rows.test.ts new file mode 100644 index 0000000000..9a15ec4df8 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -0,0 +1,139 @@ +import { parseWhereClause } from "@maple/domain/where-clause" +import { describe, expect, test } from "vitest" +import { type ConditionRow, nextRowId, parseConditionRows, printConditionRows } from "./condition-rows" +import { Array as Arr, Option } from "effect" + +const rowsOf = (whereClause: string) => + Option.map(parseConditionRows(whereClause), (rows) => + Arr.map(rows, ({ key, operator, values }) => ({ key, operator, values })), + ) + +const row = (key: string, operator: ConditionRow["operator"], values: ReadonlyArray, id = "c0") => ({ + id, + key, + operator, + values, +}) + +describe("parseConditionRows", () => { + test.each([ + ["", []], + ['service.name = "checkout"', [{ key: "service.name", operator: "=", values: ["checkout"] }]], + [ + '(service.name = "checkout" OR service.name = "api")', + [{ key: "service.name", operator: "=", values: ["checkout", "api"] }], + ], + ["attr.http.route exists", [{ key: "attr.http.route", operator: "exists", values: [] }]], + ["resource.host.name !exists", [{ key: "resource.host.name", operator: "!exists", values: [] }]], + [ + "attr.http.response.status_code >= 500", + [{ key: "attr.http.response.status_code", operator: ">=", values: ["500"] }], + ], + // Aliases read as their canonical key. + [ + "service = checkout AND env = prod", + [ + { key: "service.name", operator: "=", values: ["checkout"] }, + { key: "deployment.environment", operator: "=", values: ["prod"] }, + ], + ], + // Negations of one key fold into one row. + [ + 'severity != "DEBUG" AND severity != "TRACE"', + [{ key: "severity", operator: "!=", values: ["DEBUG", "TRACE"] }], + ], + // Attribute keys keep their case. + ["attr.userId = 'u_1'", [{ key: "attr.userId", operator: "=", values: ["u_1"] }]], + ['body contains "timed out"', [{ key: "body", operator: "contains", values: ["timed out"] }]], + ])("%s", (whereClause, expected) => { + expect(rowsOf(whereClause)).toEqual(Option.some(expected)) + }) + + test.each([ + // Not the grammar at all. + "service.name ~ checkout", + 'service.name = "unclosed', + // OR groups a row cannot show: mixed keys, mixed or negated operators, nesting. + '(service.name = "a" OR span.name = "b")', + '(service.name = "a" OR service.name contains "b")', + '(service.name != "a" OR service.name != "b")', + '(service.name = "a" OR (span.name = "b"))', + // A group without orGroups support in a row: AND inside parentheses. + '(service.name = "a" AND span.name = "b")', + ])("%s cannot be shown", (whereClause) => { + expect(Option.isNone(parseConditionRows(whereClause))).toBe(true) + }) +}) + +describe("printConditionRows", () => { + test.each([ + [[row("service.name", "=", ["checkout"])], 'service.name = "checkout"'], + [ + [row("service.name", "=", ["checkout", "api"])], + '(service.name = "checkout" OR service.name = "api")', + ], + [[row("severity", "!=", ["DEBUG", "TRACE"])], 'severity != "DEBUG" AND severity != "TRACE"'], + [[row("attr.http.route", "exists", [])], "attr.http.route exists"], + [[row("attr.http.response.status_code", ">", ["499"])], "attr.http.response.status_code > 499"], + [[row("root_only", "=", ["true"])], "root_only = true"], + // A value holding a double quote takes single quotes. + [[row("span.name", "contains", ['say "hi"'])], `span.name contains 'say "hi"'`], + [ + [row("service.name", "=", ["checkout"]), row("deployment.environment", "!exists", [], "c1")], + 'service.name = "checkout" AND deployment.environment !exists', + ], + ])("%j", (rows, expected) => { + expect(printConditionRows(rows)).toBe(expected) + }) + + test("unfinished rows print nothing", () => { + expect( + printConditionRows([ + row("", "=", []), + row("service.name", "=", [], "c1"), + row("span.name", "=", ["GET /"], "c2"), + ]), + ).toBe('span.name = "GET /"') + }) +}) + +describe("round trip", () => { + // What the grammar reads, order-free: the builder may reorder AND-ed clauses. + const meaning = (whereClause: string) => { + const parsed = parseWhereClause(whereClause, { orGroups: true }) + const keyed = (clause: { key: string; operator: string; value: string }) => + `${clause.key} ${clause.operator} ${clause.value}` + return { + clauses: Arr.sort(Arr.map(parsed.clauses, keyed), (a: string, b: string) => + a < b ? -1 : a > b ? 1 : 0, + ), + groups: Arr.map(parsed.groups, (group) => Arr.map(group, keyed)), + warnings: parsed.warnings.length, + } + } + + test.each([ + 'service.name = "checkout"', + 'service.name = checkout AND span.name contains "GET /api"', + '(service.name = "a" OR service.name = "b") AND has_error = true', + 'severity != "DEBUG" AND service.name = "x" AND severity != "TRACE"', + "attr.http.response.status_code >= 500 AND attr.http.response.status_code < 600", + "resource.k8s.pod.name exists AND attr.db.system !exists", + `attr.message contains 'it''s' AND span.name = 'say "hi"'`, + '(body contains "timeout" OR body contains "refused")', + ])("%s", (whereClause) => { + const rows = Option.getOrThrow(parseConditionRows(whereClause)) + const printed = printConditionRows(rows) + expect(meaning(printed)).toEqual(meaning(whereClause)) + expect(Option.map(parseConditionRows(printed), (again) => Arr.map(again, (r) => r.values))).toEqual( + Option.some(Arr.map(rows, (r) => r.values)), + ) + }) +}) + +describe("nextRowId", () => { + test("is past every id in use", () => { + expect(nextRowId([])).toBe("c0") + expect(nextRowId([row("a", "=", [], "c0"), row("b", "=", [], "c4")])).toBe("c5") + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts new file mode 100644 index 0000000000..c254f11f47 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -0,0 +1,116 @@ +import { + normalizeKey, + Operator, + type ParsedClause, + parseWhereClause, + quoteWhereValue, + splitWhereClause, +} from "@maple/domain/where-clause" +import { Array as Arr, Option, Schema, String as Str } from "effect" + +// The condition builder's rows and their two-way mapping to the where-clause string +// (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` +// and AND-ed for `!=`/`!contains`. + +/** One `[field] [operator] [values]` row; `key` is empty until a field is picked. */ +export const ConditionRow = Schema.Struct({ + id: Schema.String, + key: Schema.String, + operator: Operator, + values: Schema.Array(Schema.String), +}) +export type ConditionRow = typeof ConditionRow.Type + +/** Several values mean "any of them": printed as one `(k = a OR k = b)` group. */ +export const isAnyOf = (operator: Operator): boolean => operator === "=" || operator === "contains" + +/** Several values mean "none of them": printed as AND-ed clauses. */ +export const isNoneOf = (operator: Operator): boolean => operator === "!=" || operator === "!contains" + +export const isValueless = (operator: Operator): boolean => operator === "exists" || operator === "!exists" + +/** An alias (`service`) as its canonical key; any other key as typed, since attribute keys keep case. */ +const keyOf = (clause: ParsedClause): string => { + const canonical = normalizeKey(clause.key) + return canonical !== clause.key ? canonical : (clause.rawKey ?? clause.key) +} + +type Draft = Omit + +const draftOf = (clause: ParsedClause): Draft => ({ + key: keyOf(clause), + operator: clause.operator, + values: isValueless(clause.operator) ? [] : [clause.value], +}) + +/** One AND-ed part: a single clause, or an OR group whose members share key and an any-of operator. */ +const partOf = (part: string): Option.Option => { + const parsed = parseWhereClause(part, { orGroups: true }) + if (parsed.warnings.length > 0) return Option.none() + const [clause] = parsed.clauses + if (parsed.groups.length === 0 && parsed.clauses.length === 1 && clause !== undefined) + return Option.some(draftOf(clause)) + const [group] = parsed.groups + if (parsed.clauses.length > 0 || parsed.groups.length !== 1 || group === undefined) return Option.none() + const drafts = Arr.map(group, draftOf) + const [first] = drafts + if (first === undefined || !isAnyOf(first.operator)) return Option.none() + const isShared = Arr.every( + drafts, + (draft) => draft.key === first.key && draft.operator === first.operator, + ) + return isShared + ? Option.some({ ...first, values: Arr.flatMap(drafts, (draft) => draft.values) }) + : Option.none() +} + +/** `k != a AND k != b` reads as one "is not a, b" row. */ +const mergeNoneOf = (drafts: ReadonlyArray): ReadonlyArray => + Arr.reduce(drafts, Arr.empty(), (rows: ReadonlyArray, draft) => { + if (!isNoneOf(draft.operator)) return [...rows, draft] + const index = Arr.findFirstIndex( + rows, + (row) => row.key === draft.key && row.operator === draft.operator, + ) + return Option.match(index, { + onNone: () => [...rows, draft], + onSome: (at) => + Arr.map(rows, (row, i) => + i === at ? { ...row, values: Arr.dedupe([...row.values, ...draft.values]) } : row, + ), + }) + }) + +/** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ +export const parseConditionRows = (whereClause: string): Option.Option> => + Option.map(Option.all(Arr.map(splitWhereClause(Str.trim(whereClause)), partOf)), (drafts) => + Arr.map(mergeNoneOf(drafts), (draft, index) => ({ id: `c${index}`, ...draft })), + ) + +/** Numbers and booleans stay bare (`root_only = true` is what the span select reads); the rest is quoted. */ +const literalOf = (value: string): string => + /^-?\d+(\.\d+)?$/.test(value) || value === "true" || value === "false" ? value : quoteWhereValue(value) + +const clauseOf = (key: string, operator: Operator, value: string): string => + isValueless(operator) ? `${key} ${operator}` : `${key} ${operator} ${literalOf(value)}` + +/** A row the where clause can carry: a field, and a value unless the operator takes none. */ +export const isComplete = (row: ConditionRow): boolean => + row.key !== "" && (isValueless(row.operator) || row.values.length > 0) + +const printRow = (row: ConditionRow): string => { + if (isValueless(row.operator)) return clauseOf(row.key, row.operator, "") + const clauses = Arr.map(row.values, (value) => clauseOf(row.key, row.operator, value)) + if (clauses.length === 1) return Arr.join(clauses, "") + return isAnyOf(row.operator) ? `(${Arr.join(clauses, " OR ")})` : Arr.join(clauses, " AND ") +} + +/** The where clause for the complete rows; an unfinished row prints nothing. */ +export const printConditionRows = (rows: ReadonlyArray): string => + Arr.join(Arr.map(Arr.filter(rows, isComplete), printRow), " AND ") + +/** A new row's id, past every id the rows use. */ +export const nextRowId = (rows: ReadonlyArray): string => { + const used = Arr.map(rows, (row) => Number(Str.slice(1)(row.id))) + return `c${Arr.reduce(used, -1, (max, id) => (Number.isFinite(id) && id > max ? id : max)) + 1}` +} diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts new file mode 100644 index 0000000000..537a799573 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -0,0 +1,122 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { describe, expect, test } from "vitest" +import type { QueryDraft } from "./draft" +import { init, Message, type Model, operatorSelectId, type PanelInputs, update } from "./panel" + +// The builder's operator and value steps, as the where-clause edit they emit. + +const traces = (whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), + whereClause, +}) + +const inputsFor = (query: QueryDraft): PanelInputs => ({ + queryOf: (queryId) => (queryId === query.id ? query : undefined), + baseValuesOf: () => ({ services: ["checkout", "api"] }), + orgId: "org_test", + window: null, +}) + +/** The where clause a message leaves as, or null when it leaves none. */ +const emitted = (whereClause: string, message: Message, model: Model = init("test")): string | null => { + const next = update(model, message, inputsFor(traces(whereClause))) + const change = next.outMessage?._tag === "ChangedQuery" ? next.outMessage.change : null + return change?._tag === "SetText" ? change.value : null +} + +const pickOperator = (rowId: string, value: string) => + Message.GotSelectMessage({ + id: operatorSelectId("q", rowId), + message: { _tag: "SelectedItem", index: 0, value }, + }) + +describe("operator", () => { + test("is set drops the values", () => { + expect( + emitted('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "exists")), + ).toBe("service.name exists") + }) + + test("is not keeps every value, as AND-ed negations", () => { + expect(emitted('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "!="))).toBe( + 'service.name != "checkout" AND service.name != "api"', + ) + }) + + test("a comparison keeps one value", () => { + expect( + emitted( + "(attr.http.response.status_code = 500 OR attr.http.response.status_code = 503)", + pickOperator("c0", ">="), + ), + ).toBe("attr.http.response.status_code >= 500") + }) +}) + +describe("values", () => { + // The query engine ORs attribute filters only; a named field's "is" holds one value. + test("a named field's new value replaces the old one", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "api" }, + }) + expect(emitted('service.name = "checkout"', message)).toBe('service.name = "api"') + }) + + test("an attribute's new value joins the OR group", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "POST" }, + }) + expect(emitted('attr.http.request.method = "GET"', message)).toBe( + '(attr.http.request.method = "GET" OR attr.http.request.method = "POST")', + ) + }) + + test("a named field's is not keeps every value", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "api" }, + }) + expect(emitted('service.name != "checkout"', message)).toBe( + 'service.name != "checkout" AND service.name != "api"', + ) + }) + + test("a comparison's new value replaces the old one", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "404" }, + }) + expect(emitted("attr.http.response.status_code > 499", message)).toBe( + "attr.http.response.status_code > 404", + ) + }) + + test("removing a chip removes the value", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "RemovedChip", value: "api" }, + }) + expect(emitted('(service.name = "checkout" OR service.name = "api")', message)).toBe( + 'service.name = "checkout"', + ) + }) +}) + +describe("mode", () => { + test("switching editors is draft state, not an edit", () => { + const next = update( + init("test"), + Message.ChangedWhereMode({ queryId: "q", mode: "text" }), + inputsFor(traces("")), + ) + expect(next.outMessage).toBeUndefined() + expect(next.model.whereModes).toEqual({ q: "text" }) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts new file mode 100644 index 0000000000..252a1c1012 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -0,0 +1,289 @@ +import { Operator } from "@maple/domain/where-clause" +import { Command, type Update } from "foldkit" +import * as Combobox from "@maple/views/ui/combobox" +import * as Select from "@maple/views/ui/select" +import type { Resources } from "../../../platform/resources" +import { QueryChange } from "./change" +import { canMatchAnyOf, fieldOf, fieldPickerItems, fieldsOf, operatorsOf } from "./condition-fields" +import { + type ConditionRow, + isValueless, + nextRowId, + parseConditionRows, + printConditionRows, + isAnyOf, + isNoneOf, +} from "./condition-rows" +import type { QueryDraft } from "./draft" +import { Message, type Model, OutMessage, type WhereMode } from "./panel-message" +import type { PanelInputs } from "./where" +import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" + +// The condition builder's steps: every edit prints the rows and leaves as the query's +// where-clause edit, so previews and saving see only the string. + +type Return = Update.ReturnWithOutMessage + +export type PickerPart = "field" | "values" + +export const conditionPickerId = (queryId: string, rowId: string, part: PickerPart): string => + `cond-${queryId}-${rowId}-${part}` + +const OPERATOR_SELECT = "whereOp" + +export const operatorSelectId = (queryId: string, rowId: string): string => + `${OPERATOR_SELECT}:${queryId}:${rowId}` + +export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERATOR_SELECT}:`)(id) + +/** The rows a query shows: the kept ones while they still print its where clause, else a fresh parse. */ +export const conditionRowsOf = ( + model: Model, + query: QueryDraft, +): Option.Option> => { + const kept = model.conditions[query.id] + return kept !== undefined && printConditionRows(kept) === query.whereClause + ? Option.some(kept) + : parseConditionRows(query.whereClause) +} + +/** Whether a query edits as rows: chosen (the default) and representable. */ +export const whereModeOf = (model: Model, query: QueryDraft): WhereMode => + model.whereModes[query.id] ?? "builder" + +const emitted = ( + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + commands?: Return["commands"], +): Return => ({ + model: { ...model, conditions: { ...model.conditions, [query.id]: rows } }, + ...(commands === undefined ? undefined : { commands }), + outMessage: OutMessage.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ field: "whereClause", value: printConditionRows(rows) }), + }), +}) + +type RowContext = Readonly<{ query: QueryDraft; rows: ReadonlyArray }> + +const withRows = ( + model: Model, + queryId: string, + inputs: PanelInputs | undefined, + step: (context: RowContext) => Return, +): Return => { + const query = inputs?.queryOf(queryId) + if (query === undefined) return { model } + return Option.match(conditionRowsOf(model, query), { + onNone: () => ({ model }), + onSome: (rows) => step({ query, rows }), + }) +} + +const withRow = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, + step: (context: RowContext & Readonly<{ row: ConditionRow }>) => Return, +): Return => + withRows(model, queryId, inputs, (context) => + Option.match( + Arr.findFirst(context.rows, (row) => row.id === rowId), + { onNone: () => ({ model }), onSome: (row) => step({ ...context, row }) }, + ), + ) + +const replaced = (rows: ReadonlyArray, next: ConditionRow): ReadonlyArray => + Arr.map(rows, (row) => (row.id === next.id ? next : row)) + +export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ + model: { + ...model, + whereModes: { ...model.whereModes, [queryId]: mode }, + conditions: Rec.remove(model.conditions, queryId), + }, +}) + +/** A new row opens its field picker, as the first thing to choose. */ +export const addedCondition = (model: Model, queryId: string, inputs: PanelInputs | undefined): Return => + withRows(model, queryId, inputs, ({ query, rows }) => { + const id = nextRowId(rows) + const pickerId = conditionPickerId(queryId, id, "field") + const opened: Model = { + ...model, + conditionPickers: { + ...model.conditionPickers, + [pickerId]: Combobox.init(pickerId, "single", { isOpen: true }), + }, + } + return emitted(opened, query, [...rows, { id, key: "", operator: "=", values: [] }]) + }) + +export const removedCondition = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, +): Return => + withRows(model, queryId, inputs, ({ query, rows }) => { + const pickerIds = [ + conditionPickerId(queryId, rowId, "field"), + conditionPickerId(queryId, rowId, "values"), + ] + const pickers = Rec.filter(model.conditionPickers, (_, id) => !pickerIds.includes(id)) + return emitted( + { + ...model, + conditionPickers: pickers, + selects: Rec.remove(model.selects, operatorSelectId(queryId, rowId)), + }, + query, + Arr.filter(rows, (row) => row.id !== rowId), + ) + }) + +/** The value an Enter or a click picks from the list the view drew. */ +const pickedValue = (current: Combobox.Model, message: Combobox.Message): string | null => { + if (message._tag === "ClickedItem") return message.value + if (message._tag === "PressedInputKey" && message.key === "Enter" && current.highlighted !== null) + return message.items[current.highlighted] ?? null + return null +} + +const pickerOf = (model: Model, id: string, mode: Combobox.Mode): Combobox.Model => + model.conditionPickers[id] ?? Combobox.init(id, mode) + +/** The field picker: a group or "All fields" drills without closing; a field sets the row's key. */ +export const fieldStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "field") + const current = { ...pickerOf(model, id, "single"), selected: row.key === "" ? [] : [row.key] } + const drill = model.fieldDrills[id] ?? "all" + const fields = fieldsOf(query.dataSource, inputs?.baseValuesOf(query) ?? {}) + const items = fieldPickerItems(fields, drill, Combobox.queryOf(current)) + const picked = Option.flatMap(Option.fromNullishOr(pickedValue(current, message)), (value) => + Arr.findFirst(items, (item) => item.value === value), + ) + const navigated = Option.flatMap(picked, (item) => + item._tag === "Group" + ? Option.some(item.group) + : item._tag === "Back" + ? Option.some("all" as const) + : Option.none(), + ) + if (Option.isSome(navigated)) + return { + model: { + ...model, + conditionPickers: { + ...model.conditionPickers, + [id]: { ...current, inputValue: "", highlighted: 0 }, + }, + fieldDrills: { ...model.fieldDrills, [id]: navigated.value }, + }, + } + const next = Combobox.update(current, message) + const lifted: Model = { + ...model, + conditionPickers: { ...model.conditionPickers, [id]: next.model }, + fieldDrills: next.model.isOpen ? model.fieldDrills : Rec.remove(model.fieldDrills, id), + } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionField({ queryId, rowId, message: inner }), + ) + const key = Option.getOrNull( + Option.flatMap(picked, (item) => + item._tag === "Field" + ? Option.some(item.field.key) + : item._tag === "Custom" + ? Option.some(item.key) + : Option.none(), + ), + ) + if (next.outMessage?._tag !== "ChangedValue" || key === null || key === row.key) + return { model: lifted, commands } + const operators = operatorsOf(fieldOf(key, query.dataSource).kind) + const operator = operators.includes(row.operator) ? row.operator : "=" + return emitted(lifted, query, replaced(rows, { ...row, key, operator, values: [] }), commands) + }) + +/** Whether a row holds several values: "is not" always (AND-ed excludes), "is" only where the engine ORs. */ +export const holdsManyValues = (row: Pick, query: QueryDraft): boolean => + isNoneOf(row.operator) || + (isAnyOf(row.operator) && canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource)) + +const withValue = (row: ConditionRow, query: QueryDraft, value: string): ReadonlyArray => + holdsManyValues(row, query) ? Arr.dedupe([...row.values, value]) : [value] + +/** The value picker: suggestions or anything typed (Enter adds it); one value unless the row holds many. */ +export const valuesStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "values") + const current = { ...pickerOf(model, id, "multiple"), selected: row.values } + const typed = Str.trim(current.inputValue) + if ( + message._tag === "PressedInputKey" && + message.key === "Enter" && + current.highlighted === null && + typed !== "" + ) + return emitted( + { + ...model, + conditionPickers: { ...model.conditionPickers, [id]: { ...current, inputValue: "" } }, + }, + query, + replaced(rows, { ...row, values: withValue(row, query, typed) }), + ) + const next = Combobox.update(current, message) + const lifted: Model = { ...model, conditionPickers: { ...model.conditionPickers, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionValues({ queryId, rowId, message: inner }), + ) + if (next.outMessage?._tag !== "ChangedSelection") return { model: lifted, commands } + const selected = next.outMessage.selected + const values = holdsManyValues(row, query) ? selected : Arr.takeRight(selected, 1) + return emitted(lifted, query, replaced(rows, { ...row, values }), commands) + }) + +const isOperator = Schema.is(Operator) + +/** The operator select (`whereOp::`); a valueless operator drops the values. */ +export const operatorStep = ( + model: Model, + id: string, + message: Select.Message, + inputs: PanelInputs | undefined, +): Return => { + const current = model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }) + const next = Select.update(current, message) + const lifted: Model = { ...model, selects: { ...model.selects, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotSelectMessage({ id, message: inner }), + ) + const [, queryId = "", rowId = ""] = Str.split(":")(id) + const operator = next.outMessage?.value + if (operator === undefined || !isOperator(operator)) return { model: lifted, commands } + return withRow(lifted, queryId, rowId, inputs, ({ query, rows, row }) => { + const values = isValueless(operator) + ? [] + : holdsManyValues({ key: row.key, operator }, query) + ? row.values + : Arr.take(row.values, 1) + return emitted(lifted, query, replaced(rows, { ...row, operator, values }), commands) + }) +} diff --git a/apps/web/src/page/kit/query-builder/panel-message.ts b/apps/web/src/page/kit/query-builder/panel-message.ts index 521b1a73fe..5dbd66695d 100644 --- a/apps/web/src/page/kit/query-builder/panel-message.ts +++ b/apps/web/src/page/kit/query-builder/panel-message.ts @@ -4,6 +4,12 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Select from "@maple/views/ui/select" import { initWhereFields, WhereFieldMessage, WhereFields } from "../where-field" import { AddOnKey, QueryChange, QueryTextField } from "./change" +import { FieldDrill } from "./condition-fields" +import { ConditionRow } from "./condition-rows" + +/** How a query's where clause is edited: condition rows, or the text editor. */ +export const WhereMode = Schema.Literals(["builder", "text"]) +export type WhereMode = typeof WhereMode.Type // `QueryPanel`'s own state: the // selects and group-by chips of every panel, and which panels are collapsed. The drafts stay with @@ -19,6 +25,14 @@ export const Model = Schema.Struct({ collapsed: Schema.Array(Schema.String), /** Each where editor's caret, list and active key values, by query id. */ whereFields: WhereFields, + /** Draft UI state, never saved: the editor each query shows (builder when absent). */ + whereModes: Schema.Record(Schema.String, WhereMode), + /** The builder's rows by query id, kept while they still print to the draft's where clause. */ + conditions: Schema.Record(Schema.String, Schema.Array(ConditionRow)), + /** Each row's field and value pickers, by picker id (`conditionPickerId`). */ + conditionPickers: Schema.Record(Schema.String, Combobox.Model), + /** Which group a field picker has drilled into, by picker id. */ + fieldDrills: Schema.Record(Schema.String, FieldDrill), }) export type Model = typeof Model.Type @@ -29,6 +43,10 @@ export const init = (slotPrefix: string): Model => ({ metricPickers: {}, collapsed: [], whereFields: initWhereFields(), + whereModes: {}, + conditions: {}, + conditionPickers: {}, + fieldDrills: {}, }) export const Message = defineMessageUnion({ @@ -44,6 +62,11 @@ export const Message = defineMessageUnion({ ClickedRemove: { queryId: Schema.String }, /** The where editors' suggestions; a field id is its query's id. */ GotWhereField: { message: WhereFieldMessage }, + ChangedWhereMode: { queryId: Schema.String, mode: WhereMode }, + ClickedAddCondition: { queryId: Schema.String }, + ClickedRemoveCondition: { queryId: Schema.String, rowId: Schema.String }, + GotConditionField: { queryId: Schema.String, rowId: Schema.String, message: Combobox.Message }, + GotConditionValues: { queryId: Schema.String, rowId: Schema.String, message: Combobox.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/kit/query-builder/panel-select.ts b/apps/web/src/page/kit/query-builder/panel-select.ts new file mode 100644 index 0000000000..058f46eb4d --- /dev/null +++ b/apps/web/src/page/kit/query-builder/panel-select.ts @@ -0,0 +1,39 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Select from "@maple/views/ui/select" +import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" +import { Message, type Model } from "./panel-message" +import { Array as Arr, Option } from "effect" + +type H = HtmlBuilder + +/** One of the panel's selects, kept by id; its value is always the draft's. */ +export const panelSelect = ( + h: H, + model: Model, + id: string, + value: string, + options: ReadonlyArray, + inputs: Readonly<{ + size?: "sm" | "default" + className?: string + ariaLabel?: string + /** Places the trigger and its siblings (hidden input, portal) in the caller's layout. */ + toView: (parts: ReadonlyArray) => Html + }>, +): Html => { + const index = Option.getOrNull(Arr.findFirstIndex(options, (option) => option.value === value)) + const current = model.selects[id] ?? Select.init(id, { value, selectedIndex: index }) + return h.submodel({ + slotId: `${model.slotPrefix}-select-${id}`, + model: { ...current, value }, + view: selectView, + toParentMessage: (message) => Message.GotSelectMessage({ id, message }), + viewInputs: { + entries: Arr.map(options, (item) => ({ _tag: "Item" as const, item })), + size: inputs.size, + className: inputs.className, + ariaLabel: inputs.ariaLabel, + toView: inputs.toView, + }, + }) +} diff --git a/apps/web/src/page/kit/query-builder/panel.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7074687bf4..7329cab460 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -8,6 +8,16 @@ import type { Resources } from "../../../platform/resources" import * as Combobox from "@maple/views/ui/combobox" import * as Select from "@maple/views/ui/select" import { QueryChange } from "./change" +import { + addedCondition, + changedWhereMode, + fieldStep, + isOperatorSelect, + operatorSelectId, + operatorStep, + removedCondition, + valuesStep, +} from "./conditions" import { groupBySelectionOf } from "./draft" import { Message, type Model, OutMessage } from "./panel-message" import { type PanelInputs, whereStep } from "./where" @@ -15,6 +25,7 @@ import { Array as Arr, Option, String as Str } from "effect" export * from "./panel-message" export type { PanelInputs } from "./where" +export { operatorSelectId } type Return = Update.ReturnWithOutMessage @@ -123,10 +134,11 @@ const metricStep = (model: Model, queryId: string, value: string, message: Combo : { model: lifted, commands } } -/** Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. */ +/** Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. */ export const update = (model: Model, message: Message, inputs?: PanelInputs): Return => Message.match(message, { - GotSelectMessage: ({ id, message: inner }) => selectStep(model, id, inner), + GotSelectMessage: ({ id, message: inner }) => + isOperatorSelect(id) ? operatorStep(model, id, inner, inputs) : selectStep(model, id, inner), GotGroupByMessage: ({ queryId, message: inner }) => groupByStep(model, queryId, inner, inputs), GotMetricMessage: ({ queryId, value, message: inner }) => metricStep(model, queryId, value, inner), ToggledCollapsed: ({ queryId }) => ({ @@ -140,4 +152,11 @@ export const update = (model: Model, message: Message, inputs?: PanelInputs): Re ClickedRemove: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedRemove({ queryId }) }), GotWhereField: ({ message: inner }) => inputs === undefined ? { model } : whereStep(model, inner, inputs), + ChangedWhereMode: ({ queryId, mode }) => changedWhereMode(model, queryId, mode), + ClickedAddCondition: ({ queryId }) => addedCondition(model, queryId, inputs), + ClickedRemoveCondition: ({ queryId, rowId }) => removedCondition(model, queryId, rowId, inputs), + GotConditionField: ({ queryId, rowId, message: inner }) => + fieldStep(model, queryId, rowId, inner, inputs), + GotConditionValues: ({ queryId, rowId, message: inner }) => + valuesStep(model, queryId, rowId, inner, inputs), }) diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index e12dc31548..a29d43baba 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -5,13 +5,22 @@ import * as Combobox from "@maple/views/ui/combobox" import { chipsView } from "@maple/views/ui/combobox-view" import { filterChip } from "@maple/views/ui/filter-chip" import { input } from "@maple/views/ui/input" -import * as Select from "@maple/views/ui/select" -import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" import type { AddOnKey } from "./change" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, type Model } from "./panel" import { whereEditor } from "./where-editor" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { conditionBuilder } from "./condition-builder-view" +import { conditionRowsOf, whereModeOf } from "./conditions" +import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" +import { panelSelect } from "./panel-select" + +export { panelSelect } import { Array as Arr, Record as Rec, Option } from "effect" // The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. @@ -26,38 +35,6 @@ export const ADD_ON_KEYS: ReadonlyArray<{ key: AddOnKey; label: string }> = [ { key: "legend", label: "Legend" }, ] -/** One of the panel's selects, kept by id; its value is always the draft's. */ -export const panelSelect = ( - h: H, - model: Model, - id: string, - value: string, - options: ReadonlyArray, - inputs: Readonly<{ - size?: "sm" | "default" - className?: string - ariaLabel?: string - /** Places the trigger and its siblings (hidden input, portal) in the caller's layout. */ - toView: (parts: ReadonlyArray) => Html - }>, -): Html => { - const index = Option.getOrNull(Arr.findFirstIndex(options, (option) => option.value === value)) - const current = model.selects[id] ?? Select.init(id, { value, selectedIndex: index }) - return h.submodel({ - slotId: `${model.slotPrefix}-select-${id}`, - model: { ...current, value }, - view: selectView, - toParentMessage: (message) => Message.GotSelectMessage({ id, message }), - viewInputs: { - entries: Arr.map(options, (item) => ({ _tag: "Item" as const, item })), - size: inputs.size, - className: inputs.className, - ariaLabel: inputs.ariaLabel, - toView: inputs.toView, - }, - }) -} - const WHERE_PLACEHOLDER = { traces: 'service.name = "checkout" AND status.code = "Error"', logs: 'service.name = "checkout" AND severity = "ERROR"', @@ -67,21 +44,21 @@ const WHERE_PLACEHOLDER = { const WHERE_TEXTAREA_CLASS = "min-h-[32px] resize-y text-xs" -/** `WhereClauseEditor`: the textarea under its highlight overlay, and its suggestion list. */ -export const whereClauseEditor = ( +/** The text editor: the textarea under its highlight overlay, and its suggestion list. */ +const whereTextEditor = ( h: H, model: Model, query: QueryDraft, values: WhereClauseAutocompleteValues, - options: Readonly<{ placeholder?: string; className?: string }> = {}, + placeholder: string | undefined, ): Html => whereEditor(h, { id: whereTextareaId(query.id), value: query.whereClause, ariaLabel: `Where clause for query ${query.name}`, - placeholder: options.placeholder ?? WHERE_PLACEHOLDER[query.dataSource], + placeholder: placeholder ?? WHERE_PLACEHOLDER[query.dataSource], rows: 1, - className: options.className ?? "flex-1", + className: "w-full", textareaClassName: WHERE_TEXTAREA_CLASS, onInput: (value) => Message.ChangedText({ queryId: query.id, field: "whereClause", value }), autocomplete: { @@ -93,6 +70,61 @@ export const whereClauseEditor = ( }, }) +const UNPARSEABLE_NOTE = "This filter uses syntax the builder can't show, edit as text" + +/** + * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text + * editor; a Builder/Text switch under it. Both edit the same where-clause string. + */ +export const whereClauseEditor = ( + h: H, + model: Model, + query: QueryDraft, + values: WhereClauseAutocompleteValues, + options: Readonly<{ placeholder?: string; className?: string }> = {}, +): Html => { + const rows = conditionRowsOf(model, query) + const preferred = whereModeOf(model, query) + const isBuilder = preferred === "builder" && Option.isSome(rows) + const leading = isBuilder + ? button( + h, + { + variant: "ghost", + size: "xs", + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ) + : preferred === "builder" + ? typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE]) + : h.empty + const toggle = segmentedSelect(h, { + options: [ + { value: "builder", label: "Builder", isDisabled: Option.isNone(rows) }, + { value: "text", label: "Text" }, + ], + value: isBuilder ? "builder" : "text", + onChange: (mode: WhereMode) => Message.ChangedWhereMode({ queryId: query.id, mode }), + size: "xs", + ariaLabel: `Where clause editor for query ${query.name}`, + className: "ml-auto shrink-0", + }) + return vstack(h, { gap: "1.5", className: options.className ?? "flex-1 min-w-0" }, [ + ...(isBuilder + ? conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ) + : [whereTextEditor(h, model, query, values, options.placeholder)]), + hstack(h, { gap: "2", wrap: true }, [leading, toggle]), + ]) +} + /** A controlled `` that reports each keystroke. */ export const textInput = ( h: HtmlBuilder, diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..7b1311900d 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -35,6 +35,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/collapsible.ts` `collapsible`, `collapsibleTrigger`: `Collapsible` whose open state lives in the owner's Model: the trigger button and, while open, its panel (closed, Base UI renders no panel). - `packages/views/src/ui/color-input.ts` `colorInput`: `ColorInput`: a native colour picker drawn as a small bordered swatch, reporting the picked hex colour. - `packages/views/src/ui/column.ts` `columnClass`, `column`: `Column`: a cell's width in a row of flex cells (a div table's head and rows): a flexible column that takes the free width over a floor (`min`), or a fixed lane (`width`). +- `packages/views/src/ui/combobox-picker.ts` `triggerIdOf`, `comboboxPicker`: `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search input on top (a `` with ``). - `packages/views/src/ui/combobox-view.ts` `dismissButton`, `inputAttributes`, `chipsInput`, `hiddenInput`, `comboboxCheckIcon`, `listPopup`, `chipsView`, `inputField`, `view`: Base UI's visually hidden "Dismiss" button for screen readers, beside the input and the list. - `packages/views/src/ui/combobox.ts` `inputId`, `listId`, `itemId`, `filterItems`, `queryOf`, `visibleItems` (stateful: Model, init, update): Base UI `Combobox` (multiple, with chips) and `Autocomplete` (`@maple/ui/combobox`, `@maple/ui/autocomplete`): an input that filters a listbox. - `packages/views/src/ui/command.ts` `commandTitles`, `commandInput`, `commandList`, `commandGroup`, `commandFooter`, `commandHiddenInput`, `commandOption`, `commandListbox`, `commandEmpty`: `@maple/ui/command`: the command palette's body, an inline Autocomplete (always open, first match highlighted) on the Combobox Model, inside a `command-dialog` Dialog. @@ -209,11 +210,16 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. +- `apps/web/src/page/kit/query-builder/condition-builder-view.ts` `conditionBuilder`: `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each picked from the query's own vocabulary. +- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `fieldsOf`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... +- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `parseConditionRows`, `isComplete`, `printConditionRows`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... +- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `whereModeOf`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuesStep`, `operatorStep`: The condition builder's steps: every edit prints the rows and leaves as the query's where-clause edit, so previews and saving see only the string. - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. - `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields. -- `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. -- `apps/web/src/page/kit/query-builder/parts.ts` `panelSelect`, `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. +- `apps/web/src/page/kit/query-builder/panel-select.ts` `panelSelect`: One of the panel's selects, kept by id; its value is always the draft's. +- `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. +- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text editor; a Builder/Text switch under it. - `apps/web/src/page/kit/query-builder/view.ts` `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. - `apps/web/src/page/kit/query-builder/where-editor.ts` `whereEditor`, `highlightOverlay`: The suggestions of a field kept in the owner's `WhereFields` (`page/kit/where-field.ts`). - `apps/web/src/page/kit/query-builder/where.ts` `whereTextareaId`, `whereSpecOf`, `whereStep`: `WhereClauseEditor` inside a `QueryPanel`: one `WhereFields` field per query, keyed by query id. diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts new file mode 100644 index 0000000000..9988a4d954 --- /dev/null +++ b/packages/views/src/ui/combobox-picker.ts @@ -0,0 +1,117 @@ +import { cn } from "@maple/ui/lib/utils" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "./button" +import { Message, type Model, queryOf } from "./combobox" +import { chipsInput, inputAttributes, listPopup } from "./combobox-view" +import { icon } from "./icon" +import { isMounted } from "./overlay/transition" + +/** + * `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search + * input on top (a `` with ``). The caller matches `items` to the + * search itself (`queryOf`), so a list can drill into groups or offer a typed value. + */ +export type ComboboxPickerInputs = Readonly<{ + /** The list for the current search, in order. */ + items: ReadonlyArray + ariaLabel: string + searchPlaceholder: string + emptyText: string + /** The trigger's label: the pick, or a prompt drawn muted with `isPlaceholder`. */ + trigger: ReadonlyArray + isPlaceholder?: boolean + renderItem?: (h: HtmlBuilder, value: string) => ReadonlyArray + itemClassName?: (value: string) => string | undefined + triggerClassName?: string + /** Places the trigger and, while open, the list portal. */ + toView: (parts: ReadonlyArray) => Html +}> + +export const triggerIdOf = (id: string): string => `${id}-trigger` + +export const comboboxPicker = Submodel.defineView( + (model, inputs, h) => { + const triggerId = triggerIdOf(model.id) + const isShown = isMounted(model.isOpen, model.animation) + const trigger = button( + h, + { + variant: "outline", + size: "sm", + className: cn("min-w-0 max-w-full justify-between font-normal", inputs.triggerClassName), + slot: "combobox-trigger", + attributes: [ + h.Id(triggerId), + h.AriaLabel(inputs.ariaLabel), + h.AriaExpanded(model.isOpen), + h.AriaHasPopup("dialog"), + h.Role("combobox"), + ...(isShown ? [h.AriaControls(`${model.id}-popup`)] : []), + ...(model.isOpen ? [h.DataAttribute("popup-open", "")] : []), + h.OnClick(Message.ClickedTriggerOf({ items: inputs.items })), + ], + }, + [ + h.span( + [ + h.Class( + cn("min-w-0 truncate", inputs.isPlaceholder === true && "text-muted-foreground"), + ), + ], + inputs.trigger, + ), + icon(h, "ChevronDownIcon", { size: 12, className: "shrink-0 text-muted-foreground" }), + ], + ) + const header = h.div( + [h.Class("flex items-center gap-2 border-b border-border-subtle px-3")], + [ + icon(h, "MagnifierIcon", { className: "size-3.5 shrink-0 text-foreground-subtle" }), + chipsInput(h, { + size: "sm", + className: "h-9 w-full ps-0 text-xs", + attributes: [ + h.Attribute("autofocus", ""), + ...inputAttributes( + h, + { ...model, inputValue: queryOf(model) }, + { + slot: "combobox-chips-input", + ariaLabel: `Search ${inputs.ariaLabel}`, + placeholder: inputs.searchPlaceholder, + items: inputs.items, + isUnfiltered: true, + }, + (message) => message, + ), + ], + }), + ], + ) + return inputs.toView([ + trigger, + ...(isShown + ? [ + listPopup(h, model, { + slot: "combobox", + items: inputs.items, + isPrefiltered: true, + emptyText: inputs.emptyText, + anchorSelector: `#${triggerId}`, + className: "w-72 max-w-[calc(100vw-2rem)] overflow-hidden", + positionerClassName: "z-60", + listClassName: "max-h-72 overflow-y-auto p-1", + itemClassName: (value) => + cn( + "min-h-7 grid-cols-[1rem_minmax(0,1fr)] text-xs", + inputs.itemClassName?.(value), + ), + renderItem: (value) => inputs.renderItem?.(h, value) ?? [value], + trigger: { returnFocusId: triggerId, header }, + }), + ] + : []), + ]) + }, +) From 2b0962623c029d7d5c2ca4cf800babb2c20aff70 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:29:31 +0100 Subject: [PATCH 02/27] fix(query-builder): condition builder review fixes Operators come from one engine table (where-key-rules, held to the clause handlers by test), so the builder offers only clauses the query applies. Text mode is explicit state once typed in. Edits rewrite only their own clause and Add condition emits nothing. Single-value rows use a single picker; product-events excludes and user/group ids take one value; values with both quote kinds are refused. Traces intrinsics are named fields and root_only is left to the span select. Rows lay out on a grid where only the values cell wraps, controls carry row-specific labels, and builder state clears when rows re-parse. --- apps/web/src/lab/condition-builder-lab.ts | 10 +- .../query-builder/condition-builder-view.ts | 170 ++++++++++---- .../condition-builder.scene.test.ts | 54 +++-- .../query-builder/condition-fields.test.ts | 60 +++++ .../kit/query-builder/condition-fields.ts | 52 +++-- .../kit/query-builder/condition-rows.test.ts | 54 ++++- .../page/kit/query-builder/condition-rows.ts | 80 ++++++- .../page/kit/query-builder/conditions.test.ts | 67 +++++- .../src/page/kit/query-builder/conditions.ts | 221 +++++++++++------- .../page/kit/query-builder/metrics-body.ts | 3 +- apps/web/src/page/kit/query-builder/panel.ts | 17 +- apps/web/src/page/kit/query-builder/parts.ts | 51 ++-- apps/web/src/page/kit/query-builder/view.ts | 3 +- .../query-engine/src/query-builder/model.ts | 2 + .../src/query-builder/where-key-rules.test.ts | 98 ++++++++ .../src/query-builder/where-key-rules.ts | 137 +++++++++++ packages/views/src/ui/INDEX.md | 8 +- 17 files changed, 879 insertions(+), 208 deletions(-) create mode 100644 apps/web/src/page/kit/query-builder/condition-fields.test.ts create mode 100644 packages/query-engine/src/query-builder/where-key-rules.test.ts create mode 100644 packages/query-engine/src/query-builder/where-key-rules.ts diff --git a/apps/web/src/lab/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts index 1df10709d5..77d689dd65 100644 --- a/apps/web/src/lab/condition-builder-lab.ts +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -22,7 +22,13 @@ export const LAB_VALUES: WhereClauseAutocompleteValues = { environments: ["production", "staging"], severities: ["ERROR", "WARN", "INFO", "DEBUG"], httpStatusCodes: ["200", "404", "500", "503"], - attributeKeys: ["http.route", "http.request.method", "http.response.status_code", "db.system"], + attributeKeys: [ + "http.route", + "http.request.method", + "http.response.status_code", + "db.system", + "messaging.destination.partition.id", + ], resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region"], } @@ -42,7 +48,7 @@ export const SCENARIOS: ReadonlyArray = [ "populated", "B", "traces", - 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists', + 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists AND attr.messaging.destination.partition.id = "12"', ), }, { diff --git a/apps/web/src/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts index cd375741d0..0c9b61487e 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -1,8 +1,9 @@ import type { WhereClauseAutocompleteValues } from "../../../lib/query-builder/where-clause-autocomplete" +import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import * as Combobox from "@maple/views/ui/combobox" import { comboboxPicker } from "@maple/views/ui/combobox-picker" -import { chipsView } from "@maple/views/ui/combobox-view" +import { chipsView, view as comboboxView } from "@maple/views/ui/combobox-view" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { hstack, vstack } from "@maple/views/ui/stack" @@ -14,12 +15,13 @@ import { fieldPickerItems, fieldsOf, groupLabelOf, + isOwnedElsewhere, OPERATOR_LABEL, operatorsOf, valueSuggestionsOf, } from "./condition-fields" -import { type ConditionRow, isValueless } from "./condition-rows" -import { conditionPickerId, operatorSelectId } from "./conditions" +import { type ConditionRow, isUnspellable, isValueless } from "./condition-rows" +import { conditionPickerId, operatorSelectId, valuePickerOf } from "./conditions" import type { QueryDraft } from "./draft" import { Message, type Model } from "./panel-message" import { panelSelect } from "./panel-select" @@ -94,6 +96,7 @@ const fieldPicker = ( model: Model, query: QueryDraft, row: ConditionRow, + index: number, values: WhereClauseAutocompleteValues, ): Html => { const id = conditionPickerId(query.id, row.id, "field") @@ -111,12 +114,12 @@ const fieldPicker = ( Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), viewInputs: { items: Arr.map(items, (item) => item.value), - ariaLabel: "Field", + ariaLabel: `Field for condition ${index + 1}`, searchPlaceholder: "Search fields…", emptyText: "No fields match", trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), isPlaceholder: row.key === "", - triggerClassName: "max-w-56", + triggerClassName: "w-full", renderItem: (itemH, value) => { const item = byValue.get(value) return item === undefined @@ -127,42 +130,100 @@ const fieldPicker = ( firstGroup !== null && value === firstGroup.value ? "mt-1 border-t border-border-subtle pt-1" : undefined, - toView: (parts) => h.div([h.Class("contents")], parts), + toView: (parts) => h.div([h.Class("col-start-2 row-start-1 min-w-0")], parts), }, }) } +/** A row's field in words, for the labels of its controls. */ +const rowFieldLabel = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" ? "new condition" : fieldOf(row.key, dataSource).label + +/** "Service is checkout, api": what a row's remove button removes. */ +const rowSummary = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" + ? "new condition" + : Arr.join( + [ + rowFieldLabel(row, dataSource), + OPERATOR_LABEL[row.operator], + ...(row.values.length === 0 ? [] : [Arr.join(row.values, ", ")]), + ], + " ", + ) + +const UNSPELLABLE_NOTE = `A value can't hold both ' and "` + const valuePicker = ( h: H, model: Model, query: QueryDraft, row: ConditionRow, values: WhereClauseAutocompleteValues, -): Html => { +): ReadonlyArray => { const id = conditionPickerId(query.id, row.id, "values") - const picker = model.conditionPickers[id] ?? Combobox.init(id, "multiple") + const picker = valuePickerOf(model, query, row) const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) - const typed = Str.trim(picker.inputValue) + const typed = Str.trim(Combobox.queryOf(picker)) + const isUnspellableTyped = isUnspellable(typed) // What is typed is offered first when it is not already a suggestion, so a click adds it too. - const isTypedNew = typed !== "" && !suggestions.includes(typed) && !row.values.includes(typed) - return h.submodel({ - slotId: `${model.slotPrefix}-${id}`, - model: { ...picker, selected: row.values }, - view: chipsView, - toParentMessage: (message) => - Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }), - viewInputs: { - items: isTypedNew ? [typed, ...suggestions] : suggestions, - ariaLabel: "Value", - placeholder: row.values.length === 0 ? "Value" : "", - emptyText: "Type a value and press Enter", - className: "text-xs", - renderItem: (_, value) => (isTypedNew && value === typed ? [`Use "${value}"`] : [value]), - toView: (parts) => h.div([h.Class("min-w-40 flex-1")], parts), - }, - }) + const isTypedNew = + typed !== "" && !isUnspellableTyped && !suggestions.includes(typed) && !row.values.includes(typed) + const items = isTypedNew ? [typed, ...suggestions] : suggestions + const ariaLabel = `Value for ${rowFieldLabel(row, query.dataSource)}` + const toMessage = (message: Combobox.Message) => + Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }) + const renderItem = (value: string): ReadonlyArray => + isTypedNew && value === typed ? [`Use "${value}"`] : [value] + const note = isUnspellableTyped + ? [typography(h, { size: "2xs", tone: "warning", as: "p" }, [UNSPELLABLE_NOTE])] + : [] + const field = + picker.mode === "multiple" + ? h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: picker, + view: chipsView, + toParentMessage: toMessage, + viewInputs: { + items, + ariaLabel, + placeholder: row.values.length === 0 ? "Value" : "", + emptyText: "Type a value and press Enter", + className: "text-xs", + renderItem: (_, value) => renderItem(value), + toView: (parts) => h.div([h.Class("min-w-0")], parts), + }, + }) + : h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: picker, + view: comboboxView, + toParentMessage: toMessage, + viewInputs: { + items, + ariaLabel, + placeholder: "Value", + emptyText: "Type a value and press Enter", + size: "sm", + showTrigger: false, + highlightsSelected: true, + className: "text-xs", + renderItem, + toView: ({ field: input, rest }) => h.div([h.Class("min-w-0")], [input, ...rest]), + }, + }) + return [field, ...note] } +// Wide: one line of [connector] [field] [operator] [values] [remove]. Narrow: the values cell +// wraps under the field and operator, the rest stays on the first line. +const ROW_GRID = + "grid grid-cols-[2.5rem_minmax(0,1fr)_7rem_auto] items-center gap-1.5 @lg:grid-cols-[2.5rem_minmax(0,14rem)_9rem_minmax(10rem,1fr)_auto]" +const VALUES_CELL = + "col-start-2 col-span-2 row-start-2 min-w-0 @lg:col-start-4 @lg:col-span-1 @lg:row-start-1" +const REMOVE_CELL = "col-start-4 row-start-1 @lg:col-start-5" + const conditionRow = ( h: H, model: Model, @@ -171,69 +232,82 @@ const conditionRow = ( index: number, values: WhereClauseAutocompleteValues, ): Html => { - const connector = typography(h, { size: "xs", tone: "muted", className: "w-10 shrink-0" }, [ + const connector = typography(h, { size: "xs", tone: "muted", className: "col-start-1 row-start-1" }, [ index === 0 ? "Where" : "and", ]) const remove = iconButton( h, { size: "icon-xs", - label: "Remove condition", + label: `Remove condition: ${rowSummary(row, query.dataSource)}`, hasTooltip: false, - className: "text-muted-foreground hover:text-foreground active:text-foreground", + className: cn(REMOVE_CELL, "text-muted-foreground hover:text-foreground active:text-foreground"), attributes: [h.OnClick(Message.ClickedRemoveCondition({ queryId: query.id, rowId: row.id }))], }, [icon(h, "XmarkIcon")], ) - const field = fieldPicker(h, model, query, row, values) + const field = fieldPicker(h, model, query, row, index, values) const layout = (parts: ReadonlyArray) => - hstack(h, { gap: "1.5", wrap: true, attributes: [h.DataAttribute("slot", "condition-row")] }, [ - connector, - field, - ...parts, - remove, - ]) + h.div( + [h.Class(ROW_GRID), h.DataAttribute("slot", "condition-row")], + [connector, field, ...parts, remove], + ) if (row.key === "") return layout([]) - const kind = fieldOf(row.key, query.dataSource).kind + const fieldLabel = rowFieldLabel(row, query.dataSource) return panelSelect( h, model, operatorSelectId(query.id, row.id), row.operator, - Arr.map(operatorsOf(kind, row.operator), (operator) => ({ - value: operator, - label: OPERATOR_LABEL[operator], - })), + Arr.map( + operatorsOf(fieldOf(row.key, query.dataSource), query.dataSource, row.operator), + (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + }), + ), { size: "sm", - className: "w-36", - ariaLabel: "Operator", + className: "col-start-3 row-start-1 w-full", + ariaLabel: `Operator for ${fieldLabel}`, toView: (parts) => layout([ ...parts, - ...(isValueless(row.operator) ? [] : [valuePicker(h, model, query, row, values)]), + ...(isValueless(row.operator) + ? [] + : [ + vstack( + h, + { gap: "1", className: VALUES_CELL }, + valuePicker(h, model, query, row, values), + ), + ]), ]), }, ) } -/** The rows, or nothing when there are none: the caller's "Add condition" starts the first. */ +/** The rows a query shows, or nothing: the caller's "Add condition" starts the first. A row another + * control owns (traces `root_only`) stays in the clause but is not drawn. */ export const conditionBuilder = ( h: H, model: Model, query: QueryDraft, rows: ReadonlyArray, values: WhereClauseAutocompleteValues, -): ReadonlyArray => - rows.length === 0 +): ReadonlyArray => { + const shown = Arr.filter(rows, (row) => !isOwnedElsewhere(row.key, query.dataSource)) + return shown.length === 0 ? [] : [ vstack( h, { gap: "1.5", + className: "@container", attributes: [h.AriaLabel(`Conditions for query ${query.name}`), h.Role("group")], }, - Arr.map(rows, (row, index) => conditionRow(h, model, query, row, index, values)), + Arr.map(shown, (row, index) => conditionRow(h, model, query, row, index, values)), ), ] +} diff --git a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts index 6d9e2665fd..22ca447025 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -1,13 +1,11 @@ import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" import { - all, click, Command, expect as expectView, given, keydown, label, - last, Mount, role, scene, @@ -64,11 +62,9 @@ const caretMounted = () => }), ) -/** The last row's value picker: the row just added. */ -const newValue = last(all.role("combobox", { name: "Value" })) - describe("condition builder", () => { test("a new row picks an attribute, then typed values, and prints an OR group", () => { + const value = role("combobox", { name: "Value for http.route" }) scene( { update, view }, given(hosted("")), @@ -78,16 +74,19 @@ describe("condition builder", () => { click(role("option", { name: /^Span attributes/ })), click(role("option", { name: /^http\.route/ })), ...pickerClosed("cond-q-c0-field", 1), - typeInto(role("combobox", { name: "Value" }), "/cart"), + // Adding a row and picking its field leave the clause alone until a value completes it. + expectView(text("(empty where clause)")).toExist(), + typeInto(value, "/cart"), ...pickerOpened(1), - keydown(role("combobox", { name: "Value" }), "Enter"), - typeInto(role("combobox", { name: "Value" }), "/checkout"), - keydown(role("combobox", { name: "Value" }), "Enter"), + keydown(value, "Enter"), + typeInto(value, "/checkout"), + keydown(value, "Enter"), expectView(text('(attr.http.route = "/cart" OR attr.http.route = "/checkout")')).toExist(), ) }) test("the field picker drills into resource attributes", () => { + const value = role("combobox", { name: "Value for host.name" }) scene( { update, view }, given(hosted('service.name = "checkout"')), @@ -96,19 +95,31 @@ describe("condition builder", () => { click(role("option", { name: /^Resource attributes/ })), click(role("option", { name: /^host\.name/ })), ...pickerClosed("cond-q-c1-field", 1), - typeInto(newValue, "web-1"), + typeInto(value, "web-1"), ...pickerOpened(1), - keydown(newValue, "Enter"), + keydown(value, "Enter"), expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), ) }) - test("removing a row prints the rest", () => { + test("a named field holds one value: a pick replaces it and closes the list", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + click(role("combobox", { name: "Value for Service" })), + ...pickerOpened(1), + click(role("option", { name: "api" })), + ...pickerClosed("cond-q-c0-values", 2), + expectView(text('service.name = "api"')).toExist(), + ) + }) + + test("removing a row keeps the other clauses as the user wrote them", () => { scene( { update, view }, - given(hosted('service.name = "checkout" AND attr.http.route exists')), - click(label("Remove condition")), - expectView(text("attr.http.route exists")).toExist(), + given(hosted("service = checkout AND attr.http.route exists")), + click(label("Remove condition: http.route is set")), + expectView(text("service = checkout")).toExist(), ) }) @@ -137,4 +148,17 @@ describe("condition builder", () => { expectView(role("button", { name: "Builder" })).toBeDisabled(), ) }) + + test("typing in the text editor keeps it, even once the text reads as rows", () => { + const editor = label("Where clause for query A") + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + typeInto(editor, 'service.name = "a"'), + expectView(editor).toExist(), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).not.toBeDisabled(), + ) + }) }) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.test.ts b/apps/web/src/page/kit/query-builder/condition-fields.test.ts new file mode 100644 index 0000000000..74b4ae8cc3 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest" +import { canMatchAnyOf, fieldOf, fieldsOf, operatorsOf } from "./condition-fields" +import { Array as Arr } from "effect" + +// The builder offers what the source's clause handler applies (the engine's key rules). + +describe("operatorsOf", () => { + test.each([ + ["logs", "service.name", ["=", "!="]], + ["logs", "severity", ["=", "!="]], + ["logs", "body", ["=", "contains"]], + ["metrics", "service.name", ["="]], + ["metrics", "deployment.environment", ["="]], + ["metrics", "metric.type", ["="]], + ["traces", "has_error", ["="]], + ["traces", "min_duration_ms", ["="]], + ["traces", "service.name", ["=", "!=", "contains"]], + ["product_events", "event.name", ["=", "!="]], + ["product_events", "user.id", ["="]], + ] as const)("%s %s", (dataSource, key, expected) => { + expect(operatorsOf(fieldOf(key, dataSource), dataSource)).toEqual(expected) + }) + + test("a map attribute takes the operators of its kind", () => { + expect(operatorsOf(fieldOf("attr.http.route", "traces"), "traces")).toEqual([ + "=", + "!=", + "contains", + "!contains", + "exists", + "!exists", + ]) + expect(operatorsOf(fieldOf("attr.http.response.status_code", "logs"), "logs")).toContain(">=") + }) + + test("a row keeps its current operator on offer", () => { + expect(operatorsOf(fieldOf("severity", "logs"), "logs", "contains")).toEqual(["=", "!=", "contains"]) + }) +}) + +describe("canMatchAnyOf", () => { + test("only attribute filters on sources that lower OR groups", () => { + expect(canMatchAnyOf(fieldOf("attr.http.route", "logs"), "logs")).toBe(true) + expect(canMatchAnyOf(fieldOf("db.system", "traces"), "traces")).toBe(true) + expect(canMatchAnyOf(fieldOf("db.system", "logs"), "logs")).toBe(false) + expect(canMatchAnyOf(fieldOf("service.name", "traces"), "traces")).toBe(false) + expect(canMatchAnyOf(fieldOf("attr.plan", "product_events"), "product_events")).toBe(false) + }) +}) + +describe("fieldsOf", () => { + test("traces intrinsics are named fields, and root_only is left to the span select", () => { + const keys = Arr.map(fieldsOf("traces", {}), (field) => field.key) + expect(keys).toEqual( + expect.arrayContaining(["min_duration_ms", "max_duration_ms", "vcs.ref.head.revision"]), + ) + expect(keys).not.toContain("root_only") + expect(fieldOf("root_only", "traces")).toMatchObject({ group: "common", kind: "boolean" }) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.ts b/apps/web/src/page/kit/query-builder/condition-fields.ts index 192ebecf18..88f5161be0 100644 --- a/apps/web/src/page/kit/query-builder/condition-fields.ts +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -1,4 +1,5 @@ import type { Operator } from "@maple/domain/where-clause" +import { whereKeyMatchesAnyOf, whereKeyRule, whereOperatorsOf } from "@maple/query-engine/query-builder" import { getWhereClauseAutocomplete, type WhereClauseAutocompleteValues, @@ -30,9 +31,17 @@ const COMMON_FIELDS = { common("deployment.environment", "Environment"), common("attr.http.response.status_code", "HTTP status code", "number"), common("has_error", "Has error", "boolean"), + common("min_duration_ms", "Min duration (ms)", "number"), + common("max_duration_ms", "Max duration (ms)", "number"), + common("vcs.ref.head.revision", "Commit"), + common("root_only", "Root spans only", "boolean"), ], logs: [common("service.name", "Service"), common("severity", "Severity"), common("body", "Message")], - metrics: [common("service.name", "Service"), common("metric.type", "Metric type")], + metrics: [ + common("service.name", "Service"), + common("deployment.environment", "Environment"), + common("metric.type", "Metric type"), + ], product_events: [ common("event.name", "Event name"), common("host", "Host"), @@ -73,14 +82,13 @@ export const fieldOf = (key: string, dataSource: DataSource): ConditionField => return { key, label, group: "attribute", kind: kindOfKey(key) } } -/** - * Whether the query engine can OR this field's values: only attribute filters lower an - * `(a OR b)` group; a named dimension (`service.name`) or a product-events query drops it. - */ +/** Whether the query engine ORs this field's values (`whereKeyMatchesAnyOf`: attribute filters only). */ export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => - dataSource !== "product_events" && - field.kind !== "boolean" && - (field.group !== "common" || Str.startsWith(ATTRIBUTE_PREFIX)(field.key)) + field.kind !== "boolean" && whereKeyMatchesAnyOf(dataSource, field.key) + +/** A key another control owns: the traces span select writes `root_only`. */ +export const isOwnedElsewhere = (key: string, dataSource: DataSource): boolean => + dataSource === "traces" && key === "root_only" /** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ export const fieldsOf = ( @@ -89,7 +97,7 @@ export const fieldsOf = ( ): ReadonlyArray => Arr.dedupeWith( [ - ...COMMON_FIELDS[dataSource], + ...Arr.filter(COMMON_FIELDS[dataSource], (field) => !isOwnedElsewhere(field.key, dataSource)), ...Arr.map(values.resourceAttributeKeys ?? [], (key) => fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), ), @@ -171,14 +179,26 @@ export const fieldPickerItems = ( // Operators and values. -/** The operators a field takes, in menu order; a row's current operator always stays offered. */ -export const operatorsOf = (kind: FieldKind, current?: Operator): ReadonlyArray => { +const KIND_OPERATORS = { + number: ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"], + boolean: ["=", "!=", "exists", "!exists"], + string: ["=", "!=", "contains", "!contains", "exists", "!exists"], +} satisfies Record> + +/** + * The operators a field takes on its source (the engine's `whereOperatorsOf`); a map attribute + * takes every operator, narrowed to its kind. A row's current operator always stays offered. + */ +export const operatorsOf = ( + field: ConditionField, + dataSource: DataSource, + current?: Operator, +): ReadonlyArray => { + const accepted = whereOperatorsOf(dataSource, field.key) const own: ReadonlyArray = - kind === "number" - ? ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"] - : kind === "boolean" - ? ["=", "!=", "exists", "!exists"] - : ["=", "!=", "contains", "!contains", "exists", "!exists"] + whereKeyRule(dataSource, field.key)._tag === "Attribute" + ? Arr.filter(KIND_OPERATORS[field.kind], (operator) => accepted.includes(operator)) + : accepted return current === undefined || own.includes(current) ? own : [...own, current] } diff --git a/apps/web/src/page/kit/query-builder/condition-rows.test.ts b/apps/web/src/page/kit/query-builder/condition-rows.test.ts index 9a15ec4df8..ce7e69025f 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -1,6 +1,14 @@ import { parseWhereClause } from "@maple/domain/where-clause" import { describe, expect, test } from "vitest" -import { type ConditionRow, nextRowId, parseConditionRows, printConditionRows } from "./condition-rows" +import { + type ConditionRow, + isUnspellable, + nextRowId, + parseConditionRows, + printConditionRows, + printConditionRowsOver, + sameConditions, +} from "./condition-rows" import { Array as Arr, Option } from "effect" const rowsOf = (whereClause: string) => @@ -137,3 +145,47 @@ describe("nextRowId", () => { expect(nextRowId([row("a", "=", [], "c0"), row("b", "=", [], "c4")])).toBe("c5") }) }) + +describe("printConditionRowsOver", () => { + const original = + "service = checkout AND attr.http.route exists AND severity != DEBUG AND severity != TRACE" + const rows = Option.getOrThrow(parseConditionRows(original)) + + test("untouched rows keep their text, a changed row is printed afresh", () => { + const edited = Arr.map(rows, (row) => + row.key === "attr.http.route" ? { ...row, operator: "!exists" as const } : row, + ) + expect(printConditionRowsOver(edited, original)).toBe( + "service = checkout AND attr.http.route !exists AND severity != DEBUG AND severity != TRACE", + ) + }) + + test("a removed row drops only its text", () => { + expect( + printConditionRowsOver( + Arr.filter(rows, (row) => row.key !== "attr.http.route"), + original, + ), + ).toBe("service = checkout AND severity != DEBUG AND severity != TRACE") + }) +}) + +describe("sameConditions", () => { + test("ignores ids and unfinished rows, not order or values", () => { + const a = [row("service.name", "=", ["a"], "c0"), row("", "=", [], "c1")] + expect(sameConditions(a, [row("service.name", "=", ["a"], "c9")])).toBe(true) + expect(sameConditions(a, [row("service.name", "=", ["b"])])).toBe(false) + expect( + sameConditions( + [row("a", "exists", []), row("b", "exists", [], "c1")], + [row("b", "exists", []), row("a", "exists", [], "c1")], + ), + ).toBe(false) + }) +}) + +describe("isUnspellable", () => { + test("only a value with both quote kinds", () => { + expect(Arr.map([`it's`, `say "hi"`, `it's "on"`], isUnspellable)).toEqual([false, false, true]) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts index c254f11f47..9ec40a1c9e 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -64,29 +64,72 @@ const partOf = (part: string): Option.Option => { : Option.none() } +/** A row with the clause text it was read from, so an untouched row keeps the user's spelling. */ +type Spelled = Readonly<{ draft: Draft; text: string }> + /** `k != a AND k != b` reads as one "is not a, b" row. */ -const mergeNoneOf = (drafts: ReadonlyArray): ReadonlyArray => - Arr.reduce(drafts, Arr.empty(), (rows: ReadonlyArray, draft) => { - if (!isNoneOf(draft.operator)) return [...rows, draft] +const mergeNoneOf = (parts: ReadonlyArray): ReadonlyArray => + Arr.reduce(parts, Arr.empty(), (rows: ReadonlyArray, part) => { + if (!isNoneOf(part.draft.operator)) return [...rows, part] const index = Arr.findFirstIndex( rows, - (row) => row.key === draft.key && row.operator === draft.operator, + (row) => row.draft.key === part.draft.key && row.draft.operator === part.draft.operator, ) return Option.match(index, { - onNone: () => [...rows, draft], + onNone: () => [...rows, part], onSome: (at) => Arr.map(rows, (row, i) => - i === at ? { ...row, values: Arr.dedupe([...row.values, ...draft.values]) } : row, + i === at + ? { + draft: { + ...row.draft, + values: Arr.dedupe([...row.draft.values, ...part.draft.values]), + }, + text: `${row.text} AND ${part.text}`, + } + : row, ), }) }) +const spelledRows = (whereClause: string): Option.Option> => + Option.map( + Option.all( + Arr.map(splitWhereClause(Str.trim(whereClause)), (text) => + Option.map(partOf(text), (draft): Spelled => ({ draft, text })), + ), + ), + mergeNoneOf, + ) + /** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ export const parseConditionRows = (whereClause: string): Option.Option> => - Option.map(Option.all(Arr.map(splitWhereClause(Str.trim(whereClause)), partOf)), (drafts) => - Arr.map(mergeNoneOf(drafts), (draft, index) => ({ id: `c${index}`, ...draft })), + Option.map(spelledRows(whereClause), (parts) => + Arr.map(parts, ({ draft }, index) => ({ id: `c${index}`, ...draft })), ) +const sameDraft = (a: Draft, b: Draft): boolean => + a.key === b.key && + a.operator === b.operator && + a.values.length === b.values.length && + Arr.every(a.values, (value, index) => b.values[index] === value) + +/** The same complete conditions in the same order, ids aside. */ +export const sameConditions = (a: ReadonlyArray, b: ReadonlyArray): boolean => { + const left = Arr.filter(a, isComplete) + const right = Arr.filter(b, isComplete) + return ( + left.length === right.length && + Arr.every(left, (row, index) => { + const other = right[index] + return other !== undefined && sameDraft(row, other) + }) + ) +} + +/** A value the grammar cannot spell: it has no escape, so a value may not hold both quotes. */ +export const isUnspellable = (value: string): boolean => Str.includes('"')(value) && Str.includes("'")(value) + /** Numbers and booleans stay bare (`root_only = true` is what the span select reads); the rest is quoted. */ const literalOf = (value: string): string => /^-?\d+(\.\d+)?$/.test(value) || value === "true" || value === "false" ? value : quoteWhereValue(value) @@ -109,6 +152,27 @@ const printRow = (row: ConditionRow): string => { export const printConditionRows = (rows: ReadonlyArray): string => Arr.join(Arr.map(Arr.filter(rows, isComplete), printRow), " AND ") +/** + * `printConditionRows`, but a row `original` already holds keeps its text there, so an edit + * rewrites only the clauses it changed. + */ +export const printConditionRowsOver = (rows: ReadonlyArray, original: string): string => { + const spelled = Option.getOrElse(spelledRows(original), () => Arr.empty()) + const [, texts] = Arr.mapAccum( + Arr.filter(rows, isComplete), + spelled, + (unused: ReadonlyArray, row): [ReadonlyArray, string] => + Option.match( + Arr.findFirstIndex(unused, (part) => sameDraft(part.draft, row)), + { + onNone: () => [unused, printRow(row)], + onSome: (at) => [Arr.remove(unused, at), unused[at]?.text ?? printRow(row)], + }, + ), + ) + return Arr.join(texts, " AND ") +} + /** A new row's id, past every id the rows use. */ export const nextRowId = (rows: ReadonlyArray): string => { const used = Arr.map(rows, (row) => Number(Str.slice(1)(row.id))) diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts index 537a799573..5321b475f5 100644 --- a/apps/web/src/page/kit/query-builder/conditions.test.ts +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -5,11 +5,13 @@ import { init, Message, type Model, operatorSelectId, type PanelInputs, update } // The builder's operator and value steps, as the where-clause edit they emit. -const traces = (whereClause: string): QueryDraft => ({ - ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), +const draftOf = (dataSource: QueryDraft["dataSource"], whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, dataSource), whereClause, }) +const traces = (whereClause: string): QueryDraft => draftOf("traces", whereClause) + const inputsFor = (query: QueryDraft): PanelInputs => ({ queryOf: (queryId) => (queryId === query.id ? query : undefined), baseValuesOf: () => ({ services: ["checkout", "api"] }), @@ -18,12 +20,18 @@ const inputsFor = (query: QueryDraft): PanelInputs => ({ }) /** The where clause a message leaves as, or null when it leaves none. */ -const emitted = (whereClause: string, message: Message, model: Model = init("test")): string | null => { - const next = update(model, message, inputsFor(traces(whereClause))) +const emittedOn = (query: QueryDraft, message: Message, model: Model = init("test")): string | null => { + const next = update(model, message, inputsFor(query)) const change = next.outMessage?._tag === "ChangedQuery" ? next.outMessage.change : null return change?._tag === "SetText" ? change.value : null } +const emitted = (whereClause: string, message: Message, model?: Model): string | null => + emittedOn(traces(whereClause), message, model) + +const values = (rowId: string, message: Parameters[0]["message"]) => + Message.GotConditionValues({ queryId: "q", rowId, message }) + const pickOperator = (rowId: string, value: string) => Message.GotSelectMessage({ id: operatorSelectId("q", rowId), @@ -120,3 +128,54 @@ describe("mode", () => { expect(next.model.whereModes).toEqual({ q: "text" }) }) }) + +describe("rewrites", () => { + test("adding a row leaves the clause alone until the row is complete", () => { + expect(emitted('service.name = "checkout"', Message.ClickedAddCondition({ queryId: "q" }))).toBeNull() + }) + + test("an edit rewrites only its own clause", () => { + expect(emitted('service = checkout AND attr.http.route = "/a"', pickOperator("c1", "exists"))).toBe( + "service = checkout AND attr.http.route exists", + ) + }) + + test("the span select's root_only clause survives a builder edit", () => { + expect(emitted('root_only = true AND service.name = "a"', pickOperator("c1", "!="))).toBe( + 'root_only = true AND service.name != "a"', + ) + }) + + test("clicking a single value's current value changes nothing", () => { + expect( + emitted('service.name = "checkout"', values("c0", { _tag: "ClickedItem", value: "checkout" })), + ).toBeNull() + }) +}) + +describe("refusals", () => { + test("a typed value holding both quotes is not added", () => { + const query = traces('attr.note = "x"') + const typed = update( + init("test"), + values("c0", { _tag: "ChangedInput", value: `it's "on"` }), + inputsFor(query), + ) + expect(typed.outMessage).toBeUndefined() + const entered = update( + typed.model, + values("c0", { _tag: "PressedInputKey", key: "Enter", items: [] }), + inputsFor(query), + ) + expect(entered.outMessage).toBeUndefined() + }) + + test("a product-events exclude holds one value, since a second would replace the first", () => { + expect( + emittedOn( + draftOf("product_events", 'event.name != "signup"'), + values("c0", { _tag: "ClickedItem", value: "login" }), + ), + ).toBe('event.name != "login"') + }) +}) diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts index 252a1c1012..dc069b6bfa 100644 --- a/apps/web/src/page/kit/query-builder/conditions.ts +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -7,20 +7,22 @@ import { QueryChange } from "./change" import { canMatchAnyOf, fieldOf, fieldPickerItems, fieldsOf, operatorsOf } from "./condition-fields" import { type ConditionRow, + isAnyOf, + isNoneOf, + isUnspellable, isValueless, nextRowId, parseConditionRows, - printConditionRows, - isAnyOf, - isNoneOf, + printConditionRowsOver, + sameConditions, } from "./condition-rows" import type { QueryDraft } from "./draft" import { Message, type Model, OutMessage, type WhereMode } from "./panel-message" import type { PanelInputs } from "./where" import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" -// The condition builder's steps: every edit prints the rows and leaves as the query's -// where-clause edit, so previews and saving see only the string. +// The condition builder's steps: an edit that changes the conditions leaves as the query's +// where-clause edit, rewriting only the clauses it touched, so previews and saving see only the string. type Return = Update.ReturnWithOutMessage @@ -36,36 +38,68 @@ export const operatorSelectId = (queryId: string, rowId: string): string => export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERATOR_SELECT}:`)(id) -/** The rows a query shows: the kept ones while they still print its where clause, else a fresh parse. */ +/** The kept rows while they still mean the query's where clause, else none. */ +const keptRowsOf = (model: Model, query: QueryDraft): Option.Option> => + Option.flatMap(Option.fromNullishOr(model.conditions[query.id]), (kept) => + Option.filter(parseConditionRows(query.whereClause), (parsed) => sameConditions(parsed, kept)).pipe( + Option.map(() => kept), + ), + ) + +/** The rows a query shows: the kept ones while they mean its where clause, else a fresh parse. */ export const conditionRowsOf = ( model: Model, query: QueryDraft, -): Option.Option> => { - const kept = model.conditions[query.id] - return kept !== undefined && printConditionRows(kept) === query.whereClause - ? Option.some(kept) - : parseConditionRows(query.whereClause) +): Option.Option> => + Option.orElse(keptRowsOf(model, query), () => parseConditionRows(query.whereClause)) + +/** A fresh parse renumbers the rows: drop the query's rows, pickers, drills and operator selects. */ +export const withoutBuilderState = (model: Model, queryId: string): Model => { + const pickerPrefix = `cond-${queryId}-` + return { + ...model, + conditions: Rec.remove(model.conditions, queryId), + conditionPickers: Rec.filter(model.conditionPickers, (_, id) => !Str.startsWith(pickerPrefix)(id)), + fieldDrills: Rec.filter(model.fieldDrills, (_, id) => !Str.startsWith(pickerPrefix)(id)), + selects: Rec.filter(model.selects, (_, id) => !Str.startsWith(`${OPERATOR_SELECT}:${queryId}:`)(id)), + } } -/** Whether a query edits as rows: chosen (the default) and representable. */ +/** The editor a query shows; only a Builder click leaves Text once the text editor was used. */ export const whereModeOf = (model: Model, query: QueryDraft): WhereMode => model.whereModes[query.id] ?? "builder" +/** The text editor was typed in: it stays the editor, and the rows read afresh later. */ +export const typedInTextEditor = (model: Model, queryId: string): Model => ({ + ...withoutBuilderState(model, queryId), + whereModes: { ...model.whereModes, [queryId]: "text" }, +}) + +/** Keeps the rows; the where clause changes only when the complete conditions do. */ const emitted = ( model: Model, query: QueryDraft, + before: ReadonlyArray, rows: ReadonlyArray, commands?: Return["commands"], -): Return => ({ - model: { ...model, conditions: { ...model.conditions, [query.id]: rows } }, - ...(commands === undefined ? undefined : { commands }), - outMessage: OutMessage.ChangedQuery({ - queryId: query.id, - change: QueryChange.SetText({ field: "whereClause", value: printConditionRows(rows) }), - }), -}) +): Return => { + const kept: Model = { ...model, conditions: { ...model.conditions, [query.id]: rows } } + const withCommands = commands === undefined ? undefined : { commands } + if (sameConditions(before, rows)) return { model: kept, ...withCommands } + return { + model: kept, + ...withCommands, + outMessage: OutMessage.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ + field: "whereClause", + value: printConditionRowsOver(rows, query.whereClause), + }), + }), + } +} -type RowContext = Readonly<{ query: QueryDraft; rows: ReadonlyArray }> +type RowContext = Readonly<{ model: Model; query: QueryDraft; rows: ReadonlyArray }> const withRows = ( model: Model, @@ -75,9 +109,22 @@ const withRows = ( ): Return => { const query = inputs?.queryOf(queryId) if (query === undefined) return { model } - return Option.match(conditionRowsOf(model, query), { - onNone: () => ({ model }), - onSome: (rows) => step({ query, rows }), + return Option.match(keptRowsOf(model, query), { + onSome: (rows) => step({ model, query, rows }), + // Stale kept rows mean the ids renumber: their pickers go. The fresh rows are kept either way. + onNone: () => + Option.match(parseConditionRows(query.whereClause), { + onNone: () => ({ model }), + onSome: (rows) => { + const fresh = + model.conditions[queryId] === undefined ? model : withoutBuilderState(model, queryId) + return step({ + model: { ...fresh, conditions: { ...fresh.conditions, [queryId]: rows } }, + query, + rows, + }) + }, + }), }) } @@ -91,7 +138,7 @@ const withRow = ( withRows(model, queryId, inputs, (context) => Option.match( Arr.findFirst(context.rows, (row) => row.id === rowId), - { onNone: () => ({ model }), onSome: (row) => step({ ...context, row }) }, + { onNone: () => ({ model: context.model }), onSome: (row) => step({ ...context, row }) }, ), ) @@ -99,26 +146,22 @@ const replaced = (rows: ReadonlyArray, next: ConditionRow): Readon Arr.map(rows, (row) => (row.id === next.id ? next : row)) export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ - model: { - ...model, - whereModes: { ...model.whereModes, [queryId]: mode }, - conditions: Rec.remove(model.conditions, queryId), - }, + model: { ...withoutBuilderState(model, queryId), whereModes: { ...model.whereModes, [queryId]: mode } }, }) -/** A new row opens its field picker, as the first thing to choose. */ +/** A new row opens its field picker, as the first thing to choose; the clause is unchanged. */ export const addedCondition = (model: Model, queryId: string, inputs: PanelInputs | undefined): Return => - withRows(model, queryId, inputs, ({ query, rows }) => { + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { const id = nextRowId(rows) const pickerId = conditionPickerId(queryId, id, "field") const opened: Model = { - ...model, + ...current, conditionPickers: { - ...model.conditionPickers, + ...current.conditionPickers, [pickerId]: Combobox.init(pickerId, "single", { isOpen: true }), }, } - return emitted(opened, query, [...rows, { id, key: "", operator: "=", values: [] }]) + return emitted(opened, query, rows, [...rows, { id, key: "", operator: "=", values: [] }]) }) export const removedCondition = ( @@ -127,19 +170,19 @@ export const removedCondition = ( rowId: string, inputs: PanelInputs | undefined, ): Return => - withRows(model, queryId, inputs, ({ query, rows }) => { + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { const pickerIds = [ conditionPickerId(queryId, rowId, "field"), conditionPickerId(queryId, rowId, "values"), ] - const pickers = Rec.filter(model.conditionPickers, (_, id) => !pickerIds.includes(id)) return emitted( { - ...model, - conditionPickers: pickers, - selects: Rec.remove(model.selects, operatorSelectId(queryId, rowId)), + ...current, + conditionPickers: Rec.filter(current.conditionPickers, (_, id) => !pickerIds.includes(id)), + selects: Rec.remove(current.selects, operatorSelectId(queryId, rowId)), }, query, + rows, Arr.filter(rows, (row) => row.id !== rowId), ) }) @@ -152,9 +195,6 @@ const pickedValue = (current: Combobox.Model, message: Combobox.Message): string return null } -const pickerOf = (model: Model, id: string, mode: Combobox.Mode): Combobox.Model => - model.conditionPickers[id] ?? Combobox.init(id, mode) - /** The field picker: a group or "All fields" drills without closing; a field sets the row's key. */ export const fieldStep = ( model: Model, @@ -163,10 +203,11 @@ export const fieldStep = ( message: Combobox.Message, inputs: PanelInputs | undefined, ): Return => - withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const id = conditionPickerId(queryId, rowId, "field") - const current = { ...pickerOf(model, id, "single"), selected: row.key === "" ? [] : [row.key] } - const drill = model.fieldDrills[id] ?? "all" + const stored = base.conditionPickers[id] ?? Combobox.init(id, "single") + const current = { ...stored, selected: row.key === "" ? [] : [row.key] } + const drill = base.fieldDrills[id] ?? "all" const fields = fieldsOf(query.dataSource, inputs?.baseValuesOf(query) ?? {}) const items = fieldPickerItems(fields, drill, Combobox.queryOf(current)) const picked = Option.flatMap(Option.fromNullishOr(pickedValue(current, message)), (value) => @@ -182,19 +223,19 @@ export const fieldStep = ( if (Option.isSome(navigated)) return { model: { - ...model, + ...base, conditionPickers: { - ...model.conditionPickers, + ...base.conditionPickers, [id]: { ...current, inputValue: "", highlighted: 0 }, }, - fieldDrills: { ...model.fieldDrills, [id]: navigated.value }, + fieldDrills: { ...base.fieldDrills, [id]: navigated.value }, }, } const next = Combobox.update(current, message) const lifted: Model = { - ...model, - conditionPickers: { ...model.conditionPickers, [id]: next.model }, - fieldDrills: next.model.isOpen ? model.fieldDrills : Rec.remove(model.fieldDrills, id), + ...base, + conditionPickers: { ...base.conditionPickers, [id]: next.model }, + fieldDrills: next.model.isOpen ? base.fieldDrills : Rec.remove(base.fieldDrills, id), } const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotConditionField({ queryId, rowId, message: inner }), @@ -210,20 +251,34 @@ export const fieldStep = ( ) if (next.outMessage?._tag !== "ChangedValue" || key === null || key === row.key) return { model: lifted, commands } - const operators = operatorsOf(fieldOf(key, query.dataSource).kind) - const operator = operators.includes(row.operator) ? row.operator : "=" - return emitted(lifted, query, replaced(rows, { ...row, key, operator, values: [] }), commands) + const operators = operatorsOf(fieldOf(key, query.dataSource), query.dataSource) + const operator = operators.includes(row.operator) ? row.operator : (operators[0] ?? "=") + return emitted(lifted, query, rows, replaced(rows, { ...row, key, operator, values: [] }), commands) }) -/** Whether a row holds several values: "is not" always (AND-ed excludes), "is" only where the engine ORs. */ +/** + * Whether a row holds several values: "is" only where the engine ORs (attribute filters), + * "is not" as AND-ed excludes except on product events, whose excludes replace each other. + */ export const holdsManyValues = (row: Pick, query: QueryDraft): boolean => - isNoneOf(row.operator) || - (isAnyOf(row.operator) && canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource)) + isAnyOf(row.operator) + ? canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource) + : isNoneOf(row.operator) && query.dataSource !== "product_events" -const withValue = (row: ConditionRow, query: QueryDraft, value: string): ReadonlyArray => - holdsManyValues(row, query) ? Arr.dedupe([...row.values, value]) : [value] +/** The value picker's Model for the row: a multiple picker with chips, or a single one holding the value. */ +export const valuePickerOf = (model: Model, query: QueryDraft, row: ConditionRow): Combobox.Model => { + const id = conditionPickerId(query.id, row.id, "values") + const mode = holdsManyValues(row, query) ? "multiple" : "single" + const stored = model.conditionPickers[id] + const picker = stored !== undefined && stored.mode === mode ? stored : Combobox.init(id, mode) + return { + ...picker, + selected: row.values, + inputValue: mode === "single" && !picker.isOpen ? (row.values[0] ?? "") : picker.inputValue, + } +} -/** The value picker: suggestions or anything typed (Enter adds it); one value unless the row holds many. */ +/** The value picker: a suggestion or anything typed (Enter adds it); a single picker closes on a pick. */ export const valuesStep = ( model: Model, queryId: string, @@ -231,33 +286,43 @@ export const valuesStep = ( message: Combobox.Message, inputs: PanelInputs | undefined, ): Return => - withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const id = conditionPickerId(queryId, rowId, "values") - const current = { ...pickerOf(model, id, "multiple"), selected: row.values } + const current = valuePickerOf(base, query, row) + const isMany = current.mode === "multiple" + const lift = (next: Combobox.Model): Model => ({ + ...base, + conditionPickers: { ...base.conditionPickers, [id]: next }, + }) const typed = Str.trim(current.inputValue) - if ( + const isTypedEnter = message._tag === "PressedInputKey" && message.key === "Enter" && current.highlighted === null && typed !== "" - ) + // The grammar cannot spell it; the view says so beside the input. + if (isTypedEnter && isUnspellable(typed)) return { model: lift(current) } + if (isTypedEnter && isMany) return emitted( - { - ...model, - conditionPickers: { ...model.conditionPickers, [id]: { ...current, inputValue: "" } }, - }, + lift({ ...current, inputValue: "" }), query, - replaced(rows, { ...row, values: withValue(row, query, typed) }), + rows, + replaced(rows, { ...row, values: Arr.dedupe([...row.values, typed]) }), ) - const next = Combobox.update(current, message) - const lifted: Model = { ...model, conditionPickers: { ...model.conditionPickers, [id]: next.model } } + const pick = isTypedEnter ? Combobox.Message.PressedEscape() : message + const next = Combobox.update(isTypedEnter ? { ...current, selected: [typed] } : current, pick) const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotConditionValues({ queryId, rowId, message: inner }), ) - if (next.outMessage?._tag !== "ChangedSelection") return { model: lifted, commands } - const selected = next.outMessage.selected - const values = holdsManyValues(row, query) ? selected : Arr.takeRight(selected, 1) - return emitted(lifted, query, replaced(rows, { ...row, values }), commands) + const values = isTypedEnter + ? [typed] + : next.outMessage?._tag === "ChangedSelection" + ? next.outMessage.selected + : next.outMessage?._tag === "ChangedValue" + ? [next.outMessage.value] + : null + if (values === null || Arr.some(values, isUnspellable)) return { model: lift(next.model), commands } + return emitted(lift(next.model), query, rows, replaced(rows, { ...row, values }), commands) }) const isOperator = Schema.is(Operator) @@ -278,12 +343,12 @@ export const operatorStep = ( const [, queryId = "", rowId = ""] = Str.split(":")(id) const operator = next.outMessage?.value if (operator === undefined || !isOperator(operator)) return { model: lifted, commands } - return withRow(lifted, queryId, rowId, inputs, ({ query, rows, row }) => { + return withRow(lifted, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const values = isValueless(operator) ? [] : holdsManyValues({ key: row.key, operator }, query) ? row.values : Arr.take(row.values, 1) - return emitted(lifted, query, replaced(rows, { ...row, operator, values }), commands) + return emitted(base, query, rows, replaced(rows, { ...row, operator, values }), commands) }) } diff --git a/apps/web/src/page/kit/query-builder/metrics-body.ts b/apps/web/src/page/kit/query-builder/metrics-body.ts index 5d57b60350..8bf41ce7b0 100644 --- a/apps/web/src/page/kit/query-builder/metrics-body.ts +++ b/apps/web/src/page/kit/query-builder/metrics-body.ts @@ -14,7 +14,8 @@ import { QUERY_BUILDER_SPACE_AGGREGATIONS } from "@maple/query-model" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, metricPickerIdOf, type Model } from "./panel" import { type MetricOption, metricValueOf } from "./metric-options" -import { panelSelect, textInput, whereClauseEditor } from "./parts" +import { panelSelect } from "./panel-select" +import { textInput, whereClauseEditor } from "./parts" import { Array as Arr, Schema, String as Str, Option } from "effect" // `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: diff --git a/apps/web/src/page/kit/query-builder/panel.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7329cab460..af231d5039 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -16,6 +16,7 @@ import { operatorSelectId, operatorStep, removedCondition, + typedInTextEditor, valuesStep, } from "./conditions" import { groupBySelectionOf } from "./draft" @@ -145,13 +146,23 @@ export const update = (model: Model, message: Message, inputs?: PanelInputs): Re model: { ...model, collapsed: toggled(model.collapsed, queryId) }, }), ToggledVisible: ({ queryId }) => changed(model, queryId, QueryChange.ToggledHidden()), + // Typing in the text editor keeps it the editor, even once the text reads as rows. ChangedText: ({ queryId, field, value }) => - changed(model, queryId, QueryChange.SetText({ field, value })), + changed( + field === "whereClause" ? typedInTextEditor(model, queryId) : model, + queryId, + QueryChange.SetText({ field, value }), + ), ToggledAddOn: ({ queryId, key }) => changed(model, queryId, QueryChange.ToggledAddOn({ key })), ClickedClone: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedClone({ queryId }) }), ClickedRemove: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedRemove({ queryId }) }), - GotWhereField: ({ message: inner }) => - inputs === undefined ? { model } : whereStep(model, inner, inputs), + GotWhereField: ({ message: inner }) => { + if (inputs === undefined) return { model } + const next = whereStep(model, inner, inputs) + return next.outMessage === undefined + ? next + : { ...next, model: typedInTextEditor(next.model, next.outMessage.queryId) } + }, ChangedWhereMode: ({ queryId, mode }) => changedWhereMode(model, queryId, mode), ClickedAddCondition: ({ queryId }) => addedCondition(model, queryId, inputs), ClickedRemoveCondition: ({ queryId, rowId }) => removedCondition(model, queryId, rowId, inputs), diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index a29d43baba..62ed3c3119 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -19,8 +19,6 @@ import { conditionRowsOf, whereModeOf } from "./conditions" import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" import { panelSelect } from "./panel-select" - -export { panelSelect } import { Array as Arr, Record as Rec, Option } from "effect" // The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. @@ -74,7 +72,7 @@ const UNPARSEABLE_NOTE = "This filter uses syntax the builder can't show, edit a /** * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text - * editor; a Builder/Text switch under it. Both edit the same where-clause string. + * editor, under a Builder/Text switch. Both edit the same where-clause string. */ export const whereClauseEditor = ( h: H, @@ -84,22 +82,7 @@ export const whereClauseEditor = ( options: Readonly<{ placeholder?: string; className?: string }> = {}, ): Html => { const rows = conditionRowsOf(model, query) - const preferred = whereModeOf(model, query) - const isBuilder = preferred === "builder" && Option.isSome(rows) - const leading = isBuilder - ? button( - h, - { - variant: "ghost", - size: "xs", - className: "text-muted-foreground", - attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], - }, - [icon(h, "PlusIcon"), "Add condition"], - ) - : preferred === "builder" - ? typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE]) - : h.empty + const isBuilder = whereModeOf(model, query) === "builder" && Option.isSome(rows) const toggle = segmentedSelect(h, { options: [ { value: "builder", label: "Builder", isDisabled: Option.isNone(rows) }, @@ -111,17 +94,31 @@ export const whereClauseEditor = ( ariaLabel: `Where clause editor for query ${query.name}`, className: "ml-auto shrink-0", }) + const note = Option.isNone(rows) ? [typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE])] : [] + const addCondition = button( + h, + { + variant: "ghost", + size: "xs", + className: "self-start text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ) return vstack(h, { gap: "1.5", className: options.className ?? "flex-1 min-w-0" }, [ + hstack(h, { gap: "2" }, [...note, toggle]), ...(isBuilder - ? conditionBuilder( - h, - model, - query, - Option.getOrElse(rows, () => []), - values, - ) + ? [ + ...conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ), + addCondition, + ] : [whereTextEditor(h, model, query, values, options.placeholder)]), - hstack(h, { gap: "2", wrap: true }, [leading, toggle]), ]) } diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index cd6a5e0753..749461fab9 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -21,7 +21,8 @@ import type { QueryDraft } from "./draft" import { Message, type Model } from "./panel" import type { MetricOption } from "./metric-options" import { type MetricKeys, metricsBody } from "./metrics-body" -import { addOnSections, ADD_ON_KEYS, addOnToggles, panelSelect, textInput, whereClauseEditor } from "./parts" +import { panelSelect } from "./panel-select" +import { addOnSections, ADD_ON_KEYS, addOnToggles, textInput, whereClauseEditor } from "./parts" import { Array as Arr, String as Str } from "effect" // `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. diff --git a/packages/query-engine/src/query-builder/model.ts b/packages/query-engine/src/query-builder/model.ts index 79c7bea8de..a8d24c93f3 100644 --- a/packages/query-engine/src/query-builder/model.ts +++ b/packages/query-engine/src/query-builder/model.ts @@ -10,6 +10,8 @@ import { } from "@maple/domain/where-clause" import { Match, Array as Arr, Record as Rec, String as Str, Option, Result } from "effect" +export * from "./where-key-rules" + export type { QueryBuilderDataSource, QueryBuilderMetricType, diff --git a/packages/query-engine/src/query-builder/where-key-rules.test.ts b/packages/query-engine/src/query-builder/where-key-rules.test.ts new file mode 100644 index 0000000000..aec3551a62 --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.test.ts @@ -0,0 +1,98 @@ +import type { QueryBuilderQueryDraftPayload } from "@maple/domain/http" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { describe, expect, it } from "vitest" +import { buildTimeseriesQuerySpec, WHERE_DIMENSIONS, WHERE_OPERATORS, whereOperatorsOf } from "./model" +import { Array as Arr, Option, Record as Rec } from "effect" + +// The key rules against the clause handlers: every allowlisted operator is applied without a +// warning, and every other operator on a dimension warns, so the table cannot drift. + +const draft = (dataSource: QueryBuilderDataSource, whereClause: string): QueryBuilderQueryDraftPayload => ({ + id: "q1", + name: "A", + enabled: true, + hidden: false, + dataSource, + aggregation: dataSource === "metrics" ? "avg" : "count", + whereClause, + stepInterval: "", + orderByDirection: "desc", + addOns: { groupBy: false, having: false, orderBy: false, limit: false, legend: false }, + groupBy: [], + having: "", + orderBy: "", + limit: "", + legend: "", + ...(dataSource === "metrics" + ? { + metricName: "system.cpu.utilization", + metricType: "gauge", + isMonotonic: false, + signalSource: "default", + } + : undefined), +}) + +/** A value each key's handler reads as valid, so only the operator decides. */ +const VALID_VALUES: ReadonlyArray = [ + ["root_only", "true"], + ["has_error", "true"], + ["min_duration_ms", "5"], + ["max_duration_ms", "5"], + ["metric.type", "gauge"], + ["visitor.type", "new"], + ["visitor_type", "new"], +] + +const validValueOf = (key: string): string => + Option.match( + Arr.findFirst(VALID_VALUES, ([candidate]) => candidate === key), + { onNone: () => "x", onSome: ([, value]) => value }, + ) + +const clauseOf = (key: string, operator: string): string => + operator === "exists" || operator === "!exists" + ? `${key} ${operator}` + : `${key} ${operator} "${validValueOf(key)}"` + +const warningsOf = (dataSource: QueryBuilderDataSource, whereClause: string) => + buildTimeseriesQuerySpec(draft(dataSource, whereClause)).warnings + +const sources: ReadonlyArray = ["traces", "logs", "metrics", "product_events"] + +describe("where key rules", () => { + const dimensionCases = Arr.flatMap(sources, (dataSource) => + Arr.flatMap(Rec.keys(WHERE_DIMENSIONS[dataSource]), (key) => + Arr.map(WHERE_OPERATORS, (operator) => ({ + dataSource, + key, + operator, + isAllowed: whereOperatorsOf(dataSource, key).includes(operator), + })), + ), + ) + + it.each(dimensionCases)( + "$dataSource $key $operator: allowed=$isAllowed", + ({ dataSource, key, operator, isAllowed }) => { + expect(warningsOf(dataSource, clauseOf(key, operator)).length === 0).toBe(isAllowed) + }, + ) + + const attributeCases = Arr.flatMap(sources, (dataSource) => + Arr.map(WHERE_OPERATORS, (operator) => ({ dataSource, operator })), + ) + + it.each(attributeCases)("$dataSource attr.* takes $operator", ({ dataSource, operator }) => { + expect(whereOperatorsOf(dataSource, "attr.http.route")).toContain(operator) + expect(warningsOf(dataSource, clauseOf("attr.http.route", operator))).toEqual([]) + }) + + it("a bare unknown key is an attribute on traces and dropped on logs and metrics", () => { + expect(whereOperatorsOf("traces", "db.system")).toEqual(WHERE_OPERATORS) + expect(whereOperatorsOf("logs", "db.system")).toEqual([]) + expect(warningsOf("logs", 'db.system = "x"').length).toBeGreaterThan(0) + expect(whereOperatorsOf("metrics", "db.system")).toEqual([]) + expect(warningsOf("metrics", 'db.system = "x"').length).toBeGreaterThan(0) + }) +}) diff --git a/packages/query-engine/src/query-builder/where-key-rules.ts b/packages/query-engine/src/query-builder/where-key-rules.ts new file mode 100644 index 0000000000..148cfa506a --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.ts @@ -0,0 +1,137 @@ +import { normalizeKey, type Operator } from "@maple/domain/where-clause" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { Option, Record as Rec, String as Str } from "effect" + +// Which where-clause operators each source's clause handler (`apply*Clause` in `model.ts`) +// accepts per key. Editors read it so they never offer a clause the query drops with a +// warning; `where-key-rules.test.ts` holds it to the handlers in both directions. + +export const WHERE_OPERATORS: ReadonlyArray = [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "contains", + "!contains", + "exists", + "!exists", +] + +/** + * A key's rule: a column-backed `Dimension` takes its listed operators; an `Attribute` + * (a map key) takes every operator; an `Unsupported` key is dropped by the source. + */ +export type WhereKeyRule = + | Readonly<{ _tag: "Dimension"; operators: ReadonlyArray }> + | Readonly<{ _tag: "Attribute" }> + | Readonly<{ _tag: "Unsupported" }> + +const EQUALS: ReadonlyArray = ["="] +const EQUALS_OR_NOT: ReadonlyArray = ["=", "!="] +const TRACES_DIMENSION: ReadonlyArray = ["=", "!=", "contains"] +const LOGS_BODY: ReadonlyArray = ["=", "contains"] + +const PRODUCT_EVENT_LIST_KEYS = [ + "event.name", + "event", + "event_name", + "event.kind", + "kind", + "source", + "host", + "page.path", + "page_path", + "path", + "service.name", + "service", +] + +const PRODUCT_EVENT_EQUALS_KEYS = [ + "user.id", + "group.id", + "referrer.host", + "referrer_host", + "referrer", + "country", + "device.type", + "device_type", + "device", + "browser", + "browser.name", + "os", + "os.name", + "language", + "utm.source", + "utm_source", + "utm.medium", + "utm_medium", + "utm.campaign", + "utm_campaign", + "visitor.type", + "visitor_type", +] + +/** Column-backed keys by source, after `normalizeKey`. */ +export const WHERE_DIMENSIONS = { + traces: { + "service.name": TRACES_DIMENSION, + "span.name": TRACES_DIMENSION, + "deployment.environment": TRACES_DIMENSION, + "vcs.ref.head.revision": EQUALS_OR_NOT, + root_only: EQUALS, + has_error: EQUALS, + min_duration_ms: EQUALS, + max_duration_ms: EQUALS, + }, + logs: { + "service.name": EQUALS_OR_NOT, + severity: EQUALS_OR_NOT, + body: LOGS_BODY, + message: LOGS_BODY, + search: LOGS_BODY, + }, + metrics: { + "service.name": EQUALS, + "deployment.environment": EQUALS, + "metric.type": EQUALS, + }, + product_events: { + ...Rec.fromIterableWith(PRODUCT_EVENT_LIST_KEYS, (key) => [key, EQUALS_OR_NOT]), + ...Rec.fromIterableWith(PRODUCT_EVENT_EQUALS_KEYS, (key) => [key, EQUALS]), + }, +} satisfies Record>> + +/** One source's dimensions, read by any key. */ +const dimensionsOf = ( + dataSource: QueryBuilderDataSource, +): Readonly>> => WHERE_DIMENSIONS[dataSource] + +/** The rule for a key as written (aliases resolve), on one source. */ +export const whereKeyRule = (dataSource: QueryBuilderDataSource, key: string): WhereKeyRule => { + const canonical = normalizeKey(key) + if (Str.startsWith("attr.")(canonical) || Str.startsWith("resource.")(canonical)) + return { _tag: "Attribute" } + return Option.match(Rec.get(dimensionsOf(dataSource), canonical), { + onSome: (operators): WhereKeyRule => ({ _tag: "Dimension", operators }), + // Traces read a bare key as a span attribute and product events as a `track()` prop. + onNone: (): WhereKeyRule => + dataSource === "traces" || dataSource === "product_events" + ? { _tag: "Attribute" } + : { _tag: "Unsupported" }, + }) +} + +/** The operators a key takes on a source: none when the source drops it. */ +export const whereOperatorsOf = ( + dataSource: QueryBuilderDataSource, + key: string, +): ReadonlyArray => { + const rule = whereKeyRule(dataSource, key) + return rule._tag === "Dimension" ? rule.operators : rule._tag === "Attribute" ? WHERE_OPERATORS : [] +} + +/** Whether `(k = a OR k = b)` on this key is lowered: attribute filters only, on sources that lower groups. */ +export const whereKeyMatchesAnyOf = (dataSource: QueryBuilderDataSource, key: string): boolean => + dataSource !== "product_events" && whereKeyRule(dataSource, key)._tag === "Attribute" diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 7b1311900d..aaeb175bff 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -211,15 +211,15 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. - `apps/web/src/page/kit/query-builder/condition-builder-view.ts` `conditionBuilder`: `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each picked from the query's own vocabulary. -- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `fieldsOf`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... -- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `parseConditionRows`, `isComplete`, `printConditionRows`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... -- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `whereModeOf`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuesStep`, `operatorStep`: The condition builder's steps: every edit prints the rows and leaves as the query's where-clause edit, so previews and saving see only the string. +- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `isOwnedElsewhere`, `fieldsOf`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... +- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `parseConditionRows`, `sameConditions`, `isUnspellable`, `isComplete`, `printConditionRows`, `printConditionRowsOver`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... +- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `withoutBuilderState`, `whereModeOf`, `typedInTextEditor`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuePickerOf`, `valuesStep`, `operatorStep`: The condition builder's steps: an edit that changes the conditions leaves as the query's where-clause edit, rewriting only the clauses it touched, so previews and saving see onl... - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. - `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields. - `apps/web/src/page/kit/query-builder/panel-select.ts` `panelSelect`: One of the panel's selects, kept by id; its value is always the draft's. - `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. -- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text editor; a Builder/Text switch under it. +- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. - `apps/web/src/page/kit/query-builder/view.ts` `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. - `apps/web/src/page/kit/query-builder/where-editor.ts` `whereEditor`, `highlightOverlay`: The suggestions of a field kept in the owner's `WhereFields` (`page/kit/where-field.ts`). - `apps/web/src/page/kit/query-builder/where.ts` `whereTextareaId`, `whereSpecOf`, `whereStep`: `WhereClauseEditor` inside a `QueryPanel`: one `WhereFields` field per query, keyed by query id. From be018faf89646d11a8f0e6f4f8496d758a46a776 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:40:33 +0100 Subject: [PATCH 03/27] feat(alerts): rule editor lab over fixture rows --- apps/web/src/lab/entries/rule-editor.ts | 6 + apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/rule-editor-lab.ts | 194 ++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 apps/web/src/lab/entries/rule-editor.ts create mode 100644 apps/web/src/lab/rule-editor-lab.ts diff --git a/apps/web/src/lab/entries/rule-editor.ts b/apps/web/src/lab/entries/rule-editor.ts new file mode 100644 index 0000000000..2b2ed42a18 --- /dev/null +++ b/apps/web/src/lab/entries/rule-editor.ts @@ -0,0 +1,6 @@ +import { defineLabWithFlags } from "../define" +import { Flags, flags, init, Model, update, view } from "../rule-editor-lab" + +// `/lab/rule-editor`: the alert rule editor over fixture rows (`?variant=populated|new|anomaly`). + +export const lab = defineLabWithFlags("Rule editor", { Model, Flags, flags, init, update, view }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 02bc171ec3..26d882cc9d 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -443,6 +443,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/rule-editor", + title: "Rule editor", + description: + "The alert rule editor over fixture rows: a saved error rate rule (?variant=populated), a blank new rule (new) and a scoped anomaly rule (anomaly). ?theme=light|dark.", + kind: "lab", + session: "none", + }, { path: "/lab/time-range", title: "Time range picker", diff --git a/apps/web/src/lab/rule-editor-lab.ts b/apps/web/src/lab/rule-editor-lab.ts new file mode 100644 index 0000000000..84cb7bbeca --- /dev/null +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -0,0 +1,194 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Clock, Effect, Option, Result, 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 type { PageContext } from "../page/contract" +import { chainDestinations, chainUuid, populatedChains } from "../page/alerts/shared/escalation-test-fixtures" +import { scopedAnomalyRuleRow } from "../page/alerts/shared/anomaly-test-fixtures" +import { DESTINATION_ID, destinationRow, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import { init as editorInit } from "../page/alerts/editor/init" +import { Message as EditorMessage } from "../page/alerts/editor/message" +import { Model as EditorModel } from "../page/alerts/editor/model" +import { update as editorUpdate } from "../page/alerts/editor/update" +import { view as editorView } from "../page/alerts/editor/view" +import type { Resources } from "../platform/resources" + +// `/lab/rule-editor`: the alert rule editor over fixture rows, no API needed. `?variant=` picks a +// saved rule (`populated`), a blank new rule (`new`) or a scoped anomaly rule (`anomaly`). + +export const Variant = Schema.Literals(["populated", "new", "anomaly"]) +export type Variant = typeof Variant.Type + +export const Flags = Schema.Struct({ nowMs: Schema.Number, variant: Variant }) +export type Flags = typeof Flags.Type + +const decodeVariant = Schema.decodeUnknownOption(Variant) + +/** The boot clock and `?variant=`; `?theme=light|dark` sets the theme class as `index.html` does. */ +export const flags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + variant: Effect.sync(() => { + const params = new URLSearchParams(window.location.search) + const theme = params.get("theme") + if (theme === "light" || theme === "dark") { + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(theme) + document.documentElement.style.colorScheme = theme + } + return Option.getOrElse(decodeVariant(params.get("variant")), (): Variant => "populated") + }), +}) + +export const Model = Schema.Struct({ variant: Variant, editor: EditorModel }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotEditorMessage: { message: EditorMessage } }) +export type Message = typeof Message.Type + +const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", +} + +const run = (model: EditorModel, messages: ReadonlyArray): EditorModel => + Arr.reduce(messages, model, (current, message) => editorUpdate(current, message, LAB_CONTEXT).model) + +const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" +const MIN = 60_000 + +const CHAT_DESTINATION = destinationRow({ + name: "Checkout on-call", + type: "chat", + config_json: { summary: "Slack · acme", channelLabel: "#checkout-oncall" }, +}) + +/** A saved error rate rule on checkout-api, two tiers, the critical one starting a chain. */ +const ERROR_RATE_RULE = ruleRow(2, { + name: "Checkout error rate", + notes: "Pages the checkout team. Runbook: wiki/checkout/errors", + signal_type: "error_rate", + service_names_json: ["checkout-api"], + environments_json: ["production"], + tags_json: ["checkout", "tier-1"], + severity: "warning", + threshold: 0.05, + thresholds_json: [ + { + severity: "warning", + value: 0.05, + upper: null, + recoveryValue: null, + destinationIds: [DESTINATION_ID], + }, + { + severity: "critical", + value: 0.1, + upper: null, + recoveryValue: null, + destinationIds: [], + escalationChainId: chainUuid(1), + }, + ], + consecutive_breaches_required: 3, +}) + +const decodePreview = Schema.decodeUnknownSync(AlertRulePreviewResponse) + +/** A week of error rate around 2%, with two bursts over the 5% line. */ +const previewOf = (nowMs: number): AlertRulePreviewResponse => { + const bucketMs = 30 * MIN + const start = nowMs - 7 * 24 * 60 * MIN + const count = Math.floor((nowMs - start) / bucketMs) + const burst = (i: number) => (i > 120 && i < 128) || (i > 250 && i < 255) + const points = Arr.map(Arr.range(0, count - 1), (i) => { + const value = burst(i) ? 0.072 + Math.sin(i) * 0.008 : 0.02 + Math.sin(i * 0.7) * 0.006 + const point = { bucket: new Date(start + i * bucketMs).toISOString(), value, sampleCount: 300 } + return value > 0.05 + ? { ...point, status: "breached", level: "warning" } + : { ...point, status: "healthy" } + }) + const span = (from: number, to: number) => ({ + groupKey: "all", + start: new Date(start + from * bucketMs).toISOString(), + end: new Date(start + to * bucketMs).toISOString(), + severity: "warning", + }) + return decodePreview({ + bucketSeconds: bucketMs / 1000, + windowMinutes: 5, + threshold: 0.05, + thresholdUpper: null, + thresholds: [ + { severity: "warning", value: 0.05, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [{ groupKey: "all", points }], + wouldFire: [span(123, 128), span(252, 255)], + }) +} + +const opened = (search: EditorModel["search"], nowMs: number, rules: ReadonlyArray) => + run(editorInit({ _tag: "AlertsCreate", search }, LAB_CONTEXT).model, [ + EditorMessage.GeneratedDraftId({ id: DRAFT_ID }), + EditorMessage.CompletedReadClock({ nowMs }), + EditorMessage.CompletedReadGrants({ grants: Policy.allPermissions }), + EditorMessage.UpdatedDestinations({ orgId: ORG, rows: [CHAT_DESTINATION, ...chainDestinations] }), + EditorMessage.UpdatedRules({ orgId: ORG, rows: rules }), + EditorMessage.CompletedFetchEscalationChains({ chains: populatedChains([]) }), + ]) + +const withPreview = (model: EditorModel, nowMs: number): EditorModel => + run(model, [ + EditorMessage.CompletedFetchPreview({ + key: model.preview.key ?? "", + isSaved: false, + result: Result.succeed(previewOf(nowMs)), + }), + ]) + +const seed = (variant: Variant, nowMs: number): EditorModel => { + if (variant === "new") return run(opened({}, nowMs, []), [EditorMessage.ClickedStartBlank()]) + if (variant === "anomaly") return opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) + return withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs) +} + +// Reads and writes need a session; the lab keeps the editor's local commands (chart, focus, menus). +const BLOCKED = /^(FetchAlert|RunAlert|SaveAlert|TrackAlert|ReloadAlert|ReadAlertEditorGrants|GenerateAlert)/ + +const toLab = (commands: ReadonlyArray> | undefined) => + Command.mapMessages( + Arr.filter(commands ?? [], (command) => !BLOCKED.test(command.name)), + (message) => Message.GotEditorMessage({ message }), + ) + +export const init = (labFlags: Flags): Update.Return => { + const started = editorInit({ _tag: "AlertsCreate", search: {} }, LAB_CONTEXT) + return { + model: { variant: labFlags.variant, editor: seed(labFlags.variant, labFlags.nowMs) }, + commands: toLab(started.commands), + } +} + +export const update = (model: Model, message: Message): Update.Return => { + const next = editorUpdate(model.editor, message.message, LAB_CONTEXT) + return { model: { ...model, editor: next.model }, commands: toLab(next.commands) } +} + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("flex h-dvh flex-col bg-background")], + [ + h.submodel({ + slotId: "rule-editor-lab", + model: model.editor, + view: editorView, + viewInputs: { context: LAB_CONTEXT }, + toParentMessage: (message) => Message.GotEditorMessage({ message }), + }), + ], + ) From bb3897c395907de61caec0a319c8ddbf0e25fd76 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:49:57 +0100 Subject: [PATCH 04/27] feat(alerts): rule editor as sentence cards with plain-language summaries Replaces the numbered stepper with When / Alert when / Notify / Name cards on a connected rail. A complete card closes to a one-line summary derived from the form; a card with something missing stays open. The live chart sits above the cards as the preview. --- apps/web/src/lab/rule-editor-lab.ts | 5 +- apps/web/src/page/alerts/editor/derive.ts | 9 +- .../editor/editor-anomaly.scene.test.ts | 6 +- .../alerts/editor/editor-cards.scene.test.ts | 106 +++++++++ .../page/alerts/editor/editor.scene.test.ts | 2 + apps/web/src/page/alerts/editor/form.ts | 4 + apps/web/src/page/alerts/editor/init.ts | 1 + apps/web/src/page/alerts/editor/message.ts | 6 +- apps/web/src/page/alerts/editor/model.ts | 4 +- apps/web/src/page/alerts/editor/steps.ts | 21 +- .../src/page/alerts/editor/summary.test.ts | 205 ++++++++++++++++++ apps/web/src/page/alerts/editor/summary.ts | 182 ++++++++++++++++ apps/web/src/page/alerts/editor/update.ts | 6 + apps/web/src/page/alerts/editor/view-step.ts | 59 +---- apps/web/src/page/alerts/editor/view.ts | 174 ++++++++------- apps/web/src/page/kit/sentence-card.ts | 182 ++++++++++++++++ packages/views/src/ui/INDEX.md | 1 + 17 files changed, 830 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/page/alerts/editor/editor-cards.scene.test.ts create mode 100644 apps/web/src/page/alerts/editor/summary.test.ts create mode 100644 apps/web/src/page/alerts/editor/summary.ts create mode 100644 apps/web/src/page/kit/sentence-card.ts diff --git a/apps/web/src/lab/rule-editor-lab.ts b/apps/web/src/lab/rule-editor-lab.ts index 84cb7bbeca..8fe52998b9 100644 --- a/apps/web/src/lab/rule-editor-lab.ts +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -154,7 +154,10 @@ const withPreview = (model: EditorModel, nowMs: number): EditorModel => const seed = (variant: Variant, nowMs: number): EditorModel => { if (variant === "new") return run(opened({}, nowMs, []), [EditorMessage.ClickedStartBlank()]) if (variant === "anomaly") return opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) - return withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs) + // Every card on its summary except "Alert when", open for editing. + return run(withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs), [ + EditorMessage.ClickedExpandCard({ card: "condition" }), + ]) } // Reads and writes need a session; the lab keeps the editor's local commands (chart, focus, menus). diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 2ffe94d7a4..e693fe9657 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -49,6 +49,12 @@ export const conditionSubject = (form: RuleForm): string => { return Str.toLowerCase(signalLabels[form.signalType]) } +/** A threshold with its unit: "5%", "800 ms", "?" while empty. */ +export const thresholdValue = (form: RuleForm, raw: string): string => { + const unit = thresholdUnitLabel(form) ?? "" + return `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` +} + /** The rule in one plain sentence, shown in the action bar. */ export const describeRuleCondition = (form: RuleForm, isDefault = false): string => { if (form.signalType === "anomaly") @@ -57,8 +63,7 @@ export const describeRuleCondition = (form: RuleForm, isDefault = false): string : isDefaultAnomalyRule(form) ? "Anomalies in a service, environment or signal" : `Pages on anomalies in ${anomalyScopeSummary(form)}` - const unit = thresholdUnitLabel(form) ?? "" - const value = (raw: string) => `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` + const value = (raw: string) => thresholdValue(form, raw) const threshold = isRangeComparator(form.comparator) ? `${value(form.threshold)} and ${value(form.thresholdUpper)}` : value(form.threshold) diff --git a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts index 603a765e60..10ace1eb2f 100644 --- a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -64,10 +64,10 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(blank), themeResolved(), - expectView(text("When to alert")).toExist(), + expectView(text("Alert when")).toExist(), click(role("button", { name: "Anomaly" })), expectView(text(DESCRIPTION)).toExist(), - expectView(text("When to alert")).not.toExist(), + expectView(text("Alert when")).not.toExist(), expectView(role("button", { name: "Dry run" })).not.toExist(), expectView(role("group", { name: "Anomaly signals" })).toExist(), expectView(text("None picked: every signal pages.")).toExist(), @@ -148,6 +148,7 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(scoped), themeResolved(), + click(role("button", { name: /^When: / })), expectView(text("No changes")).toExist(), expectView(text(DEFAULT_NOTE)).not.toExist(), // Each chip's remove button names its chip. @@ -162,6 +163,7 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(fallback), themeResolved(), + click(role("button", { name: /^When: / })), expectView(text(DEFAULT_NOTE)).toExist(), expectView(role("button", { name: "Error rate" })).toBeDisabled(), expectView(role("button", { name: "Built-in" })).toBeDisabled(), diff --git a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts new file mode 100644 index 0000000000..2e2966f07f --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -0,0 +1,106 @@ +import { OrgId } from "@maple/domain" +import { Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + given, + Mount, + role, + scene, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { describe, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import type { PageContext } from "../../contract" +import { scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { init } from "./init" +import { Message } from "./message" +import type { Model } from "./model" +import { update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const opened = (search: Model["search"], rules = [ruleRow(2)]) => + run(init({ _tag: "AlertsCreate", search }, context).model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: rules }), + ]) + +const summary = (lead: string, rest: string) => role("button", { name: `${lead}: ${rest}. Edit` }) +const queryMode = role("button", { name: "Built-in" }) + +describe("alert rule editor, sentence cards", () => { + test("a saved rule opens on one-line summaries; clicking one opens its card, Done closes it", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) })), + themeResolved(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + expectView( + summary( + "Alert when", + "Above 800 ms (critical), over 5 min, 2 checks in a row, resolves after 2 healthy checks", + ), + ).toExist(), + expectView(summary("Notify", "https://hooks.acme.test/maple")).toExist(), + expectView(summary("Name", "Rule 2")).toExist(), + expectView(queryMode).not.toExist(), + click(summary("When", "P95 latency on payment-svc in production")), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "When" })).toExist(), + click(role("button", { name: "Done" })), + expectView(queryMode).not.toExist(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + ) + }) + + test("a card with something missing stays open, and stays open once it is filled in", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) }, [ruleRow(2, { name: "" })])), + themeResolved(), + expectView(text("Missing: Rule name")).toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + type(role("textbox", { name: "Rule name" }), "Payment latency"), + expectView(text("Missing: Rule name")).not.toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + ) + }) + + test("a new rule opens every card", () => { + scene( + { update: pageUpdate, view }, + given(opened({ template: "slow_p95" })), + themeResolved(), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "Alert when" })).toExist(), + expectView(role("heading", { name: "Notify" })).toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + ) + }) + + test("an anomaly rule reads as its scope and has no Alert when card", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(8) }, [scopedAnomalyRuleRow()])), + themeResolved(), + expectView( + summary("When", "Anomalies in checkout · production · Error rate, p95 latency"), + ).toExist(), + expectView(text("Alert when")).not.toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/editor/editor.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index 5a28d691c1..4fc9c60cb7 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -153,6 +153,7 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(withWindow(editing, "60")), themeResolved(), + click(role("button", { name: /^Alert when: / })), expectView(text(`${saved} min`)).toExist(), expectView(hint("5 min")).toExist(), ) @@ -382,6 +383,7 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(slowed), themeResolved(), + click(role("button", { name: /^Alert when: / })), expectView(notice).toExist(), click(role("button", { name: "Keep 5 min" })), expectView(notice).not.toExist(), diff --git a/apps/web/src/page/alerts/editor/form.ts b/apps/web/src/page/alerts/editor/form.ts index aad6b6dcb7..12e1f389c0 100644 --- a/apps/web/src/page/alerts/editor/form.ts +++ b/apps/web/src/page/alerts/editor/form.ts @@ -64,6 +64,10 @@ export type RuleForm = typeof RuleForm.Type export const asFormState = (form: RuleForm): RuleFormState => form export const fromFormState = (form: RuleFormState): RuleForm => form +/** The editor's sentence cards: "When" (signal and scope), "Alert when", "Notify", "Name". */ +export const RuleCard = Schema.Literals(["watch", "condition", "notify", "name"]) +export type RuleCard = typeof RuleCard.Type + /** The warning tier's destinations, or the critical tier's. */ export const Tier = Schema.Literals(["warning", "critical"]) export type Tier = typeof Tier.Type diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index c1f6d82704..09ca171c50 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -67,6 +67,7 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn @@ -43,15 +45,26 @@ export const initialize = (model: Model, context: PageContext): Return => { isNotesOpen: Str.trim(form.notes).length > 0, isMessageTemplateOpen: form.notificationTitle.length > 0 || form.notificationBody.length > 0, } - const loaded = planned(applyDefaultDestination(ready), context) + // A new rule opens every card; a saved one opens on its summaries, with any gap left open. + const withCards: Model = { + ...ready, + expandedCards: + draft.editingRule === null ? RULE_CARDS : missingCards(form, isProtectedDefault(ready)), + } + const loaded = planned(applyDefaultDestination(withCards), context) if (!draft.isShowingTemplates) return loaded const opened = liftTemplates(loaded.model, Dialog.open(loaded.model.templates)) return { model: opened.model, commands: [...(loaded.commands ?? []), ...(opened.commands ?? [])] } } /** An edit to the draft; the preview follows it (`useDeferredValue` over the form). */ -export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => - model.form === null ? { model } : planned({ ...model, form: edit(model.form) }, context) +export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => { + if (model.form === null) return { model } + const form = edit(model.form) + // A card that goes missing stays open once filled in again, so it never closes under the cursor. + const expandedCards = Arr.union(model.expandedCards, missingCards(form, isProtectedDefault(model))) + return planned({ ...model, form, expandedCards }, context) +} /** A list answered: the draft may now be known, and the default destination picked. */ export const followRows = (model: Model, context: PageContext): Return => { diff --git a/apps/web/src/page/alerts/editor/summary.test.ts b/apps/web/src/page/alerts/editor/summary.test.ts new file mode 100644 index 0000000000..c24f5e3ce1 --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -0,0 +1,205 @@ +import { AlertDestinationId, EscalationChainId } from "@maple/domain/http" +import { anomalyRuleForm, defaultRuleForm } from "@maple/web-data/alerts/form-utils" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { fromFormState, type RuleForm } from "./form" +import { + cardIssues, + cardsOf, + conditionSummary, + missingCards, + nameSummary, + notifySummary, + type NotifyNames, + watchSummary, +} from "./summary" + +const destinationId = Schema.decodeSync(AlertDestinationId) +const chainId = Schema.decodeSync(EscalationChainId) +const ONCALL = destinationId("0199a001-0000-7000-8000-000000000001") +const PAGER = destinationId("0199a001-0000-7000-8000-000000000002") +const GONE = destinationId("0199a001-0000-7000-8000-000000000009") +const CHAIN = chainId("0199a00e-0000-7000-8000-000000000001") + +const form = (fields: Partial = {}): RuleForm => ({ + ...fromFormState(defaultRuleForm()), + name: "Checkout errors", + destinationIds: [ONCALL], + ...fields, +}) + +const NAMES: NotifyNames = { + destination: (id) => (id === ONCALL ? "#oncall" : id === PAGER ? "PagerDuty Checkout" : null), + chain: (id) => (id === CHAIN ? "Checkout primary" : null), +} + +const criticalTier = (fields: Partial> = {}) => ({ + threshold: "10", + recoveryValue: "", + destinationIds: [], + escalationChainId: null, + ...fields, +}) + +describe("watchSummary", () => { + it("names a built-in signal with its services and environments", () => { + expect(watchSummary(form({ serviceNames: ["checkout-api"], environments: ["production"] }))).toBe( + "Error rate on checkout-api in production", + ) + expect(watchSummary(form({ signalType: "p95_latency", excludeServiceNames: ["batch"] }))).toBe( + "P95 latency on all services except batch", + ) + }) + + it("shortens a long service list and says how the rule is split", () => { + expect(watchSummary(form({ signalType: "throughput", serviceNames: ["a", "b", "c", "d"] }))).toBe( + "Throughput on a, b and 2 more", + ) + expect(watchSummary(form({ signalType: "apdex", groupBy: ["http.route"] }))).toBe( + "Apdex on all services per http.route", + ) + }) + + it("reads a builder query as its aggregation, source and where clause", () => { + const base = form({ signalType: "builder_query" }) + const draft = { ...base.queryBuilderDraft, whereClause: 'service.name = "checkout"' } + expect(watchSummary({ ...base, queryBuilderDraft: draft })).toBe( + 'Error rate of traces, where service.name = "checkout", per service.name', + ) + }) + + it("reads a SQL rule by its reducer", () => { + expect(watchSummary(form({ signalType: "raw_query", rawQueryReducer: "max" }))).toBe( + "SQL query, maximum of value", + ) + }) + + it("reads an anomaly rule by its scope, and the default rule as the catch-all", () => { + const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) + expect(watchSummary(anomaly)).toBe("Anomalies in checkout · all environments · all signals") + expect(watchSummary(fromFormState(anomalyRuleForm(form())), true)).toBe( + "Every anomaly no narrower rule handles", + ) + }) +}) + +describe("conditionSummary", () => { + it("says the threshold with its unit and how many checks fire it", () => { + expect(conditionSummary(form({ consecutiveBreachesRequired: "3" }))).toBe( + "Above 5%, over 5 min, 3 checks in a row, resolves after 2 healthy checks", + ) + }) + + it("adds the critical tier, in the same direction", () => { + expect( + conditionSummary( + form({ comparator: "lt", threshold: "20", escalation: criticalTier({ threshold: "10" }) }), + ), + ).toBe( + "Below 20%, critical below 10%, over 5 min, 2 checks in a row, resolves after 2 healthy checks", + ) + }) + + it("reads a range on a latency signal, and a single critical tier", () => { + expect( + conditionSummary( + form({ + signalType: "p95_latency", + comparator: "between", + threshold: "200", + thresholdUpper: "800", + severity: "critical", + consecutiveBreachesRequired: "1", + consecutiveHealthyRequired: "1", + }), + ), + ).toBe("Between 200 ms and 800 ms (critical), over 5 min") + }) + + it("reads a breach window and alerting on no data", () => { + expect( + conditionSummary( + form({ consecutiveBreachesRequired: "3", breachesOutOf: "5", alertOnNoData: true }), + ), + ).toBe( + "Above 5%, over 5 min, 3 of the last 5 checks, resolves after 2 healthy checks, no data counts", + ) + }) + + it("marks a blank threshold", () => { + expect(conditionSummary(form({ threshold: "" }))).toMatch(/^Above \?%/) + }) +}) + +describe("notifySummary", () => { + it("lists the destinations, and the chain after them", () => { + expect(notifySummary(form(), NAMES)).toBe("#oncall") + expect( + notifySummary(form({ destinationIds: [ONCALL, PAGER], escalationChainId: CHAIN }), NAMES), + ).toBe("#oncall and PagerDuty Checkout, then escalate to Checkout primary") + }) + + it("says where the critical tier goes", () => { + expect(notifySummary(form({ escalation: criticalTier({ escalationChainId: CHAIN }) }), NAMES)).toBe( + "#oncall, critical escalates to Checkout primary", + ) + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [PAGER] }) }), NAMES)).toBe( + "#oncall, critical also PagerDuty Checkout", + ) + }) + + it("reads a rule routed only by a chain, or by nobody", () => { + expect(notifySummary(form({ destinationIds: [], escalationChainId: CHAIN }), NAMES)).toBe( + "Escalate to Checkout primary", + ) + expect(notifySummary(form({ destinationIds: [] }), NAMES)).toBe("No one is notified") + }) + + it("names a destination the org no longer has instead of dropping it", () => { + expect(notifySummary(form({ destinationIds: [GONE] }), NAMES)).toBe("a removed destination") + }) + + it("names a destination on both tiers once", () => { + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [ONCALL] }) }), NAMES)).toBe( + "#oncall", + ) + }) + + it("reads both tiers of an anomaly rule", () => { + const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) + const routed = { + ...anomaly, + escalation: { ...criticalTier(), ...anomaly.escalation, destinationIds: [PAGER] }, + } + expect(notifySummary(routed, NAMES)).toBe("#oncall, critical also PagerDuty Checkout") + }) +}) + +describe("nameSummary", () => { + it("is the name, then its tags", () => { + expect(nameSummary(form({ tags: ["checkout", "tier-1"] }))).toBe( + "Checkout errors · checkout and tier-1", + ) + expect(nameSummary(form({ name: " " }))).toBe("Unnamed rule") + }) +}) + +describe("card issues", () => { + it("puts each missing field on the card that edits it", () => { + expect(missingCards(form())).toEqual([]) + expect(missingCards(form({ name: "" }))).toEqual(["name"]) + expect(missingCards(form({ threshold: "" }))).toEqual(["condition"]) + expect(missingCards(form({ destinationIds: [] }))).toEqual(["notify"]) + expect( + cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch"), + ).toEqual(["$__orgFilter in SQL"]) + expect(cardIssues(form({ escalation: criticalTier() }), "notify")).toEqual(["Critical destinations"]) + }) + + it("drops the condition card for an anomaly rule, whose scope is required unless it is the default", () => { + const unscoped = fromFormState(anomalyRuleForm(form())) + expect(cardsOf(unscoped)).toEqual(["watch", "notify", "name"]) + expect(missingCards(unscoped)).toEqual(["watch"]) + expect(missingCards(unscoped, true)).toEqual([]) + }) +}) diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts new file mode 100644 index 0000000000..6bf5d49333 --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -0,0 +1,182 @@ +import { Array as Arr, String as Str } from "effect" +import type { AlertDestinationId, EscalationChainId } from "@maple/domain/http" +import { + comparatorSupportsTiers, + isRangeComparator, + RAW_QUERY_REDUCER_LABELS, + signalLabels, +} from "@maple/web-data/alerts/form-utils" +import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" +import type { RuleCard, RuleForm } from "./form" +import { comparatorWords, conditionSubject, groupBySummary, thresholdValue, validationIssues } from "./derive" + +// The rule editor's sentence cards: which card each field lives on, and each card's one-line summary. + +export const RULE_CARDS: ReadonlyArray = ["watch", "condition", "notify", "name"] + +/** The cards an anomaly rule shows: the detector decides when, so it has no condition card. */ +export const cardsOf = (form: RuleForm): ReadonlyArray => + form.signalType === "anomaly" ? Arr.filter(RULE_CARDS, (card) => card !== "condition") : RULE_CARDS + +const WATCH_ISSUES = new Set(["SQL query", "$__orgFilter in SQL", "SQL value column", "Scope"]) +const NOTIFY_ISSUES = new Set(["Who gets notified", "Critical destinations"]) + +const cardOfIssue = (issue: string): RuleCard => + issue === "Rule name" + ? "name" + : NOTIFY_ISSUES.has(issue) + ? "notify" + : WATCH_ISSUES.has(issue) || Str.startsWith("Query:")(issue) + ? "watch" + : "condition" + +/** What blocks saving on one card, from the same list the action bar shows. */ +export const cardIssues = (form: RuleForm, card: RuleCard, isDefault = false): ReadonlyArray => + Arr.filter(validationIssues(form, isDefault), (issue) => cardOfIssue(issue) === card) + +/** The cards with something missing, which stay open until it is filled in. */ +export const missingCards = (form: RuleForm, isDefault = false): ReadonlyArray => + Arr.filter(cardsOf(form), (card) => cardIssues(form, card, isDefault).length > 0) + +const capitalize = (text: string): string => Str.toUpperCase(Str.slice(0, 1)(text)) + Str.slice(1)(text) + +/** "a, b and c", or "a, b and 3 more" past three names. */ +const listOf = (names: ReadonlyArray): string => { + if (names.length <= 1) return Arr.join(names, "") + const shown = names.length > 3 ? Arr.take(names, 2) : Arr.take(names, names.length - 1) + const rest = names.length > 3 ? `${names.length - 2} more` : (names[names.length - 1] ?? "") + return `${Arr.join(shown, ", ")} and ${rest}` +} + +const builtinScope = (form: RuleForm): ReadonlyArray => [ + form.serviceNames.length === 0 ? "on all services" : `on ${listOf(form.serviceNames)}`, + ...(form.serviceNames.length === 0 && form.excludeServiceNames.length > 0 + ? [`except ${listOf(form.excludeServiceNames)}`] + : []), + ...(form.environments.length > 0 ? [`in ${listOf(form.environments)}`] : []), +] + +/** The "When" card: the signal and what it watches. "Error rate on checkout-api in production". */ +export const watchSummary = (form: RuleForm, isDefault = false): string => { + if (form.signalType === "anomaly") + return isDefault + ? "Every anomaly no narrower rule handles" + : `Anomalies in ${anomalyScopeSummary(form)}` + const groupBy = groupBySummary(form) + const perGroup = groupBy === null ? [] : [`per ${groupBy}`] + if (form.signalType === "raw_query") + return `SQL query, ${Str.toLowerCase(RAW_QUERY_REDUCER_LABELS[form.rawQueryReducer])} of value` + if (form.signalType === "builder_query") { + const draft = form.queryBuilderDraft + const source = + draft.dataSource === "metrics" && draft.metricName.length > 0 + ? draft.metricName + : draft.dataSource + const where = Str.trim(draft.whereClause) + return Arr.join( + [ + `${capitalize(conditionSubject(form))} of ${source}`, + ...(where.length > 0 ? [`where ${where}`] : []), + ...perGroup, + ], + ", ", + ) + } + return Arr.join([signalLabels[form.signalType], ...builtinScope(form), ...perGroup], " ") +} + +const checksText = (form: RuleForm): string => { + const checks = Str.trim(form.consecutiveBreachesRequired) || "?" + const outOf = Str.trim(form.breachesOutOf) + const windowText = `over ${Str.trim(form.windowMinutes) || "?"} min` + if (outOf.length > 0) return `${windowText}, ${checks} of the last ${outOf} checks` + return checks === "1" ? windowText : `${windowText}, ${checks} checks in a row` +} + +/** The "Alert when" card: "Above 5%, critical above 10%, over 5 min, 3 checks in a row". */ +export const conditionSummary = (form: RuleForm): string => { + const words = Str.replace(/^is /, "")(comparatorWords(form.comparator)) + const threshold = isRangeComparator(form.comparator) + ? `${thresholdValue(form, form.threshold)} and ${thresholdValue(form, form.thresholdUpper)}` + : thresholdValue(form, form.threshold) + const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null + const healthy = Str.trim(form.consecutiveHealthyRequired) + return Arr.join( + [ + `${capitalize(words)} ${threshold}${escalation === null && form.severity === "critical" ? " (critical)" : ""}`, + ...(escalation === null + ? [] + : [`critical ${words} ${thresholdValue(form, escalation.threshold)}`]), + checksText(form), + ...(healthy.length > 0 && healthy !== "1" ? [`resolves after ${healthy} healthy checks`] : []), + ...(form.alertOnNoData ? ["no data counts"] : []), + ], + ", ", + ) +} + +/** How the summary names a destination and a chain; `null` for one the lists no longer hold. */ +export type NotifyNames = Readonly<{ + destination: (id: AlertDestinationId) => string | null + chain: (id: EscalationChainId) => string | null +}> + +type Route = Readonly<{ + destinationIds: ReadonlyArray + escalationChainId: EscalationChainId | null +}> + +const routeText = (route: Route, names: NotifyNames): string | null => { + const destinations = Arr.map( + route.destinationIds, + (id) => names.destination(id) ?? "a removed destination", + ) + const chain = + route.escalationChainId === null + ? null + : (names.chain(route.escalationChainId) ?? "an escalation chain") + if (destinations.length === 0) return chain === null ? null : `escalate to ${chain}` + return chain === null ? listOf(destinations) : `${listOf(destinations)}, then escalate to ${chain}` +} + +/** The "Notify" card: "#oncall, critical escalates to Checkout on-call". */ +export const notifySummary = (form: RuleForm, names: NotifyNames): string => { + const primary = routeText(form, names) + const tiered = form.signalType === "anomaly" || comparatorSupportsTiers(form.comparator) + // Critical pages on top of warning, so a destination already on warning is not named twice. + const critical = + tiered && form.escalation !== null + ? routeText( + { + ...form.escalation, + destinationIds: Arr.filter( + form.escalation.destinationIds, + (id) => !form.destinationIds.includes(id), + ), + }, + names, + ) + : null + if (primary === null && critical === null) return "No one is notified" + const criticalText = + critical === null + ? null + : Str.startsWith("escalate to ")(critical) + ? `critical ${Str.replace(/^escalate/, "escalates")(critical)}` + : `critical also ${critical}` + // Only the leading verb is capitalised: a destination keeps its own spelling ("#oncall", an email). + const lead = (text: string) => + Str.startsWith("escalate")(text) || Str.startsWith("critical")(text) ? capitalize(text) : text + if (primary === null) return lead(criticalText ?? "") + return Arr.join( + Arr.filter([lead(primary), criticalText], (part): part is string => part !== null), + ", ", + ) +} + +/** The "Name" card: the name, then its tags. */ +export const nameSummary = (form: RuleForm): string => { + const name = Str.trim(form.name) + const tags = form.tags.length === 0 ? [] : [listOf(form.tags)] + return Arr.join([name.length > 0 ? name : "Unnamed rule", ...tags], " · ") +} diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index 206340027a..d3e9ffaaa9 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -214,6 +214,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re editForm({ ...model, tagDraft: "" }, context, (form) => F.commitTag(form, model.tagDraft)), ClickedRemoveTag: ({ tag }) => editForm(model, context, (form) => F.removeTag(form, tag)), ClickedAddNotes: () => ({ model: { ...model, isNotesOpen: true } }), + ClickedExpandCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.union(model.expandedCards, [card]) }, + }), + ClickedCollapseCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.filter(model.expandedCards, (open) => open !== card) }, + }), ToggledSqlCollapsed: () => ({ model: { ...model, isSqlCollapsed: !model.isSqlCollapsed } }), ClickedSqlToken: ({ token }) => model.form === null diff --git a/apps/web/src/page/alerts/editor/view-step.ts b/apps/web/src/page/alerts/editor/view-step.ts index e93647e872..723913f84e 100644 --- a/apps/web/src/page/alerts/editor/view-step.ts +++ b/apps/web/src/page/alerts/editor/view-step.ts @@ -1,61 +1,4 @@ -import { cn } from "@maple/ui/lib/utils" -import type { Html, HtmlBuilder } from "foldkit/html" -import { heading } from "@maple/views/ui/heading" -import { typography } from "@maple/views/ui/typography" -import { hstack } from "@maple/views/ui/stack" - -// `RuleStep` and the editor's shared width. +// The editor's shared width. /** `RULE_FORM_MAX_WIDTH`: the form body and the sticky action bar line up on it. */ export const RULE_FORM_MAX_WIDTH = "max-w-[1400px]" - -export type RuleStepStatus = "done" | "missing" | "optional" - -export type RuleStepOptions = Readonly<{ - number: number - title: string - hint?: string - status: RuleStepStatus - /** A rule above the step; off for the first step and for steps sharing a row. */ - hasDivider?: boolean -}> - -/** One numbered step of the rule editor; says "Required" until it is filled in. */ -export const ruleStep = ( - h: HtmlBuilder, - options: RuleStepOptions, - children: ReadonlyArray, -): Html => { - const headingId = `rule-step-${options.number}` - return h.section( - [ - h.AriaLabelledBy(headingId), - h.Class(cn("space-y-4", (options.hasDivider ?? true) && "border-t pt-8")), - ], - [ - hstack(h, { gap: "4", align: "start", justify: "between" }, [ - h.div( - [h.Class("space-y-1")], - [ - hstack(h, { gap: "3", align: "baseline" }, [ - heading(h, { level: 2, variant: "title", id: headingId }, [ - h.span( - [h.Class("mr-2.5 font-mono text-sm font-normal text-muted-foreground")], - [String(options.number)], - ), - options.title, - ]), - ...(options.status === "missing" - ? [typography(h, { size: "xs", tone: "warning" }, ["Required"])] - : []), - ]), - ...(options.hint === undefined - ? [] - : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.hint])]), - ], - ), - ]), - ...children, - ], - ) -} diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index ebd1bae99e..b3f6b0b42c 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -1,4 +1,3 @@ -import { deriveTierIssues, isFilledNumber } from "@maple/web-data/alerts/form-utils" import { cn } from "@maple/ui/lib/utils" import { Submodel } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" @@ -16,7 +15,18 @@ import { RULE_CHART_TOKENS } from "../shared/rule-chart" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model, Notice } from "./model" -import { isAnomalyUnrouted, isProtectedDefault } from "./derive" +import { destinationTarget, isProtectedDefault } from "./derive" +import { destinationsOf } from "./derive-rows" +import type { RuleCard } from "./form" +import { + cardIssues, + conditionSummary, + nameSummary, + notifySummary, + type NotifyNames, + watchSummary, +} from "./summary" +import { sentenceCard, sentenceChain } from "../../kit/sentence-card" import { actionBar } from "./view-action-bar" import { conditionFields } from "./view-condition" import { detailsSection } from "./view-details" @@ -24,9 +34,9 @@ import { liveChartHero } from "./view-hero" import { noiseSection } from "./view-noise" import { notificationsSection } from "./view-notifications" import { querySection } from "./view-query" -import { RULE_FORM_MAX_WIDTH, ruleStep } from "./view-step" +import { RULE_FORM_MAX_WIDTH } from "./view-step" import { templatesOverlay } from "./view-templates" -import { Array as Arr, String as Str } from "effect" +import { Array as Arr, Option } from "effect" // `AlertCreatePageContent` and `AlertCreateFormSurface`: the rule editor page and its form. @@ -123,85 +133,101 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ notificationBody: "", })) -/** Who gets paged is missing: no destination or escalation chain on a tier that needs one. */ -const isNotifyMissingOf = (form: RuleForm, isDefault: boolean): boolean => - form.signalType === "anomaly" - ? !isDefault && isAnomalyUnrouted(form) - : (form.destinationIds.length === 0 && form.escalationChainId === null) || - deriveTierIssues(form).includes("Critical destinations") +/** How the Notify summary names destinations and chains, from the lists the editor holds. */ +const notifyNamesOf = (model: Model): NotifyNames => { + const destinations = destinationsOf(model) + return { + destination: (id) => + Option.match( + Arr.findFirst(destinations, (destination) => destination.id === id), + { onNone: () => null, onSome: (destination) => destinationTarget(destination).primary }, + ), + chain: (id) => + Option.match( + Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === id), + { onNone: () => null, onSome: (chain) => chain.name }, + ), + } +} + +type CardSpec = Readonly<{ + card: RuleCard + lead: string + hint?: string | undefined + summary: string + body: () => Html +}> + +const ruleCard = (h: H, model: Model, form: RuleForm, isDefault: boolean, spec: CardSpec): Html => { + const issues = cardIssues(form, spec.card, isDefault) + return sentenceCard( + h, + { + id: spec.card, + lead: spec.lead, + hint: spec.hint, + status: issues.length > 0 ? "missing" : "done", + issues, + summary: spec.summary, + isExpanded: model.expandedCards.includes(spec.card), + onExpand: Message.ClickedExpandCard({ card: spec.card }), + onCollapse: Message.ClickedCollapseCard({ card: spec.card }), + }, + // Closed cards skip their section: its lazy slot renders again once the card opens. + model.expandedCards.includes(spec.card) || issues.length > 0 ? [spec.body()] : [], + ) +} const surface = (h: H, model: Model, form: RuleForm, timeZone: string): ReadonlyArray => { const evaluated = evaluatedFormOf(form) - // An anomaly rule has no condition to tune: the detector decides, so "When to alert" goes. + // An anomaly rule has no condition to tune: the detector decides, so "Alert when" and the chart go. const isAnomaly = form.signalType === "anomaly" - const stepAfterQuery = isAnomaly ? 2 : 3 + const isDefault = isProtectedDefault(model) + const condition: CardSpec = { + card: "condition", + lead: "Alert when", + hint: "The dashed line on the chart is your threshold.", + summary: conditionSummary(form), + body: () => + h.div( + [h.Class("space-y-4")], + [conditionFields(h, model, evaluated), noiseSection(h, model, evaluated)], + ), + } + const cards: ReadonlyArray = [ + { + card: "watch", + lead: "When", + hint: isAnomaly + ? "Which anomalies this rule pages on." + : "A built-in signal, or a query built like a dashboard widget.", + summary: watchSummary(form, isDefault), + body: () => querySection(h, model, evaluated), + }, + ...(isAnomaly ? [] : [condition]), + { + card: "notify", + lead: "Notify", + summary: notifySummary(form, notifyNamesOf(model)), + body: () => notificationsSection(h, model, form), + }, + { + card: "name", + lead: "Name", + summary: nameSummary(form), + body: () => detailsSection(h, model, form), + }, + ] return [ h.div( - [h.Class(cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH))], + [h.Class(cn("mx-auto w-full space-y-6", RULE_FORM_MAX_WIDTH))], [ ...noticeBanner(h, model.notices), - ruleStep( + ...(isAnomaly ? [] : [liveChartHero(h, model, form, evaluated, timeZone)]), + sentenceChain( h, - { - number: 1, - hasDivider: false, - title: "What to watch", - hint: isAnomaly - ? "Pick which anomalies this rule pages on." - : "Pick a built-in signal, or build the query like a dashboard widget.", - status: "done", - }, - [querySection(h, model, evaluated)], - ), - ...(isAnomaly - ? [] - : [ - ruleStep( - h, - { - number: 2, - title: "When to alert", - hint: "The dashed line is your threshold. Shaded bands show when the rule would have fired.", - status: isFilledNumber(form.threshold) ? "done" : "missing", - }, - [ - h.div( - [h.Class("space-y-4")], - [ - liveChartHero(h, model, form, evaluated, timeZone), - conditionFields(h, model, evaluated), - noiseSection(h, model, evaluated), - ], - ), - ], - ), - ]), - h.div( - [h.Class("grid grid-cols-1 gap-6 border-t pt-8 lg:grid-cols-2 lg:items-start")], - [ - ruleStep( - h, - { - number: stepAfterQuery, - hasDivider: false, - title: "Who gets notified", - status: isNotifyMissingOf(form, isProtectedDefault(model)) - ? "missing" - : "done", - }, - [notificationsSection(h, model, form)], - ), - ruleStep( - h, - { - number: stepAfterQuery + 1, - hasDivider: false, - title: "Name it", - status: Str.trim(form.name).length === 0 ? "missing" : "done", - }, - [detailsSection(h, model, form)], - ), - ], + { label: "Rule" }, + Arr.map(cards, (spec) => ruleCard(h, model, form, isDefault, spec)), ), ], ), diff --git a/apps/web/src/page/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts new file mode 100644 index 0000000000..031c19068b --- /dev/null +++ b/apps/web/src/page/kit/sentence-card.ts @@ -0,0 +1,182 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { card } from "@maple/views/ui/card" +import { separator } from "@maple/views/ui/display" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { pressable } from "@maple/views/ui/pressable" +import { hstack, vstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { typography } from "@maple/views/ui/typography" +import { Array as Arr } from "effect" + +// Sentence cards: a form that reads top to bottom as one sentence ("When ... Alert when ... +// Notify ... Name"), each card a clause. No numbers: a status dot on a rail joins them. + +export type SentenceCardStatus = "done" | "missing" | "optional" + +export type SentenceCardOptions = Readonly<{ + /** Unique on the page: the heading's id. */ + id: string + /** The clause's lead word, the card's label: "When", "Alert when", "Notify". */ + lead: string + /** A line under the lead while the card is open. */ + hint?: string | undefined + status: SentenceCardStatus + /** What is missing, said while `status` is `missing`: "Threshold", "Rule name". */ + issues?: ReadonlyArray + /** The clause in plain words, shown while the card is closed. */ + summary: string + isExpanded: boolean + onExpand: M + /** Closes an open card back to its summary; a missing card stays open, so it has none. */ + onCollapse?: M +}> + +const DOT_TONE = { done: "done", missing: "warn", optional: "neutral" } as const + +const LEAD_CLASS = "w-24 shrink-0" + +/** The rail beside a card: its status dot, then the line down to the next card (none after the last). */ +const rail = (h: HtmlBuilder, status: SentenceCardStatus): Html => + vstack(h, { align: "center", className: "h-full" }, [ + h.span( + [h.Class("mt-[calc(--spacing(5)+1px)] grid size-4 shrink-0 place-items-center")], + [statusDot(h, { tone: DOT_TONE[status], size: "lg" })], + ), + // In a row the separator stretches to the row's height: the rest of the step, gap included. + hstack(h, { justify: "center", grow: true, className: "mt-1.5 group-last/sentence:hidden" }, [ + separator(h, "vertical", undefined), + ]), + ]) + +const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html => + card( + h, + [ + pressable( + h, + { + onClick: options.onExpand, + className: + "w-full rounded-md-inset px-4 py-3 text-left hover:bg-surface-sunken active:bg-surface-sunken", + attributes: [ + h.AriaExpanded(false), + h.AriaLabel(`${options.lead}: ${options.summary}. Edit`), + ], + }, + [ + hstack(h, { gap: "3", align: "center", className: "min-h-8" }, [ + typography(h, { size: "sm", weight: "medium", className: LEAD_CLASS }, [ + options.lead, + ]), + typography(h, { size: "sm", truncate: true, className: "min-w-0 grow" }, [ + options.summary, + ]), + hstack(h, { gap: "1", align: "center", as: "span", className: "shrink-0" }, [ + icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), + typography(h, { size: "xs", tone: "muted" }, ["Edit"]), + ]), + ]), + ], + ), + ], + "gap-0 p-0", + ) + +const openCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + children: ReadonlyArray, +): Html => { + const headingId = `sentence-card-${options.id}` + const issues = options.issues ?? [] + // No frame of its own: the sections inside are cards. The header lines up with a closed card's row. + return h.section( + [h.AriaLabelledBy(headingId), h.Class("flex min-w-0 flex-col gap-3 pt-[calc(--spacing(3)+1px)]")], + [ + hstack( + h, + { + gap: "3", + align: "start", + justify: "between", + className: "min-h-8 pl-[calc(--spacing(4)+1px)]", + }, + [ + hstack(h, { gap: "3", align: "baseline", wrap: true, className: "min-w-0 pt-1.5" }, [ + heading(h, { level: 2, variant: "section", id: headingId, className: LEAD_CLASS }, [ + options.lead, + ]), + ...(options.hint === undefined + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", as: "p", className: "min-w-0" }, + [options.hint], + ), + ]), + ...(options.status === "missing" + ? [ + typography(h, { size: "xs", tone: "warning", as: "p" }, [ + issues.length === 0 + ? "Required" + : `Missing: ${Arr.join(issues, ", ")}`, + ]), + ] + : []), + ]), + ...(options.onCollapse === undefined || options.status === "missing" + ? [] + : [ + button( + h, + { + variant: "ghost", + size: "xs", + attributes: [h.AriaExpanded(true), h.OnClick(options.onCollapse)], + }, + ["Done"], + ), + ]), + ], + ), + ...children, + ], + ) +} + +/** + * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead word and + * controls; closed (complete and not being edited), a one-line summary that opens it on click. + */ +export const sentenceCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + children: ReadonlyArray, +): Html => { + const isOpen = options.isExpanded || options.status === "missing" + return h.li( + [ + h.Key(options.id), + h.Class("group/sentence grid grid-cols-[--spacing(4)_minmax(0,1fr)] gap-x-3"), + h.DataAttribute("state", isOpen ? "open" : "closed"), + ], + [ + rail(h, options.status), + h.div( + [h.Class("min-w-0 pb-4 group-last/sentence:pb-0")], + [isOpen ? openCard(h, options, children) : closedCard(h, options)], + ), + ], + ) +} + +/** `SentenceChain`: the sentence cards top to bottom, their rails joined into one line. */ +export const sentenceChain = ( + h: HtmlBuilder, + options: Readonly<{ label: string; className?: string }>, + cards: ReadonlyArray, +): Html => h.ol([h.AriaLabel(options.label), h.Class(cn("flex flex-col", options.className))], cards) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index aaeb175bff..87205a600a 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -230,6 +230,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/resource-not-found.ts` `resourceNotFound` - `apps/web/src/page/kit/route-window.ts` `isWindowChange`, `snapModeOf`, `routeWindowStep`: `useEffectiveTimeRange` + `useRefreshableAtomValue` for a page's `routeChanged`: the window is re-resolved only when its own fields moved, and only Reload revalidates unchanged... - `apps/web/src/page/kit/section-heading.ts` `sectionHeading`: `SectionHeading`: the `title` row and the `eyebrow` overline. +- `apps/web/src/page/kit/sentence-card.ts` `sentenceCard`, `sentenceChain`: Sentence cards: a form that reads top to bottom as one sentence ("When ... - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. From 2de1f25e4fa58a208a8a19ce48f1e4baf03041bd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:11:02 +0100 Subject: [PATCH 05/27] fix(query-builder): field picker's accessible name includes the picked field --- .../src/page/kit/query-builder/condition-builder-view.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts index 0c9b61487e..16402480b6 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -114,7 +114,11 @@ const fieldPicker = ( Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), viewInputs: { items: Arr.map(items, (item) => item.value), - ariaLabel: `Field for condition ${index + 1}`, + // The label replaces the trigger's text for screen readers, so it carries the pick. + ariaLabel: + row.key === "" + ? `Field for condition ${index + 1}` + : `Field for condition ${index + 1}: ${row.key}`, searchPlaceholder: "Search fields…", emptyText: "No fields match", trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), From b6ae4b0a4ec66f8c848b2dddeac4cadb92a589b2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:20:14 +0100 Subject: [PATCH 06/27] fix(alerts): sentence card review fixes Card issues come from saveIssues with the query timing, so a slow SQL check opens Alert when; destination and chain names are loading, found or removed; a critical-only route reads "Critical: ..."; the Alert when summary shares the action bar's checks phrase and says recovery and no data. Cards keep the lead as the heading with the summary as a disclosure (cardButton), move focus on open and close, render their body only when open, and wrap summaries on phones. --- apps/web/src/page/alerts/editor/command.ts | 15 +++ apps/web/src/page/alerts/editor/derive.ts | 22 ++-- .../editor/editor-anomaly.scene.test.ts | 4 + .../alerts/editor/editor-cards.scene.test.ts | 63 ++++++++- .../page/alerts/editor/editor.scene.test.ts | 4 +- apps/web/src/page/alerts/editor/message.ts | 1 + apps/web/src/page/alerts/editor/steps.ts | 26 ++-- .../src/page/alerts/editor/summary.test.ts | 77 ++++++++--- apps/web/src/page/alerts/editor/summary.ts | 86 ++++++++----- apps/web/src/page/alerts/editor/update.ts | 13 +- apps/web/src/page/alerts/editor/view.ts | 47 +++++-- apps/web/src/page/kit/sentence-card.ts | 121 ++++++++++-------- packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/card-button.ts | 6 +- 14 files changed, 351 insertions(+), 136 deletions(-) diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index c5bc8415cb..c9233f85b6 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -163,6 +163,21 @@ export const FocusSqlCaret = Command.define("FocusAlertSqlCaret", { }), }) +/** After the card opens or closes: its heading or its summary button, once the next frame has it. */ +export const FocusRuleCard = Command.define("FocusAlertRuleCard", { + args: { id: Schema.String }, + messages: [Message.CompletedFocusCard], + execute: ({ id }) => + Effect.callback((resume) => { + const handle = requestAnimationFrame(() => { + const element = document.getElementById(id) + if (element instanceof HTMLElement) element.focus() + resume(Effect.succeed(Message.CompletedFocusCard())) + }) + return Effect.sync(() => cancelAnimationFrame(handle)) + }), +}) + /** `onRetry={() => window.location.reload()}`: a live query past its recovery has no other retry. */ export const ReloadPage = Command.define("ReloadAlertEditorPage", { messages: [Message.ReloadedPage], diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index e693fe9657..a6e53edc29 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -55,6 +55,18 @@ export const thresholdValue = (form: RuleForm, raw: string): string => { return `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` } +/** How many checks fire the rule: "in 3 checks in a row (5 min each)", "over 5 min". */ +export const checksPhrase = (form: RuleForm): string => { + const checks = Str.trim(form.consecutiveBreachesRequired) || "?" + const outOf = Str.trim(form.breachesOutOf) + const windowText = `${Str.trim(form.windowMinutes) || "?"} min` + return outOf.length > 0 + ? `in ${checks} of the last ${outOf} checks (${windowText} each)` + : checks === "1" + ? `over ${windowText}` + : `in ${checks} checks in a row (${windowText} each)` +} + /** The rule in one plain sentence, shown in the action bar. */ export const describeRuleCondition = (form: RuleForm, isDefault = false): string => { if (form.signalType === "anomaly") @@ -68,15 +80,7 @@ export const describeRuleCondition = (form: RuleForm, isDefault = false): string ? `${value(form.threshold)} and ${value(form.thresholdUpper)}` : value(form.threshold) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null - const checks = Str.trim(form.consecutiveBreachesRequired) || "?" - const outOf = Str.trim(form.breachesOutOf) - const windowText = `${Str.trim(form.windowMinutes) || "?"} min` - const when = - outOf.length > 0 - ? `in ${checks} of the last ${outOf} checks (${windowText} each)` - : checks === "1" - ? `over ${windowText}` - : `in ${checks} checks in a row (${windowText} each)` + const when = checksPhrase(form) const severity = escalation === null && form.severity === "critical" ? "Critical" : "Warning" const critical = escalation === null diff --git a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts index 10ace1eb2f..05d0353672 100644 --- a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -3,6 +3,7 @@ import { AlertDestinationId } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, + Command, expect as expectView, given, Mount, @@ -20,6 +21,7 @@ import { menuIdOf } from "./dest-steps" import type { PageContext } from "../../contract" import { defaultAnomalyRuleRow, scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" import { DESTINATION_ID, destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusRuleCard } from "./command" import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" @@ -149,6 +151,7 @@ describe("alert rule editor: anomaly rules", () => { given(scoped), themeResolved(), click(role("button", { name: /^When: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text("No changes")).toExist(), expectView(text(DEFAULT_NOTE)).not.toExist(), // Each chip's remove button names its chip. @@ -164,6 +167,7 @@ describe("alert rule editor: anomaly rules", () => { given(fallback), themeResolved(), click(role("button", { name: /^When: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text(DEFAULT_NOTE)).toExist(), expectView(role("button", { name: "Error rate" })).toBeDisabled(), expectView(role("button", { name: "Built-in" })).toBeDisabled(), diff --git a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts index 2e2966f07f..f53545c25d 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -1,7 +1,9 @@ import { OrgId } from "@maple/domain" -import { Schema, Array as Arr } from "effect" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Result, Schema, Array as Arr } from "effect" import { click, + Command, expect as expectView, given, Mount, @@ -11,11 +13,12 @@ import { type, withViewInputs, } from "foldkit/scene" -import { describe, test } from "vitest" +import { describe, expect, test } from "vitest" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import type { PageContext } from "../../contract" import { scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusRuleCard } from "./command" import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" @@ -38,6 +41,33 @@ const opened = (search: Model["search"], rules = [ruleRow(2)]) => Message.UpdatedRules({ orgId: ORG, rows: rules }), ]) +const SQL = + "SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket, count() AS value FROM traces WHERE $__orgFilter AND $__timeFilter(Timestamp) GROUP BY bucket ORDER BY bucket" + +/** A preview whose one check takes 7.8s: too slow for 1-minute checks. */ +const slowPreview = () => + Schema.decodeSync(AlertRulePreviewResponse)({ + bucketSeconds: 300, + windowMinutes: 5, + threshold: 800, + thresholdUpper: null, + thresholds: [ + { severity: "critical", value: 800, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [], + wouldFire: [], + queryTiming: { + elapsedMs: 7_800, + failure: null, + probeLimitMs: 25_000, + intervalMinutes: 1, + limitMs: 5_000, + fitsIntervalMinutes: [5, 10, 15, 30, 60], + }, + }) + const summary = (lead: string, rest: string) => role("button", { name: `${lead}: ${rest}. Edit` }) const queryMode = role("button", { name: "Built-in" }) @@ -51,16 +81,21 @@ describe("alert rule editor, sentence cards", () => { expectView( summary( "Alert when", - "Above 800 ms (critical), over 5 min, 2 checks in a row, resolves after 2 healthy checks", + "Above 800 ms (critical), in 2 checks in a row (5 min each), resolves after 2 healthy checks", ), ).toExist(), expectView(summary("Notify", "https://hooks.acme.test/maple")).toExist(), expectView(summary("Name", "Rule 2")).toExist(), expectView(queryMode).not.toExist(), click(summary("When", "P95 latency on payment-svc in production")), + // Opening moves focus to the card's heading; closing returns it to the summary. + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(queryMode).toExist(), expectView(role("heading", { name: "When" })).toExist(), click(role("button", { name: "Done" })), + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch-summary" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(queryMode).not.toExist(), expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), ) @@ -103,4 +138,26 @@ describe("alert rule editor, sentence cards", () => { expectView(text("Alert when")).not.toExist(), ) }) + + test("a SQL rule whose timing lands too slow opens Alert when with the blocker", () => { + const sql = opened({ ruleId: ruleId(2) }, [ + ruleRow(2, { signal_type: "raw_query", raw_query_sql: SQL }), + ]) + expect(sql.expandedCards).toEqual([]) + const timed = pageUpdate( + sql, + Message.CompletedFetchPreview({ + key: sql.preview.key ?? "", + isSaved: false, + result: Result.succeed(slowPreview()), + }), + ).model + expect(timed.expandedCards).toContain("condition") + scene( + { update: pageUpdate, view }, + given(timed), + themeResolved(), + expectView(text("Missing: Query too slow for this check interval")).toExist(), + ) + }) }) diff --git a/apps/web/src/page/alerts/editor/editor.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index 4fc9c60cb7..5d509883f5 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -31,7 +31,7 @@ import * as RadioGroup from "@maple/views/ui/radio-group" import * as Tooltip from "@maple/views/ui/tooltip" import type { PageContext } from "../../contract" import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" -import { FetchEditorPreview, ReloadPage } from "./command" +import { FetchEditorPreview, FocusRuleCard, ReloadPage } from "./command" import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" @@ -154,6 +154,7 @@ describe("alert rule editor", () => { given(withWindow(editing, "60")), themeResolved(), click(role("button", { name: /^Alert when: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text(`${saved} min`)).toExist(), expectView(hint("5 min")).toExist(), ) @@ -384,6 +385,7 @@ describe("alert rule editor", () => { given(slowed), themeResolved(), click(role("button", { name: /^Alert when: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(notice).toExist(), click(role("button", { name: "Keep 5 min" })), expectView(notice).not.toExist(), diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index 7b3a8eb3b1..2dab1c03a1 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -148,6 +148,7 @@ export const Message = defineMessageUnion({ ClickedExpandCard: { card: RuleCard }, /** An open card's Done: back to its summary. */ ClickedCollapseCard: { card: RuleCard }, + CompletedFocusCard: {}, ToggledSqlCollapsed: {}, ClickedSqlToken: { token: Schema.String }, CompletedInsertSqlToken: { sql: Schema.String, caret: Schema.Number }, diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index eef5932eaa..0215f2ea31 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -7,6 +7,7 @@ import { Message } from "./message" import type { Model } from "./model" import { applyDefaultDestination, planMetrics, planPreviews } from "./plan" import { isProtectedDefault } from "./derive" +import { draftTimingOf } from "./derive-rows" import { missingCards, RULE_CARDS } from "./summary" import { Option, String as Str, Array as Arr } from "effect" @@ -49,7 +50,7 @@ export const initialize = (model: Model, context: PageContext): Return => { const withCards: Model = { ...ready, expandedCards: - draft.editingRule === null ? RULE_CARDS : missingCards(form, isProtectedDefault(ready)), + draft.editingRule === null ? RULE_CARDS : missingCards(form, null, isProtectedDefault(ready)), } const loaded = planned(applyDefaultDestination(withCards), context) if (!draft.isShowingTemplates) return loaded @@ -58,13 +59,22 @@ export const initialize = (model: Model, context: PageContext): Return => { } /** An edit to the draft; the preview follows it (`useDeferredValue` over the form). */ -export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => { - if (model.form === null) return { model } - const form = edit(model.form) - // A card that goes missing stays open once filled in again, so it never closes under the cursor. - const expandedCards = Arr.union(model.expandedCards, missingCards(form, isProtectedDefault(model))) - return planned({ ...model, form, expandedCards }, context) -} +/** Opens every card with a save blocker; it stays open once fixed, so it never closes under the cursor. */ +export const withMissingCardsOpen = (model: Model): Model => + model.form === null + ? model + : { + ...model, + expandedCards: Arr.union( + model.expandedCards, + missingCards(model.form, draftTimingOf(model, model.form), isProtectedDefault(model)), + ), + } + +export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => + model.form === null + ? { model } + : planned(withMissingCardsOpen({ ...model, form: edit(model.form) }), context) /** A list answered: the draft may now be known, and the default destination picked. */ export const followRows = (model: Model, context: PageContext): Return => { diff --git a/apps/web/src/page/alerts/editor/summary.test.ts b/apps/web/src/page/alerts/editor/summary.test.ts index c24f5e3ce1..fb1f6d1af5 100644 --- a/apps/web/src/page/alerts/editor/summary.test.ts +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -1,4 +1,4 @@ -import { AlertDestinationId, EscalationChainId } from "@maple/domain/http" +import { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" import { anomalyRuleForm, defaultRuleForm } from "@maple/web-data/alerts/form-utils" import { Schema } from "effect" import { describe, expect, it } from "vitest" @@ -10,6 +10,7 @@ import { missingCards, nameSummary, notifySummary, + type NameLookup, type NotifyNames, watchSummary, } from "./summary" @@ -28,10 +29,24 @@ const form = (fields: Partial = {}): RuleForm => ({ ...fields, }) +const found = (name: string): NameLookup => ({ _tag: "Found", name }) +const GONE_LOOKUP: NameLookup = { _tag: "Gone" } const NAMES: NotifyNames = { - destination: (id) => (id === ONCALL ? "#oncall" : id === PAGER ? "PagerDuty Checkout" : null), - chain: (id) => (id === CHAIN ? "Checkout primary" : null), + destination: (id) => + id === ONCALL ? found("#oncall") : id === PAGER ? found("PagerDuty Checkout") : GONE_LOOKUP, + chain: (id) => (id === CHAIN ? found("Checkout primary") : GONE_LOOKUP), } +const LOADING: NotifyNames = { destination: () => ({ _tag: "Loading" }), chain: () => ({ _tag: "Loading" }) } + +/** One check of the SQL takes 7.8s: too slow for 1-minute checks, fits 5-minute ones. */ +const SLOW = Schema.decodeSync(AlertQueryTiming)({ + elapsedMs: 7_800, + failure: null, + probeLimitMs: 25_000, + intervalMinutes: 1, + limitMs: 5_000, + fitsIntervalMinutes: [5, 10, 15, 30, 60], +}) const criticalTier = (fields: Partial> = {}) => ({ threshold: "10", @@ -86,7 +101,7 @@ describe("watchSummary", () => { describe("conditionSummary", () => { it("says the threshold with its unit and how many checks fire it", () => { expect(conditionSummary(form({ consecutiveBreachesRequired: "3" }))).toBe( - "Above 5%, over 5 min, 3 checks in a row, resolves after 2 healthy checks", + "Above 5%, in 3 checks in a row (5 min each), resolves after 2 healthy checks", ) }) @@ -96,7 +111,7 @@ describe("conditionSummary", () => { form({ comparator: "lt", threshold: "20", escalation: criticalTier({ threshold: "10" }) }), ), ).toBe( - "Below 20%, critical below 10%, over 5 min, 2 checks in a row, resolves after 2 healthy checks", + "Below 20%, critical below 10%, in 2 checks in a row (5 min each), resolves after 2 healthy checks", ) }) @@ -122,7 +137,13 @@ describe("conditionSummary", () => { form({ consecutiveBreachesRequired: "3", breachesOutOf: "5", alertOnNoData: true }), ), ).toBe( - "Above 5%, over 5 min, 3 of the last 5 checks, resolves after 2 healthy checks, no data counts", + "Above 5%, in 3 of the last 5 checks (5 min each), resolves after 2 healthy checks, alerts on no data", + ) + }) + + it("says the recovery value when one is set", () => { + expect(conditionSummary(form({ recoveryValue: "4", consecutiveHealthyRequired: "1" }))).toBe( + "Above 5%, in 2 checks in a row (5 min each), recovers below 4%", ) }) @@ -156,7 +177,13 @@ describe("notifySummary", () => { }) it("names a destination the org no longer has instead of dropping it", () => { - expect(notifySummary(form({ destinationIds: [GONE] }), NAMES)).toBe("a removed destination") + expect(notifySummary(form({ destinationIds: [GONE] }), NAMES)).toBe("A removed destination") + }) + + it("never calls a destination removed while the lists are still loading", () => { + expect(notifySummary(form({ destinationIds: [GONE], escalationChainId: CHAIN }), LOADING)).toBe( + "A destination, then escalate to a chain", + ) }) it("names a destination on both tiers once", () => { @@ -165,6 +192,17 @@ describe("notifySummary", () => { ) }) + it("reads a rule routed only on its critical tier as the critical route", () => { + const anomaly = fromFormState( + anomalyRuleForm(form({ serviceNames: ["checkout"], destinationIds: [] })), + ) + const criticalOnly = { + ...anomaly, + escalation: { ...criticalTier(), ...anomaly.escalation, destinationIds: [PAGER] }, + } + expect(notifySummary(criticalOnly, NAMES)).toBe("Critical: PagerDuty Checkout") + }) + it("reads both tiers of an anomaly rule", () => { const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) const routed = { @@ -186,20 +224,29 @@ describe("nameSummary", () => { describe("card issues", () => { it("puts each missing field on the card that edits it", () => { - expect(missingCards(form())).toEqual([]) - expect(missingCards(form({ name: "" }))).toEqual(["name"]) - expect(missingCards(form({ threshold: "" }))).toEqual(["condition"]) - expect(missingCards(form({ destinationIds: [] }))).toEqual(["notify"]) + expect(missingCards(form(), null)).toEqual([]) + expect(missingCards(form({ name: "" }), null)).toEqual(["name"]) + expect(missingCards(form({ threshold: "" }), null)).toEqual(["condition"]) + expect(missingCards(form({ destinationIds: [] }), null)).toEqual(["notify"]) expect( - cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch"), + cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch", null), ).toEqual(["$__orgFilter in SQL"]) - expect(cardIssues(form({ escalation: criticalTier() }), "notify")).toEqual(["Critical destinations"]) + expect(cardIssues(form({ escalation: criticalTier() }), "notify", null)).toEqual([ + "Critical destinations", + ]) + }) + + it("puts a query too slow for its interval on Alert when, as a save blocker", () => { + const sql = form({ signalType: "raw_query" }) + expect(missingCards(sql, null)).toEqual([]) + expect(cardIssues(sql, "condition", SLOW)).toEqual(["Query too slow for this check interval"]) + expect(missingCards(sql, SLOW)).toEqual(["condition"]) }) it("drops the condition card for an anomaly rule, whose scope is required unless it is the default", () => { const unscoped = fromFormState(anomalyRuleForm(form())) expect(cardsOf(unscoped)).toEqual(["watch", "notify", "name"]) - expect(missingCards(unscoped)).toEqual(["watch"]) - expect(missingCards(unscoped, true)).toEqual([]) + expect(missingCards(unscoped, null)).toEqual(["watch"]) + expect(missingCards(unscoped, null, true)).toEqual([]) }) }) diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts index 6bf5d49333..d6e3fe8c57 100644 --- a/apps/web/src/page/alerts/editor/summary.ts +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -1,5 +1,5 @@ import { Array as Arr, String as Str } from "effect" -import type { AlertDestinationId, EscalationChainId } from "@maple/domain/http" +import type { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers, isRangeComparator, @@ -8,7 +8,15 @@ import { } from "@maple/web-data/alerts/form-utils" import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" import type { RuleCard, RuleForm } from "./form" -import { comparatorWords, conditionSubject, groupBySummary, thresholdValue, validationIssues } from "./derive" +import { + checksPhrase, + comparatorWords, + conditionSubject, + groupBySummary, + recoveryLabel, + saveIssues, + thresholdValue, +} from "./derive" // The rule editor's sentence cards: which card each field lives on, and each card's one-line summary. @@ -30,13 +38,25 @@ const cardOfIssue = (issue: string): RuleCard => ? "watch" : "condition" -/** What blocks saving on one card, from the same list the action bar shows. */ -export const cardIssues = (form: RuleForm, card: RuleCard, isDefault = false): ReadonlyArray => - Arr.filter(validationIssues(form, isDefault), (issue) => cardOfIssue(issue) === card) +/** + * What blocks saving on one card: the action bar's own list (`saveIssues`), so a closed card never + * hides a blocker. A query too slow for its check interval lands on "Alert when", where the interval is. + */ +export const cardIssues = ( + form: RuleForm, + card: RuleCard, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => + Arr.filter(saveIssues(form, timing, isDefault), (issue) => cardOfIssue(issue) === card) /** The cards with something missing, which stay open until it is filled in. */ -export const missingCards = (form: RuleForm, isDefault = false): ReadonlyArray => - Arr.filter(cardsOf(form), (card) => cardIssues(form, card, isDefault).length > 0) +export const missingCards = ( + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => + Arr.filter(cardsOf(form), (card) => cardIssues(form, card, timing, isDefault).length > 0) const capitalize = (text: string): string => Str.toUpperCase(Str.slice(0, 1)(text)) + Str.slice(1)(text) @@ -85,15 +105,7 @@ export const watchSummary = (form: RuleForm, isDefault = false): string => { return Arr.join([signalLabels[form.signalType], ...builtinScope(form), ...perGroup], " ") } -const checksText = (form: RuleForm): string => { - const checks = Str.trim(form.consecutiveBreachesRequired) || "?" - const outOf = Str.trim(form.breachesOutOf) - const windowText = `over ${Str.trim(form.windowMinutes) || "?"} min` - if (outOf.length > 0) return `${windowText}, ${checks} of the last ${outOf} checks` - return checks === "1" ? windowText : `${windowText}, ${checks} checks in a row` -} - -/** The "Alert when" card: "Above 5%, critical above 10%, over 5 min, 3 checks in a row". */ +/** The "Alert when" card: "Above 5%, critical above 10%, in 3 checks in a row (5 min each)". */ export const conditionSummary = (form: RuleForm): string => { const words = Str.replace(/^is /, "")(comparatorWords(form.comparator)) const threshold = isRangeComparator(form.comparator) @@ -101,40 +113,48 @@ export const conditionSummary = (form: RuleForm): string => { : thresholdValue(form, form.threshold) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null const healthy = Str.trim(form.consecutiveHealthyRequired) + const recovery = comparatorSupportsTiers(form.comparator) ? Str.trim(form.recoveryValue) : "" return Arr.join( [ `${capitalize(words)} ${threshold}${escalation === null && form.severity === "critical" ? " (critical)" : ""}`, ...(escalation === null ? [] : [`critical ${words} ${thresholdValue(form, escalation.threshold)}`]), - checksText(form), + checksPhrase(form), + ...(recovery.length > 0 + ? [`${Str.toLowerCase(recoveryLabel(form.comparator))} ${thresholdValue(form, recovery)}`] + : []), ...(healthy.length > 0 && healthy !== "1" ? [`resolves after ${healthy} healthy checks`] : []), - ...(form.alertOnNoData ? ["no data counts"] : []), + ...(form.alertOnNoData ? ["alerts on no data"] : []), ], ", ", ) } -/** How the summary names a destination and a chain; `null` for one the lists no longer hold. */ +/** A destination or chain by id: its list still loading, its name, or gone from the loaded list. */ +export type NameLookup = + | Readonly<{ _tag: "Loading" }> + | Readonly<{ _tag: "Found"; name: string }> + | Readonly<{ _tag: "Gone" }> + +/** How the summary names destinations and chains. */ export type NotifyNames = Readonly<{ - destination: (id: AlertDestinationId) => string | null - chain: (id: EscalationChainId) => string | null + destination: (id: AlertDestinationId) => NameLookup + chain: (id: EscalationChainId) => NameLookup }> +const nameOf = (lookup: NameLookup, noun: string): string => + lookup._tag === "Found" ? lookup.name : lookup._tag === "Gone" ? `a removed ${noun}` : `a ${noun}` + type Route = Readonly<{ destinationIds: ReadonlyArray escalationChainId: EscalationChainId | null }> const routeText = (route: Route, names: NotifyNames): string | null => { - const destinations = Arr.map( - route.destinationIds, - (id) => names.destination(id) ?? "a removed destination", - ) + const destinations = Arr.map(route.destinationIds, (id) => nameOf(names.destination(id), "destination")) const chain = - route.escalationChainId === null - ? null - : (names.chain(route.escalationChainId) ?? "an escalation chain") + route.escalationChainId === null ? null : nameOf(names.chain(route.escalationChainId), "chain") if (destinations.length === 0) return chain === null ? null : `escalate to ${chain}` return chain === null ? listOf(destinations) : `${listOf(destinations)}, then escalate to ${chain}` } @@ -158,15 +178,17 @@ export const notifySummary = (form: RuleForm, names: NotifyNames): string => { ) : null if (primary === null && critical === null) return "No one is notified" + // With no warning route, critical is the whole route: "Critical: #pager", not "Critical also". const criticalText = critical === null ? null : Str.startsWith("escalate to ")(critical) ? `critical ${Str.replace(/^escalate/, "escalates")(critical)}` - : `critical also ${critical}` - // Only the leading verb is capitalised: a destination keeps its own spelling ("#oncall", an email). - const lead = (text: string) => - Str.startsWith("escalate")(text) || Str.startsWith("critical")(text) ? capitalize(text) : text + : primary === null + ? `critical: ${critical}` + : `critical also ${critical}` + // Only our own words are capitalised: a destination keeps its spelling ("#oncall", an email). + const lead = (text: string) => (/^(escalate|critical|a |an )/.test(text) ? capitalize(text) : text) if (primary === null) return lead(criticalText ?? "") return Arr.join( Arr.filter([lead(primary), criticalText], (part): part is string => part !== null), diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index d3e9ffaaa9..a8159553e9 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -7,7 +7,8 @@ import { Array as Arr, Result } from "effect" import type { PageContext } from "../../contract" import * as DestinationDialog from "../destination/dialog" import { completeSave, completeTest, runTest, save } from "./actions" -import { FetchEditorChains, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { FetchEditorChains, FocusRuleCard, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { sentenceCardHeadingId, sentenceCardSummaryId } from "../../kit/sentence-card" import { SQL_TEXTAREA_ID } from "./view-sql" import { chatConnectStep, connectorsAnswered, liftDialog, menuStep, removeDestination } from "./dest-steps" import { isProtectedDefault, suggestedName } from "./derive" @@ -16,7 +17,7 @@ import * as F from "./form-steps" import { Message } from "./message" import type { Model } from "./model" import { planAutocomplete, planPreviews } from "./plan" -import { editForm, followRows, initialize, liftTemplates, type Return } from "./steps" +import { editForm, followRows, initialize, liftTemplates, type Return, withMissingCardsOpen } from "./steps" import { chartStep, comboStep, queryPanelStep, radioStep, selectStep, timePickerStep } from "./sub-steps" const AUTOCOMPLETE_PRESET = "24h" @@ -126,14 +127,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, // Both slots take an answer for their key: the replay can share the draft's first request. CompletedFetchPreview: ({ key, result }) => ({ - model: { + // A timing that lands as too slow opens "Alert when", where the interval is picked. + model: withMissingCardsOpen({ ...model, preview: model.preview.key === key ? Keyed.accept(model.preview, key, result) : model.preview, savedPreview: model.savedPreview.key === key ? Keyed.accept(model.savedPreview, key, result) : model.savedPreview, - }, + }), }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), @@ -216,10 +218,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedAddNotes: () => ({ model: { ...model, isNotesOpen: true } }), ClickedExpandCard: ({ card }) => ({ model: { ...model, expandedCards: Arr.union(model.expandedCards, [card]) }, + commands: [FocusRuleCard({ id: sentenceCardHeadingId(card) })], }), ClickedCollapseCard: ({ card }) => ({ model: { ...model, expandedCards: Arr.filter(model.expandedCards, (open) => open !== card) }, + commands: [FocusRuleCard({ id: sentenceCardSummaryId(card) })], }), + CompletedFocusCard: () => ({ model }), ToggledSqlCollapsed: () => ({ model: { ...model, isSqlCollapsed: !model.isSqlCollapsed } }), ClickedSqlToken: ({ token }) => model.form === null diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index b3f6b0b42c..ba5e9f4b9a 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -16,13 +16,15 @@ import type { RuleForm } from "./form" import { Message } from "./message" import type { Model, Notice } from "./model" import { destinationTarget, isProtectedDefault } from "./derive" -import { destinationsOf } from "./derive-rows" +import type { AlertQueryTiming } from "@maple/domain/http" +import { destinationsOf, draftTimingOf } from "./derive-rows" import type { RuleCard } from "./form" import { cardIssues, conditionSummary, nameSummary, notifySummary, + type NameLookup, type NotifyNames, watchSummary, } from "./summary" @@ -136,16 +138,30 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ /** How the Notify summary names destinations and chains, from the lists the editor holds. */ const notifyNamesOf = (model: Model): NotifyNames => { const destinations = destinationsOf(model) + // A list that has not answered (or failed, for chains) is loading, never "removed". + const lookup = ( + list: ReadonlyArray | null, + isMatch: (item: A) => boolean, + nameOf: (item: A) => string, + ): NameLookup => + list === null + ? { _tag: "Loading" } + : Option.match(Arr.findFirst(list, isMatch), { + onNone: (): NameLookup => ({ _tag: "Gone" }), + onSome: (item): NameLookup => ({ _tag: "Found", name: nameOf(item) }), + }) return { destination: (id) => - Option.match( - Arr.findFirst(destinations, (destination) => destination.id === id), - { onNone: () => null, onSome: (destination) => destinationTarget(destination).primary }, + lookup( + model.destinations === null ? null : destinations, + (destination) => destination.id === id, + (destination) => destinationTarget(destination).primary, ), chain: (id) => - Option.match( - Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === id), - { onNone: () => null, onSome: (chain) => chain.name }, + lookup( + model.isEscalationChainsFailed ? null : model.escalationChains, + (chain) => chain.id === id, + (chain) => chain.name, ), } } @@ -158,8 +174,15 @@ type CardSpec = Readonly<{ body: () => Html }> -const ruleCard = (h: H, model: Model, form: RuleForm, isDefault: boolean, spec: CardSpec): Html => { - const issues = cardIssues(form, spec.card, isDefault) +const ruleCard = ( + h: H, + model: Model, + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault: boolean, + spec: CardSpec, +): Html => { + const issues = cardIssues(form, spec.card, timing, isDefault) return sentenceCard( h, { @@ -173,8 +196,7 @@ const ruleCard = (h: H, model: Model, form: RuleForm, isDefault: boolean, spec: onExpand: Message.ClickedExpandCard({ card: spec.card }), onCollapse: Message.ClickedCollapseCard({ card: spec.card }), }, - // Closed cards skip their section: its lazy slot renders again once the card opens. - model.expandedCards.includes(spec.card) || issues.length > 0 ? [spec.body()] : [], + () => [spec.body()], ) } @@ -183,6 +205,7 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly // An anomaly rule has no condition to tune: the detector decides, so "Alert when" and the chart go. const isAnomaly = form.signalType === "anomaly" const isDefault = isProtectedDefault(model) + const timing = draftTimingOf(model, form) const condition: CardSpec = { card: "condition", lead: "Alert when", @@ -227,7 +250,7 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly sentenceChain( h, { label: "Rule" }, - Arr.map(cards, (spec) => ruleCard(h, model, form, isDefault, spec)), + Arr.map(cards, (spec) => ruleCard(h, model, form, timing, isDefault, spec)), ), ], ), diff --git a/apps/web/src/page/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts index 031c19068b..a09958829c 100644 --- a/apps/web/src/page/kit/sentence-card.ts +++ b/apps/web/src/page/kit/sentence-card.ts @@ -1,11 +1,10 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" -import { card } from "@maple/views/ui/card" +import { cardButton } from "@maple/views/ui/card-button" import { separator } from "@maple/views/ui/display" import { heading } from "@maple/views/ui/heading" import { icon } from "@maple/views/ui/icon" -import { pressable } from "@maple/views/ui/pressable" import { hstack, vstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" import { typography } from "@maple/views/ui/typography" @@ -14,14 +13,14 @@ import { Array as Arr } from "effect" // Sentence cards: a form that reads top to bottom as one sentence ("When ... Alert when ... // Notify ... Name"), each card a clause. No numbers: a status dot on a rail joins them. -export type SentenceCardStatus = "done" | "missing" | "optional" +export type SentenceCardStatus = "done" | "missing" export type SentenceCardOptions = Readonly<{ - /** Unique on the page: the heading's id. */ + /** Unique on the page; the card's element ids derive from it. */ id: string - /** The clause's lead word, the card's label: "When", "Alert when", "Notify". */ + /** The clause's lead word, the card's heading: "When", "Alert when", "Notify". */ lead: string - /** A line under the lead while the card is open. */ + /** A line beside the lead while the card is open. */ hint?: string | undefined status: SentenceCardStatus /** What is missing, said while `status` is `missing`: "Threshold", "Rule name". */ @@ -34,9 +33,15 @@ export type SentenceCardOptions = Readonly<{ onCollapse?: M }> -const DOT_TONE = { done: "done", missing: "warn", optional: "neutral" } as const +/** The open card's heading: focus lands here when the card opens. */ +export const sentenceCardHeadingId = (id: string): string => `sentence-card-${id}` +/** The closed card's summary button: focus returns here when the card closes. */ +export const sentenceCardSummaryId = (id: string): string => `sentence-card-${id}-summary` +const panelIdOf = (id: string): string => `sentence-card-${id}-panel` -const LEAD_CLASS = "w-24 shrink-0" +const DOT_TONE = { done: "done", missing: "warn" } as const + +const LEAD_CLASS = "w-20 shrink-0 sm:w-24" /** The rail beside a card: its status dot, then the line down to the next card (none after the last). */ const rail = (h: HtmlBuilder, status: SentenceCardStatus): Html => @@ -51,46 +56,49 @@ const rail = (h: HtmlBuilder, status: SentenceCardStatus): Html => ]), ]) +/** The lead as the heading, its whole card a disclosure button that opens the card. */ const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html => - card( - h, - [ - pressable( - h, - { - onClick: options.onExpand, - className: - "w-full rounded-md-inset px-4 py-3 text-left hover:bg-surface-sunken active:bg-surface-sunken", - attributes: [ - h.AriaExpanded(false), - h.AriaLabel(`${options.lead}: ${options.summary}. Edit`), - ], - }, - [ - hstack(h, { gap: "3", align: "center", className: "min-h-8" }, [ - typography(h, { size: "sm", weight: "medium", className: LEAD_CLASS }, [ - options.lead, - ]), - typography(h, { size: "sm", truncate: true, className: "min-w-0 grow" }, [ - options.summary, - ]), - hstack(h, { gap: "1", align: "center", as: "span", className: "shrink-0" }, [ - icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), - typography(h, { size: "xs", tone: "muted" }, ["Edit"]), - ]), - ]), + heading(h, { level: 2, variant: "section" }, [ + cardButton( + h, + { + onClick: options.onExpand, + label: `${options.lead}: ${options.summary}. Edit`, + className: "block w-full px-4 py-3", + attributes: [ + h.Id(sentenceCardSummaryId(options.id)), + h.AriaExpanded(false), + h.AriaControls(panelIdOf(options.id)), ], - ), - ], - "gap-0 p-0", - ) + }, + [ + // Top-aligned with the first line on the rail dot: a phone wraps the summary to two lines. + hstack(h, { gap: "3", align: "start", as: "span", className: "min-h-8 py-1.5" }, [ + typography(h, { size: "sm", weight: "semibold", className: LEAD_CLASS }, [options.lead]), + typography( + h, + { + size: "sm", + weight: "normal", + className: "line-clamp-2 min-w-0 grow sm:line-clamp-1", + }, + [options.summary], + ), + hstack(h, { gap: "1", align: "center", as: "span", className: "h-5 shrink-0" }, [ + icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), + typography(h, { size: "xs", weight: "normal", tone: "muted" }, ["Edit"]), + ]), + ]), + ], + ), + ]) const openCard = ( h: HtmlBuilder, options: SentenceCardOptions, - children: ReadonlyArray, + body: () => ReadonlyArray, ): Html => { - const headingId = `sentence-card-${options.id}` + const headingId = sentenceCardHeadingId(options.id) const issues = options.issues ?? [] // No frame of its own: the sections inside are cards. The header lines up with a closed card's row. return h.section( @@ -106,9 +114,17 @@ const openCard = ( }, [ hstack(h, { gap: "3", align: "baseline", wrap: true, className: "min-w-0 pt-1.5" }, [ - heading(h, { level: 2, variant: "section", id: headingId, className: LEAD_CLASS }, [ - options.lead, - ]), + heading( + h, + { + level: 2, + variant: "section", + id: headingId, + className: cn(LEAD_CLASS, "outline-none"), + attributes: [h.Tabindex(-1)], + }, + [options.lead], + ), ...(options.hint === undefined ? [] : [ @@ -136,26 +152,31 @@ const openCard = ( { variant: "ghost", size: "xs", - attributes: [h.AriaExpanded(true), h.OnClick(options.onCollapse)], + attributes: [ + h.AriaExpanded(true), + h.AriaControls(panelIdOf(options.id)), + h.OnClick(options.onCollapse), + ], }, ["Done"], ), ]), ], ), - ...children, + h.div([h.Id(panelIdOf(options.id)), h.Class("flex min-w-0 flex-col gap-3")], body()), ], ) } /** - * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead word and - * controls; closed (complete and not being edited), a one-line summary that opens it on click. + * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead and + * controls (`body`, called only then); closed (complete and not being edited), a one-line summary + * that opens it on click. A missing card is always open. */ export const sentenceCard = ( h: HtmlBuilder, options: SentenceCardOptions, - children: ReadonlyArray, + body: () => ReadonlyArray, ): Html => { const isOpen = options.isExpanded || options.status === "missing" return h.li( @@ -168,7 +189,7 @@ export const sentenceCard = ( rail(h, options.status), h.div( [h.Class("min-w-0 pb-4 group-last/sentence:pb-0")], - [isOpen ? openCard(h, options, children) : closedCard(h, options)], + [isOpen ? openCard(h, options, body) : closedCard(h, options)], ), ], ) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 87205a600a..44ded0baf8 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -230,7 +230,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/resource-not-found.ts` `resourceNotFound` - `apps/web/src/page/kit/route-window.ts` `isWindowChange`, `snapModeOf`, `routeWindowStep`: `useEffectiveTimeRange` + `useRefreshableAtomValue` for a page's `routeChanged`: the window is re-resolved only when its own fields moved, and only Reload revalidates unchanged... - `apps/web/src/page/kit/section-heading.ts` `sectionHeading`: `SectionHeading`: the `title` row and the `eyebrow` overline. -- `apps/web/src/page/kit/sentence-card.ts` `sentenceCard`, `sentenceChain`: Sentence cards: a form that reads top to bottom as one sentence ("When ... +- `apps/web/src/page/kit/sentence-card.ts` `sentenceCardHeadingId`, `sentenceCardSummaryId`, `sentenceCard`, `sentenceChain`: Sentence cards: a form that reads top to bottom as one sentence ("When ... - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. diff --git a/packages/views/src/ui/card-button.ts b/packages/views/src/ui/card-button.ts index f31e4ccbf9..0aa2d9a479 100644 --- a/packages/views/src/ui/card-button.ts +++ b/packages/views/src/ui/card-button.ts @@ -8,12 +8,15 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" const BASE = "group rounded-md border bg-card text-left hover:border-border-strong active:border-border-strong hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-64 disabled:hover:border-border disabled:active:border-border disabled:hover:bg-card disabled:active:bg-card" -const SELECTED = "border-primary-a4 bg-primary-a1 hover:border-primary-a4 active:border-primary-a4 hover:bg-primary-a2 active:bg-primary-a2" +const SELECTED = + "border-primary-a4 bg-primary-a1 hover:border-primary-a4 active:border-primary-a4 hover:bg-primary-a2 active:bg-primary-a2" export type CardButtonOptions = Readonly<{ onClick: M isSelected?: boolean isDisabled?: boolean + /** `aria-label`, when the card's text alone does not name the action. */ + label?: string className?: string attributes?: ReadonlyArray | ChildAttribute> }> @@ -35,6 +38,7 @@ export const cardButton = ( ? [] : [h.AriaPressed(options.isSelected ? "true" : "false")]), ...((options.isDisabled ?? false) ? [h.Disabled(true)] : []), + ...(options.label === undefined ? [] : [h.AriaLabel(options.label)]), ...(options.attributes ?? []), ], children, From dba6fcf7eb6eeb61a2cfdea34d997fe94cd73540 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:20:42 +0100 Subject: [PATCH 07/27] feat(views): pickers that keep the keyboard, a select that opens below Select takes alignItemWithTrigger={false} to open below its trigger. The chips field takes a size. ComboboxPicker's list is 360px wide and its trigger takes a tooltip. A popup with its own search input keeps focus there when an option is pressed, so typing, Enter and Escape go on working after a mouse pick that leaves the list open. --- packages/views/src/ui/combobox-picker.ts | 9 ++++++++- packages/views/src/ui/combobox-view.ts | 13 +++++++++++-- packages/views/src/ui/combobox.ts | 18 ++++++++++++++++++ packages/views/src/ui/select-view.ts | 20 +++++++++++++------- packages/views/src/ui/select.ts | 10 +++++++--- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts index 9988a4d954..1b0d3d7b4f 100644 --- a/packages/views/src/ui/combobox-picker.ts +++ b/packages/views/src/ui/combobox-picker.ts @@ -6,6 +6,7 @@ import { Message, type Model, queryOf } from "./combobox" import { chipsInput, inputAttributes, listPopup } from "./combobox-view" import { icon } from "./icon" import { isMounted } from "./overlay/transition" +import { tooltipTrigger } from "./tooltip-host" /** * `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search @@ -21,6 +22,8 @@ export type ComboboxPickerInputs = Readonly<{ /** The trigger's label: the pick, or a prompt drawn muted with `isPlaceholder`. */ trigger: ReadonlyArray isPlaceholder?: boolean + /** The trigger's tooltip, for a pick the trigger shows shortened. */ + tooltip?: string renderItem?: (h: HtmlBuilder, value: string) => ReadonlyArray itemClassName?: (value: string) => string | undefined triggerClassName?: string @@ -50,6 +53,10 @@ export const comboboxPicker = Submodel.defineView