diff --git a/packages/patternfly-4/react-table/src/components/Table/Table.md b/packages/patternfly-4/react-table/src/components/Table/Table.md index 030c9ddca5a..bbaa4a18f5c 100644 --- a/packages/patternfly-4/react-table/src/components/Table/Table.md +++ b/packages/patternfly-4/react-table/src/components/Table/Table.md @@ -13,6 +13,7 @@ import { TableHeader, TableBody, sortable, + SortHelpers, SortByDirection, headerCol, TableVariant, @@ -22,13 +23,16 @@ import { textCenter, wrappable, classNames, - Visibility + Visibility, + useSortableRows, + useSelectableRows } from '@patternfly/react-table'; - import { CodeBranchIcon, CodeIcon, - CubeIcon + CubeIcon, + CheckIcon, + TimesIcon } from '@patternfly/react-icons'; import DemoSortableTable from './demo/DemoSortableTable'; @@ -41,74 +45,99 @@ import { Table, TableHeader, TableBody, - sortable, - SortByDirection, - headerCol, - TableVariant, - expandable, - cellWidth, textCenter, } from '@patternfly/react-table'; -class SimpleTable extends React.Component { - constructor(props) { - super(props); - this.state = { - columns: [ - { title: 'Repositories' }, - 'Branches', - { title: 'Pull requests' }, - 'Workspaces', - { - title: 'Last Commit', - transforms: [textCenter], - cellTransforms: [textCenter] - } - ], - rows: [ - { - cells: ['one', 'two', 'three', 'four', 'five'] - }, - { - cells: [ - { - title:
one - 2
, - props: { title: 'hover title', colSpan: 3 } - }, - 'four - 2', - 'five - 2' - ] - }, - { - cells: [ - 'one - 3', - 'two - 3', - 'three - 3', - 'four - 3', - { - title: 'five - 3 (not centered)', - props: { textCenter: false } - } - ] - } - ] - }; - } +function SimpleTable() { + const cells = [ + 'Repositories', + 'Branches', + 'Pull requests', + 'Workspaces', + 'Last Commit' + ]; + const rows = [ + ['Foo', 2, 0, 6, 1533635470], + ['Bar', 1, 11, 2, 1564566670], + ['Baz', 6, 4, 99, 1565167870], + ]; + + return ( + + + +
+ ); +} +``` - render() { - const { columns, rows } = this.state; +## Cell/row options - return ( - - - -
- ); - } +```js +import React from 'react'; +import { + Table, + TableHeader, + TableBody, + cellWidth, + textCenter, +} from '@patternfly/react-table'; + +function SimpleCustomTable() { + const cells = [ + // Equivalent to just passing the string + { title: 'Repositories' }, + 'Branches', + { title: 'Pull requests' }, + 'Workspaces', + // Will run the `transform` functions on the header, and the + // `cellTransforms` on the cells. Transformers are used to inject + // extra props to the the cell component. In this case we use the + // builtin `textCenter` transformer to instruct the cell to apply + // the required stylings to center the cell content. + { + title: 'Last Commit', + transforms: [textCenter], + cellTransforms: [textCenter] + } + ]; + const rows = [ + ['Foo', 2, 0, 6, 1533635470], + [ + // Cell content can also be defined as a JSX element. + // Extra props can be passed to the cell (the `td` element). + { + title:
Bar 👾
, + props: { title: 'hover title', colSpan: 3 } + }, + 2, + { + title:
1564566670
+ } + ], + [ + 'Baz', + 6, + 4, + 99, + // In this example, we disable the default cellTransform for this specific cell, setting the `textCenter` prop to `false`. + { + title: '1565167870 (not centered)', + props: { textCenter: false } + } + ] + ]; + + return ( + + + +
+ ); } ``` -## Sortable table +## Cell formatters ```js import React from 'react'; @@ -116,52 +145,121 @@ import { Table, TableHeader, TableBody, - sortable, - SortByDirection, - headerCol, - TableVariant, - expandable, - cellWidth + textCenter } from '@patternfly/react-table'; +import { + CheckIcon, + TimesIcon +} from '@patternfly/react-icons'; -class SortableTable extends React.Component { - constructor(props) { - super(props); - this.state = { - columns: [ - { title: 'Repositories', transforms: [sortable] }, - 'Branches', - { title: 'Pull requests', transforms: [sortable] }, - 'Workspaces', - 'Last Commit' - ], - rows: [['one', 'two', 'a', 'four', 'five'], ['a', 'two', 'k', 'four', 'five'], ['p', 'two', 'b', 'four', 'five']], - sortBy: {} +function CellFormatters() { + const rows = [ + ['Foo', 1533635470, 1], + ['Bar', 1564566670, 0], + ['Baz', 1565167870, 1], + ]; + + // A component that displays a CI build status. + // *Heads-up* - this component definition should *not* stay inside + // the main component function in a real world application; it's + // done this way in this example as a work-around against the + // limitation of one component per example of the documentation + // system in use. + const CIStatusIcon = ({ passing }) => { + const styles = { + color: passing + ? 'var(--pf-global--success-color--200)' + : 'var(--pf-global--danger-color--100)' }; - this.onSort = this.onSort.bind(this); + const icon = passing ? : ; + const text = passing ? 'Passing' : 'Failing'; + return ( +
+ {icon} {text} +
+ ); + }; + + // Last commit comes as a unix timestamp (in seconds). We specify + // a formatter that convert it to something readable by humans. + const lastCommitTimestampToLocalHuman = (value, extraProps) => { + return new Date(value * 1000).toLocaleString(); } - onSort(_event, index, direction) { - const sortedRows = this.state.rows.sort((a, b) => (a[index] < b[index] ? -1 : a[index] > b[index] ? 1 : 0)); - this.setState({ - sortBy: { - index, - direction - }, - rows: direction === SortByDirection.asc ? sortedRows : sortedRows.reverse() - }); + // CI Status comes as a boolean value. We specify a formatter that + // passes the value to the `CIStatusIcon` we wrote. + const statusToIcon = (value, extraProps) => { + return ; } - render() { - const { columns, rows, sortBy } = this.state; + const cells = [ + 'Repositories', + { + title: 'Last Commit', + cellFormatters: [lastCommitTimestampToLocalHuman] + }, + { + title: 'CI Status', + cellFormatters: [statusToIcon], + // We apply some transforms to both the header and the cell, to make + // it centered. + transforms: [textCenter], + cellTransforms: [textCenter] + } + ]; + + return ( + + + +
+ ); +} +``` - return ( - - - -
- ); - } +## Sortable table + +```js +import React from 'react'; +import { + Table, + TableHeader, + TableBody, + sortable, + SortHelpers, + useSortableRows +} from '@patternfly/react-table'; + +function SortableTable () { + const rows = [ + ['Foo', 2, 0, 6, 1533635470], + ['Bar', 1, 11, 2, { title:
⏰ 1564566670
, value: 1564566670 }], + ['Baz', 6, 4, 99, 1565167870], + ['Qux', undefined, 4, undefined, 1565167870], + ]; + + const sortLastCommit = (a, b, aObj, bObj) => { + a = aObj.value || a; + b = bObj.value || b; + return SortHelpers.numbers(a, b); + }; + + const cells = [ + { title: 'Repositories', transforms: [sortable] }, + { title: 'Branches', transforms: [sortable.numbers] }, + { title: 'Pull requests', transforms: [sortable.numbers] }, + { title: 'Workspaces', transforms: [sortable.numbers] }, + { title: 'Last Commit', transforms: [sortable.custom(sortLastCommit)] } + ]; + + const [sortedRows, onSort, sortBy] = useSortableRows(rows); + + return ( + + + +
+ ); } ``` @@ -173,66 +271,78 @@ import { Table, TableHeader, TableBody, - sortable, - SortByDirection, headerCol, - TableVariant, - expandable, - cellWidth + useSelectableRows } from '@patternfly/react-table'; -class SelectableTable extends React.Component { - constructor(props) { - super(props); - this.state = { - columns: [ - { title: 'Repositories', cellTransforms: [headerCol()] }, - 'Branches', - { title: 'Pull requests' }, - 'Workspaces', - 'Last Commit' - ], - rows: [ - { - cells: ['one', 'two', 'a', 'four', 'five'] - }, - { - cells: ['a', 'two', 'k', 'four', 'five'] - }, - { - cells: ['p', 'two', 'b', 'four', 'five'] - } - ] - }; - this.onSelect = this.onSelect.bind(this); - } +function SelectableTable () { + const rows = [ + ['Foo', 2, 0, 6, 1533635470], + ['Bar', 1, 11, 2, 1564566670], + ['Baz', 6, 4, 99, 1565167870], + ]; + + const cells = [ + { title: 'Repositories', cellTransforms: [headerCol()] }, + 'Branches', + 'Pull requests', + 'Workspaces', + 'Last Commit', + ]; + + const [selectedRows, onSelect] = useSelectableRows(rows); + + return ( + + + +
+ ); +} +``` - onSelect(event, isSelected, rowId) { - let rows; - if (rowId === -1) { - rows = this.state.rows.map(oneRow => { - oneRow.selected = isSelected; - return oneRow; - }); - } else { - rows = [...this.state.rows]; - rows[rowId].selected = isSelected; - } - this.setState({ - rows - }); - } +## Sortable and selectable table - render() { - const { columns, rows } = this.state; +```js +import React from 'react'; +import { + Table, + TableHeader, + TableBody, + headerCol, + sortable, + useSelectableRows +} from '@patternfly/react-table'; - return ( - - - -
- ); - } +function SortableAndSelectableTable () { + const rows = [ + ['Foo', 2, 0, 6, 1533635470], + ['Bar', 1, 11, 2, 1564566670], + ['Baz', 6, 4, 99, 1565167870], + ]; + + const cells = [ + { title: 'Repositories', transforms: [sortable], cellTransforms: [headerCol()] }, + { title: 'Branches', transforms: [sortable.numbers] }, + { title: 'Pull requests', transforms: [sortable.numbers] }, + { title: 'Workspaces', transforms: [sortable.numbers] }, + { title: 'Last Commit', transforms: [sortable.numbers] } + ]; + + const [sortedRows, onSort, sortBy] = useSortableRows(rows, cells); + + // we need to specify the getRowKey callback to use unique ids to identify the + // selected rows. We use the repository name in this example. + const [sortedAndSelectedRows, onSelect] = useSelectableRows(sortedRows, { + getRowKey: (rowData, rowIndex) => rowData[0] + }); + + return ( + + + +
+ ); } ``` @@ -298,7 +408,7 @@ class SimpleActionsTable extends React.Component { render() { const { columns, rows, actions } = this.state; return ( - +
diff --git a/packages/patternfly-4/react-table/src/components/Table/Table.tsx b/packages/patternfly-4/react-table/src/components/Table/Table.tsx index f22fa20fea9..681c0b5ff05 100644 --- a/packages/patternfly-4/react-table/src/components/Table/Table.tsx +++ b/packages/patternfly-4/react-table/src/components/Table/Table.tsx @@ -8,7 +8,7 @@ import { BodyCell } from './BodyCell'; import { HeaderCell } from './HeaderCell'; import { RowWrapper } from './RowWrapper'; import { BodyWrapper } from './BodyWrapper'; -import { calculateColumns } from './utils/headerUtils'; +import { calculateColumns } from './utils'; import { formatterValueType, ColumnType, RowType, RowKeyType, ColumnsType } from './base'; export enum TableGridBreakpoint { @@ -23,8 +23,8 @@ export enum TableGridBreakpoint { export enum TableVariant { compact = 'compact' } - -export type OnSort = (event: React.MouseEvent, columnIndex: number, sortByDirection: SortByDirection, extraData: IExtraColumnData) => void; +export type OnSortCallback = (aValue: any, bValue: any, aObject: IRow | string, bObject: IRow | string) => number; +export type OnSort = (event: React.MouseEvent, columnIndex: number, sortByDirection: SortByDirection, extraData: IExtraColumnData, sortCallback: OnSortCallback) => void; export type OnCollapse = (event: React.MouseEvent, rowIndex: number, isOpen: boolean, rowData: IRowData, extraData: IExtraData) => void; export type OnExpand = (event: React.MouseEvent, rowIndex: number, colIndex: number, isOpen: boolean, rowData: IRowData, extraData: IExtraData) => void; export type OnSelect = (event: React.MouseEvent, isSelected: boolean, rowIndex: number, rowData: IRowData, extraData: IExtraData) => void; @@ -45,6 +45,7 @@ export interface IColumn { extraParams: { sortBy?: ISortBy; onSort?: OnSort; + sortCallback?: OnSortCallback; onCollapse?: OnCollapse; onExpand?: OnExpand; onSelect?: OnSelect; @@ -54,6 +55,7 @@ export interface IColumn { dropdownPosition?: DropdownPosition; dropdownDirection?: DropdownDirection; allRowsSelected?: boolean; + firstUserColumnIndex?: number; }; } @@ -106,6 +108,8 @@ export interface IDecorator extends React.HTMLProps { children?: React.ReactNode; } +export type ICells = (ICell | string)[]; + export interface ICell { title?: string; transforms?: ((...args: any) => any)[]; @@ -124,6 +128,8 @@ export interface IRowCell { props?: any; } +export type IRows = (((string | number | IRowCell)[]) | IRow)[]; + export interface IRow extends RowType { cells?: (React.ReactNode | IRowCell)[]; isOpen?: boolean; @@ -161,8 +167,8 @@ export interface TableProps { contentId?: string; dropdownPosition?: 'right' | 'left'; dropdownDirection?: 'up' | 'down'; - rows: (IRow | string[])[]; - cells: (ICell | string)[]; + rows: IRows; + cells: ICells; bodyWrapper?: Function; rowWrapper?: Function; role?: string; diff --git a/packages/patternfly-4/react-table/src/components/Table/hooks/index.ts b/packages/patternfly-4/react-table/src/components/Table/hooks/index.ts new file mode 100644 index 00000000000..cd628f7d4a8 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useSelectableRows'; +export * from './useSortableRows'; diff --git a/packages/patternfly-4/react-table/src/components/Table/hooks/useSelectableRows.ts b/packages/patternfly-4/react-table/src/components/Table/hooks/useSelectableRows.ts new file mode 100644 index 00000000000..c97f80f815c --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/hooks/useSelectableRows.ts @@ -0,0 +1,84 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { IRowData, IRows, OnSelect } from '../Table'; + +const defaultOptions = { + getRowKey: (rowData: IRowData, rowIndex: number) => rowIndex +}; + +/** + * Returns the onSelect callback required by the Table component to allow for + * selecting rows, and the updated `rows` with the right internal flags to tell + * the Table component which rows are selected and which are not. + * + * @example + * const [selectedRows, selectedRows] = useSelectableRows(rows); + * + * @param rows + * @param getRowKey - optional, a function to return an unique key for a row. By + * default the row's index is used. For the table to be also sortable while being + * selectable, a key that uniquely identify the row among its siblings is required. + */ +export function useSelectableRows(rows: IRows, { getRowKey } = defaultOptions) { + // Selected rows's keys will be saved in the component's state + const [selectedKeys, setSelectedKeys] = useState[]>([]); + + // When selecting/deselecting all lines, or when transitioning from an all rows + // selected state, we need to compute the new keys based on the full list of keys + // available in the original rows array. + // Since that array can be composed of many entries, we cache the value so we don't + // pay the cost of the map when the user is not changing the original data. + const allKeys = useMemo(() => rows.map((r, idx) => getRowKey(r, idx)), [rows, getRowKey]); + + // Since the Table component will not re-render rows if unchanged (because of the + // BodyRow:shouldComponentUpdate method), we need to have a reference to an alway + // up to date value of the selected keys in the callback we pass to the selectable + // cell. This way we can ensure that that callback will not run against stale state + // data. + const latestSelectedKeys = useRef(selectedKeys); + + // The callback that should be passed to the Table's onSelect property. + // It will update the list of selected keys based on the user action. + // Note that the user could be interacting with the select/deselect all button + // in the header: that case is identified by the rowIndex value passed as -1 + // by the Table component. + const onSelect = useCallback( + (event, isSelected, rowIndex, rowData, extraData) => { + const latestIndexes = latestSelectedKeys.current; + let updatedIndexes = selectedKeys; + // A rowIndex -1 indicates that the user clicked on the select all checkbox. + if (rowIndex === -1) { + updatedIndexes = isSelected ? allKeys : []; + } else { + // A specific row has been selected/deselected + const rowKey = getRowKey(rowData, rowIndex); + updatedIndexes = isSelected + ? Array.from(new Set([...latestIndexes, rowKey])) + : latestIndexes.filter(index => index !== rowKey); + } + // Here we make sure that other onSelect callbacks will work against the latest + // set of selected keys. + latestSelectedKeys.current = updatedIndexes; + // We still have to save the selected keys in the state, to trigger a re-render + // of the component so that selected rows will actually be displayed as + // selected. + setSelectedKeys(updatedIndexes); + }, + [setSelectedKeys, latestSelectedKeys, allKeys, getRowKey] + ); + + const selectedRows = rows.map((row, index) => { + const isRowSelected = selectedKeys.includes(getRowKey(row, index)); + if (Array.isArray(row)) { + const updatedRow = [...row] as typeof row; + // cast required to work with primitive types + (updatedRow as any).selected = isRowSelected; + return updatedRow; + } else { + const updatedRow = {...row}; + updatedRow.selected = isRowSelected; + return updatedRow; + } + }); + + return [selectedRows, onSelect]; +} diff --git a/packages/patternfly-4/react-table/src/components/Table/hooks/useSortableRows.ts b/packages/patternfly-4/react-table/src/components/Table/hooks/useSortableRows.ts new file mode 100644 index 00000000000..e1021f40ce2 --- /dev/null +++ b/packages/patternfly-4/react-table/src/components/Table/hooks/useSortableRows.ts @@ -0,0 +1,30 @@ +import { useMemo, useState } from 'react'; +import { IExtraColumnData, IRows, OnSort, OnSortCallback, SortByDirection } from '../Table'; + +export function useSortableRows(rows: IRows) { + const [sortBy, setSortBy] = useState< + | { index: number; direction: SortByDirection; columnData: IExtraColumnData; sortCallback: OnSortCallback } + | undefined + >(); + + const onSort: OnSort = (_event, index, direction, columnData, sortCallback) => { + setSortBy({ + index, + direction, + columnData, + sortCallback + }); + }; + + const sortCb = (rowA: any, rowB: any) => { + const [a, b] = + sortBy.direction === 'desc' ? [rowA[sortBy.index], rowB[sortBy.index]] : [rowB[sortBy.index], rowA[sortBy.index]]; + const aValue = typeof a === 'object' && a.title ? a.title : a; + const bValue = typeof b === 'object' && b.title ? b.title : b; + return sortBy.sortCallback(aValue, bValue, a, b); + }; + + const sortedRows = useMemo(() => (!sortBy ? rows : rows.sort(sortCb)), [sortBy, rows, sortCb]); + + return [sortedRows, onSort, sortBy]; +} diff --git a/packages/patternfly-4/react-table/src/components/Table/index.ts b/packages/patternfly-4/react-table/src/components/Table/index.ts index e8197c40dce..4f6069fe9b4 100644 --- a/packages/patternfly-4/react-table/src/components/Table/index.ts +++ b/packages/patternfly-4/react-table/src/components/Table/index.ts @@ -11,3 +11,4 @@ export * from './RowWrapper'; export * from './SelectColumn'; export * from './SortColumn'; export * from './utils'; +export * from './hooks'; diff --git a/packages/patternfly-4/react-table/src/components/Table/utils/decorators/sortable.tsx b/packages/patternfly-4/react-table/src/components/Table/utils/decorators/sortable.tsx index abcc2f7ad4b..f08a36eaea7 100644 --- a/packages/patternfly-4/react-table/src/components/Table/utils/decorators/sortable.tsx +++ b/packages/patternfly-4/react-table/src/components/Table/utils/decorators/sortable.tsx @@ -2,20 +2,25 @@ import * as React from 'react'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Table/table'; import buttonStyles from '@patternfly/react-styles/css/components/Button/button'; -import { SortByDirection, IExtra, IFormatterValueType } from '../../Table'; +import { SortByDirection, IExtra, IFormatterValueType, OnSortCallback } from '../../Table'; import { SortColumn } from '../../SortColumn'; -export const sortable = (label: IFormatterValueType, { columnIndex, column, property }: IExtra) => { +const sortableFn = (sortCallback: OnSortCallback, label: IFormatterValueType, { columnIndex, column, property }: IExtra) => { const { - extraParams: { sortBy, onSort } + extraParams: { sortBy, onSort, firstUserColumnIndex = 0 } } = column; + + // correct the column index based on the presence of extra columns added on the + // left of the user provided ones + const correctedColumnIndex = columnIndex - firstUserColumnIndex; + const extraData = { - columnIndex, + columnIndex: correctedColumnIndex, column, property }; - const isSortedBy = sortBy && columnIndex === sortBy.index; + const isSortedBy = sortBy && correctedColumnIndex === sortBy.index; function sortClicked(event: React.MouseEvent) { let reversedDirection; if (!isSortedBy) { @@ -24,7 +29,7 @@ export const sortable = (label: IFormatterValueType, { columnIndex, column, prop reversedDirection = sortBy.direction === SortByDirection.asc ? SortByDirection.desc : SortByDirection.asc; } // tslint:disable-next-line:no-unused-expression - onSort && onSort(event, columnIndex, reversedDirection, extraData); + onSort && onSort(event, correctedColumnIndex, reversedDirection, extraData, sortCallback); } return { @@ -42,3 +47,36 @@ export const sortable = (label: IFormatterValueType, { columnIndex, column, prop ) }; }; + +const SortHelpers = { + numbers(a: number, b: number) { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } + return 0; + }, + + booleans(a: boolean, b: boolean) { + const toNumber = (v: boolean) => (v ? 1 : 0); + return SortHelpers.numbers(toNumber(a), toNumber(b)); + }, + + strings(a: string, b: string) { + return a.localeCompare(b); + } +}; + +const partialOnSort = (fn: OnSortCallback) => sortableFn.bind(null, fn); +const defaultSortable = partialOnSort(SortHelpers.strings); +const sortableFunctions = { + custom: partialOnSort, + numbers: partialOnSort(SortHelpers.numbers), + booleans: partialOnSort(SortHelpers.booleans), + strings: partialOnSort(SortHelpers.strings), +}; + +const sortable = Object.assign(defaultSortable, sortableFunctions); + +export { sortable, SortHelpers }; diff --git a/packages/patternfly-4/react-table/src/components/Table/utils/transformers.tsx b/packages/patternfly-4/react-table/src/components/Table/utils/transformers.tsx index 3e1c2ba28de..a5b12cc5612 100644 --- a/packages/patternfly-4/react-table/src/components/Table/utils/transformers.tsx +++ b/packages/patternfly-4/react-table/src/components/Table/utils/transformers.tsx @@ -1,5 +1,5 @@ export { selectable } from './decorators/selectable'; -export { sortable } from './decorators/sortable'; +export { sortable, SortHelpers } from './decorators/sortable'; export { cellActions } from './decorators/cellActions'; export { cellWidth } from './decorators/cellWidth'; export { wrappable } from './decorators/wrappable';