diff --git a/changelog.md b/changelog.md index 1ea46f0176..4f2b739193 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,7 @@ * [Data Sources]: cursor-based pagination support. More details [here](http://uui.epam.com/documents?id=dataSources-lazy-dataSource&mode=doc&category=dataSources&theme=loveship#using_cursor-based_pagination) * [TimelineScale]: added bottom/top month text customisation. * [TimelineScale]: customisation of today line height was added. +* [DataTable]: groups of columns. **What's Fixed** * [VirtualList]: fixed estimatedHeight calculations in components with pagination diff --git a/uui-components/src/table/DataTableCellContainer.tsx b/uui-components/src/table/DataTableCellContainer.tsx index de49ccd56f..98a51dd34b 100644 --- a/uui-components/src/table/DataTableCellContainer.tsx +++ b/uui-components/src/table/DataTableCellContainer.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { DataColumnProps, IClickable, IHasCX, IHasRawProps } from '@epam/uui-core'; +import { DataColumnGroupProps, DataColumnProps, IClickable, IHasCX, IHasRawProps } from '@epam/uui-core'; import { FlexCell } from '../layout'; import css from './DataTableCellContainer.module.scss'; @@ -15,7 +15,7 @@ export interface DataTableCellContainerProps extends /** * DataTable column configuration. */ - column: DataColumnProps; + column: DataColumnProps | DataColumnGroupProps; /** * CSS text-align property. */ @@ -38,7 +38,7 @@ export const DataTableCellContainer = React.forwardRef extends React.Component { + const isFirstCell = firstColumnIdx === 0; + const isLastCell = lastColumnIdx === this.props.columns.length - 1; + return this.props.renderGroupCell({ + key: `${group.key}-${idx}`, + group, + isFirstCell, + isLastCell, + value: this.props.value, + onValueChange: this.props.onValueChange, + }); + }; + render() { return ( diff --git a/uui-components/src/table/DataTableRowContainer.module.scss b/uui-components/src/table/DataTableRowContainer.module.scss index e403dda49b..5ad53b770e 100644 --- a/uui-components/src/table/DataTableRowContainer.module.scss +++ b/uui-components/src/table/DataTableRowContainer.module.scss @@ -76,3 +76,13 @@ :global(.uui-scroll-shadow-right) { @include scroll-shadow('inset-inline-end'); } + +.groupColumnsWrapper { + display: flex; + flex-direction: row; +} + +.groupWrapper { + display: flex; + flex-direction: column; +} \ No newline at end of file diff --git a/uui-components/src/table/DataTableRowContainer.tsx b/uui-components/src/table/DataTableRowContainer.tsx index e1fe8326cb..6f4c7debce 100644 --- a/uui-components/src/table/DataTableRowContainer.tsx +++ b/uui-components/src/table/DataTableRowContainer.tsx @@ -2,17 +2,23 @@ import React from 'react'; import { DataColumnProps, IClickable, IHasCX, IHasRawProps, uuiMarkers, Link, cx, DndEventHandlers, + DataColumnGroupProps, } from '@epam/uui-core'; import { FlexRow } from '../layout'; import { Anchor } from '../navigation'; import css from './DataTableRowContainer.module.scss'; +import { getGroupsWithColumns, isGroupOfColumns } from './columnsConfigurationModal/columnsGroupsUtils'; export interface DataTableRowContainerProps extends IClickable, IHasCX, IHasRawProps> { + /** Columns groups configuration */ + columnGroups?: DataColumnGroupProps[]; columns?: DataColumnProps[]; renderCell?(column: DataColumnProps, idx: number, eventHandlers?: DndEventHandlers): React.ReactNode; + /** Columns group cell render function. */ + renderGroupCell?(group: DataColumnGroupProps, idx: number, firstColumnIdx: number, lastColumnIdx: number): React.ReactNode; renderConfigButton?(): React.ReactNode; overlays?: React.ReactNode; link?: Link; @@ -61,12 +67,40 @@ function getSectionStyle(columns: DataColumnProps[], minGrow = 0) { export const DataTableRowContainer = React.forwardRef( (props: DataTableRowContainerProps, ref: React.ForwardedRef) => { const { onPointerDown, onTouchStart, ...restRawProps } = props.rawProps ?? {}; + function renderCells(columns: DataColumnProps[]) { - return columns.reduce((cells, column) => { - const idx = props.columns?.indexOf(column) || 0; - cells.push(props.renderCell(column, idx, { onPointerDown, onTouchStart })); - return cells; - }, []); + if (!props.columnGroups) { + return columns.map((column) => { + const idx = props.columns?.indexOf(column) || 0; + return props.renderCell(column, idx, { onPointerDown, onTouchStart }); + }); + } + + const columnsWithGroups = getGroupsWithColumns(props.columnGroups, columns); + return columnsWithGroups.map((item, index) => { + if (isGroupOfColumns(item)) { + const firstColumnIdx = props.columns?.indexOf(item.columns[0]) || 0; + const lastColumnIdx = props.columns?.indexOf(item.columns[item.columns.length - 1]) || 0; + + return ( +
+ +
{props.renderGroupCell(item.group, index, firstColumnIdx, lastColumnIdx)}
+
+ { + item.columns.map((column) => { + const idx = props.columns?.indexOf(column) || 0; + return props.renderCell(column, idx, { onPointerDown, onTouchStart }); + }) + } +
+
+ ); + } + + const idx = props.columns?.indexOf(item) || 0; + return props.renderCell(item, idx, { onPointerDown, onTouchStart }); + }); } function wrapFixedSection(columns: DataColumnProps[], direction: 'left' | 'right', hasScrollingSection: boolean) { diff --git a/uui-components/src/table/columnsConfigurationModal/columnsGroupsUtils.ts b/uui-components/src/table/columnsConfigurationModal/columnsGroupsUtils.ts new file mode 100644 index 0000000000..4ce1a4186e --- /dev/null +++ b/uui-components/src/table/columnsConfigurationModal/columnsGroupsUtils.ts @@ -0,0 +1,49 @@ +import { DataColumnGroupProps, DataColumnProps } from '@epam/uui-core'; + +interface GroupOfColumns { + type: 'group'; + group: DataColumnGroupProps; + columns: DataColumnProps[]; +} + +const getGroupsByKey = (groups: DataColumnGroupProps[]) => { + if (!groups) { + return null; + } + const gRec = groups.reduce>( + (g, group) => ({ ...g, [group.key]: group }), + {}, + ); + return !Object.keys(gRec).length ? null : gRec; +}; + +export const isGroupOfColumns = ( + item: GroupOfColumns | DataColumnProps, +): item is GroupOfColumns => 'type' in item && item.type === 'group'; + +export const getGroupsWithColumns = (columnGroups: DataColumnGroupProps[], columns: DataColumnProps[]) => { + const columnGroupsByKey = getGroupsByKey(columnGroups); + if (!columnGroupsByKey) { + return columns; + } + + return columns.reduce | DataColumnProps>>((columnsAndGroups, column) => { + if (column.group) { + const group = columnGroupsByKey[column.group]; + if (!group) { + throw new Error(`The '${column.group}' group mentioned in the '${column.key}' column is undefined.`); + } + const lastItem = columnsAndGroups[columnsAndGroups.length - 1]; + if (lastItem && isGroupOfColumns(lastItem) && lastItem.group.key === column.group) { + lastItem.columns.push(column); + return columnsAndGroups; + } + + columnsAndGroups.push({ type: 'group', group: columnGroupsByKey[column.group], columns: [column] }); + return columnsAndGroups; + } + + columnsAndGroups.push(column); + return columnsAndGroups; + }, []); +}; diff --git a/uui-core/src/constants/selectors.ts b/uui-core/src/constants/selectors.ts index 23385f347f..4343066f83 100644 --- a/uui-core/src/constants/selectors.ts +++ b/uui-core/src/constants/selectors.ts @@ -82,6 +82,13 @@ export const uuiDataTableHeaderCell = { uuiTableHeaderFoldAllIcon: 'uui-table-header-fold-all-icon', } as const; +export const uuiDataTableHeaderGroupCell = { + uuiTableHeaderGroupCell: 'uui-table-header-group-cell', + uuiTableHeaderGroupCaption: 'uui-table-header-group-caption', + uuiTableHeaderGroupCaptionWrapper: 'uui-table-header-group-caption-wrapper', + uuiTableHeaderGroupCaptionTooltip: 'uui-table-header-group-caption-tooltip', +} as const; + export const uuiScrollShadows = { top: 'uui-scroll-shadow-top', topVisible: 'uui-scroll-shadow-top-visible', diff --git a/uui-core/src/types/tables.ts b/uui-core/src/types/tables.ts index a26cc490f2..7253c54a56 100644 --- a/uui-core/src/types/tables.ts +++ b/uui-core/src/types/tables.ts @@ -31,6 +31,41 @@ export type ICanBeFixed = { fix?: 'left' | 'right'; }; +/** + * Columns group configuration. + */ +export interface DataColumnGroupProps extends IHasCX, IClickable { + /** + * Unique key to identify the columns group. Used to reference columns group. + */ + key: string; + + /** Columns group caption. Can be a plain text, or any React Component */ + caption?: React.ReactNode; + + /** Aligns columns group header content horizontally */ + textAlign?: 'left' | 'center' | 'right'; + + /** Info tooltip displayed in the table header */ + info?: React.ReactNode; + + /** Overrides rendering of the whole columns group cell */ + renderCell?(column: DataColumnGroupProps): any; + + /** Render callback for columns group header tooltip. + * This tooltip will appear on cell hover with 600ms delay. + * + * If omitted, default implementation with columnGroup.caption + columnGroup.info will be rendered. + * Pass `() => null` to disable tooltip rendering. + */ + renderTooltip?(column: DataColumnGroupProps): React.ReactNode; + + /** + * Overrides rendering of the whole columns group header cell. + */ + renderHeaderCell?(cellProps: DataTableHeaderGroupCellProps): any; +} + export interface DataColumnProps extends ICanBeFixed, IHasCX, IClickable, IHasRawProps, Attributes { /** * Unique key to identify the column. Used to reference columns, e.g. in ColumnsConfig. @@ -38,6 +73,11 @@ export interface DataColumnProps extends */ key: string; + /** + * A unique identifier for a group of columns that establishes a connection between the column and the group of columns. + */ + group?: string; + /** Column caption. Can be a plain text, or any React Component */ caption?: React.ReactNode; @@ -159,6 +199,29 @@ export interface DataTableHeaderCellProps extends IEdita renderFilter?: (dropdownProps: IDropdownBodyProps) => React.ReactNode; } +/** + * DataTable columns group header cell props. + */ +export interface DataTableHeaderGroupCellProps extends IHasCX, IEditable { + /** + * A unique identifier for a group. + */ + key: string; + /** + * Columns group configuration. + */ + group: DataColumnGroupProps; + /** + * Defines if first column of the group is the first one in the table header. + */ + isFirstCell: boolean; + + /** + * Defines if last column of the group is the last one in the table header. + */ + isLastCell: boolean; +} + export type DataTableConfigModalParams = IEditable & { /** Array of all table columns */ columns: DataColumnProps[]; @@ -166,6 +229,10 @@ export type DataTableConfigModalParams = IEditable & { export interface DataTableHeaderRowProps extends IEditable, IHasCX, DataTableColumnsConfigOptions { columns: DataColumnProps[]; + /** + * Columns group configuration. + */ + columnGroups?: DataColumnGroupProps[]; selectAll?: ICheckable; /** * Enables collapse/expand all functionality. @@ -173,6 +240,10 @@ export interface DataTableHeaderRowProps extends IEditab showFoldAll?: boolean; onConfigButtonClick?: (params: DataTableConfigModalParams) => any; renderCell?: (props: DataTableHeaderCellProps) => React.ReactNode; + /** + * Columns group cell render function. + */ + renderGroupCell?: (props: DataTableHeaderGroupCellProps) => React.ReactNode; renderConfigButton?: () => React.ReactNode; } diff --git a/uui/components/tables/DataTable.tsx b/uui/components/tables/DataTable.tsx index 70e91ea85d..4909482a4a 100644 --- a/uui/components/tables/DataTable.tsx +++ b/uui/components/tables/DataTable.tsx @@ -4,6 +4,7 @@ import { useColumnsWithFilters } from '../../helpers'; import { ColumnsConfig, DataRowProps, useUuiContext, uuiScrollShadows, useColumnsConfig, IEditable, DataTableState, DataTableColumnsConfigOptions, DataSourceListProps, DataColumnProps, cx, TableFiltersConfig, DataTableRowProps, DataTableSelectedCellData, Overwrite, + DataColumnGroupProps, } from '@epam/uui-core'; import { DataTableHeaderRow, DataTableHeaderRowProps } from './DataTableHeaderRow'; import { DataTableRow, DataTableRowProps as UuiDataTableRowProps } from './DataTableRow'; @@ -26,6 +27,9 @@ export interface DataTableProps extends IEditable[]; + /** Array of all possible column groups for the table */ + columnGroups?: DataColumnGroupProps[]; + /** Array of all possible columns for the table */ columns: DataColumnProps[]; @@ -140,6 +144,7 @@ export function DataTable(props: React.PropsWithChildren + > { + getTooltipContent = (column: DataColumnGroupProps) => ( +
+ + { column.caption } + + { column.info && ( + + { column.info } + + ) } +
+ ); + + getColumnCaption = () => { + const renderTooltip = this.props.group.renderTooltip || this.getTooltipContent; + const captionCx = [ + css.caption, + this.props.textCase === 'upper' && css.upperCase, + uuiDataTableHeaderGroupCell.uuiTableHeaderGroupCaption, + settings.sizes.dataTable.header.row.groupCell.truncate.includes(this.props.size) && css.truncate, + ]; + + return ( +
+ + + { this.props.group.caption } + + +
+ ); + }; + + getLeftPadding = () => { + const { columnsGap, isFirstCell } = this.props; + + if (columnsGap) return isFirstCell ? columnsGap : +columnsGap / 2; + return isFirstCell ? settings.sizes.dataTable.header.row.groupCell.defaults.paddingEdge : settings.sizes.dataTable.header.row.groupCell.defaults.padding; + }; + + getRightPadding = () => { + const { columnsGap, isLastCell } = this.props; + + if (columnsGap) return isLastCell ? columnsGap : +columnsGap / 2; + return isLastCell ? settings.sizes.dataTable.header.row.groupCell.defaults.paddingEdge : settings.sizes.dataTable.header.row.groupCell.defaults.padding; + }; + + renderCellContent = (props: HeaderCellContentProps) => { + const computeStyles = { + '--uui-dt-header-group-cell-padding-start': `${this.getLeftPadding()}px`, + '--uui-dt-header-group-cell-padding-end': `${this.getRightPadding()}px`, + } as React.CSSProperties; + + return ( + { + (props.ref as React.RefCallback)(ref); + } } + cx={ cx( + uuiDataTableHeaderGroupCell.uuiTableHeaderGroupCell, + css.root, + `uui-size-${this.props.size || settings.sizes.dataTable.header.row.groupCell.defaults.size}`, + this.props.isFirstCell && 'uui-dt-header-first-column', + this.props.isLastCell && 'uui-dt-header-last-column', + ) } + rawProps={ { + role: 'columnheader', + ...props.eventHandlers, + } } + style={ computeStyles } + > + { this.getColumnCaption() } + + ); + }; + + render() { + if (this.props.group.renderHeaderCell) { + return this.props.group.renderHeaderCell(this.props); + } + + const computeStyles = { + '--uui-dt-header-group-cell-padding-start': `${this.getLeftPadding()}px`, + '--uui-dt-header-group-cell-padding-end': `${this.getRightPadding()}px`, + width: '100%', + } as React.CSSProperties; + + return ( + + { this.getColumnCaption() } + + ); + } +} diff --git a/uui/components/tables/DataTableHeaderRow.tsx b/uui/components/tables/DataTableHeaderRow.tsx index e7afd97939..e842bf748a 100644 --- a/uui/components/tables/DataTableHeaderRow.tsx +++ b/uui/components/tables/DataTableHeaderRow.tsx @@ -9,6 +9,7 @@ import { settings } from '../../settings'; import './variables.scss'; import css from './DataTableHeaderRow.module.scss'; +import { DataTableHeaderGroupCell } from './DataTableHeaderGroupCell'; export type DataTableHeaderRowProps = CoreDataTableHeaderRowProps & DataTableHeaderRowMods; export const DataTableHeaderRow = withMods( @@ -24,6 +25,15 @@ export const DataTableHeaderRow = withMods ), + renderGroupCell: (props) => ( + + ), renderConfigButton: () => (