Table: Add "required"/"sortable" meta to headers/columns (https://github.com/woocommerce/woocommerce-admin/pull/275)

* Switch headers to an object, use `header.label` to display title

* Change sort function to update a query param

* Clean up README

* Get the currently sorted column from the query

* Don’t allow toggling of required columns

* Add in direction-sorting (ascending/descending)

* Switch to aria-describedby so that the actual column name is read aloud

* Fix chevron orientation

* Handle hiding a sorted column

* Fall back to sorting by first item if no defaultSort col is set

* Fix arrow orientation again

* Update order_by to orderby to match wp_query param name
This commit is contained in:
Kelly Dwan 2018-08-06 13:01:41 -04:00 committed by GitHub
parent ddeacb84e5
commit 4c2797d6cf
4 changed files with 181 additions and 101 deletions

View File

@ -54,24 +54,70 @@ class RevenueReport extends Component {
/**
* This function returns an event handler for the given `param`
* @todo Move handling of this to a library?
* @param {string} param The parameter in the querystring which should be updated (ex `page`, `per_page`)
* @return {function} A callback which will update `param` to the passed value when called.
*/
onQueryChange( param ) {
return value => updateQueryString( { [ param ]: value } );
switch ( param ) {
case 'sort':
return ( key, dir ) => updateQueryString( { orderby: key, order: dir } );
default:
return value => updateQueryString( { [ param ]: value } );
}
}
getHeadersContent() {
return [
__( 'Select', 'wc-admin' ),
__( 'Date', 'wc-admin' ),
__( 'Orders', 'wc-admin' ),
__( 'Gross Revenue', 'wc-admin' ),
__( 'Refunds', 'wc-admin' ),
__( 'Coupons', 'wc-admin' ),
__( 'Taxes', 'wc-admin' ),
__( 'Shipping', 'wc-admin' ),
__( 'Net Revenue', 'wc-admin' ),
{
label: __( 'Date', 'wc-admin' ),
key: 'date_start',
required: true,
defaultSort: true,
isSortable: true,
},
{
label: __( 'Orders', 'wc-admin' ),
key: 'orders_count',
required: false,
isSortable: true,
},
{
label: __( 'Gross Revenue', 'wc-admin' ),
key: 'gross_revenue',
required: true,
isSortable: true,
},
{
label: __( 'Refunds', 'wc-admin' ),
key: 'refunds',
required: false,
isSortable: true,
},
{
label: __( 'Coupons', 'wc-admin' ),
key: 'coupons',
required: false,
isSortable: true,
},
{
label: __( 'Taxes', 'wc-admin' ),
key: 'taxes',
required: false,
isSortable: false, // For example
},
{
label: __( 'Shipping', 'wc-admin' ),
key: 'shipping',
required: false,
isSortable: true,
},
{
label: __( 'Net Revenue', 'wc-admin' ),
key: 'net_revenue',
required: false,
isSortable: true,
},
];
}
@ -95,10 +141,6 @@ class RevenueReport extends Component {
</a>
);
return [
{
display: <input type="checkbox" />,
value: false,
},
{
display: formatDate( 'm/d/Y', row.date_start ),
value: row.date_start,
@ -207,7 +249,6 @@ class RevenueReport extends Component {
<TableCard
title={ __( 'Revenue Last Week', 'wc-admin' ) }
rows={ rows }
rowHeader={ 1 }
headers={ headers }
onClickDownload={ noop }
onQueryChange={ this.onQueryChange }

View File

@ -32,12 +32,12 @@ render: function() {
### `TableCard` props
* `headers`: An array of column headers
* `headers`: An array of column headers (see `Table` props).
* `onQueryChange`: A function which returns a callback function to update the query string for a given `param`.
* `onClickDownload`: A callback function which handles then "download" button press. Optional, if not used, the button won't appear.
* `query`: An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`.
* `rows` (required): An array of arrays of display/value object pairs. `display` is used for rendering, strings or elements are best here. `value` is used for sorting, and should be a string or number. A column with `false` value will not be sortable.
* `rowHeader`: Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col is checkboxes, for example). Set to false to disable row headers.
* `rows` (required): An array of arrays of display/value object pairs (see `Table` props).
* `rowHeader`: Which column should be the row header, defaults to the first item (`0`) (see `Table` props).
* `summary`: An array of objects with `label` & `value` properties, which display in a line under the table. Optional, can be left off to show no summary.
* `title` (required): The title used in the card header, also used as the caption for the content in this table
@ -48,8 +48,14 @@ render: function() {
* `caption` (required): A label for the content in this table
* `className`: Optional additional classes
* `headers`: An array of column headers
* `rows` (required): An array of arrays of renderable elements, strings or numbers are best for sorting
* `headers`: An array of column headers, as objects with the following properties:
* `headers[].defaultSort`: Boolean, true if this column is the default for sorting. Only one column should have this set.
* `headers[].isSortable`: Boolean, true if this column is sortable.
* `headers[].key`: The API parameter name for this column, passed to `orderby` when sorting via API.
* `headers[].label`: The display label for this column.
* `headers[].required`: Boolean, true if this column should always display in the table (not shown in toggle-able list).
* `onSort`: A function called when sortable table headers are clicked, gets the `header.key` as argument.
* `rows` (required): An array of arrays of display/value object pairs. `display` is used for rendering, strings or elements are best here. `value` is used for sorting, and should be a string or number. A column with `false` value will not be sortable.
* `rowHeader`: Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col is checkboxes, for example). Set to false to disable row headers.
### `TableSummary` props

View File

@ -5,7 +5,7 @@
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { IconButton, ToggleControl } from '@wordpress/components';
import { fill, isArray, first, noop } from 'lodash';
import { fill, find, findIndex, first, isArray, noop } from 'lodash';
import PropTypes from 'prop-types';
/**
@ -27,10 +27,22 @@ class TableCard extends Component {
this.toggleCols = this.toggleCols.bind( this );
}
toggleCols( col ) {
toggleCols( selected ) {
const { headers, query, onQueryChange } = this.props;
return () => {
// Handle hiding a sorted column
if ( query.orderby ) {
const sortBy = findIndex( headers, { key: query.orderby } );
if ( sortBy === selected ) {
const defaultSort = find( headers, { defaultSort: true } ) || first( headers ) || {};
onQueryChange( 'sort' )( defaultSort.key, 'desc' );
}
}
this.setState( prevState => ( {
showCols: prevState.showCols.map( ( toggled, i ) => ( col === i ? ! toggled : toggled ) ),
showCols: prevState.showCols.map(
( toggled, i ) => ( selected === i ? ! toggled : toggled )
),
} ) );
};
}
@ -66,15 +78,20 @@ class TableCard extends Component {
menu={
<EllipsisMenu label={ __( 'Choose which values to display', 'wc-admin' ) }>
<MenuTitle>{ __( 'Columns:', 'wc-admin' ) }</MenuTitle>
{ allHeaders.map( ( label, i ) => (
<MenuItem key={ i } onInvoke={ this.toggleCols( i ) }>
<ToggleControl
label={ label }
checked={ !! showCols[ i ] }
onChange={ this.toggleCols( i ) }
/>
</MenuItem>
) ) }
{ allHeaders.map( ( { label, required }, i ) => {
if ( required ) {
return null;
}
return (
<MenuItem key={ i } onInvoke={ this.toggleCols( i ) }>
<ToggleControl
label={ label }
checked={ !! showCols[ i ] }
onChange={ this.toggleCols( i ) }
/>
</MenuItem>
);
} ) }
</EllipsisMenu>
}
>
@ -84,8 +101,8 @@ class TableCard extends Component {
headers={ headers }
rowHeader={ rowHeader }
caption={ title }
sort={ query.order_by }
onSort={ onQueryChange( 'order_by' ) }
query={ query }
onSort={ onQueryChange( 'sort' ) }
/>
{ summary && <TableSummary data={ summary } /> }
@ -103,7 +120,15 @@ class TableCard extends Component {
}
TableCard.propTypes = {
headers: PropTypes.arrayOf( PropTypes.node ),
headers: PropTypes.arrayOf(
PropTypes.shape( {
defaultSort: PropTypes.bool,
isSortable: PropTypes.bool,
key: PropTypes.string,
label: PropTypes.string,
required: PropTypes.bool,
} )
),
onQueryChange: PropTypes.func,
onClickDownload: PropTypes.func,
query: PropTypes.object,

View File

@ -3,18 +3,17 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component, createRef } from '@wordpress/element';
import { Component, createRef, Fragment } from '@wordpress/element';
import classnames from 'classnames';
import { IconButton } from '@wordpress/components';
import { find, get, isEqual, noop, uniqueId } from 'lodash';
import Gridicon from 'gridicons';
import PropTypes from 'prop-types';
import { isEqual, uniqueId } from 'lodash';
const ASC = 'ascending';
const DESC = 'descending';
const ASC = 'asc';
const DESC = 'desc';
const getDisplay = cell => cell.display || null;
const getValue = cell => cell.value || 0;
class Table extends Component {
constructor( props ) {
@ -22,11 +21,10 @@ class Table extends Component {
this.state = {
tabIndex: null,
rows: props.rows || [],
sortedBy: null,
sortDir: 'none',
};
this.container = createRef();
this.sortBy = this.sortBy.bind( this );
this.headersID = uniqueId( 'header-' );
this.captionID = uniqueId( 'caption-' );
}
@ -50,38 +48,26 @@ class Table extends Component {
/* eslint-enable react/no-did-mount-set-state */
}
isColSortable( col ) {
const { rows: [ first ] } = this.props;
if ( ! first || ! first[ col ] ) {
return false;
}
return false !== first[ col ].value;
}
sortBy( col ) {
this.setState( prevState => {
// Set the sort direction as inverse of current state
const sortDir = prevState.sortDir === ASC ? DESC : ASC;
return {
rows: prevState.rows
.slice( 0 )
.sort(
( a, b ) =>
sortDir === ASC
? getValue( a[ col ] ) > getValue( b[ col ] )
: getValue( a[ col ] ) < getValue( b[ col ] )
),
sortedBy: col,
sortDir,
};
} );
sortBy( key ) {
const { headers, query } = this.props;
return () => {
const currentKey =
query.orderby || get( find( headers, { defaultSort: true } ), 'key', false );
const currentDir = query.order || DESC;
let dir = DESC;
if ( key === currentKey ) {
dir = DESC === currentDir ? ASC : DESC;
}
this.props.onSort( key, dir );
};
}
render() {
const { caption, classNames, headers, rowHeader } = this.props;
const { rows, sortedBy, sortDir, tabIndex } = this.state;
const { caption, classNames, headers, query, rowHeader } = this.props;
const { rows, tabIndex } = this.state;
const classes = classnames( 'woocommerce-table__table', classNames );
const sortedBy = query.orderby || get( find( headers, { defaultSort: true } ), 'key', false );
const sortDir = query.order || DESC;
return (
<div
@ -99,39 +85,49 @@ class Table extends Component {
<tbody>
<tr>
{ headers.map( ( header, i ) => {
const isSortable = this.isColSortable( i );
const { isSortable, key, label } = header;
const thProps = {
className: classnames( 'woocommerce-table__header', {
'is-sortable': isSortable,
'is-sorted': sortedBy === key,
} ),
};
if ( isSortable ) {
thProps[ 'aria-sort' ] = 'none';
if ( sortedBy === key ) {
thProps[ 'aria-sort' ] = sortDir === ASC ? 'ascending' : 'descending';
}
}
// We only sort by ascending if the col is already sorted descending
const iconLabel =
sortedBy === key && sortDir !== ASC
? sprintf( __( 'Sort by %s in ascending order', 'wc-admin' ), label )
: sprintf( __( 'Sort by %s in descending order', 'wc-admin' ), label );
return (
<th
role="columnheader"
scope="col"
key={ i }
aria-sort={ sortedBy === i ? sortDir : 'none' }
className={ classnames( 'woocommerce-table__header', {
'is-sortable': isSortable,
'is-sorted': sortedBy === i,
} ) }
>
<th role="columnheader" scope="col" key={ i } { ...thProps }>
{ isSortable ? (
<IconButton
icon={
sortDir === ASC ? (
<Gridicon size={ 18 } icon="chevron-up" />
) : (
<Gridicon size={ 18 } icon="chevron-down" />
)
}
label={
sortDir !== ASC
? sprintf( __( 'Sort by %s in ascending order', 'wc-admin' ), header )
: sprintf( __( 'Sort by %s in descending order', 'wc-admin' ), header )
}
onClick={ () => this.sortBy( i ) }
isDefault
>
{ header }
</IconButton>
<Fragment>
<IconButton
icon={
sortedBy === key && sortDir === ASC ? (
<Gridicon size={ 18 } icon="chevron-up" />
) : (
<Gridicon size={ 18 } icon="chevron-down" />
)
}
aria-describedby={ `${ this.headersID }-${ i }` }
onClick={ this.sortBy( key ) }
isDefault
>
{ label }
</IconButton>
<span className="screen-reader-text" id={ `${ this.headersID }-${ i }` }>
{ iconLabel }
</span>
</Fragment>
) : (
header
label
) }
</th>
);
@ -163,7 +159,17 @@ class Table extends Component {
Table.propTypes = {
caption: PropTypes.string.isRequired,
className: PropTypes.string,
headers: PropTypes.arrayOf( PropTypes.node ),
headers: PropTypes.arrayOf(
PropTypes.shape( {
defaultSort: PropTypes.bool,
isSortable: PropTypes.bool,
key: PropTypes.string,
label: PropTypes.string,
required: PropTypes.bool,
} )
),
onSort: PropTypes.func,
query: PropTypes.object,
rows: PropTypes.arrayOf(
PropTypes.arrayOf(
PropTypes.shape( {
@ -177,6 +183,8 @@ Table.propTypes = {
Table.defaultProps = {
headers: [],
onSort: noop,
query: {},
rowHeader: 0,
};