Contacts
Search, status chips, filters by channel and by agent (records with a second line), the column picker, rows that open their record with the name as a real link, row menus and pages. Narrow the page: the channel and agent columns leave first, and the filters fold behind one button.
Contacts
18Everyone your agents talked to.
| Name | Channel | Agent | Status | Actions |
|---|---|---|---|---|
+57 300 555 1000 | Phone | Front desk | Active | |
+57 300 555 1037 | Reminders | Active | ||
+57 300 555 1074 | Front desk | Active | ||
+57 300 555 1111 | Phone | Reminders | Active | |
+57 300 555 1148 | Front desk | Archived | ||
+57 300 555 1185 | Reminders | Active | ||
+57 300 555 1222 | Phone | Front desk | Active | |
+57 300 555 1259 | Reminders | Active | ||
+57 300 555 1296 | Front desk | Active | ||
+57 300 555 1333 | Phone | Reminders | Archived | |
+57 300 555 1370 | Front desk | Active | ||
+57 300 555 1407 | Reminders | Active | ||
+57 300 555 1444 | Phone | Front desk | Active | |
+57 300 555 1481 | Reminders | Active | ||
+57 300 555 1518 | Front desk | Archived |
import { ChatCircleDotsIcon, PhoneIcon, PlusIcon, UsersIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar, Badge, Button, EmptyState, FilterChips, FilterSelect, ListPage, ListTable, RowTitle } from '@arbol-ai/ui';
import type { ColumnState, DataTableColumn } from '@arbol-ai/ui';
type Channel = 'whatsapp' | 'voice';
type Status = 'active' | 'archived';
interface Contact {
id: string;
name: string;
phone: string;
channel: Channel;
agent: string;
status: Status;
conversations: number;
}
const NAMES = [
'Ana Torres',
'Luis Gómez',
'Marta Ruiz',
'Carlos Peña',
'Sofía Rojas',
'Juan Castro',
'Laura Mejía',
'Pedro Díaz',
'Valentina Ortiz',
'Andrés Vargas',
'Camila Herrera',
'Diego Morales',
'Isabel Navarro',
'Jorge Salazar',
'Lucía Romero',
'Miguel Cárdenas',
'Paula Ríos',
'Ricardo Silva',
];
const AGENTS = [
{ value: 'Front desk', label: 'Front desk', description: 'Answers every call' },
{ value: 'Reminders', label: 'Reminders', description: 'Confirms appointments' },
];
const CONTACTS: Contact[] = NAMES.map((name, index) => ({
id: `c${index + 1}`,
name,
phone: `+57 300 555 ${1000 + index * 37}`,
channel: index % 3 === 0 ? 'voice' : 'whatsapp',
agent: AGENTS[index % 2]?.value ?? 'Front desk',
status: index % 5 === 4 ? 'archived' : 'active',
conversations: (index * 7) % 23,
}));
const CHANNEL_OPTIONS = [
{ value: 'whatsapp', label: 'WhatsApp', icon: <ChatCircleDotsIcon aria-hidden /> },
{ value: 'voice', label: 'Phone', icon: <PhoneIcon aria-hidden /> },
];
const COLUMNS: DataTableColumn<Contact>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<RowTitle
description={row.original.phone}
href={`#/contacts/${row.original.id}`}
leading={<Avatar name={row.original.name} />}
>
{row.original.name}
</RowTitle>
),
meta: { fill: true },
},
{
accessorKey: 'channel',
enableSorting: false,
header: 'Channel',
size: 130,
cell: ({ row }) => (row.original.channel === 'voice' ? 'Phone' : 'WhatsApp'),
meta: { minTableWidth: 640 },
},
{ accessorKey: 'agent', enableSorting: false, header: 'Agent', size: 150, meta: { minTableWidth: 820 } },
{
accessorKey: 'status',
header: 'Status',
size: 110,
cell: ({ row }) =>
row.original.status === 'active' ? <Badge tone="success">Active</Badge> : <Badge>Archived</Badge>,
},
{
accessorKey: 'conversations',
header: 'Conversations',
size: 140,
meta: { align: 'end', cellClassName: 'tabular-nums', defaultVisible: false },
},
];
const NO_VALUES: string[] = [];
const ContactsExample = () => {
const [status, setStatus] = useState<Status | null>(null);
const [channels, setChannels] = useState<readonly string[]>(NO_VALUES);
const [agents, setAgents] = useState<readonly string[]>(NO_VALUES);
const [pageNumber, setPageNumber] = useState(1);
const [columns, setColumns] = useState<Partial<ColumnState>>({});
const rows = CONTACTS.filter(
(contact) =>
(status === null || contact.status === status) &&
(channels.length === 0 || channels.includes(contact.channel)) &&
(agents.length === 0 || agents.includes(contact.agent))
);
/** A filter starts again from the first page. */
const filter = (change: () => void) => {
change();
setPageNumber(1);
};
return (
<ListPage
actions={
<Button>
<PlusIcon aria-hidden /> New contact
</Button>
}
count={rows.length}
description="Everyone your agents talked to."
title="Contacts"
>
<ListTable
activeFilterCount={[channels, agents].filter((values) => values.length > 0).length}
aria-label="Contacts"
chips={
<FilterChips
label="Status"
onChange={(next) => {
filter(() => {
setStatus(next);
});
}}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
]}
value={status}
/>
}
columnState={columns}
columns={COLUMNS}
data={rows}
emptyState={
<EmptyState
actions={<Button>Add contact</Button>}
description="Import a CSV or add your first contact by hand."
icon={<UsersIcon aria-hidden />}
title="No contacts yet"
/>
}
filters={
<>
<FilterSelect
allLabel="All channels"
label="Channel"
onChange={(next) => {
filter(() => {
setChannels(next);
});
}}
options={CHANNEL_OPTIONS}
selectionMode="multiple"
value={channels}
/>
<FilterSelect
allLabel="All agents"
label="Agent"
onChange={(next) => {
filter(() => {
setAgents(next);
});
}}
options={AGENTS}
value={agents}
/>
</>
}
getRowHref={(contact) => `#/contacts/${contact.id}`}
getRowId={(contact) => contact.id}
getSearchText={(contact) => `${contact.name} ${contact.phone}`}
isFiltered={status !== null}
onClearFilters={() => {
filter(() => {
setStatus(null);
setChannels(NO_VALUES);
setAgents(NO_VALUES);
});
}}
onColumnStateChange={setColumns}
pagination={{ onPage: setPageNumber, page: pageNumber }}
rowActions={(contact) => [
{ label: 'Call', href: `tel:${contact.phone.replaceAll(' ', '')}` },
{ label: 'Archive', isDestructive: true, onAction: () => undefined },
]}
rowActionsLabel={(contact) => `Actions for ${contact.name}`}
searchPlaceholder="Search by name or phone"
/>
</ListPage>
);
};
<ContactsExample />While the first page loads
The title and the toolbar stay put while placeholder rows hold the table’s place; the count shows once it is known.
Campaigns
| Name | Status | Recipients |
|---|---|---|
import { PlusIcon } from '@phosphor-icons/react';
import { Button, ListPage, ListTable } from '@arbol-ai/ui';
import type { DataTableColumn } from '@arbol-ai/ui';
interface Campaign {
name: string;
status: string;
recipients: number;
}
const CAMPAIGN_COLUMNS: DataTableColumn<Campaign>[] = [
{ accessorKey: 'name', header: 'Name', meta: { fill: true } },
{ accessorKey: 'status', header: 'Status', size: 120 },
{ accessorKey: 'recipients', header: 'Recipients', size: 120, meta: { align: 'end' } },
];
const NO_CAMPAIGNS: Campaign[] = [];
<ListPage
actions={
<Button>
<PlusIcon aria-hidden /> New campaign
</Button>
}
title="Campaigns"
>
<ListTable
aria-label="Campaigns"
columns={CAMPAIGN_COLUMNS}
data={NO_CAMPAIGNS}
isLoading
onSearchChange={() => undefined}
searchPlaceholder="Search campaigns"
/>
</ListPage>Source
The examples above are src/blocks/list-page/list-page.examples.tsx, which the library’s tests render too.