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 => (
)
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
},
}