284 lines
8.3 KiB
TypeScript
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`,
|
|
},
|
|
}),
|
|
});
|
|
};
|