Merge pull request woocommerce/woocommerce-admin#349 from woocommerce/add/advanced-filters-url-support

Advanced Filters: Add url support
This commit is contained in:
Paul Sealock 2018-09-12 14:31:13 +12:00 committed by GitHub
commit 925140fc78
11 changed files with 540 additions and 88 deletions

View File

@ -28,7 +28,7 @@ export const advancedFilterConfig = {
addLabel: __( 'Order Status', 'wc-admin' ),
rules: [
{ value: 'is', label: __( 'Is', 'wc-admin' ) },
{ value: 'is-not', label: __( 'Is Not', 'wc-admin' ) },
{ value: 'is_not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'SelectControl',
@ -43,28 +43,28 @@ export const advancedFilterConfig = {
],
},
},
product: {
product_id: {
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' ) },
{ value: 'is_not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'Search',
type: 'products',
},
},
coupon: {
code: {
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' ) },
{ value: 'is_not', label: __( 'Is Not', 'wc-admin' ) },
],
input: {
component: 'Search',
@ -74,9 +74,12 @@ export const advancedFilterConfig = {
customer: {
label: __( 'Customer is', 'wc-admin' ),
addLabel: __( 'Customer Type', 'wc-admin' ),
rules: [
{ value: 'new', label: __( 'New', 'wc-admin' ) },
{ value: 'returning', label: __( 'Returning', 'wc-admin' ) },
],
input: {
component: 'SelectControl',
options: [
{ value: 'new', label: __( 'New', 'wc-admin' ) },
{ value: 'returning', label: __( 'Returning', 'wc-admin' ) },
],
},
},
};

View File

@ -13,7 +13,11 @@ import Gridicon from 'gridicons';
* Internal dependencies
*/
import Card from 'components/card';
import Search from 'components/search';
import Link from 'components/link';
import SelectFilter from './select-filter';
import SearchFilter from './search-filter';
import { getActiveFiltersFromQuery, getQueryFromActiveFilters } from './utils';
import { getNewPath } from 'lib/nav-utils';
import './style.scss';
const matches = [
@ -27,25 +31,22 @@ const matches = [
class AdvancedFilters extends Component {
constructor( props ) {
super( props );
const activeFiltersFromQuery = getActiveFiltersFromQuery( props.query, props.config );
this.state = {
match: matches[ 0 ],
activeFilters: [
/**
* Example activeFilter
* { key: product, rule: includes, value: [ one, two ] }
*/
],
activeFilters: activeFiltersFromQuery,
previousFilters: activeFiltersFromQuery,
};
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 );
this.clearFilters = this.clearFilters.bind( this );
this.getUpdateHref = this.getUpdateHref.bind( this );
}
onMatchChange( value ) {
@ -90,35 +91,6 @@ class AdvancedFilters extends Component {
);
}
getSelector( filter ) {
const filterConfig = this.props.config[ filter.key ];
const { input } = filterConfig;
if ( ! input ) {
return null;
}
if ( 'SelectControl' === input.component ) {
return (
<SelectControl
className="woocommerce-filters-advanced__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 ( 'Search' === input.component ) {
return (
<Search
onChange={ partial( this.onFilterChange, filter.key, 'value' ) }
type={ input.type }
selected={ filter.value }
/>
);
}
return null;
}
getAvailableFilterKeys() {
const { config } = this.props;
const activeFilterKeys = this.state.activeFilters.map( f => f.key );
@ -127,9 +99,15 @@ class AdvancedFilters extends Component {
addFilter( key, onClose ) {
const filterConfig = this.props.config[ key ];
const newFilter = { key, rule: filterConfig.rules[ 0 ] };
const newFilter = { key };
if ( Array.isArray( filterConfig.rules ) && filterConfig.rules.length ) {
newFilter.rule = filterConfig.rules[ 0 ].value;
}
if ( filterConfig.input && filterConfig.input.options ) {
newFilter.value = filterConfig.input.options[ 0 ];
newFilter.value = filterConfig.input.options[ 0 ].value;
}
if ( filterConfig.input && 'Search' === filterConfig.input.component ) {
newFilter.value = [];
}
this.setState( state => {
return {
@ -144,20 +122,29 @@ class AdvancedFilters extends Component {
} );
}
clearAllFilters() {
clearFilters() {
this.setState( {
activeFilters: [],
} );
}
getUpdateHref( activeFilters ) {
const { previousFilters } = this.state;
const { path, query } = this.props;
const updatedQuery = getQueryFromActiveFilters( activeFilters, previousFilters );
return getNewPath( updatedQuery, path, query );
}
render() {
const { config } = this.props;
const { activeFilters } = this.state;
const availableFilterKeys = this.getAvailableFilterKeys();
const updateHref = this.getUpdateHref( activeFilters );
return (
<Card className="woocommerce-filters-advanced" title={ this.getTitle() }>
<ul className="woocommerce-filters-advanced__list" ref={ this.filterListRef }>
{ this.state.activeFilters.map( filter => {
const { key, rule } = filter;
{ activeFilters.map( filter => {
const { key } = filter;
const filterConfig = config[ key ];
return (
<li className="woocommerce-filters-advanced__list-item" key={ key }>
@ -166,22 +153,20 @@ class AdvancedFilters extends Component {
{ /*eslint-enable-next-line jsx-a11y/no-noninteractive-tabindex*/ }
<legend className="screen-reader-text">{ filterConfig.label }</legend>
<div className="woocommerce-filters-advanced__fieldset">
<div className="woocommerce-filters-advanced__fieldset-legend">
{ filterConfig.label }
</div>
<SelectControl
className="woocommerce-filters-advanced__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-filters-advanced__list-selector">
{ this.getSelector( filter ) }
</div>
{ 'SelectControl' === filterConfig.input.component && (
<SelectFilter
filter={ filter }
config={ filterConfig }
onFilterChange={ this.onFilterChange }
/>
) }
{ 'Search' === filterConfig.input.component && (
<SearchFilter
filter={ filter }
config={ filterConfig }
onFilterChange={ this.onFilterChange }
/>
) }
</div>
</fieldset>
<IconButton
@ -224,10 +209,17 @@ class AdvancedFilters extends Component {
) }
<div className="woocommerce-filters-advanced__controls">
<Button isPrimary>{ __( 'Filter', 'wc-admin' ) }</Button>
<Button isLink onClick={ this.clearAllFilters }>
<Link
className="components-button is-primary is-button"
type="wc-admin"
disabled={ window.location.hash.substr( 1 ) === updateHref }
href={ updateHref }
>
{ __( 'Filter', 'wc-admin' ) }
</Link>
<Link type="wc-admin" href={ this.getUpdateHref( [] ) } onClick={ this.clearFilters }>
{ __( 'Clear all filters', 'wc-admin' ) }
</Button>
</Link>
</div>
</Card>
);

View File

@ -0,0 +1,86 @@
/** @format */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { SelectControl } from '@wordpress/components';
import { partial } from 'lodash';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import Search from 'components/search';
class SearchFilter extends Component {
constructor() {
super();
this.onSearchChange = this.onSearchChange.bind( this );
}
onSearchChange( values ) {
const { filter, onFilterChange } = this.props;
const nextValues = values.map( value => value.id );
onFilterChange( filter.key, 'value', nextValues );
}
render() {
const { filter, config, onFilterChange } = this.props;
const { key, rule, value } = filter;
const selected = value.map( id => {
// For now
return {
id: parseInt( id, 10 ),
label: id.toString(),
};
} );
return (
<Fragment>
<div className="woocommerce-filters-advanced__fieldset-legend">{ config.label }</div>
{ rule && (
<SelectControl
className="woocommerce-filters-advanced__list-specifier"
options={ config.rules }
value={ rule }
onChange={ partial( onFilterChange, key, 'rule' ) }
aria-label={ sprintf( __( 'Select a %s filter match', 'wc-admin' ), config.addLabel ) }
/>
) }
<div className="woocommerce-filters-advanced__list-selector">
<Search
onChange={ this.onSearchChange }
type={ config.input.type }
selected={ selected }
/>
</div>
</Fragment>
);
}
}
SearchFilter.propTypes = {
/**
* The configuration object for the single filter to be rendered.
*/
config: PropTypes.shape( {
label: PropTypes.string,
addLabel: PropTypes.string,
rules: PropTypes.arrayOf( PropTypes.object ),
input: PropTypes.object,
} ).isRequired,
/**
* The activeFilter handed down by AdvancedFilters.
*/
filter: PropTypes.shape( {
key: PropTypes.string,
rule: PropTypes.string,
value: PropTypes.array,
} ).isRequired,
/**
* Function to be called on update.
*/
onFilterChange: PropTypes.func.isRequired,
};
export default SearchFilter;

View File

@ -0,0 +1,62 @@
/** @format */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Fragment } from '@wordpress/element';
import { SelectControl } from '@wordpress/components';
import { partial } from 'lodash';
import PropTypes from 'prop-types';
const SelectFilter = ( { filter, config, onFilterChange } ) => {
const { key, rule, value } = filter;
return (
<Fragment>
<div className="woocommerce-filters-advanced__fieldset-legend">{ config.label }</div>
{ rule && (
<SelectControl
className="woocommerce-filters-advanced__list-specifier"
options={ config.rules }
value={ rule }
onChange={ partial( onFilterChange, key, 'rule' ) }
aria-label={ sprintf( __( 'Select a %s filter match', 'wc-admin' ), config.addLabel ) }
/>
) }
<div className="woocommerce-filters-advanced__list-selector">
<SelectControl
className="woocommerce-filters-advanced__list-select"
options={ config.input.options }
value={ value }
onChange={ partial( onFilterChange, filter.key, 'value' ) }
aria-label={ sprintf( __( 'Select %s', 'wc-admin' ), config.label ) }
/>
</div>
</Fragment>
);
};
SelectFilter.propTypes = {
/**
* The configuration object for the single filter to be rendered.
*/
config: PropTypes.shape( {
label: PropTypes.string,
addLabel: PropTypes.string,
rules: PropTypes.arrayOf( PropTypes.object ),
input: PropTypes.object,
} ).isRequired,
/**
* The activeFilter handed down by AdvancedFilters.
*/
filter: PropTypes.shape( {
key: PropTypes.string,
rule: PropTypes.string,
value: PropTypes.string,
} ).isRequired,
/**
* Function to be called on update.
*/
onFilterChange: PropTypes.func.isRequired,
};
export default SelectFilter;

View File

@ -29,13 +29,17 @@
}
.woocommerce-filters-advanced__list-item {
padding: $gap-smaller $gap;
padding: 0 $gap 0 0;
margin: 0;
display: grid;
grid-template-columns: auto 40px;
background-color: $core-grey-light-100;
border-bottom: 1px solid $core-grey-light-700;
fieldset {
padding: $gap-smaller $gap;
}
&:hover {
background-color: $core-grey-light-200;
}
@ -116,7 +120,7 @@
.woocommerce-filters-advanced__controls {
padding: $gap-smaller $gap;
& > button {
.components-button {
margin-right: $gap;
}
}
@ -144,8 +148,6 @@
}
.woocommerce-filters-advanced__list-selector {
padding: 0 0 0 $gap-smaller;
@include breakpoint( '<782px' ) {
padding: $gap-smallest 0;
}
@ -155,4 +157,12 @@
@include breakpoint( '<782px' ) {
padding: $gap-smallest 0;
}
& + .woocommerce-filters-advanced__list-selector {
padding: 0 0 0 $gap-smaller;
@include breakpoint( '<782px' ) {
padding: $gap-smallest 0;
}
}
}

View File

@ -0,0 +1,179 @@
/**
* @format
*/
/**
* External dependencies
*/
/**
* Internal dependencies
*/
import {
getUrlKey,
getSearchFilterValue,
getActiveFiltersFromQuery,
getUrlValue,
getQueryFromActiveFilters,
} from '../utils';
describe( 'getUrlKey', () => {
it( 'should return a correctly formatted string', () => {
const key = getUrlKey( 'key', 'rule' );
expect( key ).toBe( 'key_rule' );
} );
it( 'should return a correctly formatted string with no rule', () => {
const key = getUrlKey( 'key' );
expect( key ).toBe( 'key' );
} );
} );
describe( 'getSearchFilterValue', () => {
it( 'should convert url query param into value readable by Search component', () => {
const str = '1,2,3';
const values = getSearchFilterValue( str );
expect( Array.isArray( values ) ).toBeTruthy();
expect( values[ 0 ] ).toBe( '1' );
expect( values[ 1 ] ).toBe( '2' );
expect( values[ 2 ] ).toBe( '3' );
} );
it( 'should convert an empty string into an empty array', () => {
const str = '';
const values = getSearchFilterValue( str );
expect( Array.isArray( values ) ).toBeTruthy();
expect( values.length ).toBe( 0 );
} );
} );
describe( 'getActiveFiltersFromQuery', () => {
const config = {
with_select: {
rules: [ { value: 'is' } ],
input: {
component: 'SelectControl',
options: [ { value: 'pending' } ],
},
},
with_search: {
rules: [ { value: 'includes' } ],
input: {
component: 'Search',
},
},
with_no_rules: {
input: {
component: 'SelectControl',
options: [ { value: 'pending' } ],
},
},
};
it( 'should return activeFilters from a query', () => {
const query = {
with_select_is: 'pending',
with_search_includes: '',
with_no_rules: 'pending',
};
const activeFilters = getActiveFiltersFromQuery( query, config );
expect( Array.isArray( activeFilters ) ).toBeTruthy();
expect( activeFilters.length ).toBe( 3 );
// with_select
const with_select = activeFilters[ 0 ];
expect( with_select.key ).toBe( 'with_select' );
expect( with_select.rule ).toBe( 'is' );
expect( with_select.value ).toBe( 'pending' );
// with_search
const with_search = activeFilters[ 1 ];
expect( with_search.key ).toBe( 'with_search' );
expect( with_search.rule ).toBe( 'includes' );
expect( with_search.value ).toEqual( [] );
// with_search
const with_no_rules = activeFilters[ 2 ];
expect( with_no_rules.key ).toBe( 'with_no_rules' );
expect( with_no_rules.rule ).toBeUndefined();
expect( with_no_rules.value ).toEqual( 'pending' );
} );
it( 'should ignore irrelevant query parameters', () => {
const query = {
with_select: 'pending', // no rule associated
status: 45,
};
const activeFilters = getActiveFiltersFromQuery( query, config );
expect( activeFilters.length ).toBe( 0 );
} );
it( 'should return an empty array with no relevant parameters', () => {
const query = {};
const activeFilters = getActiveFiltersFromQuery( query, config );
expect( Array.isArray( activeFilters ) ).toBe( true );
expect( activeFilters.length ).toBe( 0 );
} );
} );
describe( 'getUrlValue', () => {
it( 'should pass through a string', () => {
const value = getUrlValue( 'my string' );
expect( value ).toBe( 'my string' );
} );
it( 'should return null for a non-string value', () => {
const value = getUrlValue( {} );
expect( value ).toBeNull();
} );
it( 'should return null for an empty array', () => {
const value = getUrlValue( [] );
expect( value ).toBeNull();
} );
it( 'should return comma separated values when given an array', () => {
const value = getUrlValue( [ 1, 2, 3 ] );
expect( value ).toBe( '1,2,3' );
} );
} );
describe( 'getQueryFromActiveFilters', () => {
it( 'should return a query object from activeFilters', () => {
const activeFilters = [
{ key: 'status', rule: 'is', value: 'open' },
{
key: 'things',
rule: 'includes',
value: [ 1, 2, 3 ],
},
{ key: 'customer', value: 'new' },
];
const query = getQueryFromActiveFilters( activeFilters );
expect( query.status_is ).toBe( 'open' );
expect( query.things_includes ).toBe( '1,2,3' );
expect( query.customer ).toBe( 'new' );
} );
it( 'should remove parameters from the previous filters', () => {
const nextFilters = [];
const previousFilters = [
{ key: 'status', rule: 'is', value: 'open' },
{
key: 'things',
rule: 'includes',
value: [ 1, 2, 3 ],
},
{ key: 'customer', value: 'new' },
];
const query = getQueryFromActiveFilters( nextFilters, previousFilters );
expect( query.status_is ).toBeUndefined();
expect( query.things_includes ).toBeUndefined();
expect( query.customer ).toBeUndefined();
} );
} );

View File

@ -0,0 +1,115 @@
/** @format */
/**
* External dependencies
*/
import { find, compact } from 'lodash';
/**
* Get the url query key from the filter key and rule.
*
* @param {string} key - filter key.
* @param {string} rule - filter rule.
* @return {string} - url query key.
*/
export const getUrlKey = ( key, rule ) => {
if ( rule && rule.length ) {
return `${ key }_${ rule }`;
}
return key;
};
/**
* Convert url values to array of objects for <Search /> component
*
* @param {string} str - url query parameter value
* @return {array} - array of Search values
*/
export const getSearchFilterValue = str => {
return str.length ? str.trim().split( ',' ) : [];
};
/**
* Describe activeFilter object.
*
* @typedef {Object} activeFilter
* @property {string} key - filter key.
* @property {string} [rule] - a modifying rule for a filter, eg 'includes' or 'is_not'.
* @property {string|array} value - filter value(s).
*/
/**
* Given a query object, return an array of activeFilters, if any.
*
* @param {object} query - query oject
* @param {object} config - config object
* @return {activeFilters[]} - array of activeFilters
*/
export const getActiveFiltersFromQuery = ( query, config ) => {
return compact(
Object.keys( config ).map( configKey => {
const filter = config[ configKey ];
if ( filter.rules ) {
const match = find( filter.rules, rule => {
return query.hasOwnProperty( getUrlKey( configKey, rule.value ) );
} );
if ( match ) {
const rawValue = query[ getUrlKey( configKey, match.value ) ];
const value =
'Search' === filter.input.component ? getSearchFilterValue( rawValue ) : rawValue;
return {
key: configKey,
rule: match.value,
value,
};
}
return null;
}
if ( query[ configKey ] ) {
return {
key: configKey,
value: query[ configKey ],
};
}
return null;
} )
);
};
/**
* Create a string value for url. Return a string directly or concatenate ids if supplied
* an array of objects.
*
* @param {string|array} value - value of an activeFilter
* @return {string|null} - url query param value
*/
export const getUrlValue = value => {
if ( Array.isArray( value ) ) {
return value.length ? value.join( ',' ) : null;
}
return 'string' === typeof value ? value : null;
};
/**
* Given activeFilters, create a new query object to update the url. Use previousFilters to
* Remove unused params.
*
* @param {activeFilters[]} nextFilters - activeFilters shown in the UI
* @param {activeFilters[]} previousFilters - filters represented by the current url
* @return {object} - query object representing the new parameters
*/
export const getQueryFromActiveFilters = ( nextFilters, previousFilters = [] ) => {
const previousData = previousFilters.reduce( ( query, filter ) => {
query[ getUrlKey( filter.key, filter.rule ) ] = undefined;
return query;
}, {} );
const data = nextFilters.reduce( ( query, filter ) => {
const urlValue = getUrlValue( filter.value );
if ( urlValue ) {
query[ getUrlKey( filter.key, filter.rule ) ] = urlValue;
}
return query;
}, {} );
return { ...previousData, ...data };
};

View File

@ -46,6 +46,7 @@ class ReportFilters extends Component {
return (
<div className="woocommerce-filters__advanced-filters">
<AdvancedFilters
key={ JSON.stringify( query ) }
config={ advancedConfig }
filterTitle={ __( 'Orders', 'wc-admin' ) }
path={ path }

View File

@ -123,7 +123,7 @@ Search.propTypes = {
*/
type: PropTypes.oneOf( [ 'products', 'product_cats', 'orders', 'customers' ] ).isRequired,
/**
* An array of objects describing selected values
* An array of objects describing selected values.
*/
selected: PropTypes.arrayOf(
PropTypes.shape( {

View File

@ -38,6 +38,16 @@ export const getAdminLink = path => {
return wcSettings.adminUrl + path;
};
/**
* Converts a query object to a query string.
*
* @param {Object} query parameters to be converted.
* @return {String} Query string.
*/
export const stringifyQuery = query => {
return query ? '?' + stringify( query ) : '';
};
/**
* Return a URL with set query parameters.
*
@ -47,8 +57,8 @@ export const getAdminLink = path => {
* @return {String} Updated URL merging query params into existing params.
*/
export const getNewPath = ( query, path = getPath(), currentQuery = getQuery() ) => {
const queryString = stringify( { ...currentQuery, ...query } );
return `${ path }?${ queryString }`;
const queryString = stringifyQuery( { ...currentQuery, ...query } );
return `${ path }${ queryString }`;
};
/**
@ -62,13 +72,3 @@ export const updateQueryString = ( query, path = getPath(), currentQuery = getQu
const newPath = getNewPath( query, path, currentQuery );
history.push( newPath );
};
/**
* Converts a query object to a query string.
*
* @param {Object} query parameters to be converted.
* @return {String} Query string.
*/
export const stringifyQuery = query => {
return query ? '?' + stringify( query ) : '';
};

View File

@ -37,6 +37,10 @@
color: $woocommerce-500;
}
a.components-button.is-button {
color: $white;
}
a:hover,
a:active,
a:focus,