List with filters

Page recipe

Use this page for the index of a collection people browse and narrow down, such as a workspace's campaigns, with the action that creates a new one in the header. People search by name, add filters to refine the list, and page through the results in a table. Use it when the collection is too large to scan in one screen.


For guidance on when and how to use it, read List page in Patterns.

Preview

Campaigns

Send messages automatically when people match a trigger you choose.

1–10 of 23
Campaigns
Name
Status
Trigger
Last sent
Messages sent
Welcome seriesRunningSegmentOct 8, 202648,213
Abandoned cart reminderRunningEventOct 8, 202612,904
Trial ending soonRunningDateOct 8, 20263,417
Re-engage inactive usersPausedSegmentSep 22, 202621,560
Onboarding checklist nudgesRunningEventOct 7, 20269,882
Monthly product digestRunningDateOct 1, 202664,110
Webinar follow-upPausedForm submissionSep 18, 20261,245
Payment failedRunningEventOct 8, 2026742
Annual plan renewal reminderRunningDateOct 6, 20265,630
Support satisfaction surveyPausedEventSep 30, 20262,318
1–10 of 23

Source

import {
	Badge,
	Button,
	DataTable,
	FormattedDateTime,
	Page,
	PageBody,
	PageHeader,
	PageHeaderActions,
	PageHeaderDescription,
	PageHeaderTitle,
} from '@customerio/pluma-components/react';
import type { BadgeSemanticColor, DataTableFeatures, Filter, FilterValue } from '@customerio/pluma-components/react';
import { createColumnHelper } from '@tanstack/table-core';

type CampaignStatus = 'running' | 'paused' | 'draft' | 'archived';
type CampaignTrigger = 'segment' | 'event' | 'date' | 'form';

export interface Campaign {
	id: string;
	name: string;
	status: CampaignStatus;
	trigger: CampaignTrigger;
	sentCount: number;
	/** ISO 8601 timestamp, or `null` for a campaign that has not sent yet. */
	lastSentAt: string | null;
}

const campaignStatuses: Record<CampaignStatus, { label: string; color: BadgeSemanticColor }> = {
	running: { label: 'Running', color: 'success' },
	paused: { label: 'Paused', color: 'caution' },
	draft: { label: 'Draft', color: 'neutral' },
	archived: { label: 'Archived', color: 'neutral' },
};

const campaignTriggers: Record<CampaignTrigger, string> = {
	segment: 'Segment',
	event: 'Event',
	date: 'Date',
	form: 'Form submission',
};

const campaignFilters: Filter[] = [
	{
		key: 'status',
		label: 'Status',
		type: 'checkbox',
		options: [
			{ label: 'Running', value: 'running' },
			{ label: 'Paused', value: 'paused' },
			{ label: 'Draft', value: 'draft' },
			{ label: 'Archived', value: 'archived' },
		],
	},
	{
		key: 'trigger',
		label: 'Trigger',
		type: 'checkbox',
		options: [
			{ label: 'Segment', value: 'segment' },
			{ label: 'Event', value: 'event' },
			{ label: 'Date', value: 'date' },
			{ label: 'Form submission', value: 'form' },
		],
	},
];

const numberFormatter = new Intl.NumberFormat();

const columnHelper = createColumnHelper<DataTableFeatures, Campaign>();

const columns = columnHelper.columns([
	columnHelper.accessor('name', {
		header: 'Name',
		meta: { width: '2fr' },
	}),
	columnHelper.accessor('status', {
		header: 'Status',
		cell: (info) => {
			const status = campaignStatuses[info.getValue()];

			return (
				<Badge color={status.color} showStatusIndicator={true}>
					{status.label}
				</Badge>
			);
		},
	}),
	columnHelper.accessor('trigger', {
		header: 'Trigger',
		cell: (info) => campaignTriggers[info.getValue()],
	}),
	columnHelper.accessor('lastSentAt', {
		header: 'Last sent',
		cell: (info) => <FormattedDateTime date={info.getValue()} format="medium" />,
	}),
	columnHelper.accessor('sentCount', {
		header: 'Messages sent',
		cell: (info) => numberFormatter.format(info.getValue()),
		meta: { align: 'end' },
	}),
]);

export interface ListWithFiltersProps {
	/** The current page of campaigns that match the search and filters. */
	campaigns: Campaign[];
	/** How many campaigns match the search and filters, across all pages. */
	totalCampaignCount: number;
	/** The current page, starting at 1. */
	page: number;
	/** How many campaigns a page holds. */
	pageSize: number;
	/** The text in the search field. */
	searchValue: string;
	/** The filters applied to the list. */
	filterValues: FilterValue[];
	/** Whether the campaigns for the current search, filters and page are loading. */
	isLoading: boolean;
	/** Called when the create action is activated. */
	onCreate: () => void;
	/** Called on every edit of the search field. */
	onSearchInput: (value: string) => void;
	/** Called with the search to run. Show the first page of its results. */
	onSearch: (value: string) => void;
	/** Called with the filters to apply. Show the first page of their results. */
	onFilterChange: (filterValues: FilterValue[]) => void;
	/** Called with the page to show. */
	onPageChange: (page: number) => void;
}

export function ListWithFilters({
	campaigns,
	totalCampaignCount,
	page,
	pageSize,
	searchValue,
	filterValues,
	isLoading,
	onCreate,
	onSearchInput,
	onSearch,
	onFilterChange,
	onPageChange,
}: ListWithFiltersProps) {
	const handleFilterChange = (key: string, value: unknown) => {
		const isApplied = filterValues.some((filter) => filter.key === key);

		onFilterChange(
			isApplied
				? filterValues.map((filter) => (filter.key === key ? { ...filter, value } : filter))
				: [...filterValues, { key, value }],
		);
	};

	const handleFilterClear = (key: string) => {
		onFilterChange(filterValues.filter((filter) => filter.key !== key));
	};

	const handleFilterClearAll = () => {
		onFilterChange([]);
	};

	return (
		<Page isFullScreen>
			<PageHeader>
				<PageHeaderTitle>Campaigns</PageHeaderTitle>
				<PageHeaderDescription>
					Send messages automatically when people match a trigger you choose.
				</PageHeaderDescription>
				<PageHeaderActions>
					<Button variant="primary" icon="add" onClick={onCreate}>
						Create campaign
					</Button>
				</PageHeaderActions>
			</PageHeader>
			<PageBody>
				<DataTable
					caption="Campaigns"
					data={campaigns}
					columns={columns}
					isLoading={isLoading}
					withSearch={true}
					search={{
						value: searchValue,
						onSearchInput,
						onSearch,
						ariaLabel: 'Search campaigns',
						placeholder: 'Search by name',
						shouldSearchOnDebounce: 300,
						shouldSearchOnClear: true,
					}}
					withFilters={true}
					filters={{
						filters: campaignFilters,
						values: filterValues,
						onChange: handleFilterChange,
						onClear: handleFilterClear,
						onClearAll: handleFilterClearAll,
					}}
					withPagination={true}
					pagination={{
						page,
						pageSize,
						totalItemCount: totalCampaignCount,
						onPageChange,
					}}
					emptyState={{
						icon: 'campaigns',
						title: 'No campaigns found',
						description: 'Try a different search, or remove a filter.',
					}}
				/>
			</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 list-with-filters <dest-dir> --framework react

Components

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

On this page