From fc66f273ebd9dcc452e2995e3eccee4b5445e325 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 10:31:20 +0100 Subject: [PATCH 1/3] refactor(web): move the funnel step builder into the page kit The dashboard widget editor's step builder (event, page or session steps, their comboboxes and selects) becomes kit/funnel-steps: a view generic over the caller's message, the step schemas, and StepEdit with the pure edits it makes. The widget editor keeps its attribute filter line and plugs it in; its six step edits collapse into ProductEdit.Step. No behavior change. Product analytics (next) mounts the same builder. --- .../page/dashboards/widget/editor-state.ts | 16 +- .../dashboards/widget/lowering-product.ts | 3 +- .../page/dashboards/widget/product-apply.ts | 91 +-- .../page/dashboards/widget/product-edit.ts | 25 +- .../page/dashboards/widget/product-selects.ts | 27 +- .../page/dashboards/widget/product-step.ts | 39 +- .../dashboards/widget/product.scene.test.ts | 15 +- .../src/page/dashboards/widget/view-panels.ts | 4 +- .../page/dashboards/widget/view-product.ts | 3 +- .../src/page/dashboards/widget/view-steps.ts | 347 +---------- .../widget/where-fields.scene.test.ts | 10 +- .../page/dashboards/widget/where-fields.ts | 6 +- apps/web/src/page/kit/funnel-steps.test.ts | 52 ++ apps/web/src/page/kit/funnel-steps.ts | 539 ++++++++++++++++++ packages/views/src/ui/INDEX.md | 1 + 15 files changed, 684 insertions(+), 494 deletions(-) create mode 100644 apps/web/src/page/kit/funnel-steps.test.ts create mode 100644 apps/web/src/page/kit/funnel-steps.ts diff --git a/apps/web/src/page/dashboards/widget/editor-state.ts b/apps/web/src/page/dashboards/widget/editor-state.ts index 112683cf90..1775ca52fb 100644 --- a/apps/web/src/page/dashboards/widget/editor-state.ts +++ b/apps/web/src/page/dashboards/widget/editor-state.ts @@ -8,14 +8,13 @@ import { import { FunnelBreakdownBy, FunnelKeyBy, - FunnelPageStep, - FunnelSessionStep, FunnelVariant, PathsDirection, PathsInclude, QUERY_COMPARISON_MODES, } from "@maple/query-model" import { Schema } from "effect" +import { StepDraft } from "../../kit/funnel-steps" import { QueryDraft } from "../../kit/query-builder/draft" // The widget editor's state (`QueryBuilderWidgetState`). @@ -52,19 +51,6 @@ export type ListColumn = typeof ListColumn.Type export const ListDataSource = Schema.Literals(["traces", "logs", "product_events"]) export type ListDataSource = typeof ListDataSource.Type -/** `FunnelStepDraft` (funnel-filters.ts): the wire step plus an event step's filter as typed. */ -export const StepDraft = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("event"), - eventName: Schema.String, - attributeEquals: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), - filterClause: Schema.optionalKey(Schema.String), - }), - FunnelPageStep, - FunnelSessionStep, -]) -export type StepDraft = typeof StepDraft.Type - export const FunnelAddOns = Schema.Struct({ keyBy: Schema.Boolean, window: Schema.Boolean, diff --git a/apps/web/src/page/dashboards/widget/lowering-product.ts b/apps/web/src/page/dashboards/widget/lowering-product.ts index 5350a00afd..983ceb5f6e 100644 --- a/apps/web/src/page/dashboards/widget/lowering-product.ts +++ b/apps/web/src/page/dashboards/widget/lowering-product.ts @@ -28,7 +28,8 @@ import { PRODUCT_EVENTS_PATHS_ENDPOINT, } from "@maple/widgets/dashboard" import { Option, Schema } from "effect" -import type { FunnelDraft, PathsDraft, StepDraft } from "./editor-state" +import type { StepDraft } from "../../kit/funnel-steps" +import type { FunnelDraft, PathsDraft } from "./editor-state" // The funnel's and the paths widget's editor drafts, read from a stored widget // and checked (`validate`). diff --git a/apps/web/src/page/dashboards/widget/product-apply.ts b/apps/web/src/page/dashboards/widget/product-apply.ts index f9634bb426..906efcd194 100644 --- a/apps/web/src/page/dashboards/widget/product-apply.ts +++ b/apps/web/src/page/dashboards/widget/product-apply.ts @@ -3,26 +3,15 @@ import { DEFAULT_FUNNEL_WINDOW_SECONDS, FUNNEL_SESSION_DIMENSIONS, } from "../../../components/funnels/definition" -import type { EditorState, FunnelDraft, ListColumn, PathsDraft, StepDraft } from "./editor-state" +import { FUNNEL_MAX_STEPS } from "@maple/query-model" +import { applyAnchorEdit, applyStepEdit, type StepEdit, type StepScope } from "../../kit/funnel-steps" +import type { EditorState, FunnelDraft, ListColumn, PathsDraft } from "./editor-state" import { DEFAULT_LIST_COLUMNS } from "./lowering-list" -import { ProductEdit, type StepKind, type StepScope, type StepTextField } from "./product-edit" +import { ProductEdit } from "./product-edit" // What each `ProductEdit` does to the editor state: `ListConfigPanel`'s `onChange`, the // `FunnelStepBuilder`'s `onChange`, and `updateFunnel` / `updatePaths`. -/** `withKind`: a step's kind changed, carrying nothing over. */ -const withKind = (step: StepDraft, kind: StepKind): StepDraft => { - if (step.kind === kind) return step - switch (kind) { - case "event": - return { kind: "event", eventName: "" } - case "page": - return { kind: "page", pagePath: "" } - case "session": - return { kind: "session", dimension: "referrerHost", value: "" } - } -} - /** `move`: an item moved within the list; out of range is a no-op. */ const move = (items: ReadonlyArray, from: number, to: number): ReadonlyArray => { const item = items[from] @@ -31,42 +20,18 @@ const move = (items: ReadonlyArray, from: number, to: number): ReadonlyArr return [...rest.slice(0, to), item, ...rest.slice(to)] } -/** A typed field of a step; an emptied host or filter drops its key. */ -const withText = (step: StepDraft, field: StepTextField, value: string): StepDraft => { - switch (step.kind) { - case "event": { - if (field === "eventName") return { ...step, eventName: value } - if (field !== "filterClause") return step - const { filterClause: _clause, ...rest } = step - return value === "" ? rest : { ...rest, filterClause: value } - } - case "page": { - if (field === "pagePath") return { ...step, pagePath: value } - if (field !== "host") return step - const { host: _host, ...rest } = step - return value === "" ? rest : { ...rest, host: value } - } - case "session": - return field === "value" ? { ...step, value } : step - } -} - const updateAt = (items: ReadonlyArray, index: number, update: (item: T) => T): ReadonlyArray => items.map((item, position) => (position === index ? update(item) : item)) -const editSteps = ( - state: EditorState, - scope: StepScope, - index: number, - update: (step: StepDraft) => StepDraft, -): EditorState => - scope === "funnel" - ? { ...state, funnel: { ...state.funnel, steps: updateAt(state.funnel.steps, index, update) } } - : // The anchor is never a session step: the panel offers none, and `onChange` drops one. - ((anchor) => - anchor.kind === "session" ? state : { ...state, paths: { ...state.paths, anchor } })( - update(state.paths.anchor), - ) +/** A step builder edit on the funnel's steps, or on the paths anchor (never a session step). */ +const editSteps = (state: EditorState, scope: StepScope, edit: StepEdit): EditorState => { + if (scope === "funnel") + return { + ...state, + funnel: { ...state.funnel, steps: applyStepEdit(state.funnel.steps, edit, FUNNEL_MAX_STEPS) }, + } + return { ...state, paths: { ...state.paths, anchor: applyAnchorEdit(state.paths.anchor, edit) } } +} const funnel = (state: EditorState, update: (funnel: FunnelDraft) => FunnelDraft): EditorState => ({ ...state, @@ -127,12 +92,6 @@ const withBreakdown = (current: FunnelDraft, value: string | null): FunnelDraft return dimension === undefined ? rest : { ...rest, breakdownBy: dimension } } -/** A funnel step moved; a session step past step 1 is demoted to an event step (`reorder`). */ -const reorderSteps = (steps: ReadonlyArray, from: number, to: number): ReadonlyArray => - move(steps, from, to).map((step, index) => - index > 0 && step.kind === "session" ? withKind(step, "event") : step, - ) - export const applyProductEdit = (state: EditorState, edit: ProductEdit): EditorState => ProductEdit.match(edit, { // A new source starts over: its default columns, no filter. @@ -171,29 +130,7 @@ export const applyProductEdit = (state: EditorState, edit: ProductEdit): EditorS columns(state, (list) => move(list, index, Math.max(0, Math.min(list.length - 1, index + delta))), ), - SetStepKind: ({ scope, index, kind }) => - editSteps(state, scope, index, (step) => withKind(step, kind)), - ChangedStepText: ({ scope, index, field, value }) => - editSteps(state, scope, index, (step) => withText(step, field, value)), - SetStepDimension: ({ scope, index, dimension }) => - editSteps(state, scope, index, (step) => - step.kind === "session" ? { ...step, dimension } : step, - ), - MovedStep: ({ index, delta }) => - funnel(state, (current) => ({ - ...current, - steps: reorderSteps(current.steps, index, index + delta), - })), - RemovedStep: ({ index }) => - funnel(state, (current) => ({ - ...current, - steps: current.steps.filter((_, position) => position !== index), - })), - AddedStep: () => - funnel(state, (current) => ({ - ...current, - steps: [...current.steps, { kind: "event", eventName: "" }], - })), + Step: ({ scope, edit }) => editSteps(state, scope, edit), ChangedPopulationFilter: ({ scope, value }) => scope === "funnel" ? funnel(state, (current) => ({ ...current, filterClause: value })) diff --git a/apps/web/src/page/dashboards/widget/product-edit.ts b/apps/web/src/page/dashboards/widget/product-edit.ts index 400e1a2ca3..c15ac5cfd6 100644 --- a/apps/web/src/page/dashboards/widget/product-edit.ts +++ b/apps/web/src/page/dashboards/widget/product-edit.ts @@ -1,12 +1,7 @@ -import { - FunnelKeyBy, - FunnelSessionDimension, - FunnelVariant, - PathsDirection, - PathsInclude, -} from "@maple/query-model" +import { FunnelKeyBy, FunnelVariant, PathsDirection, PathsInclude } from "@maple/query-model" import { Schema } from "effect" import { defineTaggedUnion } from "foldkit/schema" +import { StepEdit, StepScope } from "../../kit/funnel-steps" import { ListDataSource } from "./editor-state" /** @@ -14,14 +9,6 @@ import { ListDataSource } from "./editor-state" * `PathsQueryPanel` and the `FunnelStepBuilder` they mount), as data for one Message. * `scope` names the step list: the funnel's steps, or the paths widget's single anchor. */ -export const StepScope = Schema.Literals(["funnel", "paths"]) -export type StepScope = typeof StepScope.Type - -export const StepKind = Schema.Literals(["event", "page", "session"]) -export type StepKind = typeof StepKind.Type - -export const StepTextField = Schema.Literals(["eventName", "pagePath", "host", "value", "filterClause"]) -export type StepTextField = typeof StepTextField.Type export const ListColumnText = Schema.Literals(["field", "header"]) @@ -40,12 +27,8 @@ export const ProductEdit = defineTaggedUnion({ RemovedListColumn: { index: Schema.Number }, AddedListColumn: {}, MovedListColumn: { index: Schema.Number, delta: Schema.Number }, - SetStepKind: { scope: StepScope, index: Schema.Number, kind: StepKind }, - ChangedStepText: { scope: StepScope, index: Schema.Number, field: StepTextField, value: Schema.String }, - SetStepDimension: { scope: StepScope, index: Schema.Number, dimension: FunnelSessionDimension }, - MovedStep: { index: Schema.Number, delta: Schema.Number }, - RemovedStep: { index: Schema.Number }, - AddedStep: {}, + /** The step builder's edit: the funnel's steps, or the paths widget's anchor. */ + Step: { scope: StepScope, edit: StepEdit }, ChangedPopulationFilter: { scope: StepScope, value: Schema.String }, ToggledFunnelAddOn: { key: FunnelAddOn }, ToggledPathsAddOn: { key: PathsAddOn }, diff --git a/apps/web/src/page/dashboards/widget/product-selects.ts b/apps/web/src/page/dashboards/widget/product-selects.ts index aa2b21dac0..ff19fee4d0 100644 --- a/apps/web/src/page/dashboards/widget/product-selects.ts +++ b/apps/web/src/page/dashboards/widget/product-selects.ts @@ -1,8 +1,9 @@ -import { FunnelKeyBy, FunnelSessionDimension } from "@maple/query-model" +import { FunnelKeyBy } from "@maple/query-model" import { Option, Schema } from "effect" import type { EditorState } from "./editor-state" import { applyProductEdit } from "./product-apply" -import { ProductEdit, type StepScope, StepKind } from "./product-edit" +import { stepEditOf, type StepScope } from "../../kit/funnel-steps" +import { ProductEdit } from "./product-edit" // The selects of the list, funnel and paths panels, by id: what a picked value edits. @@ -12,35 +13,29 @@ export const BREAKDOWN_ATTRIBUTE = "__attribute__" export const listUnitSelectId = (index: number) => `list-unit:${index}` export const listAlignSelectId = (index: number) => `list-align:${index}` -export const stepKindSelectId = (scope: StepScope, index: number) => `step-kind:${scope}:${index}` -export const stepDimensionSelectId = (scope: StepScope, index: number) => `step-dimension:${scope}:${index}` export const keyBySelectId = (scope: StepScope) => `key-by:${scope}` export const windowSelectId = (scope: StepScope) => `window:${scope}` -const decodeKind = Schema.decodeUnknownOption(StepKind) -const decodeDimension = Schema.decodeUnknownOption(FunnelSessionDimension) const decodeKeyBy = Schema.decodeUnknownOption(FunnelKeyBy) const decodeAlign = Schema.decodeUnknownOption(Schema.Literals(["left", "center", "right"])) const scopeOf = (raw: string | undefined): StepScope => (raw === "paths" ? "paths" : "funnel") /** A panel select's picked value as the edit it makes; `None` for another select. */ -export const productSelectEdit = (id: string, value: string): Option.Option => { - const [name, first, second] = id.split(":") +export const productSelectEdit = (id: string, value: string): Option.Option => + Option.orElse( + Option.map(stepEditOf(id, value), (step) => ProductEdit.Step(step)), + () => panelSelectEdit(id, value), + ) + +const panelSelectEdit = (id: string, value: string): Option.Option => { + const [name, first] = id.split(":") const index = Number(first) switch (name) { case "list-unit": return Option.some(ProductEdit.SetListColumnUnit({ index, unit: value })) case "list-align": return Option.map(decodeAlign(value), (align) => ProductEdit.SetListColumnAlign({ index, align })) - case "step-kind": - return Option.map(decodeKind(value), (kind) => - ProductEdit.SetStepKind({ scope: scopeOf(first), index: Number(second), kind }), - ) - case "step-dimension": - return Option.map(decodeDimension(value), (dimension) => - ProductEdit.SetStepDimension({ scope: scopeOf(first), index: Number(second), dimension }), - ) case "key-by": return Option.map(decodeKeyBy(value), (keyBy) => ProductEdit.SetKeyBy({ scope: scopeOf(first), keyBy }), diff --git a/apps/web/src/page/dashboards/widget/product-step.ts b/apps/web/src/page/dashboards/widget/product-step.ts index 7d0a6aa0fe..a87adb917a 100644 --- a/apps/web/src/page/dashboards/widget/product-step.ts +++ b/apps/web/src/page/dashboards/widget/product-step.ts @@ -1,18 +1,18 @@ import { resolveTimeRangeWindow } from "@maple/query-engine" import { Option } from "effect" -import { Command } from "foldkit" import * as Keyed from "@maple/views/data/keyed" import { requestKey } from "@maple/views/data/request-key" import type { TimeWindow } from "../../../data/time-range" import * as Combobox from "@maple/views/ui/combobox" import type { PageContext } from "../../contract" +import { stepComboUpdate } from "../../kit/funnel-steps" import { toPanelType } from "../kit/chart-registry" import { FetchSuggestions } from "./command" import { isProductEventsFunnel } from "./lowering" import { Message } from "./message" import type { Model } from "./model" import { applyProductEdit } from "./product-apply" -import { ProductEdit, type StepScope, type StepTextField } from "./product-edit" +import { ProductEdit } from "./product-edit" import { SUGGESTION_LOOKUPS, suggestionRequestOf } from "./suggestions" import type { Return } from "./update" @@ -70,23 +70,6 @@ export const requestSuggestions = (model: Model, context: PageContext): Return = ) } -/** A step input's combobox id: scope, step and field. */ -export const stepComboId = (scope: StepScope, index: number, field: "eventName" | "pagePath"): string => - `widget-step-${scope}-${index}-${field}` - -const parseComboId = ( - id: string, -): Option.Option> => { - const match = /^widget-step-(funnel|paths)-(\d+)-(eventName|pagePath)$/.exec(id) - const [, scope, index, field] = match ?? [] - if (scope === undefined || index === undefined || field === undefined) return Option.none() - return Option.some({ - scope: scope === "paths" ? "paths" : "funnel", - index: Number(index), - field: field === "pagePath" ? "pagePath" : "eventName", - }) -} - /** `SuggestingInput`: the value is whatever was typed, and picking a suggestion types it. */ export const stepComboStep = ( model: Model, @@ -95,21 +78,13 @@ export const stepComboStep = ( message: Combobox.Message, edit: (model: Model, context: PageContext, change: ProductEdit) => Return, ): Return => { - const next = Combobox.update(model.stepCombos[id] ?? Combobox.init(id, "single"), message) - const lifted: Model = { ...model, stepCombos: { ...model.stepCombos, [id]: next.model } } - const commands = Command.mapMessages(next.commands ?? [], (inner) => + const next = stepComboUpdate(model.stepCombos, id, message, (inner) => Message.GotStepComboMessage({ id, message: inner }), ) - const typed = - message._tag === "ChangedInput" - ? Option.some(message.value) - : next.outMessage?._tag === "ChangedValue" - ? Option.some(next.outMessage.value) - : Option.none() - const target = parseComboId(id) - if (Option.isNone(typed) || Option.isNone(target)) return { model: lifted, commands } - const edited = edit(lifted, context, ProductEdit.ChangedStepText({ ...target.value, value: typed.value })) - return { ...edited, commands: [...commands, ...(edited.commands ?? [])] } + const lifted: Model = { ...model, stepCombos: next.combos } + if (Option.isNone(next.edit)) return { model: lifted, commands: next.commands } + const edited = edit(lifted, context, ProductEdit.Step(next.edit.value)) + return { ...edited, commands: [...next.commands, ...(edited.commands ?? [])] } } /** A panel edit applied to the editing state. */ diff --git a/apps/web/src/page/dashboards/widget/product.scene.test.ts b/apps/web/src/page/dashboards/widget/product.scene.test.ts index 3f9b88d389..d1a54ff722 100644 --- a/apps/web/src/page/dashboards/widget/product.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/product.scene.test.ts @@ -9,6 +9,7 @@ import { buildDisplay, validationErrorOf } from "./lowering" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" +import { StepEdit } from "../../kit/funnel-steps" import { ProductEdit } from "./product-edit" import { setDataSource } from "./edits" import { targetIndex } from "./list-column-drag" @@ -109,11 +110,9 @@ describe("list, funnel and paths panels", () => { const anchored = run( paths, edit( - ProductEdit.ChangedStepText({ + ProductEdit.Step({ scope: "paths", - index: 0, - field: "eventName", - value: "checkout_started", + edit: StepEdit.ChangedText({ index: 0, field: "eventName", value: "checkout_started" }), }), ), edit(ProductEdit.SetPathsDirection({ direction: "before" })), @@ -152,10 +151,10 @@ describe("list, funnel and paths panels", () => { const step = run( opened(), Message.ClickedPanelType({ panel: "funnel" }), - edit(ProductEdit.AddedStep()), - edit(ProductEdit.AddedStep()), - edit(ProductEdit.SetStepKind({ scope: "funnel", index: 0, kind: "session" })), - edit(ProductEdit.MovedStep({ index: 0, delta: 1 })), + edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.Added() })), + edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.Added() })), + edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.SetKind({ index: 0, kind: "session" }) })), + edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.Moved({ index: 0, delta: 1 }) })), ) expect(step.model.state?.funnel.steps.map((candidate) => candidate.kind)).toEqual(["event", "event"]) }) diff --git a/apps/web/src/page/dashboards/widget/view-panels.ts b/apps/web/src/page/dashboards/widget/view-panels.ts index 8e3403532d..8a63f706e9 100644 --- a/apps/web/src/page/dashboards/widget/view-panels.ts +++ b/apps/web/src/page/dashboards/widget/view-panels.ts @@ -1,4 +1,4 @@ -import { PATHS_MAX_BRANCHES, PATHS_MAX_DEPTH } from "@maple/query-model" +import { FUNNEL_MAX_STEPS, PATHS_MAX_BRANCHES, PATHS_MAX_DEPTH } from "@maple/query-model" import type { Html, HtmlBuilder } from "foldkit/html" import { AsyncData } from "foldkit" import { Option } from "effect" @@ -23,7 +23,7 @@ import { runRow, windowRow, } from "./view-product" -import { FUNNEL_MAX_STEPS, stepBuilder } from "./view-steps" +import { stepBuilder } from "./view-steps" import { lookupField } from "../../kit/autocomplete-lookups" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" diff --git a/apps/web/src/page/dashboards/widget/view-product.ts b/apps/web/src/page/dashboards/widget/view-product.ts index f600b22c0f..6b480e1efe 100644 --- a/apps/web/src/page/dashboards/widget/view-product.ts +++ b/apps/web/src/page/dashboards/widget/view-product.ts @@ -20,7 +20,8 @@ import { whereEditor } from "../../kit/query-builder/where-editor" import { autocompleteFor, populationField } from "./where-fields" import { Message } from "./message" import type { Model } from "./model" -import { ProductEdit, type StepScope } from "./product-edit" +import type { StepScope } from "../../kit/funnel-steps" +import { ProductEdit } from "./product-edit" import { BREAKDOWN_ATTRIBUTE, BREAKDOWN_NONE, diff --git a/apps/web/src/page/dashboards/widget/view-steps.ts b/apps/web/src/page/dashboards/widget/view-steps.ts index 37d0233c3a..2b9609a03c 100644 --- a/apps/web/src/page/dashboards/widget/view-steps.ts +++ b/apps/web/src/page/dashboards/widget/view-steps.ts @@ -1,37 +1,20 @@ -import { formatNumber } from "@maple/domain/format" -import { FUNNEL_MAX_STEPS, FUNNEL_SESSION_DIMENSION_LABEL } from "@maple/query-model" -import { cn } from "@maple/ui/lib/utils" -import { FUNNEL_SESSION_DIMENSIONS } from "../../../components/funnels/definition" import { parseFunnelStepFilter } from "../../../lib/query-builder/funnel-filters" import type { Html, HtmlBuilder } from "foldkit/html" -import { button } from "@maple/views/ui/button" -import * as Combobox from "@maple/views/ui/combobox" -import * as ComboboxView from "@maple/views/ui/combobox-view" import { eyebrow } from "@maple/views/ui/eyebrow" -import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" -import { input } from "@maple/views/ui/input" -import { panel } from "@maple/views/ui/panel" +import { stepBuilder as kitStepBuilder, StepEdit, type StepDraft } from "../../kit/funnel-steps" import { whereEditor } from "../../kit/query-builder/where-editor" -import { autocompleteFor, stepField } from "./where-fields" -import type { StepDraft } from "./editor-state" +import { autocompleteFor, stepField, stepFilterId } from "./where-fields" import { Message } from "./message" import type { Model } from "./model" -import { ProductEdit, type StepScope, type StepTextField } from "./product-edit" -import { stepDimensionSelectId, stepKindSelectId } from "./product-selects" -import { stepComboId } from "./product-step" +import type { StepScope } from "../../kit/funnel-steps" +import { ProductEdit } from "./product-edit" import type { Suggestion } from "./suggestions" -import { pageSelect } from "./view-select" -import { typography, typographyClass } from "@maple/views/ui/typography" -import { hstack, vstack } from "@maple/views/ui/stack" +import { hstack } from "@maple/views/ui/stack" -// The funnel step builder, compact: an ordered list of -// steps, each an event name, a page path or (first step only) a session dimension. +// The widget editor's funnel step builder: the kit's, with an attribute filter under event steps. type H = HtmlBuilder -const KIND_LABEL = { event: "Event", page: "Page", session: "Session" } as const - export type StepBuilderOptions = Readonly<{ scope: StepScope steps: ReadonlyArray @@ -41,222 +24,6 @@ export type StepBuilderOptions = Readonly<{ maxSteps: number }> -const product = (edit: ProductEdit): Message => Message.ChangedProduct({ edit }) - -/** `SuggestingInput`: free text, with the window's names to pick from. */ -const suggestingInput = ( - h: H, - model: Model, - id: string, - value: string, - suggestions: ReadonlyArray, - inputs: Readonly<{ placeholder: string; ariaLabel: string; empty: string; isMono: boolean }>, -): Html => { - const names = suggestions.map((suggestion) => suggestion.name) - const current = model.stepCombos[id] ?? Combobox.init(id, "single") - const counts = new Map(suggestions.map((suggestion) => [suggestion.name, suggestion.count])) - return h.submodel({ - slotId: `widget-editor-${id}`, - model: { ...current, inputValue: value, selected: names.includes(value) ? [value] : [] }, - view: ComboboxView.view, - toParentMessage: (message) => Message.GotStepComboMessage({ id, message }), - viewInputs: { - items: names, - placeholder: inputs.placeholder, - ariaLabel: inputs.ariaLabel, - emptyText: inputs.empty, - size: "sm", - showTrigger: names.length > 0, - className: cn("min-w-0 flex-1", inputs.isMono && typographyClass({ size: "xs", mono: true })), - renderItem: (name) => { - const count = counts.get(name) - return [ - hstack(h, { gap: "2", shrink: true, as: "span" }, [ - h.span( - [ - h.Class( - cn( - typographyClass({ truncate: true, className: "min-w-0 flex-1" }), - inputs.isMono && typographyClass({ size: "xs", mono: true }), - ), - ), - ], - [name], - ), - ...(count === null || count === undefined - ? [] - : [ - typography( - h, - { - size: "3xs", - tone: "muted", - mono: true, - numeric: true, - className: "shrink-0", - }, - [formatNumber(count)], - ), - ]), - ]), - ] - }, - toView: ({ field, rest }) => h.div([h.Class("contents")], [field, ...rest]), - }, - }) -} - -const textField = ( - h: H, - id: string, - value: string, - inputs: Readonly<{ placeholder: string; ariaLabel: string; className: string }>, - onInput: (value: string) => Message, -): Html => - input(h, { - id, - size: "sm", - className: inputs.className, - attributes: [ - h.Value(value), - h.Placeholder(inputs.placeholder), - h.AriaLabel(inputs.ariaLabel), - h.OnInput(onInput), - ], - }) - -const SESSION_PLACEHOLDER = { - referrerHost: "e.g. news.ycombinator.com", - utmSource: "e.g. twitter", - utmMedium: "e.g. cpc", - utmCampaign: "e.g. launch-week", - country: "e.g. DE", - host: "e.g. app.example.com", -} as const - -const text = (scope: StepScope, index: number, field: StepTextField) => (value: string) => - product(ProductEdit.ChangedStepText({ scope, index, field, value })) - -/** The step's value inputs: an event name, a page path and host, or a session dimension and value. */ -const valueInputs = ( - h: H, - model: Model, - options: StepBuilderOptions, - step: StepDraft, - index: number, -): ReadonlyArray => { - const { scope } = options - const label = `Step ${index + 1}` - switch (step.kind) { - case "event": - return [ - suggestingInput( - h, - model, - stepComboId(scope, index, "eventName"), - step.eventName, - options.eventNames, - { - placeholder: "Event name, e.g. signup_completed", - ariaLabel: `${label} event name`, - empty: "No events recorded in this window. Type a name.", - isMono: false, - }, - ), - ] - case "page": - return [ - suggestingInput( - h, - model, - stepComboId(scope, index, "pagePath"), - step.pagePath, - options.pagePaths, - { - placeholder: "Page path, e.g. /pricing", - ariaLabel: `${label} page path`, - empty: "No page paths in this window. Type a path.", - isMono: true, - }, - ), - textField( - h, - `widget-step-${scope}-${index}-host`, - step.host ?? "", - { - placeholder: "host (optional)", - ariaLabel: `${label} page host`, - className: "w-40 min-w-0 shrink-0 font-mono text-xs", - }, - text(scope, index, "host"), - ), - ] - case "session": - return [ - pageSelect( - h, - model, - stepDimensionSelectId(scope, index), - step.dimension, - FUNNEL_SESSION_DIMENSIONS.map((dimension) => ({ - value: dimension, - label: FUNNEL_SESSION_DIMENSION_LABEL[dimension], - })), - { - size: "sm", - className: "w-32 min-w-0 shrink-0", - ariaLabel: "Session dimension", - toView: (parts) => h.div([h.Class("contents")], parts), - }, - ), - textField( - h, - `widget-step-${scope}-${index}-value`, - step.value, - { - placeholder: SESSION_PLACEHOLDER[step.dimension], - ariaLabel: `${label} session value`, - className: "min-w-0 flex-1 font-mono text-xs", - }, - text(scope, index, "value"), - ), - ] - } -} - -const rowActions = (h: H, index: number, total: number): Html => - hstack(h, { className: "shrink-0" }, [ - iconButton( - h, - { - label: "Move step up", - size: "icon-xs", - isDisabled: index === 0, - attributes: [h.OnClick(product(ProductEdit.MovedStep({ index, delta: -1 })))], - }, - [icon(h, "ArrowUpIcon")], - ), - iconButton( - h, - { - label: "Move step down", - size: "icon-xs", - isDisabled: index === total - 1, - attributes: [h.OnClick(product(ProductEdit.MovedStep({ index, delta: 1 })))], - }, - [icon(h, "ArrowDownIcon")], - ), - iconButton( - h, - { - label: "Remove step", - size: "icon-xs", - attributes: [h.OnClick(product(ProductEdit.RemovedStep({ index })))], - }, - [icon(h, "XmarkIcon")], - ), - ]) - /** The event step's attribute filter line, its parse error under it. */ const filterLine = ( h: H, @@ -273,14 +40,20 @@ const filterLine = ( hstack(h, { gap: "1.5" }, [ eyebrow(h, { className: "shrink-0" }, ["where"]), whereEditor(h, { - id: `widget-step-${scope}-${index}-filter`, + id: stepFilterId(scope, index), value: filterClause, ariaLabel: `Step ${index + 1} attribute filter`, placeholder: 'plan = "pro" AND source = "cli"', rows: 1, className: "min-w-0 flex-1", textareaClassName: "min-h-[28px] resize-none text-xs", - onInput: text(scope, index, "filterClause"), + onInput: (value) => + Message.ChangedProduct({ + edit: ProductEdit.Step({ + scope, + edit: StepEdit.ChangedText({ index, field: "filterClause", value }), + }), + }), autocomplete: autocompleteFor(model, stepField(scope, index, filterClause)), }), ]), @@ -289,79 +62,21 @@ const filterLine = ( ) } -const stepRow = (h: H, model: Model, options: StepBuilderOptions, step: StepDraft, index: number): Html => { - const isSingle = options.maxSteps <= 1 - const total = isSingle ? 1 : options.steps.length - const kinds = - index === 0 && !isSingle ? (["event", "page", "session"] as const) : (["event", "page"] as const) - return panel(h, { className: "gap-1 overflow-visible px-1.5 py-1" }, [ - hstack(h, { gap: "1.5" }, [ - ...(isSingle - ? [] - : [ - h.span( - [ - h.Class( - "grid shrink-0 place-items-center rounded-sm bg-muted font-mono text-3xs tabular-nums text-muted-foreground size-5", - ), - h.AriaLabel(`Step ${index + 1}`), - ], - [String(index + 1)], - ), - ]), - pageSelect( - h, - model, - stepKindSelectId(options.scope, index), - step.kind, - kinds.map((kind) => ({ value: kind, label: KIND_LABEL[kind] })), - { - size: "sm", - className: "w-24 min-w-0 shrink-0", - ariaLabel: "Step type", - toView: (parts) => h.div([h.Class("contents")], parts), - }, - ), - h.div( - [h.Class("flex min-w-0 flex-1 items-center gap-1.5")], - valueInputs(h, model, options, step, index), - ), - ...(isSingle ? [] : [rowActions(h, index, total)]), - ]), - ...(options.eventStepFilter && step.kind === "event" - ? [filterLine(h, model, options.scope, step, index)] - : []), - ]) -} - /** `FunnelStepBuilder compact`. */ -export const stepBuilder = (h: H, model: Model, options: StepBuilderOptions): Html => { - const isSingle = options.maxSteps <= 1 - const isFull = options.steps.length >= options.maxSteps - return vstack(h, { gap: "1.5" }, [ - ...options.steps.map((step, index) => stepRow(h, model, options, step, index)), - ...(isSingle - ? [] - : [ - button( - h, - { - variant: "outline", - size: "sm", - isDisabled: isFull, - className: "w-fit", - attributes: [h.OnClick(product(ProductEdit.AddedStep()))], - }, - [ - icon(h, "PlusIcon"), - "Add step", - ...(isFull - ? [h.span([h.Class("text-muted-foreground")], [`(max ${options.maxSteps})`])] - : []), - ], - ), - ]), - ]) -} - -export { FUNNEL_MAX_STEPS } +export const stepBuilder = (h: H, model: Model, options: StepBuilderOptions): Html => + kitStepBuilder(h, { + scope: options.scope, + slotPrefix: "widget-editor", + steps: options.steps, + eventNames: options.eventNames, + pagePaths: options.pagePaths, + maxSteps: options.maxSteps, + combos: model.stepCombos, + selects: model.selects, + onCombo: (id, message) => Message.GotStepComboMessage({ id, message }), + onSelect: (id, message) => Message.GotSelectMessage({ id, message }), + onEdit: (edit) => Message.ChangedProduct({ edit: ProductEdit.Step({ scope: options.scope, edit }) }), + eventStepExtra: options.eventStepFilter + ? (step, index) => filterLine(h, model, options.scope, step, index) + : undefined, + }) diff --git a/apps/web/src/page/dashboards/widget/where-fields.scene.test.ts b/apps/web/src/page/dashboards/widget/where-fields.scene.test.ts index dd9951da51..01aaed3ab7 100644 --- a/apps/web/src/page/dashboards/widget/where-fields.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/where-fields.scene.test.ts @@ -9,7 +9,8 @@ import { WhereFieldMessage } from "../../kit/where-field" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" -import { ProductEdit, type StepScope } from "./product-edit" +import { StepEdit, type StepScope } from "../../kit/funnel-steps" +import { ProductEdit } from "./product-edit" import { init } from "./update" import { LIST_FILTER_ID, populationFilterId, stepFilterId } from "./where-fields" @@ -140,10 +141,13 @@ describe("widget where fields", () => { }) test("a funnel step's filter completes only `=`, and Enter writes it into the step", () => { - const onFunnel = opened(Message.ClickedPanelType({ panel: "funnel" }), edit(ProductEdit.AddedStep())) + const onFunnel = opened(Message.ClickedPanelType({ panel: "funnel" }), edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.Added() }))) const id = stepFilterId("funnel", 0) const typedIn = typed(onFunnel, id, "plan ", (value) => - ProductEdit.ChangedStepText({ scope: "funnel", index: 0, field: "filterClause", value }), + ProductEdit.Step({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 0, field: "filterClause", value }), + }), ) const accepted = run(typedIn, field(WhereFieldMessage.PressedEnter({ fieldId: id }))) expect(accepted.model.state?.funnel.steps[0]).toMatchObject({ filterClause: "plan = " }) diff --git a/apps/web/src/page/dashboards/widget/where-fields.ts b/apps/web/src/page/dashboards/widget/where-fields.ts index 9e480d3027..8e3f70883f 100644 --- a/apps/web/src/page/dashboards/widget/where-fields.ts +++ b/apps/web/src/page/dashboards/widget/where-fields.ts @@ -9,7 +9,8 @@ import type { WhereFieldSpec, WhereFieldsInputs } from "../../kit/where-field-up import type { ListDataSource } from "./editor-state" import { Message } from "./message" import type { Model } from "./model" -import { ProductEdit, type StepScope } from "./product-edit" +import { StepEdit, type StepScope } from "../../kit/funnel-steps" +import { ProductEdit } from "./product-edit" import { autocompleteWindowOf } from "./update" import { providerValuesOf } from "./where-inputs" @@ -75,7 +76,8 @@ export const stepField = (scope: StepScope, index: number, expression: string): values: {}, requestOf: noValues, }, - edit: (value) => ProductEdit.ChangedStepText({ scope, index, field: "filterClause", value }), + edit: (value) => + ProductEdit.Step({ scope, edit: StepEdit.ChangedText({ index, field: "filterClause", value }) }), }) /** diff --git a/apps/web/src/page/kit/funnel-steps.test.ts b/apps/web/src/page/kit/funnel-steps.test.ts new file mode 100644 index 0000000000..796be66b38 --- /dev/null +++ b/apps/web/src/page/kit/funnel-steps.test.ts @@ -0,0 +1,52 @@ +import { Option } from "effect" +import { describe, expect, test } from "vitest" +import { applyStepEdit, StepEdit, stepControlId, stepEditOf, type StepDraft } from "./funnel-steps" + +const steps: ReadonlyArray = [ + { kind: "session", dimension: "referrerHost", value: "google.com" }, + { kind: "event", eventName: "signup_completed" }, +] + +describe("applyStepEdit", () => { + test("a session step moved below step 1 becomes an empty event step", () => { + expect(applyStepEdit(steps, StepEdit.Moved({ index: 0, delta: 1 }), 10)).toEqual([ + { kind: "event", eventName: "signup_completed" }, + { kind: "event", eventName: "" }, + ]) + }) + + test("adding stops at the step limit", () => { + expect(applyStepEdit(steps, StepEdit.Added(), 2)).toBe(steps) + expect(applyStepEdit(steps, StepEdit.Added(), 3)).toHaveLength(3) + }) + + test("an emptied page host drops the key", () => { + const page: ReadonlyArray = [{ kind: "page", pagePath: "/", host: "maple.dev" }] + expect(applyStepEdit(page, StepEdit.ChangedText({ index: 0, field: "host", value: "" }), 10)).toEqual( + [{ kind: "page", pagePath: "/" }], + ) + }) +}) + +describe("stepEditOf", () => { + test("a control's value comes back as its builder's edit", () => { + expect(stepEditOf(stepControlId("funnel", 2, "eventName"), "query_run")).toEqual( + Option.some({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 2, field: "eventName", value: "query_run" }), + }), + ) + expect(stepEditOf(stepControlId("paths", 0, "kind"), "page")).toEqual( + Option.some({ scope: "paths", edit: StepEdit.SetKind({ index: 0, kind: "page" }) }), + ) + }) + + test("a control id works as a CSS id selector, which the combobox anchors its list with", () => { + expect(stepControlId("funnel", 3, "pagePath")).toMatch(/^[A-Za-z][\w-]*$/) + }) + + test("an unknown kind or another control's id is no edit", () => { + expect(Option.isNone(stepEditOf(stepControlId("funnel", 0, "kind"), "span"))).toBe(true) + expect(Option.isNone(stepEditOf("key-by:funnel", "person"))).toBe(true) + }) +}) diff --git a/apps/web/src/page/kit/funnel-steps.ts b/apps/web/src/page/kit/funnel-steps.ts new file mode 100644 index 0000000000..f5655f474d --- /dev/null +++ b/apps/web/src/page/kit/funnel-steps.ts @@ -0,0 +1,539 @@ +import { formatNumber } from "@maple/domain/format" +import { + FUNNEL_SESSION_DIMENSION_LABEL, + FunnelPageStep, + FunnelSessionDimension, + FunnelSessionStep, +} from "@maple/query-model" +import { cn } from "@maple/ui/lib/utils" +import { Command } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineTaggedUnion } from "foldkit/schema" +import { Option, Schema } from "effect" +import { button } from "@maple/views/ui/button" +import * as Combobox from "@maple/views/ui/combobox" +import * as ComboboxView from "@maple/views/ui/combobox-view" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { input } from "@maple/views/ui/input" +import { panel } from "@maple/views/ui/panel" +import * as Select from "@maple/views/ui/select" +import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { FUNNEL_SESSION_DIMENSIONS } from "../../components/funnels/definition" + +// The funnel step builder: an ordered list of steps, each an event name, a page path or (first +// step only) a session dimension. The dashboard funnel and paths editors and /product-analytics +// mount it; the caller keeps the combobox and select states and applies the `StepEdit`s. + +const StepKind = Schema.Literals(["event", "page", "session"]) +type StepKind = typeof StepKind.Type + +const StepTextField = Schema.Literals(["eventName", "pagePath", "host", "value", "filterClause"]) +type StepTextField = typeof StepTextField.Type + +/** Which builder a control belongs to: a funnel's steps, or a paths view's single anchor. */ +export const StepScope = Schema.Literals(["funnel", "paths"]) +export type StepScope = typeof StepScope.Type + +/** `FunnelStepDraft` (funnel-filters.ts): the wire step plus an event step's filter as typed. */ +export const StepDraft = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("event"), + eventName: Schema.String, + attributeEquals: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + filterClause: Schema.optionalKey(Schema.String), + }), + FunnelPageStep, + FunnelSessionStep, +]) +export type StepDraft = typeof StepDraft.Type + +/** A name to pick for a step, with how often it was seen (`null` when unknown). */ +export type StepSuggestion = Readonly<{ name: string; count: number | null }> + +/** What the builder's controls ask of the step list. */ +export const StepEdit = defineTaggedUnion({ + SetKind: { index: Schema.Number, kind: StepKind }, + ChangedText: { index: Schema.Number, field: StepTextField, value: Schema.String }, + SetDimension: { index: Schema.Number, dimension: FunnelSessionDimension }, + Moved: { index: Schema.Number, delta: Schema.Number }, + Removed: { index: Schema.Number }, + Added: {}, +}) +export type StepEdit = typeof StepEdit.Type + +/** `withKind`: a step's kind changed, carrying nothing over. */ +const withKind = (step: StepDraft, kind: StepKind): StepDraft => { + if (step.kind === kind) return step + switch (kind) { + case "event": + return { kind: "event", eventName: "" } + case "page": + return { kind: "page", pagePath: "" } + case "session": + return { kind: "session", dimension: "referrerHost", value: "" } + } +} + +/** A typed field of a step; an emptied host or filter drops its key. */ +const withText = (step: StepDraft, field: StepTextField, value: string): StepDraft => { + switch (step.kind) { + case "event": { + if (field === "eventName") return { ...step, eventName: value } + if (field !== "filterClause") return step + const { filterClause: _clause, ...rest } = step + return value === "" ? rest : { ...rest, filterClause: value } + } + case "page": { + if (field === "pagePath") return { ...step, pagePath: value } + if (field !== "host") return step + const { host: _host, ...rest } = step + return value === "" ? rest : { ...rest, host: value } + } + case "session": + return field === "value" ? { ...step, value } : step + } +} + +/** `move`: an item moved within the list; out of range is a no-op. */ +const move = (items: ReadonlyArray, from: number, to: number): ReadonlyArray => { + const item = items[from] + if (to < 0 || to >= items.length || item === undefined) return items + const rest = items.filter((_, index) => index !== from) + return [...rest.slice(0, to), item, ...rest.slice(to)] +} + +/** A step moved; a session step past step 1 is demoted to an event step (`reorder`). */ +const reorderSteps = (steps: ReadonlyArray, from: number, to: number): ReadonlyArray => + move(steps, from, to).map((step, index) => + index > 0 && step.kind === "session" ? withKind(step, "event") : step, + ) + +const updateAt = (items: ReadonlyArray, index: number, update: (item: T) => T): ReadonlyArray => + items.map((item, position) => (position === index ? update(item) : item)) + +/** A `StepEdit` applied to a step list; adding stops at `maxSteps`. */ +export const applyStepEdit = ( + steps: ReadonlyArray, + edit: StepEdit, + maxSteps: number, +): ReadonlyArray => { + switch (edit._tag) { + case "SetKind": + return updateAt(steps, edit.index, (step) => withKind(step, edit.kind)) + case "ChangedText": + return updateAt(steps, edit.index, (step) => withText(step, edit.field, edit.value)) + case "SetDimension": + return updateAt(steps, edit.index, (step) => + step.kind === "session" ? { ...step, dimension: edit.dimension } : step, + ) + case "Moved": + return reorderSteps(steps, edit.index, edit.index + edit.delta) + case "Removed": + return steps.filter((_, index) => index !== edit.index) + case "Added": + return steps.length >= maxSteps ? steps : [...steps, { kind: "event", eventName: "" }] + } +} + +/** A `StepEdit` applied to a paths anchor; an edit that would make it a session step is dropped. */ +export const applyAnchorEdit = (anchor: StepDraft, edit: StepEdit): StepDraft => { + const [next] = applyStepEdit([anchor], edit, 1) + return next === undefined || next.kind === "session" ? anchor : next +} + +// Control ids: `step---`, so one parent can hold several builders. No `:` or +// `.`: the combobox finds its input with the id in a CSS selector. + +type StepControl = "kind" | "dimension" | "eventName" | "pagePath" | "host" | "value" + +export const stepControlId = (scope: StepScope, index: number, control: StepControl): string => + `step-${control}-${scope}-${index}` + +export type ScopedStepEdit = Readonly<{ scope: StepScope; edit: StepEdit }> + +const decodeKind = Schema.decodeUnknownOption(StepKind) +const decodeScope = Schema.decodeUnknownOption(StepScope) +const decodeDimension = Schema.decodeUnknownOption(FunnelSessionDimension) + +/** A control's new value as the edit it makes, and the builder it belongs to; `None` for another id. */ +export const stepEditOf = (id: string, value: string): Option.Option => { + const match = /^step-(kind|dimension|eventName|pagePath)-(funnel|paths)-(\d+)$/.exec(id) + const [, control, rawScope, rawIndex] = match ?? [] + const scope = decodeScope(rawScope) + if (control === undefined || Option.isNone(scope) || rawIndex === undefined) return Option.none() + const index = Number(rawIndex) + const edit: Option.Option = + control === "kind" + ? Option.map(decodeKind(value), (kind) => StepEdit.SetKind({ index, kind })) + : control === "dimension" + ? Option.map(decodeDimension(value), (dimension) => + StepEdit.SetDimension({ index, dimension }), + ) + : Option.some( + StepEdit.ChangedText({ + index, + field: control === "pagePath" ? "pagePath" : "eventName", + value, + }), + ) + return Option.map(edit, (stepEdit) => ({ scope: scope.value, edit: stepEdit })) +} + +/** + * `SuggestingInput`'s update: the value is whatever was typed, and picking a suggestion types it. + * `edit` is the step edit that makes, and `isTyping` tells a keystroke from a pick. + */ +export const stepComboUpdate = ( + combos: Readonly>, + id: string, + message: Combobox.Message, + toMessage: (message: Combobox.Message) => M, +) => { + const next = Combobox.update(combos[id] ?? Combobox.init(id, "single"), message) + const typed: Option.Option = + message._tag === "ChangedInput" + ? Option.some(message.value) + : next.outMessage?._tag === "ChangedValue" + ? Option.some(next.outMessage.value) + : Option.none() + return { + combos: { ...combos, [id]: next.model }, + commands: Command.mapMessages(next.commands ?? [], toMessage), + edit: Option.flatMap(typed, (value) => stepEditOf(id, value)), + isTyping: message._tag === "ChangedInput", + } +} + +// View + +const KIND_LABEL = { event: "Event", page: "Page", session: "Session" } as const + +const SESSION_PLACEHOLDER = { + referrerHost: "e.g. news.ycombinator.com", + utmSource: "e.g. twitter", + utmMedium: "e.g. cpc", + utmCampaign: "e.g. launch-week", + country: "e.g. DE", + host: "e.g. app.example.com", +} as const + +export type StepBuilderOptions = Readonly<{ + /** Names this builder's controls apart from another one on the page. */ + scope: StepScope + /** Prefixes the submodel slots, unique per page. */ + slotPrefix: string + steps: ReadonlyArray + eventNames: ReadonlyArray + pagePaths: ReadonlyArray + maxSteps: number + combos: Readonly> + selects: Readonly> + onCombo: (id: string, message: Combobox.Message) => M + onSelect: (id: string, message: Select.Message) => M + onEdit: (edit: StepEdit) => M + /** Drawn under an event step's row: the dashboard's attribute filter. */ + eventStepExtra?: ((step: StepDraft & { kind: "event" }, index: number) => Html) | undefined +}> + +const stepSelect = ( + h: HtmlBuilder, + options: StepBuilderOptions, + id: string, + value: string, + items: ReadonlyArray, + inputs: Readonly<{ className: string; ariaLabel: string }>, +): Html => { + const index = items.findIndex((item) => item.value === value) + const current = + options.selects[id] ?? Select.init(id, { value, selectedIndex: index === -1 ? null : index }) + return h.submodel({ + slotId: `${options.slotPrefix}-select-${id}`, + model: { ...current, value }, + view: selectView, + toParentMessage: (message) => options.onSelect(id, message), + viewInputs: { + entries: items.map((item) => ({ _tag: "Item" as const, item })), + size: "sm", + className: inputs.className, + ariaLabel: inputs.ariaLabel, + toView: (parts) => h.div([h.Class("contents")], parts), + }, + }) +} + +/** `SuggestingInput`: free text, with the window's names to pick from. */ +const suggestingInput = ( + h: HtmlBuilder, + options: StepBuilderOptions, + id: string, + value: string, + suggestions: ReadonlyArray, + inputs: Readonly<{ placeholder: string; ariaLabel: string; empty: string; isMono: boolean }>, +): Html => { + const names = suggestions.map((suggestion) => suggestion.name) + const current = options.combos[id] ?? Combobox.init(id, "single") + const counts = new Map(suggestions.map((suggestion) => [suggestion.name, suggestion.count])) + return h.submodel({ + slotId: `${options.slotPrefix}-${id}`, + model: { ...current, inputValue: value, selected: names.includes(value) ? [value] : [] }, + view: ComboboxView.view, + toParentMessage: (message) => options.onCombo(id, message), + viewInputs: { + items: names, + placeholder: inputs.placeholder, + ariaLabel: inputs.ariaLabel, + emptyText: inputs.empty, + size: "sm", + showTrigger: names.length > 0, + className: cn("min-w-0 flex-1", inputs.isMono && typographyClass({ size: "xs", mono: true })), + renderItem: (name) => { + const count = counts.get(name) + return [ + hstack(h, { gap: "2", shrink: true, as: "span" }, [ + typography( + h, + { + truncate: true, + size: inputs.isMono ? "xs" : undefined, + mono: inputs.isMono, + className: "min-w-0 flex-1", + }, + [name], + ), + ...(count === null || count === undefined + ? [] + : [ + typography( + h, + { + size: "3xs", + tone: "muted", + mono: true, + numeric: true, + className: "shrink-0", + }, + [formatNumber(count)], + ), + ]), + ]), + ] + }, + toView: ({ field, rest }) => h.div([h.Class("contents")], [field, ...rest]), + }, + }) +} + +const textField = ( + h: HtmlBuilder, + id: string, + value: string, + inputs: Readonly<{ placeholder: string; ariaLabel: string; className: string }>, + onInput: (value: string) => M, +): Html => + input(h, { + id, + size: "sm", + className: inputs.className, + attributes: [ + h.Value(value), + h.Placeholder(inputs.placeholder), + h.AriaLabel(inputs.ariaLabel), + h.OnInput(onInput), + ], + }) + +/** The step's value inputs: an event name, a page path and host, or a session dimension and value. */ +const valueInputs = ( + h: HtmlBuilder, + options: StepBuilderOptions, + step: StepDraft, + index: number, +): ReadonlyArray => { + const { scope } = options + const label = `Step ${index + 1}` + const text = (field: StepTextField) => (value: string) => + options.onEdit(StepEdit.ChangedText({ index, field, value })) + switch (step.kind) { + case "event": + return [ + suggestingInput( + h, + options, + stepControlId(scope, index, "eventName"), + step.eventName, + options.eventNames, + { + placeholder: "Event name, e.g. signup_completed", + ariaLabel: `${label} event name`, + empty: "No events recorded in this window. Type a name.", + isMono: false, + }, + ), + ] + case "page": + return [ + suggestingInput( + h, + options, + stepControlId(scope, index, "pagePath"), + step.pagePath, + options.pagePaths, + { + placeholder: "Page path, e.g. /pricing", + ariaLabel: `${label} page path`, + empty: "No page paths in this window. Type a path.", + isMono: true, + }, + ), + textField( + h, + stepControlId(scope, index, "host"), + step.host ?? "", + { + placeholder: "host (optional)", + ariaLabel: `${label} page host`, + className: "w-40 min-w-0 shrink-0 font-mono text-xs", + }, + text("host"), + ), + ] + case "session": + return [ + stepSelect( + h, + options, + stepControlId(scope, index, "dimension"), + step.dimension, + FUNNEL_SESSION_DIMENSIONS.map((dimension) => ({ + value: dimension, + label: FUNNEL_SESSION_DIMENSION_LABEL[dimension], + })), + { className: "w-32 min-w-0 shrink-0", ariaLabel: "Session dimension" }, + ), + textField( + h, + stepControlId(scope, index, "value"), + step.value, + { + placeholder: SESSION_PLACEHOLDER[step.dimension], + ariaLabel: `${label} session value`, + className: "min-w-0 flex-1 font-mono text-xs", + }, + text("value"), + ), + ] + } +} + +const rowActions = ( + h: HtmlBuilder, + options: StepBuilderOptions, + index: number, + total: number, +): Html => + hstack(h, { className: "shrink-0" }, [ + iconButton( + h, + { + label: "Move step up", + size: "icon-xs", + isDisabled: index === 0, + attributes: [h.OnClick(options.onEdit(StepEdit.Moved({ index, delta: -1 })))], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: "Move step down", + size: "icon-xs", + isDisabled: index === total - 1, + attributes: [h.OnClick(options.onEdit(StepEdit.Moved({ index, delta: 1 })))], + }, + [icon(h, "ArrowDownIcon")], + ), + iconButton( + h, + { + label: "Remove step", + size: "icon-xs", + attributes: [h.OnClick(options.onEdit(StepEdit.Removed({ index })))], + }, + [icon(h, "XmarkIcon")], + ), + ]) + +const stepRow = ( + h: HtmlBuilder, + options: StepBuilderOptions, + step: StepDraft, + index: number, +): Html => { + const isSingle = options.maxSteps <= 1 + const total = isSingle ? 1 : options.steps.length + const kinds = + index === 0 && !isSingle ? (["event", "page", "session"] as const) : (["event", "page"] as const) + return panel(h, { className: "gap-1 overflow-visible px-1.5 py-1" }, [ + hstack(h, { gap: "1.5" }, [ + ...(isSingle + ? [] + : [ + h.span( + [ + h.Class( + "grid shrink-0 place-items-center rounded-sm bg-muted font-mono text-3xs tabular-nums text-muted-foreground size-5", + ), + h.AriaLabel(`Step ${index + 1}`), + ], + [String(index + 1)], + ), + ]), + stepSelect( + h, + options, + stepControlId(options.scope, index, "kind"), + step.kind, + kinds.map((kind) => ({ value: kind, label: KIND_LABEL[kind] })), + { className: "w-24 min-w-0 shrink-0", ariaLabel: "Step type" }, + ), + hstack(h, { gap: "1.5", grow: true, className: "min-w-0" }, valueInputs(h, options, step, index)), + ...(isSingle ? [] : [rowActions(h, options, index, total)]), + ]), + ...(options.eventStepExtra !== undefined && step.kind === "event" + ? [options.eventStepExtra(step, index)] + : []), + ]) +} + +/** `FunnelStepBuilder compact`: one row per step, then Add step (a single anchor has neither numbers nor Add). */ +export const stepBuilder = (h: HtmlBuilder, options: StepBuilderOptions): Html => { + const isSingle = options.maxSteps <= 1 + const isFull = options.steps.length >= options.maxSteps + return vstack(h, { gap: "1.5" }, [ + ...options.steps.map((step, index) => stepRow(h, options, step, index)), + ...(isSingle + ? [] + : [ + button( + h, + { + variant: "outline", + size: "sm", + isDisabled: isFull, + className: "w-fit", + attributes: [h.OnClick(options.onEdit(StepEdit.Added()))], + }, + [ + icon(h, "PlusIcon"), + "Add step", + ...(isFull + ? [typography(h, { tone: "muted" }, [`(max ${options.maxSteps})`])] + : []), + ], + ), + ]), + ]) +} diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 82fe4f60cb..506c42874a 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -182,6 +182,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/custom-range.ts` `readToday`, `parseTimeInput`, `addToRange`, `appliedRange` (stateful: Model, init, update): The custom range picker: a two-month range calendar and two time inputs in the selected zone's wall clock. - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. +- `apps/web/src/page/kit/funnel-steps.ts` `applyStepEdit`, `applyAnchorEdit`, `stepControlId`, `stepEditOf`, `stepComboUpdate`, `stepBuilder`: The funnel step builder: an ordered list of steps, each an event name, a page path or (first step only) a session dimension. - `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. - `apps/web/src/page/kit/infra-correlation/update.ts` `chartIdOf`, `requestsOf` (stateful: Model, init, update): A chart's slot and chart id, `-` (the linked-cursor id). From a35cb9010d0f2cfcfdfba7ac860b0f9d87c0f189 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:12:39 +0100 Subject: [PATCH 2/3] test(web): drive the widget editor's step combobox and kind select The step edits arrive through the kit's control ids; this covers the combobox and select paths end to end, not just the parsed ids. --- .../dashboards/widget/product.scene.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/dashboards/widget/product.scene.test.ts b/apps/web/src/page/dashboards/widget/product.scene.test.ts index d1a54ff722..e3e7fecea9 100644 --- a/apps/web/src/page/dashboards/widget/product.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/product.scene.test.ts @@ -9,7 +9,8 @@ import { buildDisplay, validationErrorOf } from "./lowering" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" -import { StepEdit } from "../../kit/funnel-steps" +import { StepEdit, stepControlId } from "../../kit/funnel-steps" +import * as Combobox from "@maple/views/ui/combobox" import { ProductEdit } from "./product-edit" import { setDataSource } from "./edits" import { targetIndex } from "./list-column-drag" @@ -159,6 +160,27 @@ describe("list, funnel and paths panels", () => { expect(step.model.state?.funnel.steps.map((candidate) => candidate.kind)).toEqual(["event", "event"]) }) + test("typing in a step's combobox and picking its kind edit that step", () => { + const step = run( + opened(), + Message.ClickedPanelType({ panel: "funnel" }), + edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.Added() })), + edit(ProductEdit.Step({ scope: "funnel", edit: StepEdit.Added() })), + Message.GotStepComboMessage({ + id: stepControlId("funnel", 1, "eventName"), + message: Combobox.Message.ChangedInput({ value: "checkout_started" }), + }), + Message.GotSelectMessage({ + id: stepControlId("funnel", 0, "kind"), + message: Select.Message.SelectedItem({ index: 1, value: "page" }), + }), + ) + expect(step.model.state?.funnel.steps).toEqual([ + { kind: "page", pagePath: "" }, + { kind: "event", eventName: "checkout_started" }, + ]) + }) + test("the funnel's view writes only its rendering flags on a query-set funnel", () => { const step = run( opened(), From 3a1f118c10bf4c0d68913798d33e2f2755052e26 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 12:17:04 +0100 Subject: [PATCH 3/3] test(web): the step combobox's typed and picked branches --- apps/web/src/page/kit/funnel-steps.test.ts | 42 +++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/kit/funnel-steps.test.ts b/apps/web/src/page/kit/funnel-steps.test.ts index 796be66b38..54805e34d3 100644 --- a/apps/web/src/page/kit/funnel-steps.test.ts +++ b/apps/web/src/page/kit/funnel-steps.test.ts @@ -1,6 +1,14 @@ import { Option } from "effect" import { describe, expect, test } from "vitest" -import { applyStepEdit, StepEdit, stepControlId, stepEditOf, type StepDraft } from "./funnel-steps" +import * as Combobox from "@maple/views/ui/combobox" +import { + applyStepEdit, + StepEdit, + stepComboUpdate, + stepControlId, + stepEditOf, + type StepDraft, +} from "./funnel-steps" const steps: ReadonlyArray = [ { kind: "session", dimension: "referrerHost", value: "google.com" }, @@ -50,3 +58,35 @@ describe("stepEditOf", () => { expect(Option.isNone(stepEditOf("key-by:funnel", "person"))).toBe(true) }) }) + +describe("stepComboUpdate", () => { + const id = stepControlId("funnel", 1, "eventName") + const toMessage = (message: Combobox.Message) => message + + test("a typed name edits the step and says it is still typing", () => { + const next = stepComboUpdate({}, id, Combobox.Message.ChangedInput({ value: "sig" }), toMessage) + expect(next.isTyping).toBe(true) + expect(next.edit).toEqual( + Option.some({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 1, field: "eventName", value: "sig" }), + }), + ) + }) + + test("a picked suggestion edits the step and is not typing", () => { + const next = stepComboUpdate( + {}, + id, + Combobox.Message.ClickedItem({ value: "signup_completed" }), + toMessage, + ) + expect(next.isTyping).toBe(false) + expect(next.edit).toEqual( + Option.some({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 1, field: "eventName", value: "signup_completed" }), + }), + ) + }) +})