Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 1 addition & 15 deletions apps/web/src/page/dashboards/widget/editor-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

export { QueryDraft } from "../../kit/query-builder/draft"
Expand Down Expand Up @@ -48,19 +47,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,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/page/dashboards/widget/lowering-product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
PRODUCT_EVENTS_PATHS_ENDPOINT,
} from "@maple/widgets/dashboard"
import { Option, Schema, Array as Arr, String as Str } from "effect"
import type { FunnelDraft, PathsDraft, StepDraft } from "./editor-state"
import type { StepDraft } from "../../kit/funnel-steps"
import type { FunnelDraft, PathsDraft } from "./editor-state"

type Widget = DashboardDocument["widgets"][number]

Expand Down
91 changes: 14 additions & 77 deletions apps/web/src/page/dashboards/widget/product-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,15 @@ import {
DEFAULT_FUNNEL_WINDOW_SECONDS,
FUNNEL_SESSION_DIMENSIONS,
} from "../kit/funnel-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"
import { Array as Arr, String as Str, Option } from "effect"

// What each `ProductEdit` does to the editor state.

/** 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: "" }
}
}

/** An item moved within the list; out of range is a no-op. */
const move = <T>(items: ReadonlyArray<T>, from: number, to: number): ReadonlyArray<T> => {
const item = items[from]
Expand All @@ -31,42 +20,18 @@ const move = <T>(items: ReadonlyArray<T>, 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 = <T>(items: ReadonlyArray<T>, index: number, update: (item: T) => T): ReadonlyArray<T> =>
Arr.map(items, (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,
Expand Down Expand Up @@ -129,12 +94,6 @@ const withBreakdown = (current: FunnelDraft, value: string | null): FunnelDraft
)
}

/** A funnel step moved; a session step past step 1 is demoted to an event step. */
const reorderSteps = (steps: ReadonlyArray<StepDraft>, from: number, to: number): ReadonlyArray<StepDraft> =>
Arr.map(move(steps, from, to), (step, index) =>
index > 0 && step.kind === "session" ? withKind(step, "event") : step,
)

export const applyProductEdit = (state: EditorState, edit: ProductEdit): EditorState =>
ProductEdit.match<EditorState>(edit, {
// A new source starts over: its default columns, no filter.
Expand Down Expand Up @@ -173,29 +132,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: Arr.filter(current.steps, (_, 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 }))
Expand Down
25 changes: 4 additions & 21 deletions apps/web/src/page/dashboards/widget/product-edit.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
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"

/**
* The edits of the list, funnel and paths panels, 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"])

Expand All @@ -39,12 +26,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 },
Expand Down
27 changes: 11 additions & 16 deletions apps/web/src/page/dashboards/widget/product-selects.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { FunnelKeyBy, FunnelSessionDimension } from "@maple/query-model"
import { FunnelKeyBy } from "@maple/query-model"
import { Option, Schema, String as Str } 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.

Expand All @@ -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<ProductEdit> => {
const [name, first, second] = Str.split(":")(id)
export const productSelectEdit = (id: string, value: string): Option.Option<ProductEdit> =>
Option.orElse(
Option.map(stepEditOf(id, value), (step) => ProductEdit.Step(step)),
() => panelSelectEdit(id, value),
)

const panelSelectEdit = (id: string, value: string): Option.Option<ProductEdit> => {
const [name, first] = Str.split(":")(id)
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 }),
Expand Down
39 changes: 7 additions & 32 deletions apps/web/src/page/dashboards/widget/product-step.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { resolveTimeRangeWindow } from "@maple/query-engine"
import { Option, Array as Arr } 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"

Expand Down Expand Up @@ -68,23 +68,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<Readonly<{ scope: StepScope; index: number; field: StepTextField }>> => {
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",
})
}

/** The value is whatever was typed, and picking a suggestion types it. */
export const stepComboStep = (
model: Model,
Expand All @@ -93,21 +76,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. */
Expand Down
Loading