Skip to content

Commit 9cb5053

Browse files
committed
feat: add new recipes and demos for data table functionalities
- Implemented PersistColumnWidthsDemo to demonstrate persistent column widths using localStorage. - Created ServerSideRecipeDemo for server-side sorting, pagination, and filtering. - Developed UrlSyncDemo to sync table state with URL parameters. - Updated DocsLayout to include new recipe links for better navigation. - Added bulk action toolbar recipe with multi-step approval workflow. - Introduced editable grid recipe with URL-synced state. - Created master-detail recipe showcasing dashboard drill-down functionality. - Added persist column widths recipe demonstrating localStorage and API integration. - Implemented inline row actions recipe with confirmation modals and optimistic updates. - Developed server-side recipe for sorting, pagination, and filtering. - Created sticky footer recipe for an audit log viewer with fixed headers and multi-field search.
1 parent dd38041 commit 9cb5053

18 files changed

Lines changed: 1810 additions & 44 deletions

apps/docs/src/components/LiveDemo.tsx

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,21 @@ const columns = [
9090
},
9191
];
9292

93+
function ExpandedRow({ data }: { data: Row }) {
94+
return (
95+
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100 text-sm text-gray-600 grid grid-cols-2 gap-x-8 gap-y-1.5">
96+
<div><span className="font-medium text-gray-700">Department:</span> {data.department}</div>
97+
<div><span className="font-medium text-gray-700">Status:</span> {data.status}</div>
98+
<div><span className="font-medium text-gray-700">Salary:</span> ${data.salary.toLocaleString()}</div>
99+
<div><span className="font-medium text-gray-700">Role:</span> {data.role}</div>
100+
</div>
101+
);
102+
}
103+
93104
export default function LiveDemo() {
94105
const [theme, setTheme] = useState<Theme>('default');
95106
const [selectable, setSelectable] = useState(true);
107+
const [expandable, setExpandable] = useState(false);
96108
const [striped, setStriped] = useState(false);
97109
const [animateRows, setAnimateRows] = useState(true);
98110
const [selectedCount, setSelectedCount] = useState(0);
@@ -108,10 +120,16 @@ export default function LiveDemo() {
108120
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
109121
}`;
110122

123+
const toggleClass = (active: boolean) =>
124+
`relative inline-flex h-4 w-7 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
125+
active ? 'bg-brand-600' : 'bg-gray-200'
126+
}`;
127+
111128
return (
112129
<div className="rounded-xl border border-gray-200 overflow-hidden shadow-sm">
113130
{/* Toolbar */}
114-
<div className="flex flex-col sm:flex-row sm:items-center gap-2 px-4 py-3 bg-gray-50 border-b border-gray-200 text-sm">
131+
<div className="flex flex-col gap-3 px-4 py-3 bg-gray-50 border-b border-gray-200 text-sm">
132+
{/* Theme row */}
115133
<div className="flex items-center gap-2 flex-wrap">
116134
<span className="text-gray-500 font-medium shrink-0">Theme</span>
117135
{THEMES.map(t => (
@@ -121,34 +139,29 @@ export default function LiveDemo() {
121139
))}
122140
</div>
123141

124-
<div className="flex items-center gap-3 sm:ml-auto flex-wrap">
125-
<label className="flex items-center gap-1.5 text-gray-500 cursor-pointer select-none">
126-
<input
127-
type="checkbox"
128-
checked={selectable}
129-
onChange={e => setSelectable(e.target.checked)}
130-
className="rounded"
131-
/>
132-
Selectable
133-
</label>
134-
135-
<label className="flex items-center gap-1.5 text-gray-500 cursor-pointer select-none">
136-
<input type="checkbox" checked={striped} onChange={e => setStriped(e.target.checked)} className="rounded" />
137-
Striped
138-
</label>
139-
140-
<label className="flex items-center gap-1.5 text-gray-500 cursor-pointer select-none">
141-
<input
142-
type="checkbox"
143-
checked={animateRows}
144-
onChange={e => setAnimateRows(e.target.checked)}
145-
className="rounded"
146-
/>
147-
Animate
148-
</label>
142+
{/* Toggles row */}
143+
<div className="flex items-center gap-4 flex-wrap">
144+
{([
145+
['Selectable', selectable, setSelectable],
146+
['Expandable', expandable, setExpandable],
147+
['Striped', striped, setStriped],
148+
['Animate', animateRows, setAnimateRows],
149+
] as [string, boolean, (v: boolean) => void][]).map(([label, value, setter]) => (
150+
<label key={label} className="flex items-center gap-1.5 text-gray-500 cursor-pointer select-none">
151+
<button
152+
role="switch"
153+
aria-checked={value}
154+
onClick={() => setter(!value)}
155+
className={toggleClass(value)}
156+
>
157+
<span className={`pointer-events-none block h-3 w-3 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-3' : 'translate-x-0'}`} />
158+
</button>
159+
{label}
160+
</label>
161+
))}
149162

