Segment Builder
Build an audience from conditions, with precedence shown as nesting rather than implied by a word — and the output a structure, not a concatenated string.
src/components/ui/segment-builder.tsxAbout
From the component's own source, where it was written.
Build an audience from conditions, with precedence visible rather than implied.
**The join is a rail, not a column of dropdowns.** A flat list with an and/or select on every line is ambiguous — "A or B and C" is two different audiences depending on how it binds, and people read it the way English reads, not the way the engine evaluates. Here a group has exactly one join, shown once on a bracket down its left edge and clickable there. The bracket is the parse tree: what it spans is what the join applies to, and nesting is the only way to say anything else.
**There is a plain-language summary underneath.** It is the cheapest possible check on the thing that goes wrong — parentheses — and it costs one pass over a tree the component already holds. If the sentence is not the audience you meant, the query is not either.
**The output is a structure, not a string.** onChange hands back nested groups, so it compiles to SQL or an API filter with values still separated from operators, which is what keeps it parameterisable rather than concatenated into an injection.
**Incomplete rows are marked, not silently dropped.** A condition with no value is a half-written thought; treating it as absent means the segment quietly matches more people than the person building it believes, which for a marketing send is an expensive kind of wrong.
The estimate is a prop, not something computed here — only your backend knows how many people match.
Install
npx astralyx-ui add segment-builder
Usage
import { SegmentBuilder } from '@/components/ui/segment-builder'
<SegmentBuilder fields={fields} value={segment} onChange={setSegment} />Composer
Change the props and the source below follows.
Segment
≈ 2,191 people
Matches: Plan is Team and Seats ≥ 5 and (Country is DE or Country is FR)
<SegmentBuilder
fields={fields}
value={segment}
onChange={setSegment}
maxDepth={2}
/>Examples
Team plans in DE or FR
<SegmentBuilder fields={fields} value={segment} onChange={setSegment} />API
Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.
SegmentBuilder
Extends Omit<ComponentProps<'div'>, 'onChange'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultValue | string | number | readonly string[] | — | — |
| fields | FieldSpec[] | required | { key, label, type?, options?, operators? }. The type picks the operator list and the value control; operators replaces that list when the caller knows more than a type name conveys — a SQL dialect with ILIKE, a metric with a percentile comparison. |
| value | ConditionGroup | — | Nested groups of conditions, each with one join. Not a string — so it compiles to SQL or an API filter with values still separated from operators, which is what keeps it parameterisable rather than concatenated. |
| onChange | (group: ConditionGroup) => void | — | — |
| estimate | ReactNode | — | Supplied by the caller — only your backend knows how many people match. |
| maxDepth | number | 2 | Depth of nesting allowed. |
| summary | boolean | true | Hide the plain-language summary. |
| addLabel | string | 'Condition' | — |
| addGroupLabel | string | 'Group' | — |
| label | string | 'Segment' | — |
| emptyLabel | string | 'No conditions — this matches everyone.' | — |
| disabled | boolean | — | — |
| rows | marked, not dropped | — | A condition with no value is a half-written thought. Treating it as absent means the segment quietly matches more people than the author believes — expensive for a marketing send. |
Functions
| Function | Signature | Description |
|---|---|---|
| describeSegment | (node: Condition | ConditionGroup, fields: FieldSpec[], top = true) => string | The tree as a sentence, with real parentheses. |
Types
FieldSpec
Build an audience from conditions, with precedence visible rather than implied.
| Field | Type | Description |
|---|---|---|
| key | string | — |
| label | string | — |
| type? | 'string' | 'number' | 'date' | 'boolean' | 'enum' | — |
| options? | { value: string; label: string }[] | For enum, the allowed values. |
| operators? | Operator[] | Operators for this field, replacing the ones its type implies. |
Condition
| Field | Type | Description |
|---|---|---|
| id | string | — |
| field | string | — |
| operator | string | — |
| value | string | — |
| values? | string[] | For many operators — "is one of", IN. |
ConditionGroup
| Field | Type | Description |
|---|---|---|
| id | string | — |
| join | 'and' | 'or' | — |
| conditions | (Condition | ConditionGroup)[] | — |
Operator
| Field | Type | Description |
|---|---|---|
| value | string | — |
| label | string | — |
| arity? | 'none' | 'one' | 'many' | "Is one of" was in the operator list from the start, rendered as a single select — which quietly made it a synonym for "is". A many operator now takes a list through [[tag-input]], and switching between arities clears the side that no longer applies rather than leaving a stale value to widen the segment later. |
| unary? | boolean | — |
Behaviour
| Topic | Detail | Description |
|---|---|---|
| precedence | one join per group | "A or B and C" means two different audiences depending on how it binds. The join is set once at the head of a group and echoed read-only between its rows, so the box you see is the parse tree — a dropdown on every line is what makes a flat builder ambiguous. |
| nesting | tinted, not just indented | The boundary of a group carries the precedence, so it is drawn as a filled box that deepens with depth rather than a margin you have to measure by eye. |