import type { SerializedTree } from "@charcuterie/logic" import { createTree, useTree } from "@charcuterie/logic" import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import type { ReactNode } from "react" import { useState } from "react" import { StorySection } from "../board.storyHelpers.tsx" import { Picker } from "../Picker/Picker.tsx" import { QueryBuilder } from "./QueryBuilder.tsx" /** * The concrete demo leaf — a field/operator/value triple, Mail * Sifter's shape. `QueryBuilder` never sees this type; it flows * through `renderLeaf` and `createLeafValue` untouched, which is what * lets mux-magic pass a non-uniform per-kind shape instead. */ type DemoLeaf = { field: string operator: string value: string } type Combinator = "and" | "or" const COMBINATOR_OPTIONS: readonly { label: string value: Combinator }[] = [ { label: "ALL — every condition", value: "and" }, { label: "ANY — one condition", value: "or" }, ] const FIELD_OPTIONS = [ { label: "Subject", value: "subject" }, { label: "From", value: "from" }, { label: "Body", value: "body" }, ] as const const OPERATOR_OPTIONS = [ { label: "contains", value: "contains" }, { label: "is", value: "is" }, { label: "starts with", value: "startsWith" }, ] as const const createLeafValue = (): DemoLeaf => ({ field: "subject", operator: "contains", value: "", }) const renderLeaf = ({ onChange, value, }: { nodeId: string onChange: (value: DemoLeaf) => void value: DemoLeaf }): ReactNode => (
{ onChange({ ...value, field }) }} options={FIELD_OPTIONS} size="sm" value={value.field} /> { onChange({ ...value, operator }) }} options={OPERATOR_OPTIONS} size="sm" value={value.operator} /> { onChange({ ...value, value: changeEvent.currentTarget.value, }) }} value={value.value} />
) const FLAT_TREE: SerializedTree = { children: [ { kind: "leaf", value: { field: "from", operator: "contains", value: "@newsletter", }, }, { kind: "leaf", value: { field: "subject", operator: "contains", value: "sale", }, }, ], combinator: "and", kind: "group", } const NESTED_TREE: SerializedTree = { children: [ { kind: "leaf", value: { field: "from", operator: "is", value: "billing@acme.test", }, }, { children: [ { kind: "leaf", value: { field: "subject", operator: "contains", value: "invoice", }, }, { kind: "leaf", value: { field: "subject", operator: "contains", value: "receipt", }, }, ], combinator: "or", kind: "group", }, ], combinator: "and", kind: "group", } const QueryBuilderHarness = ({ initialTree, }: { initialTree?: SerializedTree }): ReactNode => { const tree = useTree({ defaultCombinator: "and", initialTree, }) return ( ) } const meta = { title: "Components/Controls/QueryBuilder", component: QueryBuilder, parameters: { layout: "padded" }, args: { combinatorOptions: COMBINATOR_OPTIONS, createLeafValue, renderLeaf, tree: createTree({ defaultCombinator: "and", }), }, } satisfies Meta> export default meta type Story = StoryObj /** * A flat group of two conditions joined by ALL. Change the combinator, * edit a condition, or add another with "+ Add condition". */ export const Playground: Story = { parameters: playgroundParameters, render: () => ( ), } /** * Two shapes side by side: a flat rule, and a nested one where a * sub-group joined by ANY sits inside a group joined by ALL. The * nested group wears the inline-start accent rail that marks depth. */ export const AllStates: Story = { render: () => (
), } /** * Drive it: add a condition, add a nested group, flip a combinator, * delete a row. Starts from a single condition so every control is * unique and nameable. */ export const Interactive: Story = { render: () => ( ), } /** * `renderCombinator` — the group's combinator control, owned by the app. * * The default single picker is right when a combinator is a plain enum. * mux-magic's is not: it is a **quantifier** (ANY/ALL/NO) crossed with a * **target** (these groups, style rows, script-info blocks), and the legal * pairs are asymmetric — the DSL has `notAllScriptInfo` and no * `notAllStyle`. Flattened to one list, that asymmetry is invisible; split * in two, the second picker filters and the illegal pair cannot be built. * * This story is that shape in miniature: pick `NOT ALL` and the target * list collapses to the one target it can legally take. */ const PAIR_COMBINATORS = { "all:group": "and", "all:row": "and", "any:group": "or", "any:row": "or", "notAll:group": "and", } as const const QUANTIFIER_OPTIONS = [ { label: "ALL", value: "all" }, { label: "ANY", value: "any" }, { label: "NOT ALL", value: "notAll" }, ] as const const CombinatorPair = ({ onChange, value, }: { onChange: (combinator: Combinator) => void value: Combinator }): ReactNode => { const [quantifier, setQuantifier] = useState("all") // "NOT ALL" is legal over groups only here — the same filtered // second picker mux-magic needs, in one line. const targetOptions = quantifier === "notAll" ? [{ label: "of these groups", value: "group" }] : [ { label: "of these groups", value: "group" }, { label: "matching rows", value: "row" }, ] const [target, setTarget] = useState("group") const resolvedTarget = quantifier === "notAll" ? "group" : target return (
Match
{ setQuantifier(nextQuantifier) const nextTarget = nextQuantifier === "notAll" ? "group" : resolvedTarget onChange( PAIR_COMBINATORS[ `${nextQuantifier}:${nextTarget}` as keyof typeof PAIR_COMBINATORS ], ) }} options={QUANTIFIER_OPTIONS} size="sm" value={quantifier} /> { setTarget(nextTarget) onChange( PAIR_COMBINATORS[ `${quantifier}:${nextTarget}` as keyof typeof PAIR_COMBINATORS ], ) }} options={targetOptions} size="sm" value={resolvedTarget} />
{`Combinator: ${String(value)}`}
) } export const CustomCombinator: Story = { render: () => { const CustomCombinatorHarness = (): ReactNode => { const tree = useTree({ defaultCombinator: "and", initialTree: FLAT_TREE, }) return ( ( )} renderLeaf={renderLeaf} tree={tree} /> ) } return }, }