150163
{selectable && selectedCount > 0 && (
151-
<span className="text-brand-600 font-medium shrink-0">{selectedCount} selected</span>
164+
<span className="text-brand-600 font-medium shrink-0 ml-auto">{selectedCount} selected</span>
152165
)}
153166
</div>
154167
</div>
@@ -163,6 +176,8 @@ export default function LiveDemo() {
163176
highlightOnHover
164177
selectableRows={selectable}
165178
onSelectedRowsChange={handleSelectedChange}
179+
expandableRows={expandable}
180+
expandableRowsComponent={ExpandedRow}
166181
animateRows={animateRows}
167182
resizable
168183
pagination
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import React, { useRef, useState } from 'react';
2+
import DataTable, { type DataTableHandle, type TableColumn } from 'react-data-table-component';
3+
4+
type Status = 'pending' | 'approved' | 'rejected' | 'needs-info';
5+
6+
interface Request {
7+
id: number;
8+
title: string;
9+
requester: string;
10+
department: string;
11+
amount: number;
12+
status: Status;
13+
submittedAt: string;
14+
}
15+
16+
const STATUS_LABEL: Record<Status, string> = {
17+
'pending': 'Pending',
18+
'approved': 'Approved',
19+
'rejected': 'Rejected',
20+
'needs-info': 'Needs info',
21+
};
22+
23+
const STATUS_CLASS: Record<Status, string> = {
24+
'pending': 'bg-yellow-50 text-yellow-700 border border-yellow-200',
25+
'approved': 'bg-green-50 text-green-700 border border-green-200',
26+
'rejected': 'bg-red-50 text-red-700 border border-red-200',
27+
'needs-info': 'bg-blue-50 text-blue-700 border border-blue-200',
28+
};
29+
30+
const initialData: Request[] = [
31+
{ id: 1, title: 'New MacBook Pro', requester: 'Sam Rivera', department: 'Engineering', amount: 2499, status: 'pending', submittedAt: '2024-05-14' },
32+
{ id: 2, title: 'Figma Teams plan', requester: 'Priya Kapoor', department: 'Design', amount: 720, status: 'pending', submittedAt: '2024-05-14' },
33+
{ id: 3, title: 'AWS reserved instance', requester: 'Aria Chen', department: 'Engineering', amount: 8400, status: 'needs-info', submittedAt: '2024-05-13' },
34+
{ id: 4, title: 'Office chairs (x4)', requester: 'Marcus Webb', department: 'Product', amount: 1200, status: 'pending', submittedAt: '2024-05-12' },
35+
{ id: 5, title: 'Tableau license', requester: 'Jordan Ellis', department: 'Analytics', amount: 1800, status: 'approved', submittedAt: '2024-05-10' },
36+
{ id: 6, title: 'Conference travel', requester: 'Taylor Brooks', department: 'Sales', amount: 950, status: 'rejected', submittedAt: '2024-05-09' },
37+
{ id: 7, title: 'Slack Enterprise', requester: 'Casey Morgan', department: 'Engineering', amount: 3600, status: 'pending', submittedAt: '2024-05-08' },
38+
];
39+
40+
const ACTIONABLE: Status[] = ['pending', 'needs-info'];
41+
42+
export default function ApprovalWorkflowDemo() {
43+
const ref = useRef<DataTableHandle>(null);
44+
const [data, setData] = useState(initialData);
45+
const [selected, setSelected] = useState<Request[]>([]);
46+
const [toast, setToast] = useState('');
47+
48+
function showToast(msg: string) {
49+
setToast(msg);
50+
setTimeout(() => setToast(''), 2500);
51+
}
52+
53+
function applyStatus(ids: number[], next: Status) {
54+
setData(prev => prev.map(r => ids.includes(r.id) ? { ...r, status: next } : r));
55+
ref.current?.clearSelectedRows();
56+
showToast(`${ids.length} request${ids.length !== 1 ? 's' : ''} marked as "${STATUS_LABEL[next]}"`);
57+
}
58+
59+
const actionable = selected.filter(r => ACTIONABLE.includes(r.status));
60+
const selectedIds = actionable.map(r => r.id);
61+
62+
const columns: TableColumn<Request>[] = [
63+
{ id: 'title', name: 'Request', selector: r => r.title, grow: 2 },
64+
{ id: 'requester', name: 'Requester', selector: r => r.requester, sortable: true },
65+
{ id: 'department', name: 'Dept', selector: r => r.department, sortable: true, width: '120px' },
66+
{
67+
id: 'amount',
68+
name: 'Amount',
69+
selector: r => r.amount,
70+
sortable: true,
71+
right: true,
72+
width: '110px',
73+
format: r => `$${r.amount.toLocaleString()}`,
74+
},
75+
{
76+
id: 'status',
77+
name: 'Status',
78+
selector: r => r.status,
79+
sortable: true,
80+
width: '130px',
81+
cell: r => (
82+
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_CLASS[r.status]}`}>
83+
{STATUS_LABEL[r.status]}
84+
</span>
85+
),
86+
},
87+
{ id: 'submittedAt', name: 'Submitted', selector: r => r.submittedAt, sortable: true, width: '115px' },
88+
];
89+
90+
return (
91+
<div className="relative space-y-2">
92+
{/* Bulk toolbar — actions change based on what's selected */}
93+
{selected.length > 0 && (
94+
<div className="flex items-center gap-3 px-4 py-2.5 bg-brand-50 border border-brand-100 rounded-lg text-sm">
95+
<span className="font-medium text-brand-700">
96+
{selected.length} selected
97+
{actionable.length < selected.length && (
98+
<span className="ml-1 font-normal text-brand-500">
99+
({selected.length - actionable.length} already resolved, excluded)
100+
</span>
101+
)}
102+
</span>
103+
<div className="flex gap-2 ml-auto">
104+
{actionable.length > 0 && (
105+
<>
106+
<button
107+
onClick={() => applyStatus(selectedIds, 'approved')}
108+
className="px-3 py-1 rounded-md bg-green-50 border border-green-200 text-green-700 hover:bg-green-100 text-xs font-medium"
109+
>
110+
Approve {actionable.length > 1 ? `(${actionable.length})` : ''}
111+
</button>
112+
<button
113+
onClick={() => applyStatus(selectedIds, 'needs-info')}
114+
className="px-3 py-1 rounded-md bg-blue-50 border border-blue-200 text-blue-700 hover:bg-blue-100 text-xs font-medium"
115+
>
116+
Needs info
117+
</button>
118+
<button
119+
onClick={() => applyStatus(selectedIds, 'rejected')}
120+
className="px-3 py-1 rounded-md bg-red-50 border border-red-200 text-red-700 hover:bg-red-100 text-xs font-medium"
121+
>
122+
Reject
123+
</button>
124+
</>
125+
)}
126+
<button
127+
onClick={() => ref.current?.clearSelectedRows()}
128+
className="px-3 py-1 rounded-md text-gray-400 hover:text-gray-600 text-xs"
129+
>
130+
Cancel
131+
</button>
132+
</div>
133+
</div>
134+
)}
135+
136+
<div className="rounded-xl border border-gray-200 overflow-hidden">
137+
<DataTable
138+
ref={ref}
139+
columns={columns}
140+
data={data}
141+
keyField="id"
142+
selectableRows
143+
selectableRowDisabled={r => !ACTIONABLE.includes(r.status)}
144+
onSelectedRowsChange={({ selectedRows }) => setSelected(selectedRows)}
145+
highlightOnHover
146+
defaultSortFieldId="submittedAt"
147+
defaultSortAsc={false}
148+
/>
149+
</div>
150+
151+
{toast && (
152+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-xs px-4 py-2 rounded-lg shadow-lg whitespace-nowrap">
153+
{toast}
154+
</div>
155+
)}
156+
</div>
157+
);
158+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React, { useState } from 'react';
2+
import DataTable, { type ConditionalStyles, type TableColumn } from 'react-data-table-component';
3+
4+
type Severity = 'info' | 'warning' | 'error' | 'critical';
5+
6+
interface LogEntry {
7+
id: number;
8+
timestamp: string;
9+
severity: Severity;
10+
user: string;
11+
action: string;
12+
resource: string;
13+
ip: string;
14+
}
15+
16+
const SEVERITY_STYLE: Record<Severity, string> = {
17+
info: 'bg-blue-50 text-blue-700 border border-blue-100',
18+
warning: 'bg-yellow-50 text-yellow-700 border border-yellow-100',
19+
error: 'bg-red-50 text-red-700 border border-red-100',
20+
critical: 'bg-red-100 text-red-900 border border-red-300 font-bold',
21+
};
22+
23+
const ALL_LOGS: LogEntry[] = [
24+
{ id: 1, timestamp: '2024-05-16 09:01:12', severity: 'info', user: 'aria.chen', action: 'LOGIN', resource: '/dashboard', ip: '10.0.1.42' },
25+
{ id: 2, timestamp: '2024-05-16 09:03:44', severity: 'info', user: 'marcus.webb', action: 'VIEW', resource: '/reports/q1', ip: '10.0.1.18' },
26+
{ id: 3, timestamp: '2024-05-16 09:12:05', severity: 'warning', user: 'aria.chen', action: 'EXPORT', resource: '/users/all', ip: '10.0.1.42' },
27+
{ id: 4, timestamp: '2024-05-16 09:15:30', severity: 'error', user: 'unknown', action: 'LOGIN_FAILED', resource: '/auth', ip: '203.0.113.7' },
28+
{ id: 5, timestamp: '2024-05-16 09:15:31', severity: 'error', user: 'unknown', action: 'LOGIN_FAILED', resource: '/auth', ip: '203.0.113.7' },
29+
{ id: 6, timestamp: '2024-05-16 09:15:32', severity: 'critical', user: 'unknown', action: 'BRUTE_FORCE', resource: '/auth', ip: '203.0.113.7' },
30+
{ id: 7, timestamp: '2024-05-16 09:22:18', severity: 'info', user: 'priya.kapoor', action: 'UPDATE', resource: '/settings/theme', ip: '10.0.1.55' },
31+
{ id: 8, timestamp: '2024-05-16 09:31:00', severity: 'warning', user: 'jordan.ellis', action: 'DELETE', resource: '/reports/draft-4', ip: '10.0.2.11' },
32+
{ id: 9, timestamp: '2024-05-16 09:45:09', severity: 'info', user: 'sam.rivera', action: 'DEPLOY', resource: '/infra/staging', ip: '10.0.1.99' },
33+
{ id: 10, timestamp: '2024-05-16 10:02:44', severity: 'critical', user: 'system', action: 'DB_CONN_LOST', resource: '/db/primary', ip: '10.0.0.1' },
34+
{ id: 11, timestamp: '2024-05-16 10:03:01', severity: 'critical', user: 'system', action: 'FAILOVER', resource: '/db/replica', ip: '10.0.0.2' },
35+
{ id: 12, timestamp: '2024-05-16 10:15:22', severity: 'info', user: 'aria.chen', action: 'LOGOUT', resource: '/auth', ip: '10.0.1.42' },
36+
{ id: 13, timestamp: '2024-05-16 10:28:33', severity: 'warning', user: 'taylor.brooks',action: 'PERMISSION_DENY', resource: '/admin/users', ip: '10.0.1.77' },
37+
{ id: 14, timestamp: '2024-05-16 10:55:50', severity: 'error', user: 'system', action: 'DISK_FULL', resource: '/storage/logs', ip: '10.0.0.5' },
38+
{ id: 15, timestamp: '2024-05-16 11:10:04', severity: 'info', user: 'marcus.webb', action: 'LOGIN', resource: '/dashboard', ip: '10.0.1.18' },
39+
];
40+
41+
const SEVERITIES: ('all' | Severity)[] = ['all', 'info', 'warning', 'error', 'critical'];
42+
43+
const conditionalRowStyles: ConditionalStyles<LogEntry>[] = [
44+
{ when: r => r.severity === 'critical', style: { backgroundColor: '#fef2f2' } },
45+
{ when: r => r.severity === 'error', style: { backgroundColor: '#fff7f7' } },
46+
];
47+
48+
const columns: TableColumn<LogEntry>[] = [
49+
{ id: 'timestamp', name: 'Timestamp', selector: r => r.timestamp, sortable: true, width: '175px' },
50+
{
51+
id: 'severity',
52+
name: 'Severity',
53+
selector: r => r.severity,
54+
sortable: true,
55+
width: '110px',
56+
cell: r => (
57+
<span className={`text-xs px-2 py-0.5 rounded-full ${SEVERITY_STYLE[r.severity]}`}>
58+
{r.severity}
59+
</span>
60+
),
61+
},
62+
{ id: 'user', name: 'User', selector: r => r.user, sortable: true, width: '145px' },
63+
{ id: 'action', name: 'Action', selector: r => r.action, sortable: true, width: '145px', style: { fontFamily: 'monospace', fontSize: 12 } },
64+
{ id: 'resource', name: 'Resource', selector: r => r.resource, grow: 1, style: { fontFamily: 'monospace', fontSize: 12 } },
65+
{ id: 'ip', name: 'IP', selector: r => r.ip, width: '130px', style: { fontFamily: 'monospace', fontSize: 12 } },
66+
];
67+
68+
export default function AuditLogDemo() {
69+
const [severity, setSeverity] = useState<'all' | Severity>('all');
70+
const [search, setSearch] = useState('');
71+
72+
const filtered = ALL_LOGS.filter(r => {
73+
if (severity !== 'all' && r.severity !== severity) return false;
74+
if (search) {
75+
const q = search.toLowerCase();
76+
return r.user.includes(q) || r.action.includes(q) || r.resource.includes(q) || r.ip.includes(q);
77+
}
78+
return true;
79+
});
80+
81+
return (
82+
<div className="space-y-3">
83+
<div className="flex items-center gap-2 flex-wrap">
84+
<input
85+
value={search}
86+
onChange={e => setSearch(e.target.value)}
87+
placeholder="Search user, action, resource, IP…"
88+
className="px-3 py-1.5 text-xs border border-gray-200 rounded-md w-56 focus:outline-none focus:border-gray-400"
89+
/>
90+
<div className="flex gap-1 ml-auto">
91+
{SEVERITIES.map(s => (
92+
<button
93+
key={s}
94+
onClick={() => setSeverity(s)}
95+
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
96+
severity === s
97+
? 'bg-gray-800 border-gray-800 text-white'
98+
: 'border-gray-200 text-gray-500 hover:border-gray-300'
99+
}`}
100+
>
101+
{s}
102+
</button>
103+
))}
104+
</div>
105+
</div>
106+
<div className="rounded-xl border border-gray-200 overflow-hidden">
107+
<DataTable
108+
columns={columns}
109+
data={filtered}
110+
conditionalRowStyles={conditionalRowStyles}
111+
fixedHeader
112+
fixedHeaderScrollHeight="340px"
113+
defaultSortFieldId="timestamp"
114+
highlightOnHover
115+
dense
116+
noDataComponent={<div className="py-8 text-sm text-gray-400">No matching log entries</div>}
117+
/>
118+
</div>
119+
</div>
120+
);
121+
}

0 commit comments

Comments
 (0)