diff --git a/plugins/woocommerce-admin/client/analytics/report/orders/constants.js b/plugins/woocommerce-admin/client/analytics/report/orders/constants.js index 7ac6327240a..251ba6ae2ba 100644 --- a/plugins/woocommerce-admin/client/analytics/report/orders/constants.js +++ b/plugins/woocommerce-admin/client/analytics/report/orders/constants.js @@ -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' ) }, + ], + }, }, }; diff --git a/plugins/woocommerce-admin/client/components/filters/advanced/index.js b/plugins/woocommerce-admin/client/components/filters/advanced/index.js index 5b8c1f95974..c74960b9b73 100644 --- a/plugins/woocommerce-admin/client/components/filters/advanced/index.js +++ b/plugins/woocommerce-admin/client/components/filters/advanced/index.js @@ -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 ( - - ); - } - if ( 'Search' === input.component ) { - return ( - - ); - } - 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 (
    - { this.state.activeFilters.map( filter => { - const { key, rule } = filter; + { activeFilters.map( filter => { + const { key } = filter; const filterConfig = config[ key ]; return (
  • @@ -166,22 +153,20 @@ class AdvancedFilters extends Component { { /*eslint-enable-next-line jsx-a11y/no-noninteractive-tabindex*/ } { filterConfig.label }
    -
    - { filterConfig.label } -
    - -
    - { this.getSelector( filter ) } -
    + { 'SelectControl' === filterConfig.input.component && ( + + ) } + { 'Search' === filterConfig.input.component && ( + + ) }
    - - + ); diff --git a/plugins/woocommerce-admin/client/components/filters/advanced/search-filter.js b/plugins/woocommerce-admin/client/components/filters/advanced/search-filter.js new file mode 100644 index 00000000000..5ad345ada80 --- /dev/null +++ b/plugins/woocommerce-admin/client/components/filters/advanced/search-filter.js @@ -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 ( + +
    { config.label }
    + { rule && ( + + ) } +
    + +
    +
    + ); + } +} + +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; diff --git a/plugins/woocommerce-admin/client/components/filters/advanced/select-filter.js b/plugins/woocommerce-admin/client/components/filters/advanced/select-filter.js new file mode 100644 index 00000000000..958afbc12aa --- /dev/null +++ b/plugins/woocommerce-admin/client/components/filters/advanced/select-filter.js @@ -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 ( + +
    { config.label }
    + { rule && ( + + ) } +
    + +
    +
    + ); +}; + +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; diff --git a/plugins/woocommerce-admin/client/components/filters/advanced/style.scss b/plugins/woocommerce-admin/client/components/filters/advanced/style.scss index 18170bca01f..a6b6c7ba78b 100644 --- a/plugins/woocommerce-admin/client/components/filters/advanced/style.scss +++ b/plugins/woocommerce-admin/client/components/filters/advanced/style.scss @@ -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; + } + } } diff --git a/plugins/woocommerce-admin/client/components/filters/advanced/test/utils.js b/plugins/woocommerce-admin/client/components/filters/advanced/test/utils.js new file mode 100644 index 00000000000..3e5cd8d28a9 --- /dev/null +++ b/plugins/woocommerce-admin/client/components/filters/advanced/test/utils.js @@ -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(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/components/filters/advanced/utils.js b/plugins/woocommerce-admin/client/components/filters/advanced/utils.js new file mode 100644 index 00000000000..53c73d27ebf --- /dev/null +++ b/plugins/woocommerce-admin/client/components/filters/advanced/utils.js @@ -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 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 }; +}; diff --git a/plugins/woocommerce-admin/client/components/filters/index.js b/plugins/woocommerce-admin/client/components/filters/index.js index 2233b1f621f..ff257f64473 100644 --- a/plugins/woocommerce-admin/client/components/filters/index.js +++ b/plugins/woocommerce-admin/client/components/filters/index.js @@ -46,6 +46,7 @@ class ReportFilters extends Component { return (
    { 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 ) : ''; -}; diff --git a/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss b/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss index 416e7e61f3e..4c77c243bda 100644 --- a/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss +++ b/plugins/woocommerce-admin/client/stylesheets/shared/_global.scss @@ -37,6 +37,10 @@ color: $woocommerce-500; } + a.components-button.is-button { + color: $white; + } + a:hover, a:active, a:focus,