From c771ab1b7333fbdcff3af3758b0b988702cb0b87 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 23 Jul 2020 15:18:52 -0400 Subject: [PATCH] feat(table,empty,skeleton): issues/10 add table component (#346) * table, convenience wrapper for pf tables * tableEmpty, convenience wrapper for platform comp, empty state * tableSkeleton, wrapper for skeleton table, allow variants * locales, i18n, table strings * styles, correct pf table behavior on small screens, no border fix --- public/locales/en-US.json | 6 + .../__tests__/__snapshots__/i18n.test.js.snap | 21 + .../__snapshots__/table.test.js.snap | 445 ++++++++++++++++++ .../__snapshots__/tableEmpty.test.js.snap | 40 ++ .../__snapshots__/tableSkeleton.test.js.snap | 285 +++++++++++ src/components/table/__tests__/table.test.js | 67 +++ .../table/__tests__/tableEmpty.test.js | 28 ++ .../table/__tests__/tableSkeleton.test.js | 35 ++ src/components/table/table.js | 221 +++++++++ src/components/table/tableEmpty.js | 53 +++ src/components/table/tableSkeleton.js | 66 +++ src/styles/_table.scss | 25 + src/styles/index.scss | 1 + 13 files changed, 1293 insertions(+) create mode 100644 src/components/table/__tests__/__snapshots__/table.test.js.snap create mode 100644 src/components/table/__tests__/__snapshots__/tableEmpty.test.js.snap create mode 100644 src/components/table/__tests__/__snapshots__/tableSkeleton.test.js.snap create mode 100644 src/components/table/__tests__/table.test.js create mode 100644 src/components/table/__tests__/tableEmpty.test.js create mode 100644 src/components/table/__tests__/tableSkeleton.test.js create mode 100644 src/components/table/table.js create mode 100644 src/components/table/tableEmpty.js create mode 100644 src/components/table/tableSkeleton.js create mode 100644 src/styles/_table.scss diff --git a/public/locales/en-US.json b/public/locales/en-US.json index bd2ea241d..197d8497b 100644 --- a/public/locales/en-US.json +++ b/public/locales/en-US.json @@ -41,6 +41,12 @@ "thresholdSocketsLegendTooltip_OpenShift": "Maximum capacity, as CPU sockets, based on total {{product}} subscriptions in this account.", "tooltipSummary": "Your subscription data facets. With one level of column and row headers." }, + "curiosity-inventory": { + "tableAriaLabel": "Subscription Watch systems inventory table.", + "tableSummary": "A generated table with one level of column headers.", + "tableEmptyInventoryTitle": "No results found", + "tableEmptyInventoryMessage": "No results match the filter criteria. Remove filters or clear all filters to show results." + }, "curiosity-toolbar": { "slaCategory": "SLA", "slaNone": "No SLA", diff --git a/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap b/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap index c288fd59c..0d35eb94b 100644 --- a/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap +++ b/src/components/i18n/__tests__/__snapshots__/i18n.test.js.snap @@ -288,6 +288,27 @@ Array [ }, ], }, + Object { + "file": "./src/components/table/table.js", + "keys": Array [ + Object { + "key": "curiosity-inventory.tableEmptyInventoryTitle", + "match": "t('curiosity-inventory.tableEmptyInventoryTitle')", + }, + Object { + "key": "curiosity-inventory.tableEmptyInventoryMessage", + "match": "t('curiosity-inventory.tableEmptyInventoryMessage')", + }, + Object { + "key": "curiosity-inventory.tableAriaLabel", + "match": "t('curiosity-inventory.tableAriaLabel')", + }, + Object { + "key": "curiosity-inventory.tableSummary", + "match": "t('curiosity-inventory.tableSummary')", + }, + ], + }, Object { "file": "./src/components/toolbar/toolbar.js", "keys": Array [ diff --git a/src/components/table/__tests__/__snapshots__/table.test.js.snap b/src/components/table/__tests__/__snapshots__/table.test.js.snap new file mode 100644 index 000000000..f0188e27a --- /dev/null +++ b/src/components/table/__tests__/__snapshots__/table.test.js.snap @@ -0,0 +1,445 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Table Component should allow expandable content: expandable content 1`] = ` + + + + + +
+
+
+`; + +exports[`Table Component should allow expandable content: expanded row 1`] = ` + + + + + +
+
+
+`; + +exports[`Table Component should allow variations in table layout: ariaLabel and summary 1`] = ` + + + + +
+
+
+`; + +exports[`Table Component should allow variations in table layout: borders and table header removed 1`] = ` + + + + +
+
+
+`; + +exports[`Table Component should allow variations in table layout: className and variant 1`] = ` + + + + +
+
+
+`; + +exports[`Table Component should allow variations in table layout: generated rows 1`] = ` + + + + + +
+
+
+`; + +exports[`Table Component should pass child components, nodes when there are no rows: children 1`] = ` + + + + + +
+ Loading... +
+
+`; + +exports[`Table Component should render a non-connected component: non-connected 1`] = ` + + + + + +
+ +
+
+`; diff --git a/src/components/table/__tests__/__snapshots__/tableEmpty.test.js.snap b/src/components/table/__tests__/__snapshots__/tableEmpty.test.js.snap new file mode 100644 index 000000000..2e482d2d9 --- /dev/null +++ b/src/components/table/__tests__/__snapshots__/tableEmpty.test.js.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TableEmpty Component should have fallback checks for certain props: fallback display 1`] = ` + + + + Lorem ipsum title + + + Lorem ipsum message. + + + +`; + +exports[`TableEmpty Component should render a non-connected component: non-connected 1`] = ` + + + + + Lorem ipsum title + + + Lorem ipsum message. + + + +`; diff --git a/src/components/table/__tests__/__snapshots__/tableSkeleton.test.js.snap b/src/components/table/__tests__/__snapshots__/tableSkeleton.test.js.snap new file mode 100644 index 000000000..9049df980 --- /dev/null +++ b/src/components/table/__tests__/__snapshots__/tableSkeleton.test.js.snap @@ -0,0 +1,285 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TableSkeleton Component should allow variations in table layout: borders and table header row removed 1`] = ` +, + , + , + , + , + ] + } + isHeader={false} + rows={ + Array [ + Object { + "cells": Array [ + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + ], + }, + ] + } + variant={null} +/> +`; + +exports[`TableSkeleton Component should allow variations in table layout: className and variant 1`] = ` +, + , + , + , + , + ] + } + isHeader={false} + rows={ + Array [ + Object { + "cells": Array [ + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + ], + }, + ] + } + variant="compact" +/> +`; + +exports[`TableSkeleton Component should allow variations in table layout: column and row count 1`] = ` +, + , + , + , + , + ] + } + isHeader={true} + rows={ + Array [ + Object { + "cells": Array [ + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + Object { + "cell": , + }, + ], + }, + ] + } + variant={null} +/> +`; + +exports[`TableSkeleton Component should render a non-connected component: non-connected 1`] = ` +, + ] + } + isHeader={true} + rows={ + Array [ + Object { + "cells": Array [ + Object { + "cell": , + }, + ], + }, + Object { + "cells": Array [ + Object { + "cell": , + }, + ], + }, + Object { + "cells": Array [ + Object { + "cell": , + }, + ], + }, + Object { + "cells": Array [ + Object { + "cell": , + }, + ], + }, + Object { + "cells": Array [ + Object { + "cell": , + }, + ], + }, + ] + } + variant={null} +/> +`; diff --git a/src/components/table/__tests__/table.test.js b/src/components/table/__tests__/table.test.js new file mode 100644 index 000000000..2080b04d9 --- /dev/null +++ b/src/components/table/__tests__/table.test.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TableVariant } from '@patternfly/react-table'; +import { Table } from '../table'; + +describe('Table Component', () => { + it('should render a non-connected component', () => { + const props = { + columnHeaders: ['lorem', 'ipsum', 'dolor', 'sit'] + }; + + const component = shallow(); + expect(component).toMatchSnapshot('non-connected'); + }); + + it('should allow variations in table layout', () => { + const props = { + columnHeaders: ['lorem ipsum'], + rows: [{ cells: ['dolor'] }, { cells: ['sit'] }] + }; + + const component = shallow(
); + expect(component).toMatchSnapshot('generated rows'); + + component.setProps({ + borders: false, + isHeader: false + }); + expect(component).toMatchSnapshot('borders and table header removed'); + + component.setProps({ + ariaLabel: 'lorem ipsum aria-label', + summary: 'lorem ipsum summary' + }); + expect(component).toMatchSnapshot('ariaLabel and summary'); + + component.setProps({ + className: 'lorem-ipsum-class', + variant: TableVariant.compact + }); + expect(component).toMatchSnapshot('className and variant'); + }); + + it('should allow expandable content', () => { + const props = { + columnHeaders: ['lorem ipsum'], + rows: [{ cells: ['dolor'], expandedContent: 'dolor sit expandable content' }, { cells: ['sit'] }] + }; + + const component = shallow(
); + expect(component).toMatchSnapshot('expandable content'); + + const componentInstance = component.instance(); + componentInstance.onCollapse({ index: 0, isOpen: true }); + expect(component).toMatchSnapshot('expanded row'); + }); + + it('should pass child components, nodes when there are no rows', () => { + const props = { + columnHeaders: ['lorem ipsum'], + rows: [] + }; + + const component = shallow(
Loading...
); + expect(component).toMatchSnapshot('children'); + }); +}); diff --git a/src/components/table/__tests__/tableEmpty.test.js b/src/components/table/__tests__/tableEmpty.test.js new file mode 100644 index 000000000..8cbcd3717 --- /dev/null +++ b/src/components/table/__tests__/tableEmpty.test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { SearchIcon } from '@patternfly/react-icons'; +import { TableEmpty } from '../tableEmpty'; + +describe('TableEmpty Component', () => { + it('should render a non-connected component', () => { + const props = { + icon: SearchIcon, + title: 'Lorem ipsum title', + message: 'Lorem ipsum message.' + }; + + const component = shallow(); + expect(component).toMatchSnapshot('non-connected'); + }); + + it('should have fallback checks for certain props', () => { + const props = { + title: 'Lorem ipsum title', + message: 'Lorem ipsum message.', + tableHeading: 'h1' + }; + + const component = shallow(); + expect(component).toMatchSnapshot('fallback display'); + }); +}); diff --git a/src/components/table/__tests__/tableSkeleton.test.js b/src/components/table/__tests__/tableSkeleton.test.js new file mode 100644 index 000000000..994a1cb33 --- /dev/null +++ b/src/components/table/__tests__/tableSkeleton.test.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TableVariant } from '@patternfly/react-table'; +import { TableSkeleton } from '../tableSkeleton'; + +describe('TableSkeleton Component', () => { + it('should render a non-connected component', () => { + const props = {}; + + const component = shallow(); + expect(component).toMatchSnapshot('non-connected'); + }); + + it('should allow variations in table layout', () => { + const props = { + colCount: 5, + rowCount: 1 + }; + + const component = shallow(); + expect(component).toMatchSnapshot('column and row count '); + + component.setProps({ + borders: false, + isHeader: false + }); + expect(component).toMatchSnapshot('borders and table header row removed'); + + component.setProps({ + className: 'lorem-ipsum-class', + variant: TableVariant.compact + }); + expect(component).toMatchSnapshot('className and variant'); + }); +}); diff --git a/src/components/table/table.js b/src/components/table/table.js new file mode 100644 index 000000000..ddd9e271f --- /dev/null +++ b/src/components/table/table.js @@ -0,0 +1,221 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Grid, GridItem } from '@patternfly/react-core'; +import { withTranslation } from 'react-i18next'; +import SearchIcon from '@patternfly/react-icons/dist/js/icons/search-icon'; +import { Table as PfTable, TableBody, TableHeader, TableVariant } from '@patternfly/react-table'; +import _isEqual from 'lodash/isEqual'; +import { TableEmpty } from './tableEmpty'; +import { helpers } from '../../common/helpers'; + +/** + * A table. + * + * @augments React.Component + * @fires onCollapse + */ +class Table extends React.Component { + state = { + updatedColumnHeaders: null, + updatedRows: null + }; + + componentDidMount() { + const { updatedRows } = this.state; + + if (updatedRows === null) { + this.setRowData(); + } + } + + componentDidUpdate(prevProps) { + const { columnHeaders, rows } = this.props; + + if (!_isEqual(prevProps.rows, rows) || !_isEqual(prevProps.columnHeaders, columnHeaders)) { + this.setRowData(); + } + } + + /** + * Apply expanded row content. + * + * @event onCollapse + * @param {object} params + * @param {number} params.index + * @param {boolean} params.isOpen + */ + onCollapse = ({ index, isOpen }) => { + const { updatedRows } = this.state; + updatedRows[index].isOpen = isOpen; + + if (isOpen) { + updatedRows[index + 1].fullWidth = false; + updatedRows[index + 1].cells = [{ title: updatedRows[index + 1].expandedContent }]; + } else { + updatedRows[index + 1].cells = [{ title: '' }]; + } + + this.setState({ + updatedRows + }); + }; + + /** + * Convert row objects into the required PF Table format. + */ + setRowData() { + const { columnHeaders, rows } = this.props; + const updatedColumnHeaders = []; + const updatedRows = []; + + columnHeaders.forEach(columnHeader => { + updatedColumnHeaders.push(columnHeader); + }); + + rows.forEach(({ expandedContent, cells, isExpanded }) => { + const rowObj = { + cells: [] + }; + updatedRows.push(rowObj); + + if (expandedContent) { + rowObj.isOpen = isExpanded || false; + + updatedRows.push({ + parent: updatedRows.length - 1, + fullWidth: true, + cells: [{ title: '' }], + expandedContent + }); + } + + cells.forEach(cell => { + if (cell?.cell) { + const { cell: contentCell, ...settings } = cell; + rowObj.cells.push({ title: contentCell, ...settings }); + } else { + rowObj.cells.push({ title: cell }); + } + }); + }); + + this.setState({ + updatedColumnHeaders, + updatedRows + }); + } + + /** + * Apply props to table. + * + * @returns {Node} + */ + renderTable() { + const { updatedColumnHeaders, updatedRows } = this.state; + const { ariaLabel, borders, children, className, isHeader, summary, t, variant } = this.props; + let emptyTable = null; + + if (!updatedRows?.length) { + emptyTable = children || ( + + ); + } + + return ( + + this.onCollapse({ event, index, isOpen, data })} + rows={(updatedRows?.length && updatedRows) || []} + cells={updatedColumnHeaders || []} + > + {isHeader && } + + + {emptyTable} + + ); + } + + /** + * Render a table. + * + * @returns {Node} + */ + render() { + return ( + + {this.renderTable()} + + ); + } +} + +/** + * Prop types. + * + * @type {{summary: string, columnHeaders: Array, t: Function, borders: boolean, children: Node, + * isHeader: boolean, variant: string, className: string, rows, ariaLabel: string}} + */ +Table.propTypes = { + ariaLabel: PropTypes.string, + borders: PropTypes.bool, + children: PropTypes.node, + className: PropTypes.string, + columnHeaders: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.node, + PropTypes.shape({ + title: PropTypes.node + }) + ]) + ).isRequired, + isHeader: PropTypes.bool, + rows: PropTypes.arrayOf( + PropTypes.shape({ + expandedContent: PropTypes.node, + cells: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.node, + PropTypes.shape({ + cell: PropTypes.node + }) + ]) + ), + isExpanded: PropTypes.bool + }) + ), + summary: PropTypes.string, + t: PropTypes.func, + variant: PropTypes.oneOf([...Object.values(TableVariant)]) +}; + +/** + * Default props. + * + * @type {{summary: null, t: Function, borders: boolean, children: null, isHeader: boolean, + * variant: null, className: null, rows: Array, ariaLabel: null}} + */ +Table.defaultProps = { + ariaLabel: null, + borders: true, + children: null, + className: null, + isHeader: true, + rows: [], + summary: null, + t: helpers.noopTranslate, + variant: null +}; + +const TranslatedTable = withTranslation()(Table); + +export { TranslatedTable as default, TranslatedTable, Table }; diff --git a/src/components/table/tableEmpty.js b/src/components/table/tableEmpty.js new file mode 100644 index 000000000..052c2391a --- /dev/null +++ b/src/components/table/tableEmpty.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { EmptyState, EmptyStateIcon, EmptyStateBody, EmptyStateVariant, Title } from '@patternfly/react-core'; +import { EmptyTable as PlatformEmptyTableWrapper } from '@redhat-cloud-services/frontend-components/components/cjs/EmptyTable'; + +/** + * Render an empty table. + * + * @param {object} props + * @param {Node|Function} props.icon + * @param {Node} props.message + * @param {string} props.tableHeading + * @param {Node} props.title + * @param {string} props.variant + * @returns {Node} + */ +const TableEmpty = ({ icon, message, tableHeading, title, variant }) => ( + + + {icon && } + + {title} + + {message} + + +); + +/** + * Prop types. + * + * @type {{icon: Node|Function, variant: string, message: Node, title: Node, tableHeading: string}} + */ +TableEmpty.propTypes = { + icon: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + message: PropTypes.node.isRequired, + tableHeading: PropTypes.string, + title: PropTypes.node.isRequired, + variant: PropTypes.oneOf(Object.keys(EmptyStateVariant)) +}; + +/** + * Default props. + * + * @type {{icon: null, variant: EmptyStateVariant.small, tableHeading: string}} + */ +TableEmpty.defaultProps = { + icon: null, + tableHeading: 'h2', + variant: EmptyStateVariant.small +}; + +export { TableEmpty as default, TableEmpty }; diff --git a/src/components/table/tableSkeleton.js b/src/components/table/tableSkeleton.js new file mode 100644 index 000000000..b30c93459 --- /dev/null +++ b/src/components/table/tableSkeleton.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { TableVariant } from '@patternfly/react-table'; +import { Skeleton, SkeletonSize } from '@redhat-cloud-services/frontend-components/components/cjs/Skeleton'; +import Table from './table'; + +/** + * Render a table with skeleton loaders. + * + * @param {object} props + * @param {string} props.className + * @param {boolean} props.borders + * @param {number} props.colCount + * @param {boolean} props.isHeader + * @param {number} props.rowCount + * @param {string} props.variant + * @returns {Node} + */ +const TableSkeleton = ({ className, borders, colCount, isHeader, rowCount, variant }) => { + const updatedColumnHeaders = [...new Array(colCount)].map(() => ); + + const updatedRows = [...new Array(rowCount)].map(() => ({ + cells: [...new Array(colCount)].map(() => ({ cell: })) + })); + + return ( + + ); +}; + +/** + * Prop types. + * + * @type {{borders: boolean, isHeader: boolean, colCount: number, variant: string, className: string, rowCount: number}} + */ +TableSkeleton.propTypes = { + borders: PropTypes.bool, + className: PropTypes.string, + colCount: PropTypes.number, + isHeader: PropTypes.bool, + rowCount: PropTypes.number, + variant: PropTypes.oneOf([...Object.values(TableVariant)]) +}; + +/** + * Default props. + * + * @type {{borders: boolean, isHeader: boolean, colCount: number, variant: null, className: null, rowCount: number}} + */ +TableSkeleton.defaultProps = { + borders: true, + className: null, + colCount: 1, + isHeader: true, + rowCount: 5, + variant: null +}; + +export { TableSkeleton as default, TableSkeleton }; diff --git a/src/styles/_table.scss b/src/styles/_table.scss new file mode 100644 index 000000000..2cef99b2b --- /dev/null +++ b/src/styles/_table.scss @@ -0,0 +1,25 @@ +.curiosity { + /** + * FixMe: PF CSS, appears updating resources causes an issue with an aggressive selector + * This is related to responsive table headers on browser widths below 800px. Remove this + * block once PF has a fix exposed via the platform. + */ + tr.pf-c-table__expandable-row.pf-m-expanded > td:before { + content: ''; + } + + tr.pf-c-table__expandable-row.pf-m-expanded > td { + padding: 0; + padding-bottom: var(--pf-c-table--m-compact-th--PaddingBottom); + } + + /** + * FixMe: PF CSS, appears nested tables within expandable tables can't have borders disabled? + * Remove this block once PF has a fix. + */ + table.pf-m-no-border-rows, + table.pf-m-no-border-rows > thead > tr, + table.pf-m-no-border-rows > tbody > tr { + border-width: 0 + } +} diff --git a/src/styles/index.scss b/src/styles/index.scss index c492494aa..8c7e92d97 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -14,3 +14,4 @@ @import 'usage-graph'; @import 'tour'; @import 'skeleton'; +@import 'table';