This commit is contained in:
seb
2026-08-08 16:58:48 +02:00
commit 253d0f95ee
27 changed files with 4083 additions and 0 deletions

196
src/lists/RecordList.jsx Normal file
View File

@@ -0,0 +1,196 @@
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.
*/
class RecordList extends Component {
handleRowClick(row) {
this.props.onEdit?.(row);
}
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,
} = this.props;
const hasActions = Boolean(onEdit || onDelete);
return (
<TableContainer>
<Table size="small">
<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)}
>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
padding={col.padding}
>
{col.render ? col.render(row) : row[col.key]}
</TableCell>
))}
{hasActions ? (
<TableCell
padding="checkbox"
onClick={(e) => e.stopPropagation()}
>
{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}
</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;

1
src/lists/index.js Normal file
View File

@@ -0,0 +1 @@
export { default as RecordList } from './RecordList';