Segment builder

Page recipe

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.


Preview

Create segment

Group people by who they are and what they do. People join and leave the segment as their data changes.

Conditions

People who match all of these conditions are in the segment.

And
And
Matching people1,284Updates as you edit

Source

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>
	);
}

Scaffold

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 react

Components

This recipe uses these components. Each one's docs page lists its props.

On this page