diff --git a/plugins/woocommerce-admin/client/analytics/report/orders/constants.js b/plugins/woocommerce-admin/client/analytics/report/orders/constants.js new file mode 100644 index 00000000000..b2bb7dfc29c --- /dev/null +++ b/plugins/woocommerce-admin/client/analytics/report/orders/constants.js @@ -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' ) }, + ], + }, +}; diff --git a/plugins/woocommerce-admin/client/analytics/report/orders/index.js b/plugins/woocommerce-admin/client/analytics/report/orders/index.js index a676a9fcc64..2786591f7ec 100644 --- a/plugins/woocommerce-admin/client/analytics/report/orders/index.js +++ b/plugins/woocommerce-admin/client/analytics/report/orders/index.js @@ -15,6 +15,11 @@ import { partial } from 'lodash'; */ import Header from 'layout/header/index'; 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 { constructor( props ) { @@ -32,7 +37,7 @@ class OrdersReport extends Component { } render() { - const { orders, orderIds } = this.props; + const { orders, orderIds, query, path } = this.props; return (
+
+ + +
+ { 'advanced' === query[ FILTER_PARAM ] && ( + + ) } +

Below is a temporary example

diff --git a/plugins/woocommerce-admin/client/analytics/report/orders/style.scss b/plugins/woocommerce-admin/client/analytics/report/orders/style.scss new file mode 100644 index 00000000000..cee881fa805 --- /dev/null +++ b/plugins/woocommerce-admin/client/analytics/report/orders/style.scss @@ -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; + } + } +} diff --git a/plugins/woocommerce-admin/client/analytics/report/products/index.js b/plugins/woocommerce-admin/client/analytics/report/products/index.js index 38cf4cb7c0d..561296a2547 100644 --- a/plugins/woocommerce-admin/client/analytics/report/products/index.js +++ b/plugins/woocommerce-admin/client/analytics/report/products/index.js @@ -15,17 +15,6 @@ import { filters, filterPaths } from './constants'; import './style.scss'; export default class extends Component { - constructor() { - super(); - - this.getQueryParamValue = this.getQueryParamValue.bind( this ); - } - - getQueryParamValue() { - const { query } = this.props; - return query.product || 'all'; - } - render() { const { query, path } = this.props; @@ -44,8 +33,6 @@ export default class extends Component { path={ path } filters={ filters } filterPaths={ filterPaths } - queryParam="product" - getQueryParamValue={ this.getQueryParamValue } /> diff --git a/plugins/woocommerce-admin/client/analytics/report/products/style.scss b/plugins/woocommerce-admin/client/analytics/report/products/style.scss index 119228694dd..92f1dfc95c2 100644 --- a/plugins/woocommerce-admin/client/analytics/report/products/style.scss +++ b/plugins/woocommerce-admin/client/analytics/report/products/style.scss @@ -4,7 +4,7 @@ display: flex; > div { - margin-right: 24px; + margin-right: $gap-large; } @include breakpoint( '<1100px' ) { diff --git a/plugins/woocommerce-admin/client/components/advanced-filters/README.md b/plugins/woocommerce-admin/client/components/advanced-filters/README.md new file mode 100644 index 00000000000..358c36854ee --- /dev/null +++ b/plugins/woocommerce-admin/client/components/advanced-filters/README.md @@ -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 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]: [*] + * }, + * } + * }} + */ +``` diff --git a/plugins/woocommerce-admin/client/components/advanced-filters/index.js b/plugins/woocommerce-admin/client/components/advanced-filters/index.js new file mode 100644 index 00000000000..0d3014a6a5a --- /dev/null +++ b/plugins/woocommerce-admin/client/components/advanced-filters/index.js @@ -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 ( + + { sprintf( __( '%s Match', 'wc-admin' ), filterTitle ) } + + { __( 'Filters', 'wc-admin' ) } + + ); + } + + getSelector( filter ) { + const filterConfig = this.props.config[ filter.key ]; + const { input } = filterConfig; + if ( ! input ) { + return null; + } + if ( 'SelectControl' === input.component ) { + return ( + + ); + } + if ( 'FormTokenField' === input.component ) { + return ( + + ); + } + 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 ( + +
    + { this.state.activeFilters.map( filter => { + const { key, rule } = filter; + const filterConfig = config[ key ]; + return ( +
  • + { /*eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex*/ } +
    + { /*eslint-enable-next-line jsx-a11y/no-noninteractive-tabindex*/ } + { filterConfig.label } +
    +
    + { filterConfig.label } +
    + +
    + { this.getSelector( filter ) } +
    +
    +
    + } + /> +
  • + ); + } ) } +
+ { availableFilterKeys.length > 0 && ( +
+ ( + } + onClick={ onToggle } + aria-expanded={ isOpen } + > + { __( 'Add a Filter', 'wc-admin' ) } + + ) } + renderContent={ ( { onClose } ) => ( +
    + { availableFilterKeys.map( key => ( +
  • + +
  • + ) ) } +
+ ) } + /> +
+ ) } + +
+ + +
+
+ ); + } +} + +AdvancedFilters.propTypes = { + config: PropTypes.object.isRequired, + filterTitle: PropTypes.string.isRequired, +}; + +export default AdvancedFilters; diff --git a/plugins/woocommerce-admin/client/components/advanced-filters/style.scss b/plugins/woocommerce-admin/client/components/advanced-filters/style.scss new file mode 100644 index 00000000000..8f20f9b36f0 --- /dev/null +++ b/plugins/woocommerce-admin/client/components/advanced-filters/style.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/components/card/index.js b/plugins/woocommerce-admin/client/components/card/index.js index 42c28a32166..02ed089153b 100644 --- a/plugins/woocommerce-admin/client/components/card/index.js +++ b/plugins/woocommerce-admin/client/components/card/index.js @@ -39,7 +39,7 @@ Card.propTypes = { menu: PropTypes.shape( { type: PropTypes.oneOf( [ EllipsisMenu ] ), } ), - title: PropTypes.string.isRequired, + title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ).isRequired, }; export default Card; diff --git a/plugins/woocommerce-admin/client/components/filter-picker/README.md b/plugins/woocommerce-admin/client/components/filter-picker/README.md index 3f82ca9cadd..5407d1635c4 100644 --- a/plugins/woocommerce-admin/client/components/filter-picker/README.md +++ b/plugins/woocommerce-admin/client/components/filter-picker/README.md @@ -1,7 +1,7 @@ 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 @@ -36,21 +36,12 @@ const renderFilterPicker = ( { path, query } ) { other_fish: [ 'lunch', 'fish' ], }; - const queryParam = 'meal'; - - const getQueryParamValue = () => { - const { query } = this.props; - return return query[ queryParam ] || 'breakfast'; - } - return ( ); } @@ -62,5 +53,3 @@ const renderFilterPicker = ( { path, query } ) { * `path` (required): Parameter supplied by React-Router * `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 -* `queryParam` (required): The query parameter to update -* `getQueryParamValue` (required): A function used to obtain the current value represented in the url diff --git a/plugins/woocommerce-admin/client/components/filter-picker/index.js b/plugins/woocommerce-admin/client/components/filter-picker/index.js index e2f5067fa00..c0d1ccb4019 100644 --- a/plugins/woocommerce-admin/client/components/filter-picker/index.js +++ b/plugins/woocommerce-admin/client/components/filter-picker/index.js @@ -18,13 +18,16 @@ import AnimationSlider from 'components/animation-slider'; import Link from 'components/link'; import './style.scss'; +export const FILTER_PARAM = 'filter'; +export const DEFAULT_FILTER_PARAM = 'all'; + class FilterPicker extends Component { constructor( props ) { super( props ); - const { filterPaths, getQueryParamValue } = props; + const { filterPaths, query } = props; this.state = { - nav: filterPaths[ getQueryParamValue() ], + nav: filterPaths[ this.getQueryParamValue( query ) ], animate: null, }; @@ -36,24 +39,27 @@ class FilterPicker extends Component { this.goBack = this.goBack.bind( this ); } + getQueryParamValue( query ) { + return query[ FILTER_PARAM ] || DEFAULT_FILTER_PARAM; + } + getOtherQueries( query ) { - const { queryParam } = this.props; - return omit( query, queryParam ); + return omit( query, FILTER_PARAM ); } getSelectionPath( filter ) { - const { path, query, queryParam } = this.props; + const { path, query } = this.props; const otherQueries = this.getOtherQueries( query ); const data = { - [ queryParam ]: filter.value, + [ FILTER_PARAM ]: filter.value, }; const queryString = stringifyQueryObject( Object.assign( otherQueries, data ) ); return `${ path }?${ queryString }`; } getSelectedFilter() { - const { filters, getQueryParamValue, filterPaths } = this.props; - const value = getQueryParamValue(); + const { filters, filterPaths, query } = this.props; + const value = this.getQueryParamValue( query ); const filterPath = filterPaths[ value ]; const visibleFilters = this.getVisibleFilters( filters, [ ...filterPath ] ); return find( visibleFilters, filter => filter.value === value ); @@ -176,8 +182,6 @@ FilterPicker.propTypes = { query: PropTypes.object.isRequired, filters: PropTypes.array.isRequired, filterPaths: PropTypes.object.isRequired, - queryParam: PropTypes.string.isRequired, - getQueryParamValue: PropTypes.func.isRequired, }; export default FilterPicker;