Merge pull request #383 from johndoe6345789/codex/create-fieldgroup-and-validationsummary-components

Add shared data form and table components
This commit is contained in:
2025-12-29 17:12:51 +00:00
committed by GitHub
6 changed files with 265 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Box, Stack, Typography } from '@mui/material'
interface FieldGroupProps {
title: string
description?: ReactNode
actions?: ReactNode
children: ReactNode
spacing?: number
}
export function FieldGroup({
title,
description,
actions,
children,
spacing = 2,
}: FieldGroupProps) {
return (
<Stack
spacing={2}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
backgroundColor: 'background.paper',
}}
>
<Stack direction="row" justifyContent="space-between" spacing={2} alignItems="flex-start">
<Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ lineHeight: 1.3 }}>
{title}
</Typography>
{description ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{description}
</Typography>
) : null}
</Box>
{actions ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{actions}</Box>
) : null}
</Stack>
<Stack spacing={spacing}>{children}</Stack>
</Stack>
)
}

View File

@@ -0,0 +1,53 @@
import type { ReactNode } from 'react'
import { Alert, AlertTitle, List, ListItem, ListItemText } from '@mui/material'
/**
* Props for {@link ValidationSummary}.
*
* @property errors List of validation errors to display. Each entry can be a plain
* string message or a ReactNode for fully custom rendering (e.g. including links
* or emphasized text). The list is rendered as items in a bulleted list.
* @property title Optional title rendered inside the error alert header. This
* is only shown when {@link ValidationSummaryProps.showTitle | showTitle} is
* `true`. Defaults to `"Please fix the following"`.
* @property showTitle When `true` (default), renders an {@link AlertTitle} using
* the provided `title`. Set to `false` when you want to suppress the header and
* only show the list of errors.
*/
interface ValidationSummaryProps {
errors: Array<string | ReactNode>
title?: string
showTitle?: boolean
}
/**
* Displays a standardized validation error summary as an error {@link Alert}
* containing an optional title and a bulleted list of errors.
*
* Renders nothing when the `errors` array is empty.
*/
export function ValidationSummary({
errors,
title = 'Please fix the following',
showTitle = true,
}: ValidationSummaryProps) {
if (!errors.length) return null
return (
<Alert severity="error" variant="outlined" sx={{ alignItems: 'flex-start' }}>
{showTitle ? <AlertTitle>{title}</AlertTitle> : null}
<List dense disablePadding component="ul" sx={{ pl: 3 }}>
{errors.map((error, index) => (
<ListItem
key={index}
disableGutters
component="li"
sx={{ py: 0.25, px: 0 }}
>
<ListItemText primaryTypographyProps={{ variant: 'body2' }} primary={error} />
</ListItem>
))}
</List>
</Alert>
)
}

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from 'react'
import { TableBody, TableCell, TableRow } from '@mui/material'
import { EmptyState } from './EmptyState'
import type { DataTableColumn } from './types'
interface BodyProps<T> {
columns: Array<DataTableColumn<T>>
rows: T[]
getRowId?: (row: T, rowIndex: number) => string | number
renderActions?: (row: T) => ReactNode
onRowClick?: (row: T) => void
emptyMessage?: string
}
export function Body<T>({
columns,
rows,
getRowId,
renderActions,
onRowClick,
emptyMessage = 'No records found',
}: BodyProps<T>) {
const colSpan = columns.length + (renderActions ? 1 : 0)
return (
<TableBody>
{rows.length === 0 ? (
<EmptyState colSpan={colSpan} message={emptyMessage} />
) : (
rows.map((row, rowIndex) => {
const rowId = getRowId ? getRowId(row, rowIndex) : rowIndex
const handleClick = onRowClick ? () => onRowClick(row) : undefined
return (
<TableRow
key={rowId}
hover={Boolean(onRowClick)}
onClick={handleClick}
sx={onRowClick ? { cursor: 'pointer' } : undefined}
>
{columns.map((column) => {
const content = column.render ? column.render(row, rowIndex) : (row as Record<string, unknown>)[column.key]
return (
<TableCell key={column.key} align={column.align} sx={column.sx}>
{content ?? '—'}
</TableCell>
)
})}
{renderActions ? <TableCell align="right">{renderActions(row)}</TableCell> : null}
</TableRow>
)
})
)}
</TableBody>
)
}

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import { Stack, TableCell, TableRow, Typography } from '@mui/material'
/**
* Props for the {@link EmptyState} table row.
*
* This is typically used when a data-driven table has no rows to display and
* you want to show a friendly message, optionally with a follow-up action.
*
* @property colSpan - Number of table columns the empty-state cell should span.
* @property message - Optional message shown to explain that there is no data.
* @property action - Optional call-to-action content (for example, a "Create"
* button or "Reload" button) rendered below the message.
*/
interface EmptyStateProps {
colSpan: number
message?: string
action?: ReactNode
}
/**
* Renders a full-width table row indicating that there is currently no data
* to display for the surrounding table.
*
* Use this component as the only row in a table body when the data source is
* empty. You can pass an optional `action` to surface primary actions such as
* creating a new item or retrying a failed load.
*/
export function EmptyState({ colSpan, message = 'No data to display', action }: EmptyStateProps) {
return (
<TableRow>
<TableCell colSpan={colSpan} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1}>
<Typography variant="subtitle1">{message}</Typography>
{action ? <Stack direction="row" spacing={1}>{action}</Stack> : null}
</Stack>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,52 @@
import { TableCell, TableHead, TableRow, Typography } from '@mui/material'
import type { DataTableColumn } from './types'
interface HeaderProps<T> {
columns: Array<DataTableColumn<T>>
actionsHeader?: string
}
export function Header<T>({ columns, actionsHeader }: HeaderProps<T>) {
return (
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell
key={column.key}
align={column.align}
sx={{
borderBottom: 1,
borderColor: 'divider',
...(column.width ? { width: column.width } : {}),
...(column.sx ?? {}),
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: 'uppercase', letterSpacing: 0.2 }}
>
{column.label}
</Typography>
</TableCell>
))}
{actionsHeader ? (
<TableCell
align="right"
sx={{ borderBottom: 1, borderColor: 'divider', width: 1, whiteSpace: 'nowrap' }}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: 'uppercase', letterSpacing: 0.2 }}
>
{actionsHeader}
</Typography>
</TableCell>
) : null}
</TableRow>
</TableHead>
)
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
import type { TableCellProps } from '@mui/material'
export interface DataTableColumn<T> {
key: string
label: string
align?: TableCellProps['align']
width?: number | string
render?: (row: T, rowIndex: number) => ReactNode
sx?: TableCellProps['sx']
}