Use this page when people define a group of people by rules on their attributes and activity, such as everyone on the Growth plan who signed up in the last 30 days. They name the group, build the conditions everyone in it has to match, and see how many people match as they edit. Use a list with filters instead when people only narrow a list for the moment and don't save the rules.
Group people by who they are and what they do. People join and leave the segment as their data changes.
import {
Button,
ButtonGroup,
ConditionEditor,
ConditionEditorItem,
Form,
FormLayout,
InlineStack,
Page,
PageBody,
PageBodyContent,
PageHeader,
PageHeaderDescription,
PageHeaderTitle,
PageRail,
PageSection,
Panel,
PanelDescription,
PanelHeader,
PanelSection,
PanelTitle,
Select,
SkeletonText,
Stack,
Text,
TextField,
} from '@customerio/pluma-components/react';
export type SegmentBuilderAttributeOperator = 'eq' | 'neq' | 'contains' | 'exists' | 'not_exists';
export type SegmentBuilderEventOperator = 'performed' | 'not_performed';
export type SegmentBuilderTimeframe = '7d' | '30d' | '90d' | 'ever';
/** Matches people by the value of one of their attributes. */
export interface SegmentBuilderAttributeCondition {
id: string;
type: 'attribute';
/** The attribute name, or `null` until one is chosen. */
attribute: string | null;
operator: SegmentBuilderAttributeOperator;
/** The value to compare with. Unused by `exists` and `not_exists`. */
value: string;
}
/** Matches people by whether they performed an event within a timeframe. */
export interface SegmentBuilderEventCondition {
id: string;
type: 'event';
/** The event name, or `null` until one is chosen. */
eventName: string | null;
operator: SegmentBuilderEventOperator;
timeframe: SegmentBuilderTimeframe;
}
export type SegmentBuilderCondition = SegmentBuilderAttributeCondition | SegmentBuilderEventCondition;
const attributeOperatorOptions: { value: SegmentBuilderAttributeOperator; label: string }[] = [
{ value: 'eq', label: 'is equal to' },
{ value: 'neq', label: 'is not equal to' },
{ value: 'contains', label: 'contains' },
{ value: 'exists', label: 'exists' },
{ value: 'not_exists', label: 'does not exist' },
];
const eventOperatorOptions: { value: SegmentBuilderEventOperator; label: string }[] = [
{ value: 'performed', label: 'was performed' },
{ value: 'not_performed', label: 'was not performed' },
];
const timeframeOptions: { value: SegmentBuilderTimeframe; label: string }[] = [
{ value: '7d', label: 'in the last 7 days' },
{ value: '30d', label: 'in the last 30 days' },
{ value: '90d', label: 'in the last 90 days' },
{ value: 'ever', label: 'at any time' },
];
const countFormatter = new Intl.NumberFormat();
// Select reports the picked value as a plain string, or null. Looking it up in
// the options gives it back as the options' own type, and ignores anything else.
function findOptionValue<T extends string>(options: { value: T }[], value: string | null) {
return options.find((option) => option.value === value)?.value;
}
function takesValue(operator: SegmentBuilderAttributeOperator) {
return operator !== 'exists' && operator !== 'not_exists';
}
function newAttributeCondition(): SegmentBuilderAttributeCondition {
return { id: crypto.randomUUID(), type: 'attribute', attribute: null, operator: 'eq', value: '' };
}
function newEventCondition(): SegmentBuilderEventCondition {
return { id: crypto.randomUUID(), type: 'event', eventName: null, operator: 'performed', timeframe: '30d' };
}
export interface SegmentBuilderProps {
/** The segment's name. */
name: string;
/** The segment's conditions, in order. A person is in the segment while they match all of them. */
conditions: SegmentBuilderCondition[];
/** The attribute names people in this workspace have, offered in an attribute condition. */
attributeNames: string[];
/** The event names this workspace has received, offered in an event condition. */
eventNames: string[];
/** How many people match the current conditions. */
matchingPeopleCount: number;
/** Whether the count for the current conditions is loading. */
isCountLoading: boolean;
/** Called on every keystroke in the name field. */
onNameInput: (event: Event & { target: HTMLInputElement }) => void;
/** Called with the whole list of conditions after any of them is added, removed or edited. Recount from it. */
onConditionsChange: (conditions: SegmentBuilderCondition[]) => void;
/** Called when the form is submitted. Return a promise to lock the fields until it settles. */
onSave: () => void | Promise<void>;
/** Called when the user leaves without creating the segment. */
onCancel: () => void;
}
export function SegmentBuilder({
name,
conditions,
attributeNames,
eventNames,
matchingPeopleCount,
isCountLoading,
onNameInput,
onConditionsChange,
onSave,
onCancel,
}: SegmentBuilderProps) {
const attributeOptions = attributeNames.map((attribute) => ({ value: attribute, label: attribute }));
const eventOptions = eventNames.map((eventName) => ({ value: eventName, label: eventName }));
const replaceCondition = (next: SegmentBuilderCondition) => {
onConditionsChange(conditions.map((condition) => (condition.id === next.id ? next : condition)));
};
const removeCondition = (id: string) => {
onConditionsChange(conditions.filter((condition) => condition.id !== id));
};
const changeAttributeOperator = (condition: SegmentBuilderAttributeCondition, value: string | null) => {
const operator = findOptionValue(attributeOperatorOptions, value);
if (operator != null) {
replaceCondition({ ...condition, operator });
}
};
const changeEventOperator = (condition: SegmentBuilderEventCondition, value: string | null) => {
const operator = findOptionValue(eventOperatorOptions, value);
if (operator != null) {
replaceCondition({ ...condition, operator });
}
};
const changeTimeframe = (condition: SegmentBuilderEventCondition, value: string | null) => {
const timeframe = findOptionValue(timeframeOptions, value);
if (timeframe != null) {
replaceCondition({ ...condition, timeframe });
}
};
return (
<Page isFullScreen bodyContentWidth="narrow">
<PageHeader>
<PageHeaderTitle>Create segment</PageHeaderTitle>
<PageHeaderDescription>
Group people by who they are and what they do. People join and leave the segment as their data changes.
</PageHeaderDescription>
</PageHeader>
<PageBody>
<PageBodyContent>
<Form onSubmit={onSave}>
<PageSection>
<Stack gap="300">
<Panel>
<PanelSection>
<FormLayout>
<TextField
label="Name"
description="Shown in your list of segments and wherever you choose a segment, such as a campaign trigger."
value={name}
onInput={onNameInput}
/>
</FormLayout>
</PanelSection>
</Panel>
<Panel>
<PanelHeader>
<PanelTitle>Conditions</PanelTitle>
<PanelDescription>People who match all of these conditions are in the segment.</PanelDescription>
</PanelHeader>
<PanelSection>
<Stack gap="300">
<ConditionEditor>
{conditions.map((condition, index) => (
<ConditionEditorItem
key={condition.id}
operator={index === 0 ? undefined : 'And'}
actions={
<Button
icon="close"
isIconOnly
variant="tertiary"
size="sm"
aria-label="Remove condition"
onClick={() => removeCondition(condition.id)}
/>
}
>
{condition.type === 'attribute' ? (
<>
<Select
ariaLabel="Attribute"
size="sm"
placeholder="Choose an attribute"
searchPlaceholder="Search attributes"
isSearchable
options={attributeOptions}
value={condition.attribute}
onChange={(attribute) => replaceCondition({ ...condition, attribute })}
/>
<Select
ariaLabel="Operator"
size="sm"
options={attributeOperatorOptions}
value={condition.operator}
onChange={(value) => changeAttributeOperator(condition, value)}
/>
{takesValue(condition.operator) ? (
<TextField
ariaLabel="Value"
size="sm"
value={condition.value}
onInput={(event) => replaceCondition({ ...condition, value: event.target.value })}
/>
) : null}
</>
) : (
<>
<Select
ariaLabel="Event name"
size="sm"
placeholder="Choose an event"
searchPlaceholder="Search events"
isSearchable
options={eventOptions}
value={condition.eventName}
onChange={(eventName) => replaceCondition({ ...condition, eventName })}
/>
<Select
ariaLabel="Operator"
size="sm"
options={eventOperatorOptions}
value={condition.operator}
onChange={(value) => changeEventOperator(condition, value)}
/>
<Select
ariaLabel="Timeframe"
size="sm"
options={timeframeOptions}
value={condition.timeframe}
onChange={(value) => changeTimeframe(condition, value)}
/>
</>
)}
</ConditionEditorItem>
))}
</ConditionEditor>
<InlineStack gap="200">
<Button
icon="add"
variant="secondary"
size="sm"
onClick={() => onConditionsChange([...conditions, newAttributeCondition()])}
>
Add attribute condition
</Button>
<Button
icon="add"
variant="secondary"
size="sm"
onClick={() => onConditionsChange([...conditions, newEventCondition()])}
>
Add event condition
</Button>
</InlineStack>
</Stack>
</PanelSection>
</Panel>
</Stack>
</PageSection>
<PageSection>
<ButtonGroup groupVariant="spaced" display="flex">
<Button variant="tertiary" mr="auto" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" type="submit">
Create segment
</Button>
</ButtonGroup>
</PageSection>
</Form>
</PageBodyContent>
<PageRail isSticky>
<Panel>
<PanelSection>
<Stack gap="100" aria-live="polite">
<Text color="secondary">Matching people</Text>
{isCountLoading ? (
<SkeletonText size="xxl" />
) : (
<Text size="xxl" weight="semibold">
{countFormatter.format(matchingPeopleCount)}
</Text>
)}
<Text color="secondary" size="sm">
Updates as you edit
</Text>
</Stack>
</PanelSection>
</Panel>
</PageRail>
</PageBody>
</Page>
);
}
Copy this recipe into your app with the Pluma CLI. Replace <dest-dir> with the folder the file goes in, and add --name <Name> to rename the component and its file.
pluma-cli scaffold segment-builder <dest-dir> --framework reactThis recipe uses these components. Each one's docs page lists its props.