DynamicTable.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import { css, cx } from '@emotion/css';
  2. import React, { ReactNode, useState } from 'react';
  3. import { GrafanaTheme2 } from '@grafana/data';
  4. import { IconButton, useStyles2 } from '@grafana/ui';
  5. export interface DynamicTableColumnProps<T = unknown> {
  6. id: string | number;
  7. label: string;
  8. renderCell: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
  9. size?: number | string;
  10. }
  11. export interface DynamicTableItemProps<T = unknown> {
  12. id: string | number;
  13. data: T;
  14. renderExpandedContent?: () => ReactNode;
  15. }
  16. export interface DynamicTableProps<T = unknown> {
  17. cols: Array<DynamicTableColumnProps<T>>;
  18. items: Array<DynamicTableItemProps<T>>;
  19. isExpandable?: boolean;
  20. // provide these to manually control expanded status
  21. onCollapse?: (item: DynamicTableItemProps<T>) => void;
  22. onExpand?: (item: DynamicTableItemProps<T>) => void;
  23. isExpanded?: (item: DynamicTableItemProps<T>) => boolean;
  24. renderExpandedContent?: (
  25. item: DynamicTableItemProps<T>,
  26. index: number,
  27. items: Array<DynamicTableItemProps<T>>
  28. ) => ReactNode;
  29. testIdGenerator?: (item: DynamicTableItemProps<T>, index: number) => string;
  30. renderPrefixHeader?: () => ReactNode;
  31. renderPrefixCell?: (
  32. item: DynamicTableItemProps<T>,
  33. index: number,
  34. items: Array<DynamicTableItemProps<T>>
  35. ) => ReactNode;
  36. }
  37. export const DynamicTable = <T extends object>({
  38. cols,
  39. items,
  40. isExpandable = false,
  41. onCollapse,
  42. onExpand,
  43. isExpanded,
  44. renderExpandedContent,
  45. testIdGenerator,
  46. // render a cell BEFORE expand icon for header/ each row.
  47. // currently use by RuleList to render guidelines
  48. renderPrefixCell,
  49. renderPrefixHeader,
  50. }: DynamicTableProps<T>) => {
  51. if ((onCollapse || onExpand || isExpanded) && !(onCollapse && onExpand && isExpanded)) {
  52. throw new Error('either all of onCollapse, onExpand, isExpanded must be provided, or none');
  53. }
  54. if ((isExpandable || renderExpandedContent) && !(isExpandable && renderExpandedContent)) {
  55. throw new Error('either both isExpanded and renderExpandedContent must be provided, or neither');
  56. }
  57. const styles = useStyles2(getStyles(cols, isExpandable, !!renderPrefixHeader));
  58. const [expandedIds, setExpandedIds] = useState<Array<DynamicTableItemProps['id']>>([]);
  59. const toggleExpanded = (item: DynamicTableItemProps<T>) => {
  60. if (isExpanded && onCollapse && onExpand) {
  61. isExpanded(item) ? onCollapse(item) : onExpand(item);
  62. } else {
  63. setExpandedIds(
  64. expandedIds.includes(item.id) ? expandedIds.filter((itemId) => itemId !== item.id) : [...expandedIds, item.id]
  65. );
  66. }
  67. };
  68. return (
  69. <div className={styles.container} data-testid="dynamic-table">
  70. <div className={styles.row} data-testid="header">
  71. {renderPrefixHeader && renderPrefixHeader()}
  72. {isExpandable && <div className={styles.cell} />}
  73. {cols.map((col) => (
  74. <div className={styles.cell} key={col.id}>
  75. {col.label}
  76. </div>
  77. ))}
  78. </div>
  79. {items.map((item, index) => {
  80. const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id);
  81. return (
  82. <div className={styles.row} key={`${item.id}-${index}`} data-testid={testIdGenerator?.(item, index) ?? 'row'}>
  83. {renderPrefixCell && renderPrefixCell(item, index, items)}
  84. {isExpandable && (
  85. <div className={cx(styles.cell, styles.expandCell)}>
  86. <IconButton
  87. aria-label={`${isItemExpanded ? 'Collapse' : 'Expand'} row`}
  88. size="xl"
  89. data-testid="collapse-toggle"
  90. className={styles.expandButton}
  91. name={isItemExpanded ? 'angle-down' : 'angle-right'}
  92. onClick={() => toggleExpanded(item)}
  93. type="button"
  94. />
  95. </div>
  96. )}
  97. {cols.map((col) => (
  98. <div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}>
  99. {col.renderCell(item, index)}
  100. </div>
  101. ))}
  102. {isItemExpanded && renderExpandedContent && (
  103. <div className={styles.expandedContentRow} data-testid="expanded-content">
  104. {renderExpandedContent(item, index, items)}
  105. </div>
  106. )}
  107. </div>
  108. );
  109. })}
  110. </div>
  111. );
  112. };
  113. const getStyles = <T extends unknown>(
  114. cols: Array<DynamicTableColumnProps<T>>,
  115. isExpandable: boolean,
  116. hasPrefixCell: boolean
  117. ) => {
  118. const sizes = cols.map((col) => {
  119. if (!col.size) {
  120. return 'auto';
  121. }
  122. if (typeof col.size === 'number') {
  123. return `${col.size}fr`;
  124. }
  125. return col.size;
  126. });
  127. if (isExpandable) {
  128. sizes.unshift('calc(1em + 16px)');
  129. }
  130. if (hasPrefixCell) {
  131. sizes.unshift('0');
  132. }
  133. return (theme: GrafanaTheme2) => ({
  134. container: css`
  135. border: 1px solid ${theme.colors.border.strong};
  136. border-radius: 2px;
  137. color: ${theme.colors.text.secondary};
  138. `,
  139. row: css`
  140. display: grid;
  141. grid-template-columns: ${sizes.join(' ')};
  142. grid-template-rows: 1fr auto;
  143. &:nth-child(2n + 1) {
  144. background-color: ${theme.colors.background.secondary};
  145. }
  146. &:nth-child(2n) {
  147. background-color: ${theme.colors.background.primary};
  148. }
  149. ${theme.breakpoints.down('sm')} {
  150. grid-template-columns: auto 1fr;
  151. grid-template-areas: 'left right';
  152. padding: 0 ${theme.spacing(0.5)};
  153. &:first-child {
  154. display: none;
  155. }
  156. ${hasPrefixCell
  157. ? `
  158. & > *:first-child {
  159. display: none;
  160. }
  161. `
  162. : ''}
  163. }
  164. `,
  165. cell: css`
  166. align-items: center;
  167. padding: ${theme.spacing(1)};
  168. ${theme.breakpoints.down('sm')} {
  169. padding: ${theme.spacing(1)} 0;
  170. grid-template-columns: 1fr;
  171. }
  172. `,
  173. bodyCell: css`
  174. overflow: hidden;
  175. ${theme.breakpoints.down('sm')} {
  176. grid-column-end: right;
  177. grid-column-start: right;
  178. &::before {
  179. content: attr(data-column);
  180. display: block;
  181. color: ${theme.colors.text.primary};
  182. }
  183. }
  184. `,
  185. expandCell: css`
  186. justify-content: center;
  187. ${theme.breakpoints.down('sm')} {
  188. align-items: start;
  189. grid-area: left;
  190. }
  191. `,
  192. expandedContentRow: css`
  193. grid-column-end: ${sizes.length + 1};
  194. grid-column-start: ${hasPrefixCell ? 3 : 2};
  195. grid-row: 2;
  196. padding: 0 ${theme.spacing(3)} 0 ${theme.spacing(1)};
  197. position: relative;
  198. ${theme.breakpoints.down('sm')} {
  199. grid-column-start: 2;
  200. border-top: 1px solid ${theme.colors.border.strong};
  201. grid-row: auto;
  202. padding: ${theme.spacing(1)} 0 0 0;
  203. }
  204. `,
  205. expandButton: css`
  206. margin-right: 0;
  207. display: block;
  208. `,
  209. });
  210. };