2025-04-01 10:38:02 +09:00

284 lines
8.3 KiB
TypeScript

import { css, cx } from '@emotion/css';
import { ReactNode, useState } from 'react';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { IconButton, Pagination, useStyles2 } from '@grafana/ui';
import { usePagination } from '../hooks/usePagination';
import { getPaginationStyles } from '../styles/pagination';
interface DynamicTablePagination {
itemsPerPage: number;
}
export interface DynamicTableColumnProps<T = unknown> {
id: string | number;
/** Column header to display */
label: string;
alignColumn?: 'end' | string;
renderCell: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
size?: number | string;
className?: string;
}
export interface DynamicTableItemProps<T = unknown> {
id: string | number;
data: T;
renderExpandedContent?: () => ReactNode;
}
export interface DynamicTableProps<T = unknown> {
cols: Array<DynamicTableColumnProps<T>>;
items: Array<DynamicTableItemProps<T>>;
dataTestId?: string;
isExpandable?: boolean;
pagination?: DynamicTablePagination;
paginationStyles?: string;
// provide these to manually control expanded status
onCollapse?: (item: DynamicTableItemProps<T>) => void;
onExpand?: (item: DynamicTableItemProps<T>) => void;
isExpanded?: (item: DynamicTableItemProps<T>) => boolean;
renderExpandedContent?: (
item: DynamicTableItemProps<T>,
index: number,
items: Array<DynamicTableItemProps<T>>
) => ReactNode;
testIdGenerator?: (item: DynamicTableItemProps<T>, index: number) => string;
renderPrefixHeader?: () => ReactNode;
renderPrefixCell?: (
item: DynamicTableItemProps<T>,
index: number,
items: Array<DynamicTableItemProps<T>>
) => ReactNode;
footerRow?: React.ReactNode;
}
export const DynamicTable = <T extends object>({
cols,
items,
isExpandable = false,
onCollapse,
onExpand,
isExpanded,
renderExpandedContent,
testIdGenerator,
pagination,
paginationStyles,
// render a cell BEFORE expand icon for header/ each row.
// currently use by RuleList to render guidelines
renderPrefixCell,
renderPrefixHeader,
footerRow,
dataTestId,
}: DynamicTableProps<T>) => {
const defaultPaginationStyles = useStyles2(getPaginationStyles);
if ((onCollapse || onExpand || isExpanded) && !(onCollapse && onExpand && isExpanded)) {
throw new Error('either all of onCollapse, onExpand, isExpanded must be provided, or none');
}
if ((isExpandable || renderExpandedContent) && !(isExpandable && renderExpandedContent)) {
throw new Error('either both isExpanded and renderExpandedContent must be provided, or neither');
}
const styles = useStyles2(getStyles(cols, isExpandable, !!renderPrefixHeader));
const [expandedIds, setExpandedIds] = useState<Array<DynamicTableItemProps['id']>>([]);
const toggleExpanded = (item: DynamicTableItemProps<T>) => {
if (isExpanded && onCollapse && onExpand) {
isExpanded(item) ? onCollapse(item) : onExpand(item);
} else {
setExpandedIds(
expandedIds.includes(item.id) ? expandedIds.filter((itemId) => itemId !== item.id) : [...expandedIds, item.id]
);
}
};
const itemsPerPage = pagination?.itemsPerPage ?? items.length;
const { page, numberOfPages, onPageChange, pageItems } = usePagination(items, 1, itemsPerPage);
return (
<>
<div className={styles.container} data-testid={dataTestId ?? 'dynamic-table'}>
<div className={styles.row} data-testid="header">
{renderPrefixHeader && renderPrefixHeader()}
{isExpandable && <div className={styles.cell()} />}
{cols.map((col) => (
<div className={styles.cell(col.alignColumn)} key={col.id}>
{col.label}
</div>
))}
</div>
{pageItems.map((item, index) => {
const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id);
return (
<div
className={styles.row}
key={`${item.id}-${index}`}
data-testid={testIdGenerator?.(item, index) ?? 'row'}
>
{renderPrefixCell && renderPrefixCell(item, index, items)}
{isExpandable && (
<div className={cx(styles.cell(), styles.expandCell)}>
<IconButton
tooltip={`${isItemExpanded ? 'Collapse' : 'Expand'} row`}
data-testid={selectors.components.AlertRules.toggle}
name={isItemExpanded ? 'angle-down' : 'angle-right'}
onClick={() => toggleExpanded(item)}
/>
</div>
)}
{cols.map((col) => (
<div
className={cx(styles.cell(col.alignColumn), styles.bodyCell, col.className)}
data-column={col.label}
key={`${item.id}-${col.id}`}
>
{col.renderCell(item, index)}
</div>
))}
{isItemExpanded && renderExpandedContent && (
<div
className={styles.expandedContentRow}
data-testid={selectors.components.AlertRules.expandedContent}
>
{renderExpandedContent(item, index, items)}
</div>
)}
</div>
);
})}
{footerRow && <div className={cx(styles.row, styles.footerRow)}>{footerRow}</div>}
</div>
{pagination && (
<Pagination
className={cx(defaultPaginationStyles, paginationStyles)}
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage
/>
)}
</>
);
};
const getStyles = <T extends unknown>(
cols: Array<DynamicTableColumnProps<T>>,
isExpandable: boolean,
hasPrefixCell: boolean
) => {
const sizes = cols.map((col) => {
if (!col.size) {
return 'auto';
}
if (typeof col.size === 'number') {
return `${col.size}fr`;
}
return col.size;
});
if (isExpandable) {
sizes.unshift('calc(1em + 16px)');
}
if (hasPrefixCell) {
sizes.unshift('0');
}
return (theme: GrafanaTheme2) => ({
container: css({
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
color: theme.colors.text.secondary,
}),
row: css({
display: 'grid',
gridTemplateColumns: sizes.join(' '),
gridTemplateRows: '1fr auto',
'&:nth-child(2n + 1)': {
backgroundColor: theme.colors.background.secondary,
},
'&:nth-child(2n)': {
backgroundColor: theme.colors.background.primary,
},
[theme.breakpoints.down('sm')]: {
gridTemplateColumns: 'auto 1fr',
gridTemplateAreas: 'left right',
padding: `0 ${theme.spacing(0.5)}`,
'&:first-child': {
display: 'none',
},
'& > *:first-child': {
display: hasPrefixCell ? 'none' : undefined,
},
},
}),
footerRow: css({
display: 'flex',
padding: theme.spacing(1),
}),
cell: (alignColumn?: string) =>
css({
display: 'flex',
alignItems: 'center',
padding: theme.spacing(1),
justifyContent: alignColumn || 'initial',
[theme.breakpoints.down('sm')]: {
padding: `${theme.spacing(1)} 0`,
gridTemplateColumns: '1fr',
},
}),
bodyCell: css({
overflow: 'hidden',
[theme.breakpoints.down('sm')]: {
gridColumnEnd: 'right',
gridColumnStart: 'right',
'&::before': {
content: 'attr(data-column)',
display: 'block',
color: theme.colors.text.primary,
},
},
}),
expandCell: css({
justifyContent: 'center',
[theme.breakpoints.down('sm')]: {
alignItems: 'start',
gridArea: 'left',
},
}),
expandedContentRow: css({
gridColumnEnd: sizes.length + 1,
gridColumnStart: hasPrefixCell ? 3 : 2,
gridRow: 2,
padding: `0 ${theme.spacing(3)} 0 ${theme.spacing(1)}`,
position: 'relative',
[theme.breakpoints.down('sm')]: {
gridColumnStart: 2,
borderTop: `1px solid ${theme.colors.border.strong}`,
gridRow: 'auto',
padding: `${theme.spacing(1)} 0 0 0`,
},
}),
});
};