* Switch to withInstanceId higher order component

* Add checkboxes to the rows in a TableCard, if a compareBy prop is set

* Add Compare button to update query param

* Populate the selected rows from the query parameter

* Update compare filter display & selected table rows when the query changes

* Skip displaying tags if the label is null/undefined

* Style table header with compare button, search placeholder

* Prevent setting just an ID list as the state, as this will wipe out already-fetched tag labels

* Update docs

* Shortcut out of fetching tag labels if the query is empty
This commit is contained in:
Kelly Dwan 2018-09-18 10:12:13 -04:00 committed by GitHub
parent 7ecd6d160b
commit f2e0165d5f
8 changed files with 200 additions and 32 deletions

View File

@ -11,8 +11,11 @@ import apiFetch from '@wordpress/api-fetch';
import { getIdsFromQuery, stringifyQuery } from 'lib/nav-utils';
import { NAMESPACE } from 'store/constants';
export function getProductLabelsById( queryString ) {
export function getProductLabelsById( queryString = '' ) {
const idList = getIdsFromQuery( queryString );
if ( idList.length < 1 ) {
return Promise.resolve( [] );
}
const payload = stringifyQuery( {
include: idList.join( ',' ),
per_page: idList.length,
@ -20,8 +23,11 @@ export function getProductLabelsById( queryString ) {
return apiFetch( { path: `${ NAMESPACE }products${ payload }` } );
}
export function getCategoryLabelsById( queryString ) {
export function getCategoryLabelsById( queryString = '' ) {
const idList = getIdsFromQuery( queryString );
if ( idList.length < 1 ) {
return Promise.resolve( [] );
}
const payload = stringifyQuery( {
include: idList.join( ',' ),
per_page: idList.length,

View File

@ -139,6 +139,8 @@ export default class extends Component {
totalRows={ 500 }
rowsPerPage={ rowsPerPage }
headers={ headers }
compareBy={ 'product' }
ids={ products.map( p => p.product_id ) }
onClickDownload={ noop }
onQueryChange={ onQueryChange }
query={ tableQuery }

View File

@ -22,15 +22,15 @@
align-items: center;
.has-action & {
grid-template-columns: 1fr 1fr;
grid-template-columns: auto 1fr;
}
.has-menu & {
grid-template-columns: 1fr 24px;
grid-template-columns: auto 24px;
}
.has-menu.has-action & {
grid-template-columns: 1fr 1fr 48px;
grid-template-columns: auto 1fr 48px;
}
}
@ -52,7 +52,7 @@
}
.woocommerce-card__title {
margin: 0;
margin: 0 $gap 0 0;
// EllipsisMenu is 24px, so to match we add 6px padding around the
// heading text, which we know is 18px from line-height.
padding: 3px 0;

View File

@ -4,14 +4,15 @@
*/
import { Button } from '@wordpress/components';
import { Component } from '@wordpress/element';
import { isEqual } from 'lodash';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import Card from 'components/card';
import { getIdsFromQuery, updateQueryString } from 'lib/nav-utils';
import Search from 'components/search';
import { updateQueryString } from 'lib/nav-utils';
/**
* Displays a card + search used to filter results as a comparison between objects.
@ -31,12 +32,20 @@ class CompareFilter extends Component {
}
}
componentDidUpdate( { param } ) {
if ( param !== this.props.param ) {
componentDidUpdate( { param: prevParam, query: prevQuery } ) {
const { getLabels, param, path, query } = this.props;
if ( prevParam !== param ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( { selected: [] } );
updateQueryString( { [ param ]: '' }, this.props.path, this.props.query );
/* eslint-enable react/no-did-update-set-state */
updateQueryString( { [ param ]: '' }, path, query );
return;
}
const prevIds = getIdsFromQuery( prevQuery[ param ] );
const currentIds = getIdsFromQuery( query[ param ] );
if ( ! isEqual( prevIds.sort(), currentIds.sort() ) ) {
getLabels( query[ param ] ).then( this.updateLabels );
}
}

View File

@ -3,9 +3,10 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, IconButton, ToggleControl } from '@wordpress/components';
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import { IconButton, ToggleControl } from '@wordpress/components';
import { fill, find, findIndex, first, noop } from 'lodash';
import { fill, find, findIndex, first, isEqual, noop, partial, uniq } from 'lodash';
import PropTypes from 'prop-types';
/**
@ -14,6 +15,7 @@ import PropTypes from 'prop-types';
import './style.scss';
import Card from 'components/card';
import EllipsisMenu from 'components/ellipsis-menu';
import { getIdsFromQuery } from 'lib/nav-utils';
import MenuItem from 'components/ellipsis-menu/menu-item';
import MenuTitle from 'components/ellipsis-menu/menu-title';
import Pagination from 'components/pagination';
@ -31,10 +33,26 @@ import TableSummary from './summary';
class TableCard extends Component {
constructor( props ) {
super( props );
const { compareBy, query } = props;
this.state = {
showCols: fill( Array( props.headers.length ), true ),
selectedRows: getIdsFromQuery( query[ compareBy ] ),
};
this.toggleCols = this.toggleCols.bind( this );
this.onCompare = this.onCompare.bind( this );
this.selectRow = this.selectRow.bind( this );
this.selectAllRows = this.selectAllRows.bind( this );
}
componentDidUpdate( { query: prevQuery } ) {
const { compareBy, query } = this.props;
const prevIds = getIdsFromQuery( prevQuery[ compareBy ] );
const currentIds = getIdsFromQuery( query[ compareBy ] );
if ( ! isEqual( prevIds.sort(), currentIds.sort() ) ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( { selectedRows: currentIds } );
/* eslint-enable react/no-did-update-set-state */
}
}
toggleCols( selected ) {
@ -57,6 +75,14 @@ class TableCard extends Component {
};
}
onCompare() {
const { compareBy, onQueryChange } = this.props;
const { selectedRows } = this.state;
if ( compareBy ) {
onQueryChange( 'compare' )( compareBy, selectedRows.join( ',' ) );
}
}
filterCols( rows = [] ) {
const { showCols } = this.state;
// Header is a 1d array
@ -67,33 +93,119 @@ class TableCard extends Component {
return rows.map( row => row.filter( ( col, i ) => showCols[ i ] ) );
}
selectAllRows( event ) {
const { ids } = this.props;
if ( event.target.checked ) {
this.setState( {
selectedRows: ids,
} );
} else {
this.setState( {
selectedRows: [],
} );
}
}
selectRow( i, event ) {
const { ids } = this.props;
if ( event.target.checked ) {
this.setState( ( { selectedRows } ) => ( {
selectedRows: uniq( [ ids[ i ], ...selectedRows ] ),
} ) );
} else {
this.setState( ( { selectedRows } ) => {
const index = selectedRows.indexOf( ids[ i ] );
return {
selectedRows: [ ...selectedRows.slice( 0, index ), ...selectedRows.slice( index + 1 ) ],
};
} );
}
}
getCheckbox( i ) {
const { ids = [] } = this.props;
const { selectedRows } = this.state;
const isChecked = -1 !== selectedRows.indexOf( ids[ i ] );
return {
display: (
<input type="checkbox" onChange={ partial( this.selectRow, i ) } checked={ isChecked } />
),
value: false,
};
}
getAllCheckbox() {
const { ids = [] } = this.props;
const { selectedRows } = this.state;
const isAllChecked = ids.length === selectedRows.length;
return {
label: (
<input
type="checkbox"
onChange={ this.selectAllRows }
aria-label={ __( 'Select All' ) }
checked={ isAllChecked }
/>
),
required: true,
};
}
render() {
const {
compareBy,
onClickDownload,
onQueryChange,
query,
rowHeader,
rowsPerPage,
summary,
title,
totalRows,
rowsPerPage,
} = this.props;
const { showCols } = this.state;
const allHeaders = this.props.headers;
const headers = this.filterCols( this.props.headers );
const rows = this.filterCols( this.props.rows );
let headers = this.filterCols( this.props.headers );
let rows = this.filterCols( this.props.rows );
if ( compareBy ) {
rows = rows.map( ( row, i ) => {
return [ this.getCheckbox( i ), ...row ];
} );
headers = [ this.getAllCheckbox(), ...headers ];
}
const className = classnames( {
'woocommerce-table': true,
'has-compare': !! compareBy,
} );
return (
<Card
className="woocommerce-table"
className={ className }
title={ title }
action={
action={ [
compareBy && (
<Button key="compare" onClick={ this.onCompare } isDefault>
{ __( 'Compare', 'wc-admin' ) }
</Button>
),
compareBy && (
<div key="search" style={ { padding: '4px 12px', color: '#6c7781' } }>
Placeholder for search
</div>
),
onClickDownload && (
<IconButton onClick={ onClickDownload } icon="arrow-down" size={ 18 } isDefault>
<IconButton
key="download"
onClick={ onClickDownload }
icon="arrow-down"
size={ 18 }
isDefault
>
{ __( 'Download', 'wc-admin' ) }
</IconButton>
)
}
),
] }
menu={
<EllipsisMenu label={ __( 'Choose which values to display', 'wc-admin' ) }>
<MenuTitle>{ __( 'Columns:', 'wc-admin' ) }</MenuTitle>
@ -114,7 +226,6 @@ class TableCard extends Component {
</EllipsisMenu>
}
>
{ /* @todo Switch a placeholder view if we don't have rows */ }
<Table
rows={ rows }
headers={ headers }
@ -139,6 +250,10 @@ class TableCard extends Component {
}
TableCard.propTypes = {
/**
* The string to use as a query parameter when comparing row items.
*/
compareBy: PropTypes.string,
/**
* An array of column headers (see `Table` props).
*/
@ -151,6 +266,10 @@ TableCard.propTypes = {
required: PropTypes.bool,
} )
),
/**
* A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ].
*/
ids: PropTypes.arrayOf( PropTypes.number ),
/**
* A function which returns a callback function to update the query string for a given `param`.
*/
@ -178,6 +297,10 @@ TableCard.propTypes = {
} )
)
).isRequired,
/**
* The total number of rows to display per page.
*/
rowsPerPage: PropTypes.number.isRequired,
/**
* 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.
@ -192,8 +315,10 @@ TableCard.propTypes = {
* The title used in the card header, also used as the caption for the content in this table.
*/
title: PropTypes.string.isRequired,
/**
* The total number of rows (across all pages).
*/
totalRows: PropTypes.number.isRequired,
rowsPerPage: PropTypes.number.isRequired,
};
TableCard.defaultProps = {

View File

@ -10,6 +10,15 @@
.woocommerce-card__menu {
justify-self: flex-end;
}
&.has-compare {
.woocommerce-card__action {
text-align: left;
display: grid;
width: 100%;
grid-template-columns: auto 1fr auto;
}
}
}
.woocommerce-table__caption {

View File

@ -6,9 +6,10 @@ import { __, sprintf } from '@wordpress/i18n';
import { Component, createRef, Fragment } from '@wordpress/element';
import classnames from 'classnames';
import { IconButton } from '@wordpress/components';
import { find, get, noop, uniqueId } from 'lodash';
import { find, get, noop } from 'lodash';
import Gridicon from 'gridicons';
import PropTypes from 'prop-types';
import { withInstanceId } from '@wordpress/compose';
const ASC = 'asc';
const DESC = 'desc';
@ -26,8 +27,6 @@ class Table extends Component {
};
this.container = createRef();
this.sortBy = this.sortBy.bind( this );
this.headersID = uniqueId( 'header-' );
this.captionID = uniqueId( 'caption-' );
}
componentDidMount() {
@ -55,7 +54,16 @@ class Table extends Component {
}
render() {
const { ariaHidden, caption, classNames, headers, query, rowHeader, rows } = this.props;
const {
ariaHidden,
caption,
classNames,
headers,
instanceId,
query,
rowHeader,
rows,
} = this.props;
const { tabIndex } = this.state;
const classes = classnames( 'woocommerce-table__table', classNames );
const sortedBy = query.orderby || get( find( headers, { defaultSort: true } ), 'key', false );
@ -67,11 +75,14 @@ class Table extends Component {
ref={ this.container }
tabIndex={ tabIndex }
aria-hidden={ ariaHidden }
aria-labelledby={ this.captionID }
aria-labelledby={ `caption-${ instanceId }` }
role="group"
>
<table>
<caption id={ this.captionID } className="woocommerce-table__caption screen-reader-text">
<caption
id={ `caption-${ instanceId }` }
className="woocommerce-table__caption screen-reader-text"
>
{ caption }
{ tabIndex === '0' && <small>{ __( '(scroll to see more)', 'wc-admin' ) }</small> }
</caption>
@ -79,6 +90,7 @@ class Table extends Component {
<tr>
{ headers.map( ( header, i ) => {
const { isSortable, isNumeric, key, label } = header;
const labelId = `header-${ instanceId } -${ i }`;
const thProps = {
className: classnames( 'woocommerce-table__header', {
'is-sortable': isSortable,
@ -110,13 +122,13 @@ class Table extends Component {
<Gridicon size={ 18 } icon="chevron-down" />
)
}
aria-describedby={ `${ this.headersID }-${ i }` }
aria-describedby={ labelId }
onClick={ this.sortBy( key ) }
isDefault
>
{ label }
</IconButton>
<span className="screen-reader-text" id={ `${ this.headersID }-${ i }` }>
<span className="screen-reader-text" id={ labelId }>
{ iconLabel }
</span>
</Fragment>
@ -189,7 +201,7 @@ Table.propTypes = {
/**
* The display label for this column.
*/
label: PropTypes.string,
label: PropTypes.node,
/**
* Boolean, true if this column should always display in the table (not shown in toggle-able list).
*/
@ -236,4 +248,4 @@ Table.defaultProps = {
rowHeader: 0,
};
export default Table;
export default withInstanceId( Table );

View File

@ -22,6 +22,11 @@ import './style.scss';
*/
const Tag = ( { id, instanceId, label, remove, removeLabel, screenReaderLabel, className } ) => {
screenReaderLabel = screenReaderLabel || label;
if ( ! label ) {
// A null label probably means something went wrong
// @todo Maybe this should be a loading indicator?
return null;
}
const classes = classnames( 'woocommerce-tag', className, {
'has-remove': !! remove,
} );