Merge pull request woocommerce/woocommerce-admin#276 from woocommerce/add/advanced-filters-card

Add/advanced filters card
This commit is contained in:
Paul Sealock 2018-08-09 13:00:53 +12:00 committed by GitHub
commit 8683726d06
11 changed files with 611 additions and 38 deletions

View File

@ -0,0 +1,89 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const filters = [
{ label: __( 'All Orders', 'wc-admin' ), value: 'all' },
{
label: __( 'Single Order', 'wc-admin' ),
value: 'single',
subFilters: [
{
label: __( 'Single Order', 'wc-admin' ),
component: 'Search',
value: 'single_order',
},
],
},
{ label: __( 'Top Orders by Items Sold', 'wc-admin' ), value: 'top_items' },
{ label: __( 'Top Orders by Gross Sales', 'wc-admin' ), value: 'top_sales' },
{ label: __( 'Advanced Filters', 'wc-admin' ), value: 'advanced' },
];
export const filterPaths = {
all: [],
single: [],
single_order: [ 'single' ],
top_items: [],
top_sales: [],
advanced: [],
};
export const advancedFilterConfig = {
status: {
label: __( 'Order Status', 'wc-admin' ),
addLabel: __( 'Order Status', 'wc-admin' ),
rules: [
{ value: 'is', label: __( 'Is', 'wc-admin' ) },
{ value: 'is-not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'SelectControl',
options: [
{ value: 'pending', label: __( 'Pending', 'wc-admin' ) },
{ value: 'processing', label: __( 'Processing', 'wc-admin' ) },
{ value: 'on-hold', label: __( 'On Hold', 'wc-admin' ) },
{ value: 'completed', label: __( 'Completed', 'wc-admin' ) },
{ value: 'refunded', label: __( 'Refunded', 'wc-admin' ) },
{ value: 'cancelled', label: __( 'Cancelled', 'wc-admin' ) },
{ value: 'failed', label: __( 'Failed', 'wc-admin' ) },
],
},
},
product: {
label: __( 'Product', 'wc-admin' ),
addLabel: __( 'Products', 'wc-admin' ),
rules: [
{ value: 'includes', label: __( 'Includes', 'wc-admin' ) },
{ value: 'excludes', label: __( 'Excludes', 'wc-admin' ) },
{ value: 'is', label: __( 'Is', 'wc-admin' ) },
{ value: 'is-not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'FormTokenField',
},
},
coupon: {
label: __( 'Coupon Code', 'wc-admin' ),
addLabel: __( 'Coupon Codes', 'wc-admin' ),
rules: [
{ value: 'includes', label: __( 'Includes', 'wc-admin' ) },
{ value: 'excludes', label: __( 'Excludes', 'wc-admin' ) },
{ value: 'is', label: __( 'Is', 'wc-admin' ) },
{ value: 'is-not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'FormTokenField',
},
},
customer: {
label: __( 'Customer is', 'wc-admin' ),
addLabel: __( 'Customer Type', 'wc-admin' ),
rules: [
{ value: 'new', label: __( 'New', 'wc-admin' ) },
{ value: 'returning', label: __( 'Returning', 'wc-admin' ) },
],
},
};

View File

@ -15,6 +15,11 @@ import { partial } from 'lodash';
*/ */
import Header from 'layout/header/index'; import Header from 'layout/header/index';
import Card from 'components/card'; import Card from 'components/card';
import DatePicker from 'components/date-picker';
import FilterPicker, { FILTER_PARAM } from 'components/filter-picker';
import AdvancedFilters from 'components/advanced-filters';
import { filters, filterPaths, advancedFilterConfig } from './constants';
import './style.scss';
class OrdersReport extends Component { class OrdersReport extends Component {
constructor( props ) { constructor( props ) {
@ -32,7 +37,7 @@ class OrdersReport extends Component {
} }
render() { render() {
const { orders, orderIds } = this.props; const { orders, orderIds, query, path } = this.props;
return ( return (
<Fragment> <Fragment>
<Header <Header
@ -41,6 +46,22 @@ class OrdersReport extends Component {
__( 'Orders', 'wc-admin' ), __( 'Orders', 'wc-admin' ),
] } ] }
/> />
<div className="woocommerce-orders__pickers">
<DatePicker query={ query } path={ path } key={ JSON.stringify( query ) } />
<FilterPicker
query={ query }
path={ path }
filters={ filters }
filterPaths={ filterPaths }
/>
</div>
{ 'advanced' === query[ FILTER_PARAM ] && (
<AdvancedFilters
config={ advancedFilterConfig }
filterTitle={ __( 'Orders', 'wc-admin' ) }
/>
) }
<p>Below is a temporary example</p>
<Card title="Orders"> <Card title="Orders">
<table style={ { width: '100%' } }> <table style={ { width: '100%' } }>
<thead> <thead>

View File

@ -0,0 +1,17 @@
/** @format */
.woocommerce-orders__pickers {
display: flex;
> div {
margin-right: $gap-large;
}
@include breakpoint( '<1100px' ) {
flex-direction: column;
> div {
margin-right: 0;
}
}
}

View File

@ -15,17 +15,6 @@ import { filters, filterPaths } from './constants';
import './style.scss'; import './style.scss';
export default class extends Component { export default class extends Component {
constructor() {
super();
this.getQueryParamValue = this.getQueryParamValue.bind( this );
}
getQueryParamValue() {
const { query } = this.props;
return query.product || 'all';
}
render() { render() {
const { query, path } = this.props; const { query, path } = this.props;
@ -44,8 +33,6 @@ export default class extends Component {
path={ path } path={ path }
filters={ filters } filters={ filters }
filterPaths={ filterPaths } filterPaths={ filterPaths }
queryParam="product"
getQueryParamValue={ this.getQueryParamValue }
/> />
</div> </div>
</Fragment> </Fragment>

View File

@ -4,7 +4,7 @@
display: flex; display: flex;
> div { > div {
margin-right: 24px; margin-right: $gap-large;
} }
@include breakpoint( '<1100px' ) { @include breakpoint( '<1100px' ) {

View File

@ -0,0 +1,70 @@
Advanced Filters
============
Displays a configurable set of filters which can modify query parameters.
## How to use:
```jsx
import AdvancedFilters from 'components/advanced-filters';
filters = {
status: {
label: __( 'Order Status', 'wc-admin' ),
addLabel: __( 'Order Status', 'wc-admin' ),
rules: [
{ value: 'is', label: __( 'Is', 'wc-admin' ) },
{ value: 'is-not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'SelectControl',
options: [
{ value: 'pending', label: __( 'Pending', 'wc-admin' ) },
{ value: 'processing', label: __( 'Processing', 'wc-admin' ) },
{ value: 'on-hold', label: __( 'On Hold', 'wc-admin' ) },
],
},
},
product: {
label: __( 'Product', 'wc-admin' ),
addLabel: __( 'Products', 'wc-admin' ),
rules: [
{ value: 'includes', label: __( 'Includes', 'wc-admin' ) },
{ value: 'excludes', label: __( 'Excludes', 'wc-admin' ) },
{ value: 'is', label: __( 'Is', 'wc-admin' ) },
{ value: 'is-not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'FormTokenField',
},
},
};
render: function() {
return (
<AdvancedFilters config={ filters } />
);
}
```
## AdvancedFilters Props
* `config` (required): The configuration object required to render filters
## config object jsDoc
```js
/**
* @type filterConfig {{
* key: {
* label: {string},
* addLabel: {string},
* rules: [{{ value:{string}, label:{string} }}],
* input: {
* component: {string},
* [options]: [*]
* },
* }
* }}
*/
```

View File

@ -0,0 +1,238 @@
/** @format */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component, Fragment, createRef } from '@wordpress/element';
import { SelectControl, Button, FormTokenField, Dropdown, IconButton } from '@wordpress/components';
import { partial, findIndex, find, difference } from 'lodash';
import PropTypes from 'prop-types';
import Gridicon from 'gridicons';
/**
* Internal dependencies
*/
import Card from 'components/card';
import './style.scss';
const matches = [
{ value: 'all', label: __( 'All', 'wc-admin' ) },
{ value: 'any', label: __( 'Any', 'wc-admin' ) },
];
class AdvancedFilters extends Component {
constructor( props ) {
super( props );
this.state = {
match: matches[ 0 ],
activeFilters: [
/**
* Example activeFilter
* { key: product, rule: includes, value: [ one, two ] }
*/
],
};
this.filterListRef = createRef();
this.onMatchChange = this.onMatchChange.bind( this );
this.onFilterChange = this.onFilterChange.bind( this );
this.getSelector = this.getSelector.bind( this );
this.getAvailableFilterKeys = this.getAvailableFilterKeys.bind( this );
this.addFilter = this.addFilter.bind( this );
this.removeFilter = this.removeFilter.bind( this );
this.clearAllFilters = this.clearAllFilters.bind( this );
}
onMatchChange( value ) {
this.setState( {
match: find( matches, match => value === match.value ),
} );
}
onFilterChange( key, property, value ) {
const activeFilters = this.state.activeFilters.map( activeFilter => {
if ( key === activeFilter.key ) {
return Object.assign( {}, activeFilter, { [ property ]: value } );
}
return activeFilter;
} );
this.setState( { activeFilters } );
}
removeFilter( key ) {
const activeFilters = [ ...this.state.activeFilters ];
const index = findIndex( activeFilters, filter => filter.key === key );
activeFilters.splice( index, 1 );
this.setState( { activeFilters } );
}
getTitle() {
const { match } = this.state;
const { filterTitle } = this.props;
return (
<Fragment>
<span>{ sprintf( __( '%s Match', 'wc-admin' ), filterTitle ) }</span>
<SelectControl
className="woocommerce-advanced-filters__title-select"
options={ matches }
value={ match.value }
onChange={ this.onMatchChange }
aria-label={ __( 'Match any or all filters', 'wc-admin' ) }
/>
<span>{ __( 'Filters', 'wc-admin' ) }</span>
</Fragment>
);
}
getSelector( filter ) {
const filterConfig = this.props.config[ filter.key ];
const { input } = filterConfig;
if ( ! input ) {
return null;
}
if ( 'SelectControl' === input.component ) {
return (
<SelectControl
className="woocommerce-advanced-filters__list-select"
options={ input.options }
value={ filter.value }
onChange={ partial( this.onFilterChange, filter.key, 'value' ) }
aria-label={ sprintf( __( 'Select %s', 'wc-admin' ), filterConfig.label ) }
/>
);
}
if ( 'FormTokenField' === input.component ) {
return (
<FormTokenField
value={ filter.value }
onChange={ partial( this.onFilterChange, filter.key, 'value' ) }
placeholder={ sprintf( __( 'Add %s', 'wc-admin' ), filterConfig.label ) }
/>
);
}
return null;
}
getAvailableFilterKeys() {
const { config } = this.props;
const activeFilterKeys = this.state.activeFilters.map( f => f.key );
return difference( Object.keys( config ), activeFilterKeys );
}
addFilter( key, onClose ) {
const filterConfig = this.props.config[ key ];
const newFilter = { key, rule: filterConfig.rules[ 0 ] };
if ( filterConfig.input && filterConfig.input.options ) {
newFilter.value = filterConfig.input.options[ 0 ];
}
this.setState( state => {
return {
activeFilters: [ ...state.activeFilters, newFilter ],
};
} );
onClose();
// after render, focus the newly added filter's first focusable element
setTimeout( () => {
const addedFilter = this.filterListRef.current.querySelector( 'li:last-of-type fieldset' );
addedFilter.focus();
} );
}
clearAllFilters() {
this.setState( {
activeFilters: [],
} );
}
render() {
const { config } = this.props;
const availableFilterKeys = this.getAvailableFilterKeys();
return (
<Card className="woocommerce-advanced-filters" title={ this.getTitle() }>
<ul className="woocommerce-advanced-filters__list" ref={ this.filterListRef }>
{ this.state.activeFilters.map( filter => {
const { key, rule } = filter;
const filterConfig = config[ key ];
return (
<li className="woocommerce-advanced-filters__list-item" key={ key }>
{ /*eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex*/ }
<fieldset tabIndex="0">
{ /*eslint-enable-next-line jsx-a11y/no-noninteractive-tabindex*/ }
<legend className="screen-reader-text">{ filterConfig.label }</legend>
<div className="woocommerce-advanced-filters__fieldset">
<div className="woocommerce-advanced-filters__fieldset-legend">
{ filterConfig.label }
</div>
<SelectControl
className="woocommerce-advanced-filters__list-specifier"
options={ filterConfig.rules }
value={ rule }
onChange={ partial( this.onFilterChange, key, 'rule' ) }
aria-label={ sprintf(
__( 'Select a %s filter match', 'wc-admin' ),
filterConfig.addLabel
) }
/>
<div className="woocommerce-advanced-filters__list-selector">
{ this.getSelector( filter ) }
</div>
</div>
</fieldset>
<IconButton
className="woocommerce-advanced-filters__remove"
label={ sprintf( __( 'Remove %s filter', 'wc-admin' ), filterConfig.label ) }
onClick={ partial( this.removeFilter, key ) }
icon={ <Gridicon icon="cross-small" /> }
/>
</li>
);
} ) }
</ul>
{ availableFilterKeys.length > 0 && (
<div className="woocommerce-advanced-filters__add-filter">
<Dropdown
position="bottom center"
renderToggle={ ( { isOpen, onToggle } ) => (
<IconButton
className="woocommerce-advanced-filters__add-btn"
icon={ <Gridicon icon="add-outline" /> }
onClick={ onToggle }
aria-expanded={ isOpen }
>
{ __( 'Add a Filter', 'wc-admin' ) }
</IconButton>
) }
renderContent={ ( { onClose } ) => (
<ul className="woocommerce-advanced-filters__add-dropdown">
{ availableFilterKeys.map( key => (
<li key={ key }>
<Button onClick={ partial( this.addFilter, key, onClose ) }>
{ config[ key ].addLabel }
</Button>
</li>
) ) }
</ul>
) }
/>
</div>
) }
<div className="woocommerce-advanced-filters__controls">
<Button isPrimary>{ __( 'Filter', 'wc-admin' ) }</Button>
<Button isLink onClick={ this.clearAllFilters }>
{ __( 'Clear all filters', 'wc-admin' ) }
</Button>
</div>
</Card>
);
}
}
AdvancedFilters.propTypes = {
config: PropTypes.object.isRequired,
filterTitle: PropTypes.string.isRequired,
};
export default AdvancedFilters;

View File

@ -0,0 +1,158 @@
/** @format */
.woocommerce-advanced-filters {
margin: $gap-large 0;
.woocommerce-card__header {
padding: $gap-smaller $gap;
}
.woocommerce-card__body {
padding: 0;
}
.components-select-control__input {
height: 38px;
padding: 0;
margin: 0;
}
}
.woocommerce-advanced-filters__title-select {
width: 70px;
display: inline-block;
margin: 0 $gap-smaller;
}
.woocommerce-advanced-filters__list {
margin: 0;
}
.woocommerce-advanced-filters__list-item {
padding: $gap-smaller $gap;
margin: 0;
display: grid;
grid-template-columns: auto 40px;
background-color: $core-grey-light-100;
border-bottom: 1px solid $core-grey-light-700;
&:hover {
background-color: $core-grey-light-200;
}
.components-base-control {
margin: 0;
}
.woocommerce-advanced-filters__remove {
width: 40px;
height: 38px;
align-self: center;
}
.components-form-token-field {
border-radius: 4px;
}
}
.woocommerce-advanced-filters__add-filter {
padding: $gap-small;
margin: 0;
color: $woocommerce;
display: block;
background-color: $core-grey-light-100;
border-bottom: 1px solid $core-grey-light-700;
&:hover {
background-color: $core-grey-light-200;
}
div div {
display: inline-block;
}
.components-popover:not(.is-mobile) .components-popover__content {
min-width: 180px;
}
}
.woocommerce-advanced-filters__fieldset {
display: grid;
grid-template-columns: 100px 150px auto;
@include breakpoint( '<782px' ) {
display: flex;
flex-direction: column;
}
}
.woocommerce-advanced-filters__fieldset-legend {
align-self: center;
@include breakpoint( '<782px' ) {
align-self: initial;
padding: $gap-smallest 0;
}
}
.woocommerce-advanced-filters__add-btn {
color: inherit;
padding: $gap-smaller;
svg {
fill: currentColor;
}
&.components-icon-button:not(:disabled):not([aria-disabled='true']):not(.is-default):hover {
color: $woocommerce-300;
}
&:not(:disabled):not([aria-disabled='true']):focus {
color: $woocommerce;
background-color: transparent;
}
}
.woocommerce-advanced-filters__controls {
padding: $gap-smaller $gap;
& > button {
margin-right: $gap;
}
}
.woocommerce-advanced-filters__add-dropdown {
padding: $gap-smaller 0;
li {
margin: 0;
}
.components-button {
width: 100%;
padding: $gap-smaller;
&:hover {
background-color: $core-grey-light-200;
}
&:not(:disabled):not([aria-disabled='true']):focus {
background-color: $core-grey-light-300;
box-shadow: none;
}
}
}
.woocommerce-advanced-filters__list-selector {
padding: 0 0 0 $gap-smaller;
@include breakpoint( '<782px' ) {
padding: $gap-smallest 0;
}
}
.woocommerce-advanced-filters__list-specifier {
@include breakpoint( '<782px' ) {
padding: $gap-smallest 0;
}
}

View File

@ -39,7 +39,7 @@ Card.propTypes = {
menu: PropTypes.shape( { menu: PropTypes.shape( {
type: PropTypes.oneOf( [ EllipsisMenu ] ), type: PropTypes.oneOf( [ EllipsisMenu ] ),
} ), } ),
title: PropTypes.string.isRequired, title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ).isRequired,
}; };
export default Card; export default Card;

View File

@ -1,7 +1,7 @@
Filter Picker Filter Picker
=== ===
Modify a url query parameter via a dropdown selection of configurable options Modify a url query parameter via a dropdown selection of configurable options. This component manipulates the `filter` query parameter.
## Usage ## Usage
@ -36,21 +36,12 @@ const renderFilterPicker = ( { path, query } ) {
other_fish: [ 'lunch', 'fish' ], other_fish: [ 'lunch', 'fish' ],
}; };
const queryParam = 'meal';
const getQueryParamValue = () => {
const { query } = this.props;
return return query[ queryParam ] || 'breakfast';
}
return ( return (
<FilterPicker <FilterPicker
query={ query } query={ query }
path={ path } path={ path }
filters={ filters } filters={ filters }
filterPaths={ filterPaths } filterPaths={ filterPaths }
queryParam={ queryParam }
getQueryParamValue={ getQueryParamValue }
/> />
); );
} }
@ -62,5 +53,3 @@ const renderFilterPicker = ( { path, query } ) {
* `path` (required): Parameter supplied by React-Router * `path` (required): Parameter supplied by React-Router
* `filters` (required): An array of filters and subFilters to construct the menu * `filters` (required): An array of filters and subFilters to construct the menu
* `filterPaths` (required): A map of representing the structure of the tree. Required for faster lookups than searches * `filterPaths` (required): A map of representing the structure of the tree. Required for faster lookups than searches
* `queryParam` (required): The query parameter to update
* `getQueryParamValue` (required): A function used to obtain the current value represented in the url

View File

@ -18,13 +18,16 @@ import AnimationSlider from 'components/animation-slider';
import Link from 'components/link'; import Link from 'components/link';
import './style.scss'; import './style.scss';
export const FILTER_PARAM = 'filter';
export const DEFAULT_FILTER_PARAM = 'all';
class FilterPicker extends Component { class FilterPicker extends Component {
constructor( props ) { constructor( props ) {
super( props ); super( props );
const { filterPaths, getQueryParamValue } = props; const { filterPaths, query } = props;
this.state = { this.state = {
nav: filterPaths[ getQueryParamValue() ], nav: filterPaths[ this.getQueryParamValue( query ) ],
animate: null, animate: null,
}; };
@ -36,24 +39,27 @@ class FilterPicker extends Component {
this.goBack = this.goBack.bind( this ); this.goBack = this.goBack.bind( this );
} }
getQueryParamValue( query ) {
return query[ FILTER_PARAM ] || DEFAULT_FILTER_PARAM;
}
getOtherQueries( query ) { getOtherQueries( query ) {
const { queryParam } = this.props; return omit( query, FILTER_PARAM );
return omit( query, queryParam );
} }
getSelectionPath( filter ) { getSelectionPath( filter ) {
const { path, query, queryParam } = this.props; const { path, query } = this.props;
const otherQueries = this.getOtherQueries( query ); const otherQueries = this.getOtherQueries( query );
const data = { const data = {
[ queryParam ]: filter.value, [ FILTER_PARAM ]: filter.value,
}; };
const queryString = stringifyQueryObject( Object.assign( otherQueries, data ) ); const queryString = stringifyQueryObject( Object.assign( otherQueries, data ) );
return `${ path }?${ queryString }`; return `${ path }?${ queryString }`;
} }
getSelectedFilter() { getSelectedFilter() {
const { filters, getQueryParamValue, filterPaths } = this.props; const { filters, filterPaths, query } = this.props;
const value = getQueryParamValue(); const value = this.getQueryParamValue( query );
const filterPath = filterPaths[ value ]; const filterPath = filterPaths[ value ];
const visibleFilters = this.getVisibleFilters( filters, [ ...filterPath ] ); const visibleFilters = this.getVisibleFilters( filters, [ ...filterPath ] );
return find( visibleFilters, filter => filter.value === value ); return find( visibleFilters, filter => filter.value === value );
@ -176,8 +182,6 @@ FilterPicker.propTypes = {
query: PropTypes.object.isRequired, query: PropTypes.object.isRequired,
filters: PropTypes.array.isRequired, filters: PropTypes.array.isRequired,
filterPaths: PropTypes.object.isRequired, filterPaths: PropTypes.object.isRequired,
queryParam: PropTypes.string.isRequired,
getQueryParamValue: PropTypes.func.isRequired,
}; };
export default FilterPicker; export default FilterPicker;