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:
parent
ddeacb84e5
commit
4c2797d6cf
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in New Issue