diff --git a/spec/pivotal-ui-react/sortable-table/sortable-table_spec.js b/spec/pivotal-ui-react/sortable-table/sortable-table_spec.js index 1adcfa6ce..465c653f7 100644 --- a/spec/pivotal-ui-react/sortable-table/sortable-table_spec.js +++ b/spec/pivotal-ui-react/sortable-table/sortable-table_spec.js @@ -1,46 +1,59 @@ require('../spec_helper'); +import {SortableTable, TableCell, TableHeader, TableRow} from '../../../src/pivotal-ui-react/sortable-table/sortable-table'; + describe('SortableTable', function() { - var SortableTable, data, columns; - beforeEach(function() { - SortableTable = require('../../../src/pivotal-ui-react/sortable-table/sortable-table').SortableTable; - columns = [ - { - name: 'title', - title: 'Title', - sortable: true - }, - { - name: 'instances', - title: 'Instances', - sortable: true, - align: 'center' - }, - { - name: 'unsortable', - title: 'Unsortable', - sortable: false, - align: 'right' - } - ]; - data = [ - { - instances: '1', - title: 'foo', - unsortable: '14' - }, - { - instances: '3', - title: 'sup', - unsortable: '22' - }, - { - title: 'yee', - instances: '2', - unsortable: '1' - } + var headers, clickSpy; + + const data = [ + { + instances: '1', + bar: '9', + title: 'foo', + unsortable: '14' + }, + { + instances: '3', + bar: '7', + title: 'sup', + unsortable: '22' + }, + { + title: 'yee', + instances: '2', + bar: '8', + unsortable: '1' + } + ]; + + function renderSortableTable(data, props = {}) { + clickSpy = jasmine.createSpy('click'); + headers = [ + Title, + Instances, + Bar, + Unsortable ]; - React.render(, root); + React.render(( + + {data.map(function(datum, key) { + return ( + + {datum.title} + {datum.instances} + {datum.bar} + {datum.unsortable} + + ); + })} + + ), + root + ); + } + + beforeEach(function() { + renderSortableTable(data); }); afterEach(function() { @@ -48,89 +61,93 @@ describe('SortableTable', function() { }); it('adds the class "sortable" on all sortable columns', function() { - expect('th:contains("Title")').toHaveClass('sortable'); + expect('th:contains("Title")').not.toHaveClass('sortable'); expect('th:contains("Instances")').toHaveClass('sortable'); expect('th:contains("Unsortable")').not.toHaveClass('sortable'); }); - it('adds the additional classes specified in the "classes" property', function() { - React.render(, root); - + it('adds the additional classes, id and styles to the table', function() { + renderSortableTable(data, {className: ['table-light'], id: 'table-id', style: {opacity: '0.5'}}); expect('table.table-sortable').toHaveClass('table'); expect('table.table-sortable').toHaveClass('table-sortable'); expect('table.table-sortable').toHaveClass('table-light'); + expect('table.table-sortable').toHaveProp('id', 'table-id'); + expect('table.table-sortable').toHaveCss({opacity: '0.5'}); }); - it('column data is aligned to the specified margin', function() { - expect('tbody tr:nth-of-type(1) > td:eq(0)').not.toHaveClass('txt-l'); - expect('tbody tr:nth-of-type(2) > td:eq(1)').toHaveClass('txt-c'); - expect('tbody tr:nth-of-type(1) > td:eq(2)').toHaveClass('txt-r'); - }); - - it('sorts table rows by the first column in ascending order by default', function() { - expect('th:contains("Title")').toHaveClass('sorted-asc'); + it('sorts table rows by the first sortable column in ascending order by default', function() { + expect('th:contains("Instances")').toHaveClass('sorted-asc'); expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('foo'); - expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('sup'); - expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('yee'); + expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('yee'); + expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('sup'); expect('tbody tr:nth-of-type(1) > td:eq(1)').toContainText('1'); - expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('3'); - expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('2'); + expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('2'); + expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('3'); }); - describe('clicking on the already asc-sorted column', function() { + describe('clicking on the already asc-sorted column that has an existing onClick function', function() { beforeEach(function() { - $('th:contains("Title")').simulate('click'); + $('th:contains("Instances")').simulate('click'); + }); + + it('calls the onClick function', function() { + expect(clickSpy).toHaveBeenCalled(); }); it('reverses the sort order', function() { - expect('th:contains("Title")').toHaveClass('sorted-desc'); + expect('th:contains("Instances")').toHaveClass('sorted-desc'); - expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('yee'); - expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('sup'); + expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('sup'); + expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('yee'); expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('foo'); - expect('tbody tr:nth-of-type(1) > td:eq(1)').toContainText('2'); - expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('3'); + expect('tbody tr:nth-of-type(1) > td:eq(1)').toContainText('3'); + expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('2'); expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('1'); }); describe('clicking on the already desc-sorted column', function() { beforeEach(function() { - $('th:contains("Title")').simulate('click'); + clickSpy.calls.reset(); + $('th:contains("Instances")').simulate('click'); + }); + + it('calls the onClick function', function() { + expect(clickSpy).toHaveBeenCalled(); }); it('reverses the sort order', function() { - expect('th:contains("Title")').toHaveClass('sorted-asc'); + expect('th:contains("Instances")').toHaveClass('sorted-asc'); expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('foo'); - expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('sup'); - expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('yee'); + expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('yee'); + expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('sup'); expect('tbody tr:nth-of-type(1) > td:eq(1)').toContainText('1'); - expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('3'); - expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('2'); + expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('2'); + expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('3'); }); }); }); describe('clicking on a sortable column', function() { beforeEach(function() { - $('th:contains("Instances")').simulate('click'); + $('th:contains("Bar")').simulate('click'); }); it('sorts table rows by that column', function() { - expect('th:contains("Instances")').toHaveClass('sorted-asc'); + expect('th:contains("Bar")').toHaveClass('sorted-asc'); expect('th:contains("Title")').not.toHaveClass('sorted-asc'); - expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('foo'); + expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('sup'); expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('yee'); - expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('sup'); + expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('foo'); - expect('tbody tr:nth-of-type(1) > td:eq(1)').toContainText('1'); - expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('2'); - expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('3'); + expect('tbody tr:nth-of-type(1) > td:eq(2)').toContainText('7'); + expect('tbody tr:nth-of-type(2) > td:eq(2)').toContainText('8'); + expect('tbody tr:nth-of-type(3) > td:eq(2)').toContainText('9'); }); }); @@ -141,16 +158,173 @@ describe('SortableTable', function() { it('does not change the sort', function() { expect('th:contains("Unsortable")').not.toHaveClass('sorted-asc'); - expect('th:contains("Title")').toHaveClass('sorted-asc'); + expect('th:contains("Instances")').toHaveClass('sorted-asc'); expect('tbody tr:nth-of-type(1) > td:eq(0)').toContainText('foo'); - expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('sup'); - expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('yee'); + expect('tbody tr:nth-of-type(2) > td:eq(0)').toContainText('yee'); + expect('tbody tr:nth-of-type(3) > td:eq(0)').toContainText('sup'); expect('tbody tr:nth-of-type(1) > td:eq(1)').toContainText('1'); - expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('3'); - expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('2'); + expect('tbody tr:nth-of-type(2) > td:eq(1)').toContainText('2'); + expect('tbody tr:nth-of-type(3) > td:eq(1)').toContainText('3'); }); }); + + describe('when the rows change', function() { + beforeEach(function() { + var newData = data.concat({ + title: 'new title', + instances: '1.5', + unsortable: '1', + bar: '123' + }); + renderSortableTable(newData); + }); + + it('shows the new rows in the correct sort order', function() { + expect('tbody tr').toHaveLength(4); + var instances = $('tbody tr > td:nth-of-type(2)').map(function() {return $(this).text(); }).toArray(); + expect(instances).toEqual(['1', '1.5', '2', '3']); + }); + }); +}); + +describe('TableHeader', function() { + function renderTableHeader({children, ...props}) { + return React.render(( + + + + + {children} + + + +
+ ), root + ); + + } + + it('contains the given children', function() { + renderTableHeader({children: (

)}); + expect('th').toExist(); + expect('th > p#header-id').toExist(); + }); + + describe('when the header is sortable', function() { + const props = { + sortable: true, + id: 'header-id', + className: 'header-light', + style: {opacity: '0.5'} + }; + + it('adds the additional classes, id and styles to the th', function() { + renderTableHeader(props); + expect('th').toHaveClass('sortable'); + expect('th').toHaveClass('header-light'); + expect('th').toHaveProp('id', 'header-id'); + expect('th').toHaveCss({opacity: '0.5'}); + }); + + describe('when there is an onSortableTableHeaderClick provided', function() { + var onSortableTableHeaderClickSpy; + beforeEach(function() { + onSortableTableHeaderClickSpy = jasmine.createSpy('onSortableTableHeaderClick'); + renderTableHeader({onSortableTableHeaderClick: onSortableTableHeaderClickSpy, ...props}); + }); + + describe('when clicking on the table header', function() { + it('calls the callback', function() { + $('th').simulate('click'); + expect(onSortableTableHeaderClickSpy).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when the header is not sortable', function() { + it('adds the additional classes, id and styles to the th', function() { + renderTableHeader({ + sortable: false, + id: 'header-id', + className: 'header-light', + style: {opacity: '0.5'} + }); + expect('th').not.toHaveClass('sortable'); + expect('th').toHaveClass('header-light'); + expect('th').toHaveProp('id', 'header-id'); + expect('th').toHaveCss({opacity: '0.5'}); + }); + }); +}); + +describe('TableRow', function() { + function renderTableRow({children=(), ...props}) { + return React.render(( + + + + {children} + + +
+ ), root + ); + + } + it('contains the given children', function() { + renderTableRow({children: ()}); + expect('tr').toExist(); + expect('tr > td#cell-id').toExist(); + }); + + + it('adds the additional classes, id and styles to the th', function() { + renderTableRow({ + id: 'row-id', + className: 'row-light', + style: {opacity: '0.5'} + }); + expect('tr').toHaveClass('row-light'); + expect('tr').toHaveProp('id', 'row-id'); + expect('tr').toHaveCss({opacity: '0.5'}); + }); }); +describe('TableCell', function() { + function renderTableCell({children, ...props}) { + return React.render(( + + + + + {children} + + + +
+ ), root + ); + + } + + it('contains the given children', function() { + renderTableCell({children: (

This is my text

)}); + expect('td').toExist(); + expect('td > p').toExist(); + expect('td > p').toContainText('This is my text'); + }); + + it('adds the additional classes, id and styles to the th', function() { + renderTableCell({ + id: 'cell-id', + className: 'cell-light', + style: {opacity: '0.5'} + }); + expect('td').toHaveClass('cell-light'); + expect('td').toHaveProp('id', 'cell-id'); + expect('td').toHaveCss({opacity: '0.5'}); + }); +}); diff --git a/src/pivotal-ui-react/sortable-table/sortable-table.js b/src/pivotal-ui-react/sortable-table/sortable-table.js index a40e04ca2..4865aed7a 100644 --- a/src/pivotal-ui-react/sortable-table/sortable-table.js +++ b/src/pivotal-ui-react/sortable-table/sortable-table.js @@ -1,42 +1,60 @@ -var classnames = require('classnames'); -var React = require('react'); -var sortBy = require('lodash.sortby'); +const classnames = require('classnames'); +const React = require('react'); +const sortBy = require('lodash.sortby'); +import {mergeProps} from '../../../src/pivotal-ui-react/helpers/helpers'; -var types = React.PropTypes; -var TableHeader = React.createClass({ - handleClick() { - if (this.props.sortable) { - this.props.onSortableTableHeaderClick(this); - } +const types = React.PropTypes; + +/** + * @component TableHeader + * @description Wrapper for a th + * + * @property `sortable` {boolean} (defaults to false) indicates whether the table can be sorted by this column; + * + * @see [Pivotal UI React](http://styleguide.pivotal.io/react_beta.html#table_sortable_react) + */ +export const TableHeader = React.createClass({ + propTypes: { + onClick: types.func, + onSortableTableHeaderClick: types.func + }, + + handleClick(...args) { + var {sortable, onClick, onSortableTableHeaderClick} = this.props; + if (sortable) onSortableTableHeaderClick(...args); + if (onClick) onClick(...args); }, render() { - var {children, sortable, sortState: {column, order}, name} = this.props; + const {sortable, className, ...props} = this.props; + const classes = classnames({'sortable': sortable}, className); - var sortClass = classnames({ - 'sortable': sortable, - 'sorted-none': sortable && column !== name, - 'sorted-asc': sortable && column === name && order === 'asc', - 'sorted-desc': sortable && column === name && order === 'desc' - }); + return ; + } +}); - return {children}; +/** + * @component TableCell + * @description Wrapper for a td + * + * @see [Pivotal UI React](http://styleguide.pivotal.io/react_beta.html#table_sortable_react) + */ +export const TableCell = React.createClass({ + render() { + return ; } }); -const ALIGNMENT = { - left: 'txt-l', - center: 'txt-c', - right: 'txt-r' -}; -var TableRow = React.createClass({ +/** + * @component TableRow + * @description Wrapper for a tr + * + * @see [Pivotal UI React](http://styleguide.pivotal.io/react_beta.html#table_sortable_react) + */ +export const TableRow = React.createClass({ render() { - var {data, alignment, columnNames} = this.props; - var tableCells = columnNames.map(function(name, i) { - return {data[name]}; - }); - return {tableCells}; + return ; } }); @@ -44,100 +62,95 @@ var TableRow = React.createClass({ * @component SortableTable * @description A table that can be sorted by column * - * @property columns {Array} A list of column configuration parameters: - * `name` is the `data` key associated with the column; - * `title` is the header text for the column; - * `sortable` (defaults to false) indicates whether the table can be sorted by this column; and - * `align` (defaults to left) sets the text alignment for cells in this column - * @property data {Array} The data to be displayed in the table - * @property classes {Array} Class names to add to the table + * @property `headers` {Array} A list of `TableHeader` components * * @example ```js - * var SortableTable = require('pui-react-sortable-table').SortableTable; + * var {SortableTable, TableHeader, TableRow, TableCell} = require('pui-react-sortable-table'); * var MyComponent = React.createClass({ * render() { - * var columns = [ - * {name: 'c1', title: 'Column 1', sortable: true}, - * {name: 'c2', title: 'Column 2'} - * ]; + * var headers = [ + * c1, + * c2, + * ]; * var data = [ * {c1: 'yes', c2: 'foo'}, * {c1: 'no', c2: 'bar'} * ]; - * return ; + * return + * {sortTableData.map(function(datum, key) { + * return ( + * + * {datum.c1} + * {datum.c2} + * + * ); + * })} + * ; * } * }); * ``` * * @see [Pivotal UI React](http://styleguide.pivotal.io/react.html#table_sortable_react) */ -var SortableTable = React.createClass({ +export const SortableTable = React.createClass({ propTypes: { - classes: types.arrayOf(types.string), - columns: types.arrayOf(types.shape({ - name: types.string.isRequired, - title: types.string.isRequired, - align: types.oneOf(['left', 'center', 'right']), - sortable: types.bool - })), - data: types.arrayOf(types.object) + headers: types.arrayOf(types.element) }, getInitialState() { - var {columns, data} = this.props; - var column = columns[0].name; - return { - sort: { - column, - order: 'asc' - }, - data: sortBy(data, column) - }; + const sortCol = this.props.headers.findIndex((header) => { + return header.props.sortable; + }); + // If none of the columns are sortable we default to the 0th column + return {sortColumn: sortCol === -1 ? 0 : sortCol, sortAscending: true}; + }, + + setSortedColumn(sortColumn, sortAscending) { + this.setState({sortColumn, sortAscending}); }, - sortData(header) { - var {data: oldData, sort: {column: oldSortColumn, order: oldSortOrder}} = this.state; - var newSortColumn = header.props.name; - var newData, newSortOrder; - - if (oldSortColumn !== newSortColumn) { - newSortOrder = 'asc'; - newData = sortBy(oldData, newSortColumn); - } else { - newSortOrder = oldSortOrder === 'asc' ? 'desc' : 'asc'; - newData = oldData.reverse(); - } - - this.setState({ - sort: { - column: newSortColumn, - order: newSortOrder - }, - data: newData + headerClassesWithSortDirection({headerClassName, isSortColumn}) { + return classnames(headerClassName, { + 'sorted-asc': isSortColumn && this.state.sortAscending, + 'sorted-desc': isSortColumn && !this.state.sortAscending }); }, - render() { - var {columns, classes} = this.props; - - var headings = columns.map(function(column) { - return ( - - {column.title} - - ); - }, this); - - var rows = this.state.data.map(function(datum, i) { - return ( c.name)} alignment={columns.map(c => c.align)}/>); + sortedRows() { + const {sortColumn, sortAscending} = this.state; + const sortedRows = sortBy(this.props.children, (row) => { + const cellForSorting = row.props.children[sortColumn]; + return cellForSorting.props.children; }); + return sortAscending ? sortedRows : sortedRows.reverse(); + }, + + renderHeaders() { + var {headers} = this.props; + const {sortColumn, sortAscending} = this.state; + return headers.map((header, index) => { + const isSortColumn = (sortColumn === index); + return React.cloneElement(header, { + key: index, + className: this.headerClassesWithSortDirection( + { + headerClassName: header.props.className, + isSortColumn + } + ), + onSortableTableHeaderClick: () => this.setSortedColumn(index, isSortColumn ? !sortAscending : true) + }); + }); + }, + + render() { + const props = mergeProps(this.props, {className: ['table', 'table-sortable']}); + let rows = this.sortedRows(); return ( - +
- - {headings} - + {this.renderHeaders()} {rows} @@ -146,5 +159,3 @@ var SortableTable = React.createClass({ ); } }); - -module.exports = {SortableTable}; diff --git a/src/pivotal-ui/components/tables/tables.scss b/src/pivotal-ui/components/tables/tables.scss index 37d232f1f..425ffcbed 100644 --- a/src/pivotal-ui/components/tables/tables.scss +++ b/src/pivotal-ui/components/tables/tables.scss @@ -335,51 +335,21 @@ functioning sort. The `SortableTable` expects the following properties: -Property | Type | Description -----------| ---------------------------------| -------------------------------------------------------------------------- -`data` | Array of JS objects | The collection of data to be displayed -`columns` | Array of column objects | Informs the table how to render columns as well as which columns should be sortable -`classes` | Array of strings | A list of class names to be added to the table element +Property | Required? | Type | Description +-----------| ----------| ---------------------------------| -------------------------------------------------------------------------- +`headers` | **yes** | Array of TableHeader components | The headers to display in the desired order +The `TableHeader` objects should have the following structure: -The column objects should have the following structure: +Property | Required? | Type | Description +-----------| ----------|------------------| -------------------------------------------------------------------------- +`sortable` | no | Boolean | Is this column sortable? Defaults to false -Property | Required? | Type | Description ------------| ----------|------------------------------| -------------------------------------------------------------------------- -`name` | **yes** | String | Name of the property on a data object (how to lookup the value for this column) -`title` | **yes** | String | Name to display on the table header -`sortable` | no | Boolean | Is this column sortable? Defaults to false -`align` | no | 'center', 'left', or 'right' | How should text be aligned within this column? Defaults to left If a column is marked as being sortable, it will attempt to sort the values as strings. -```js_example -var sortTableCols = [ - { - name: 'name', - title: 'Name', - sortable: true - }, - { - name: 'instances', - title: 'Instances', - sortable: true, - align: 'center' - }, - { - name: 'cpu', - title: 'CPU', - sortable: true, - align: 'right' - }, - { - name: 'synergy', - title: 'Synergy', - align: 'left' - } -] - +```jsx_example var sortTableData = [ { instances: '1', @@ -411,9 +381,24 @@ var sortTableData = [ ```react_example + headers={[ + Name, + Instances, + CPU, + Synergy, + ]} + className="table-light"> + {sortTableData.map(function(datum, key) { + return ( + + {datum.name} + {datum.instances} + {datum.cpu} + {datum.synergy} + + ); + })} + ``` */ diff --git a/src/pivotal-ui/javascripts/components.js b/src/pivotal-ui/javascripts/components.js index a599ca0ec..027e818b3 100644 --- a/src/pivotal-ui/javascripts/components.js +++ b/src/pivotal-ui/javascripts/components.js @@ -1,7 +1,5 @@ - +var sortableTable = require('pui-react-sortable-table'); module.exports = { - SortableTable: require('pui-react-sortable-table').SortableTable, - DefaultH1: require('pui-react-typography').DefaultH1, DefaultH2: require('pui-react-typography').DefaultH2, DefaultH3: require('pui-react-typography').DefaultH3, @@ -120,5 +118,5 @@ module.exports = { BackToTop: require('pui-react-back-to-top').BackToTop, PortalSource: require('pui-react-portals').PortalSource, - PortalDestination: require('pui-react-portals').PortalDestination -}; + PortalDestination: require('pui-react-portals').PortalDestination, + ...sortableTable};