Files
dashgui/src/lists/RecordList.jsx
2026-10-21 11:54:48 +02:00

230 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { PanelTile, PanelTileGrid } from '../panels';
/**
* Generic data-list GUI component — pure presentation, no data layer.
*
* Renders a PanelTileGrid > PanelTile with an optional create button, an empty
* state, and a table of rows with per-row edit/delete actions. Everything data
* related is injected via props, so this component never touches a storage
* manager, router, toaster, or backend error formatter.
*
* @component RecordList
* @import dashgui/lists
*
* @prop {'loading' | 'ready' | 'failed'} status - Controls which state renders: a centered spinner, the table, or the `error` node.
* @prop {object[]} rows - Row objects to render. Each row needs the `rowKey` field for the React key.
* @prop {Array<{key: string, label: ReactNode, render?: (row) => ReactNode, align?: string, padding?: string}>} columns - Column definitions. `key` is the row field (or used with `render`); `render(row)` customizes the cell; `align`/`padding` map to TableCell.
* @prop {ReactNode} [error] - Node rendered when `status === "failed"` (e.g. a formatted backend error).
* @prop {string} [rowKey=id] - Field used as the row identity / React key.
* @prop {string} [createLabel] - Label of the top-right create button. Omit to hide the button entirely.
* @prop {() => void} [onCreate] - Called when the create button is clicked.
* @prop {(row) => void} [onEdit] - Called when a row is clicked or its edit button is pressed. Omit to hide the edit button and row-click affordance.
* @prop {(row) => void} [onDelete] - Called when a row's delete button is pressed. Omit to hide the delete button.
* @prop {boolean} [deleteDisabled=false] - Disables the delete buttons — e.g. `busyId != null` while a delete is in flight.
* @prop {ReactNode} [emptyMessage] - Shown instead of the table when `rows` is empty. Omit to render nothing.
* @prop {string} [color] - Background color of the wrapping PanelTile.
* @prop {number | string} [minHeight=240] - Minimum height of the wrapping PanelTile.
* @prop {(row) => string} [getEditLabel] - aria-label for each edit button (defaults to `„<id>“ bearbeiten`).
* @prop {(row) => string} [getDeleteLabel] - aria-label for each delete button (defaults to `„<id>“ löschen`).
* @prop {object} [sx] - Extra styles passed to the wrapping PanelTile.
*
* @behaviour `status === "loading"` renders only a centered CircularProgress (no panel chrome).
* @behaviour `status === "failed"` renders only the `error` node — or nothing if `error` is omitted.
* @behaviour Clicking a row calls `onEdit(row)` (only when `onEdit` is provided); the action buttons stop propagation so they do not double-trigger the row click.
* @behaviour The actions column appears only if `onEdit` or `onDelete` is provided.
* @behaviour No data layer: this component never touches storage, routing, toasters, or backend error formatting.
* @behaviour When `color` is a hex value, the row dividers are tinted to a darker shade of it so the horizontal lines fit the tile background.
*/
class RecordList extends Component {
handleRowClick(row) {
this.props.onEdit?.(row);
}
/** Darken a #rrggbb / #rgb hex color by `amount` (0–1); falls back to the divider grey for non-hex colors. */
dividerColor(color, amount = 0.15) {
if (typeof color !== 'string') return 'divider';
const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!m) return 'divider';
let hex = m[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
}
const num = parseInt(hex, 16);
const channels = [16, 8, 0].map((shift) => {
const c = (num >> shift) & 0xff;
return Math.max(0, Math.round(c * (1 - amount)));
});
const toHex = (v) => v.toString(16).padStart(2, '0');
return `#${channels.map(toHex).join('')}`;
}
renderLoading() {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
renderCreateButton() {
const { createLabel, onCreate } = this.props;
if (!createLabel) return null;
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="button"
variant="contained"
size="small"
onClick={onCreate}
>
{createLabel}
</Button>
</Box>
);
}
renderEmpty() {
const { emptyMessage } = this.props;
if (!emptyMessage) return null;
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
}
renderTable() {
const {
rows,
columns,
rowKey = 'id',
onEdit,
onDelete,
deleteDisabled,
getEditLabel,
getDeleteLabel,
color,
} = this.props;
const hasActions = Boolean(onEdit || onDelete);
const borderColor = this.dividerColor(color);
return (
<TableContainer>
<Table
size="small"
sx={{
'& .MuiTableCell-root': {
borderColor,
},
}}
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.key} align={col.align} padding={col.padding}>
{col.label}
</TableCell>
))}
{hasActions ? <TableCell padding="checkbox" /> : null}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
const id = row[rowKey];
return (
<TableRow
key={id}
hover
sx={{ cursor: onEdit ? 'pointer' : undefined }}
onClick={() => this.handleRowClick(row)}
>
<TableCell
key={col.key}
align={col.align}
padding={col.padding}
sx={col.mono || col.key === 'number' || col.key === 'sku' ? { fontFamily: 'monospace' } : undefined}
>
{col.render ? col.render(row) : row[col.key]}
</TableCell>
{hasActions ? (
<TableCell
padding="checkbox"
onClick={(e) => e.stopPropagation()}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{onEdit ? (
<IconButton
size="small"
aria-label={
getEditLabel?.(row) ?? `„${id}“ bearbeiten`
}
onClick={() => onEdit(row)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
) : null}
{onDelete ? (
<IconButton
size="small"
aria-label={
getDeleteLabel?.(row) ?? `„${id}“ löschen`
}
disabled={deleteDisabled}
onClick={() => onDelete(row)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
) : null}
</Box>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
render() {
const { status, error, rows, color, minHeight = 240, sx } = this.props;
if (status === 'loading') return this.renderLoading();
if (status === 'failed') return error ?? null;
return (
<PanelTileGrid>
<PanelTile wide={3} minHeight={minHeight} color={color} sx={sx}>
<Stack spacing={2}>
{this.renderCreateButton()}
{rows.length === 0 ? this.renderEmpty() : this.renderTable()}
</Stack>
</PanelTile>
</PanelTileGrid>
);
}
}
export default RecordList;