DataList.jsx
1 import { useState, useMemo } from "react"; 2 import { 3 Box, Button, Card, Checkbox, Chip, IconButton, InputAdornment, MenuItem, Select, 4 Table, TableBody, TableCell, TableHead, TableRow, TablePagination, 5 TableSortLabel, TextField, Tooltip, Typography, styled, 6 } from "@mui/material"; 7 import { Search, Inbox, Close, SearchOff } from "@mui/icons-material"; 8 import { useTranslation } from "react-i18next"; 9 10 const StyledCard = styled(Card)(({ theme }) => ({ 11 borderRadius: 12, 12 border: "1px solid", 13 borderColor: theme.palette.divider, 14 overflow: "hidden", 15 })); 16 17 const Header = styled(Box)(({ theme }) => ({ 18 padding: "20px 24px 16px", 19 borderBottom: `1px solid ${theme.palette.divider}`, 20 display: "flex", 21 alignItems: "center", 22 justifyContent: "space-between", 23 gap: theme.spacing(2), 24 flexWrap: "wrap", 25 })); 26 27 const ToolbarRow = styled(Box)(({ theme }) => ({ 28 padding: "14px 24px", 29 display: "flex", 30 alignItems: "center", 31 gap: theme.spacing(2), 32 flexWrap: "wrap", 33 borderBottom: `1px solid ${theme.palette.divider}`, 34 })); 35 36 // Shown above the table when one or more rows are selected (bulkActions 37 // opt-in). Sits between the toolbar and the table so the selection 38 // count + action buttons are unmistakable. 39 const BulkActionBar = styled(Box)(({ theme }) => ({ 40 padding: "10px 24px", 41 display: "flex", 42 alignItems: "center", 43 gap: theme.spacing(1.5), 44 backgroundColor: theme.palette.action.selected, 45 borderBottom: `1px solid ${theme.palette.divider}`, 46 flexWrap: "wrap", 47 })); 48 49 const StyledTableRow = styled(TableRow)(({ theme, clickable }) => ({ 50 "& td": { 51 borderBottom: `1px solid ${theme.palette.divider}`, 52 padding: "14px 16px", 53 }, 54 "& td:first-of-type": { paddingLeft: 24 }, 55 "& td:last-of-type": { paddingRight: 24 }, 56 "&:last-child td": { borderBottom: "none" }, 57 ...(clickable === "true" && { 58 cursor: "pointer", 59 "&:hover": { 60 backgroundColor: theme.palette.action.hover, 61 }, 62 }), 63 })); 64 65 const EmptyState = styled(Box)(({ theme }) => ({ 66 padding: "60px 24px", 67 textAlign: "center", 68 color: theme.palette.text.secondary, 69 display: "flex", 70 flexDirection: "column", 71 alignItems: "center", 72 gap: theme.spacing(1), 73 })); 74 75 /** 76 * Generic data list with search, filter, sort, pagination. 77 * 78 * Props: 79 * - title: string — header title 80 * - subtitle: string — header description (optional) 81 * - data: array — rows 82 * - columns: [{ key, label, sortable, align, render(row), width }] 83 * - searchKeys: array of field paths to search (supports dot notation) 84 * - filters: [{ key, label, options: [{ value, label }], getValue(row) }] 85 * - onRowClick: (row) => void — makes rows clickable 86 * - rowKey: (row) => string — default: row.id 87 * - actions: (row) => ReactNode — right-side action buttons per row 88 * - headerAction: ReactNode — right-side header button (e.g. "New") 89 * - emptyMessage: string — fallback text when `emptyState` is not provided 90 * - emptyState: { icon, title, message, actionLabel, onAction } — rich 91 * empty-state block (shown when data.length === 0). `icon` is a 92 * component class (e.g. `Group`). Falls back to Inbox + emptyMessage 93 * when the prop is omitted. 94 * - bulkActions: [{ label, icon, color, onClick(selectedRows) }] — when 95 * non-empty, renders a select-column + a bulk-action bar. Parent 96 * handles the API calls; DataList just hands back selected rows and 97 * clears the selection after each action. 98 * - defaultSort: { key, direction } 99 * - pageSize: default 10 100 */ 101 export default function DataList({ 102 title, 103 subtitle, 104 data = [], 105 columns, 106 searchKeys = [], 107 filters = [], 108 onRowClick, 109 rowKey = (row) => row.id, 110 actions, 111 headerAction, 112 emptyMessage, 113 emptyState = null, 114 bulkActions = [], 115 defaultSort = null, 116 pageSize = 10, 117 }) { 118 const { t } = useTranslation(); 119 const effectiveEmptyMessage = emptyMessage ?? t("dataList.noResults"); 120 const [search, setSearch] = useState(""); 121 const [sortKey, setSortKey] = useState(defaultSort?.key || null); 122 const [sortDir, setSortDir] = useState(defaultSort?.direction || "asc"); 123 const [filterValues, setFilterValues] = useState({}); 124 const [page, setPage] = useState(0); 125 const [rowsPerPage, setRowsPerPage] = useState(pageSize); 126 // Set of selected row keys. Stored as a Set so toggling is O(1) and 127 // the parent can read size via .size. Cleared whenever the filtered 128 // view changes so hidden rows don't silently stay selected. 129 const [selectedKeys, setSelectedKeys] = useState(() => new Set()); 130 131 const getNested = (obj, path) => { 132 if (!obj || !path) return ""; 133 return path.split(".").reduce((acc, part) => (acc == null ? acc : acc[part]), obj); 134 }; 135 136 const matchesSearch = (row) => { 137 if (!search) return true; 138 const needle = search.toLowerCase(); 139 return searchKeys.some((key) => { 140 const value = getNested(row, key); 141 if (value == null) return false; 142 return String(value).toLowerCase().includes(needle); 143 }); 144 }; 145 146 const matchesFilters = (row) => { 147 return filters.every((f) => { 148 const selected = filterValues[f.key]; 149 if (!selected || selected === "__all__") return true; 150 const rowValue = f.getValue ? f.getValue(row) : getNested(row, f.key); 151 return String(rowValue) === String(selected); 152 }); 153 }; 154 155 const sorted = useMemo(() => { 156 let result = data.filter((row) => matchesSearch(row) && matchesFilters(row)); 157 if (sortKey) { 158 const col = columns.find((c) => c.key === sortKey); 159 const getter = col?.sortValue || ((row) => getNested(row, sortKey)); 160 result = [...result].sort((a, b) => { 161 const av = getter(a); 162 const bv = getter(b); 163 if (av == null && bv == null) return 0; 164 if (av == null) return 1; 165 if (bv == null) return -1; 166 if (typeof av === "number" && typeof bv === "number") { 167 return sortDir === "asc" ? av - bv : bv - av; 168 } 169 return sortDir === "asc" 170 ? String(av).localeCompare(String(bv)) 171 : String(bv).localeCompare(String(av)); 172 }); 173 } 174 return result; 175 // eslint-disable-next-line react-hooks/exhaustive-deps 176 }, [data, search, sortKey, sortDir, filterValues, columns]); 177 178 const paged = useMemo(() => { 179 const start = page * rowsPerPage; 180 return sorted.slice(start, start + rowsPerPage); 181 }, [sorted, page, rowsPerPage]); 182 183 const handleSort = (key) => { 184 if (sortKey === key) { 185 setSortDir(sortDir === "asc" ? "desc" : "asc"); 186 } else { 187 setSortKey(key); 188 setSortDir("asc"); 189 } 190 }; 191 192 // ── Bulk selection helpers ───────────────────────────────────────── 193 const bulkEnabled = bulkActions.length > 0; 194 const pagedKeys = useMemo(() => paged.map(rowKey), [paged, rowKey]); 195 const allPageSelected = pagedKeys.length > 0 && pagedKeys.every((k) => selectedKeys.has(k)); 196 const somePageSelected = pagedKeys.some((k) => selectedKeys.has(k)); 197 198 const toggleRow = (row) => { 199 const k = rowKey(row); 200 setSelectedKeys((prev) => { 201 const next = new Set(prev); 202 if (next.has(k)) next.delete(k); else next.add(k); 203 return next; 204 }); 205 }; 206 const toggleAllOnPage = () => { 207 setSelectedKeys((prev) => { 208 const next = new Set(prev); 209 if (allPageSelected) { 210 pagedKeys.forEach((k) => next.delete(k)); 211 } else { 212 pagedKeys.forEach((k) => next.add(k)); 213 } 214 return next; 215 }); 216 }; 217 const clearSelection = () => setSelectedKeys(new Set()); 218 219 const runBulkAction = (action) => { 220 const selectedRows = data.filter((row) => selectedKeys.has(rowKey(row))); 221 if (selectedRows.length === 0) return; 222 // Hand rows to the consumer; they're responsible for API calls and 223 // refreshing `data`. Clear selection so stale keys don't linger on 224 // rows that the parent just deleted. 225 Promise.resolve(action.onClick(selectedRows)).finally(clearSelection); 226 }; 227 228 // ── Render ───────────────────────────────────────────────────────── 229 const hasToolbar = searchKeys.length > 0 || filters.length > 0; 230 const selectedCount = selectedKeys.size; 231 const searching = search.length > 0; 232 const hasData = data.length > 0; 233 234 return ( 235 <StyledCard elevation={0}> 236 {(title || headerAction) && ( 237 <Header> 238 <Box> 239 {title && <Typography variant="h6" fontWeight={700}>{title}</Typography>} 240 {subtitle && ( 241 <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> 242 {subtitle} 243 </Typography> 244 )} 245 </Box> 246 {headerAction} 247 </Header> 248 )} 249 250 {hasToolbar && ( 251 <ToolbarRow> 252 {searchKeys.length > 0 && ( 253 <TextField 254 size="small" 255 placeholder={t("dataList.search")} 256 value={search} 257 onChange={(e) => { setSearch(e.target.value); setPage(0); }} 258 sx={{ flex: 1, minWidth: 220 }} 259 InputProps={{ 260 startAdornment: ( 261 <InputAdornment position="start"> 262 <Search fontSize="small" color="action" /> 263 </InputAdornment> 264 ), 265 endAdornment: searching ? ( 266 <InputAdornment position="end"> 267 <Tooltip title={t("dataList.clearSearch")}> 268 <IconButton 269 size="small" edge="end" aria-label={t("dataList.clearSearch")} 270 onClick={() => { setSearch(""); setPage(0); }} 271 > 272 <Close fontSize="small" /> 273 </IconButton> 274 </Tooltip> 275 </InputAdornment> 276 ) : null, 277 }} 278 /> 279 )} 280 {filters.map((f) => ( 281 <Select 282 key={f.key} 283 size="small" 284 value={filterValues[f.key] || "__all__"} 285 onChange={(e) => { 286 setFilterValues({ ...filterValues, [f.key]: e.target.value }); 287 setPage(0); 288 }} 289 sx={{ minWidth: 160 }} 290 displayEmpty 291 renderValue={(selected) => { 292 if (!selected || selected === "__all__") return `${f.label}: ${t("common.all")}`; 293 const opt = f.options.find((o) => String(o.value) === String(selected)); 294 return `${f.label}: ${opt?.label || selected}`; 295 }} 296 > 297 <MenuItem value="__all__">{f.label}: {t("common.all")}</MenuItem> 298 {f.options.map((opt) => ( 299 <MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem> 300 ))} 301 </Select> 302 ))} 303 <Box sx={{ flex: "0 0 auto", ml: "auto" }}> 304 <Chip label={t("common.resultCount", { count: sorted.length })} size="small" variant="outlined" /> 305 </Box> 306 </ToolbarRow> 307 )} 308 309 {bulkEnabled && selectedCount > 0 && ( 310 <BulkActionBar> 311 <Typography variant="body2" fontWeight={600}> 312 {t("dataList.selected", { count: selectedCount })} 313 </Typography> 314 {bulkActions.map((a, i) => ( 315 <Button 316 key={i} 317 size="small" 318 variant="outlined" 319 color={a.color || "primary"} 320 startIcon={a.icon} 321 onClick={() => runBulkAction(a)} 322 > 323 {a.label} 324 </Button> 325 ))} 326 <Button size="small" onClick={clearSelection} sx={{ ml: "auto" }}> 327 {t("dataList.clear")} 328 </Button> 329 </BulkActionBar> 330 )} 331 332 {paged.length === 0 ? ( 333 <EmptyState> 334 {/* Three distinct empty-states: 335 1. Searched, no matches → "no matches" + clear-search CTA. 336 2. Data present but no rich empty-state provided → plain text. 337 3. No data at all, rich empty-state provided → icon + title + action. */} 338 {searching && hasData ? ( 339 <> 340 <SearchOff sx={{ fontSize: 48, opacity: 0.4 }} /> 341 <Typography variant="body2"> 342 {t("dataList.noMatches", { query: search })} 343 </Typography> 344 <Button size="small" onClick={() => { setSearch(""); setPage(0); }}> 345 {t("dataList.clearSearch")} 346 </Button> 347 </> 348 ) : !hasData && emptyState ? ( 349 <> 350 {emptyState.icon ? ( 351 <Box 352 sx={{ 353 width: 72, height: 72, borderRadius: "50%", 354 display: "flex", alignItems: "center", justifyContent: "center", 355 backgroundColor: "action.hover", mb: 1, 356 }} 357 > 358 <emptyState.icon sx={{ fontSize: 36, color: "text.secondary" }} /> 359 </Box> 360 ) : ( 361 <Inbox sx={{ fontSize: 48, opacity: 0.4 }} /> 362 )} 363 {emptyState.title && ( 364 <Typography variant="subtitle1" fontWeight={600} sx={{ color: "text.primary" }}> 365 {emptyState.title} 366 </Typography> 367 )} 368 <Typography variant="body2" sx={{ maxWidth: 360 }}> 369 {emptyState.message || effectiveEmptyMessage} 370 </Typography> 371 {emptyState.actionLabel && emptyState.onAction && ( 372 <Button 373 size="small" variant="contained" 374 startIcon={emptyState.actionIcon} 375 onClick={emptyState.onAction} 376 sx={{ mt: 1 }} 377 > 378 {emptyState.actionLabel} 379 </Button> 380 )} 381 </> 382 ) : ( 383 <> 384 <Inbox sx={{ fontSize: 48, opacity: 0.4 }} /> 385 <Typography variant="body2">{effectiveEmptyMessage}</Typography> 386 </> 387 )} 388 </EmptyState> 389 ) : ( 390 <Box sx={{ overflowX: "auto" }}> 391 <Table> 392 <TableHead> 393 <TableRow 394 sx={{ 395 "& th:first-of-type": { paddingLeft: "24px" }, 396 "& th:last-of-type": { paddingRight: "24px" }, 397 }} 398 > 399 {bulkEnabled && ( 400 <TableCell 401 padding="checkbox" 402 sx={{ backgroundColor: "action.hover", borderBottom: "1px solid", borderColor: "divider" }} 403 > 404 <Checkbox 405 size="small" 406 checked={allPageSelected} 407 indeterminate={!allPageSelected && somePageSelected} 408 onChange={toggleAllOnPage} 409 inputProps={{ "aria-label": "select all on page" }} 410 /> 411 </TableCell> 412 )} 413 {columns.map((col) => ( 414 <TableCell 415 key={col.key} 416 align={col.align || "left"} 417 sx={{ 418 fontWeight: 600, 419 fontSize: "0.78rem", 420 textTransform: "uppercase", 421 letterSpacing: "0.5px", 422 color: "text.secondary", 423 backgroundColor: "action.hover", 424 borderBottom: "1px solid", 425 borderColor: "divider", 426 width: col.width, 427 }} 428 > 429 {col.sortable ? ( 430 <TableSortLabel 431 active={sortKey === col.key} 432 direction={sortKey === col.key ? sortDir : "asc"} 433 onClick={() => handleSort(col.key)} 434 > 435 {col.label} 436 </TableSortLabel> 437 ) : ( 438 col.label 439 )} 440 </TableCell> 441 ))} 442 {actions && <TableCell align="right" sx={{ backgroundColor: "action.hover" }} />} 443 </TableRow> 444 </TableHead> 445 <TableBody> 446 {paged.map((row) => { 447 const k = rowKey(row); 448 const isSelected = selectedKeys.has(k); 449 return ( 450 <StyledTableRow 451 key={k} 452 clickable={onRowClick ? "true" : "false"} 453 selected={isSelected} 454 onClick={onRowClick ? (e) => { 455 // Don't fire row click on action button clicks / checkbox clicks. 456 if (e.target.closest("button, a, input")) return; 457 onRowClick(row); 458 } : undefined} 459 > 460 {bulkEnabled && ( 461 <TableCell padding="checkbox"> 462 <Checkbox 463 size="small" 464 checked={isSelected} 465 onChange={() => toggleRow(row)} 466 onClick={(e) => e.stopPropagation()} 467 inputProps={{ "aria-label": `select row ${k}` }} 468 /> 469 </TableCell> 470 )} 471 {columns.map((col) => ( 472 <TableCell key={col.key} align={col.align || "left"}> 473 {col.render ? col.render(row) : getNested(row, col.key)} 474 </TableCell> 475 ))} 476 {actions && ( 477 <TableCell align="right" sx={{ whiteSpace: "nowrap" }}> 478 {actions(row)} 479 </TableCell> 480 )} 481 </StyledTableRow> 482 ); 483 })} 484 </TableBody> 485 </Table> 486 </Box> 487 )} 488 489 {sorted.length > rowsPerPage && ( 490 <TablePagination 491 component="div" 492 count={sorted.length} 493 page={page} 494 onPageChange={(_, p) => setPage(p)} 495 rowsPerPage={rowsPerPage} 496 onRowsPerPageChange={(e) => { 497 setRowsPerPage(parseInt(e.target.value, 10)); 498 setPage(0); 499 }} 500 rowsPerPageOptions={[10, 25, 50, 100]} 501 /> 502 )} 503 </StyledCard> 504 ); 505 }