Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3ec81ce
feat(query-builder): condition builder for the where clause
Makisuo Nov 9, 2026
2b09626
fix(query-builder): condition builder review fixes
Makisuo Nov 9, 2026
178965e
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 9, 2026
2de1f25
fix(query-builder): field picker's accessible name includes the picke…
Makisuo Nov 9, 2026
dba6fcf
feat(views): pickers that keep the keyboard, a select that opens below
Makisuo Nov 10, 2026
1d0d2e9
fix(query-builder): condition builder UX pass
Makisuo Nov 10, 2026
aacec21
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
9090847
fix(query-builder): where controls wrap inside a narrow panel
Makisuo Nov 10, 2026
ee110de
fix(query-builder): rows in progress survive a look at Text; clearer …
Makisuo Nov 10, 2026
67cbbe7
fix(query-builder): product events take no operator on resource.* keys
Makisuo Nov 10, 2026
cc4cce2
fix(views): Escape in a picker's search input stops at the popup
Makisuo Nov 10, 2026
44ac0a0
fix(query-builder): the builder shows only what the query applies
Makisuo Nov 10, 2026
4b22e15
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
6640df8
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
b9a7016
fix(views): a mouse pick in a combobox list leaves focus in its input
Makisuo Nov 10, 2026
7fbd687
fix(query-builder): the value list says why a value with both quote k…
Makisuo Nov 10, 2026
07724a1
fix(query-builder): a doubled operator or missing value stays in Text
Makisuo Nov 10, 2026
4766623
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
2258d80
fix(query-builder): row controls are named by condition number; a sib…
Makisuo Nov 10, 2026
1ac178f
test(query-engine): pin the operator list to the grammar and the OR-g…
Makisuo Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions apps/web/src/lab/condition-builder-lab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder"
import { Option, Schema, Array as Arr } from "effect"
import { Command, type Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { typography } from "@maple/views/ui/typography"
import { vstack } from "@maple/views/ui/stack"
import type { WhereClauseAutocompleteValues } from "../lib/query-builder/where-clause-autocomplete"
import type { Resources } from "../platform/resources"
import { sectionHeading } from "../page/kit/section-heading"
import { applyQueryChange } from "../page/kit/query-builder/change"
import { QueryDraft } from "../page/kit/query-builder/draft"
import * as QueryPanel from "../page/kit/query-builder/panel"
import { queryPanelView } from "../page/kit/query-builder/view"

// `/lab/condition-builder`: the query panel's where clause as condition rows, over fixture
// vocabulary: empty, populated, clauses the rows can't show, and the text editor.

export const LAB_VALUES: WhereClauseAutocompleteValues = {
services: ["checkout", "api", "payments", "frontend"],
spanNames: ["GET /api/cart", "POST /api/checkout", "db.query"],
environments: ["production", "staging"],
severities: ["ERROR", "WARN", "INFO", "DEBUG"],
httpStatusCodes: ["200", "404", "500", "503"],
attributeKeys: [
"http.route",
"http.request.method",
"http.response.status_code",
"db.system",
"messaging.destination.partition.id",
"service.version",
],
// `service.version` is in both groups: its trigger names the group.
resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region", "service.version"],
}

type Scenario = Readonly<{ title: string; hint: string; query: QueryDraft }>

const draft = (id: string, name: string, dataSource: "traces" | "logs", whereClause: string): QueryDraft => ({
...resetQueryForDataSource({ ...createQueryDraft(0), id, name }, dataSource),
whereClause,
})

export const SCENARIOS: ReadonlyArray<Scenario> = [
{ title: "Empty", hint: "No conditions yet.", query: draft("empty", "A", "traces", "") },
{
title: "Populated",
hint: "An attribute row with several values matches any of them; rows are AND-ed.",
query: draft(
"populated",
"B",
"traces",
'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.service.version exists AND attr.messaging.destination.partition.id = "12"',
),
},
{
title: "Can't show as rows",
hint: "An OR across different fields stays in the text editor.",
query: draft(
"unparseable",
"C",
"traces",
'(attr.http.route = "/cart" OR attr.rpc.method = "Checkout")',
),
},
{
title: "OR group the query drops",
hint: "A named field can't be OR-ed: the filter stays in Text, and the warning names the group the query drops.",
query: draft(
"dropped",
"E",
"traces",
'(service.name = "checkout" OR service.name = "worker") AND attr.http.route exists',
),
},
{
title: "Text mode",
hint: "Switched to Text by hand.",
query: draft("text", "D", "logs", 'service.name = "checkout" AND severity = "ERROR"'),
},
]

export const Model = Schema.Struct({ panel: QueryPanel.Model, queries: Schema.Array(QueryDraft) })
export type Model = typeof Model.Type

export const Message = defineMessageUnion({ GotPanelMessage: { message: QueryPanel.Message } })
export type Message = typeof Message.Type

export const init = (): Update.Return<Model, Message> => {
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<QueryDraft>): 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<Model, Message, Resources> => {
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<Message>): Html =>
// As wide as the alert editor gives the panel, so rows lay out as they do there.
vstack(h, { gap: "8", className: "mx-auto w-full max-w-5xl p-4 sm:p-6" }, [
vstack(h, { gap: "1" }, [
sectionHeading(h, { title: "Condition builder" }),
typography(h, { size: "sm", tone: "muted", as: "p" }, [
"The where clause as rows. Every edit prints the same string the text editor writes.",
]),
]),
...Arr.map(model.queries, (query, index) => {
const scenario = SCENARIOS[index]
return vstack(h, { gap: "2", as: "section", attributes: [h.Key(query.id)] }, [
sectionHeading(h, { title: scenario?.title ?? query.name, hint: scenario?.hint, as: "h3" }),
h.submodel({
slotId: `condition-lab-${query.id}`,
model: model.panel,
view: queryPanelView,
toParentMessage: (message) => Message.GotPanelMessage({ message }),
viewInputs: {
query,
index,
canRemove: false,
intervalPlaceholder: "Auto",
hasHeaderActions: false,
hasVisibilityToggle: false,
autocompleteValues: LAB_VALUES,
},
}),
typography(h, { size: "2xs", tone: "subtle", mono: true, as: "p", className: "break-all" }, [
query.whereClause === "" ? "(empty where clause)" : query.whereClause,
]),
])
}),
])
18 changes: 18 additions & 0 deletions apps/web/src/lab/entries/condition-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Effect, Schema } from "effect"
import { init, Message, Model, update, view } from "../condition-builder-lab"
import { defineShellLab } from "../shell-lab"

// `/lab/condition-builder`: the query panel's condition rows over fixture vocabulary, inside the
// real shell so the kit tooltips show.

export const lab = defineShellLab("Condition builder", {
Model,
Message,
Flags: Schema.Struct({}),
flags: Effect.succeed({}),
init,
update,
view,
currentPath: "/lab/condition-builder",
breadcrumbs: [{ label: "Lab" }, { label: "Condition builder" }],
})
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
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",
Expand Down
12 changes: 1 addition & 11 deletions apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<string, QueryPanel.Message>(
MountWhereFieldCaret,
QueryPanel.Message.GotWhereField({
message: WhereFieldMessage.GotCaret({ fieldId: DRAFT_ID, message: CaretMessage.BlurredEditor() }),
}),
),
]
const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox())

Expand Down
23 changes: 0 additions & 23 deletions apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, QueryPanel.Message>(
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(),
)
Expand Down Expand Up @@ -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<string, QueryPanel.Message>(
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(),
Expand Down
Loading
Loading