Add settings page with excluded order statuses (https://github.com/woocommerce/woocommerce-admin/pull/1364)

* Add settings page routes

* Add control options for excluded statuses

* Add control options for excluded statuses

* Add excluded order statuses to rest api

* Add wc settings to wc-api

* Add wc settings to wc-api

* Split and validate multiselect values in settings controller

* Add wcAdminSettings to wcSettings global

* Set initial excluded statuses from serverside wcSettings data

* Add extensible filter for wcSettings global

* Split arrays into comma separated strings in wc-api

* Extract setting as separate component

* Extra settings to config file

* Add checkboxGroup option as input type

* Separate status types into default and custom groups

* Add setting option styling

* Add responsive styling for settings

* Fix wpClosedMenu and wpOpenMenu for settings page

* Add support for resetting to default values

* Only show checkbox group if options are available

* Add proptypes to Setting component

* Add extensible filter to analytics settings

* Add readme for settings config and extensibility

* Hook up excluded status settings to reports

* Pass object to settings API instead of comma delimited string

* Fix inpuType -> inputType typo

* Remove hasError from constructor

* Bump settings API to v4

* Use interpolateComponents instead of dangerously setting html

* Use empty array in initial excldued statuses setting value if none is retrieved

* Remove double check for refunded status in default order statuses

* Update settings wc-api to use namespace

* Add aria=labelledby to checkbox group
This commit is contained in:
Joshua T Flowers 2019-01-31 09:04:11 +08:00 committed by GitHub
parent 713d64c97a
commit 49e78b90cf
17 changed files with 676 additions and 2 deletions

View File

@ -0,0 +1,34 @@
Settings
=======
The settings used to modify the way data is retreived or displayed in WooCommerce reports.
## Extending Settings
Settings can be added, removed, or modified outside oc `wc-admin` by hooking into `woocommerce_admin_analytics_settings`. For example:
```js
addFilter( 'woocommerce_admin_analytics_settings', 'wc-example/my-setting', settings => {
return [
...settings,
{
name: 'custom_setting',
label: __( 'Custom setting:', 'wc-admin' ),
inputType: 'text',
helpText: __( 'Help text to describe what the setting does.' ),
initialValue: 'Initial value used',
defaultValue: 'Default value',
},
];
} );
```
Each settings has the following properties:
- `name` (string): The slug of the setting to be updated.
- `label` (string): The label used to describe and displayed next to the setting.
- `inputType` (enum: text|checkbox|checkboxGroup): The type of input to use.
- `helpText` (string): Text displayed beneath the setting.
- `options` (array): Array of options used for inputs with selectable options.
- `initialValue` (string|array): Initial value used when rendering the setting.
- `defaultValue` (string|array): Value used when resetting to default settings.

View File

@ -0,0 +1,68 @@
/** @format */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { applyFilters } from '@wordpress/hooks';
import interpolateComponents from 'interpolate-components';
/**
* WooCommerce dependencies
*/
import { Link } from '@woocommerce/components';
const SETTINGS_FILTER = 'woocommerce_admin_analytics_settings';
const defaultOrderStatuses = [
'completed',
'processing',
'refunded',
'cancelled',
'failed',
'pending',
'on-hold',
];
const orderStatuses = Object.keys( wcSettings.orderStatuses )
.filter( status => status !== 'refunded' )
.map( key => {
return {
value: key,
label: wcSettings.orderStatuses[ key ],
description: sprintf(
__( 'Exclude the %s status from reports', 'wc-admin' ),
wcSettings.orderStatuses[ key ]
),
};
} );
export const analyticsSettings = applyFilters( SETTINGS_FILTER, [
{
name: 'woocommerce_excluded_report_order_statuses',
label: __( 'Excluded Statuses:', 'wc-admin' ),
inputType: 'checkboxGroup',
options: [
{
key: 'defaultStatuses',
options: orderStatuses.filter( status => defaultOrderStatuses.includes( status.value ) ),
},
{
key: 'customStatuses',
label: __( 'Custom Statuses', 'wc-admin' ),
options: orderStatuses.filter( status => ! defaultOrderStatuses.includes( status.value ) ),
},
],
helpText: interpolateComponents( {
mixedString: __(
'Orders with these statuses are excluded from the totals in your reports. ' +
'The {{strong}}Refunded{{/strong}} status can not be excluded. {{moreLink}}Learn more{{/moreLink}}',
'wc-admin'
),
components: {
strong: <strong />,
moreLink: <Link href="#" type="external" />, // @TODO: this needs to be replaced with a real link.
},
} ),
initialValue: wcSettings.wcAdminSettings.woocommerce_excluded_report_order_statuses || [],
defaultValue: [ 'pending', 'cancelled', 'failed' ],
},
] );

View File

@ -0,0 +1,134 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { remove } from 'lodash';
import { withDispatch } from '@wordpress/data';
/**
* WooCommerce dependencies
*/
import { SectionHeader, useFilters } from '@woocommerce/components';
/**
* Internal dependencies
*/
import './index.scss';
import { analyticsSettings } from './config';
import Header from 'header';
import Setting from './setting';
const SETTINGS_FILTER = 'woocommerce_admin_analytics_settings';
class Settings extends Component {
constructor() {
super( ...arguments );
const settings = {};
analyticsSettings.forEach( setting => ( settings[ setting.name ] = setting.initialValue ) );
this.state = {
settings: settings,
};
this.handleInputChange = this.handleInputChange.bind( this );
}
componentDidCatch( error ) {
this.setState( {
hasError: true,
} );
/* eslint-disable no-console */
console.warn( error );
/* eslint-enable no-console */
}
resetDefaults = () => {
if (
window.confirm(
__( 'Are you sure you want to reset all settings to default values?', 'wc-admin' )
)
) {
const settings = {};
analyticsSettings.forEach( setting => ( settings[ setting.name ] = setting.defaultValue ) );
this.setState( { settings }, this.saveChanges );
}
};
saveChanges = () => {
this.props.updateSettings( this.state.settings );
// @TODO: Need a confirmation on successful update.
};
handleInputChange( e ) {
const { checked, name, type, value } = e.target;
const { settings } = this.state;
if ( 'checkbox' === type ) {
if ( checked ) {
settings[ name ].push( value );
} else {
remove( settings[ name ], v => v === value );
}
} else {
settings[ name ] = value;
}
this.setState( { settings } );
}
render() {
const { hasError } = this.state;
if ( hasError ) {
return null;
}
return (
<Fragment>
<Header
sections={ [
[ '/analytics/revenue', __( 'Analytics', 'wc-admin' ) ],
__( 'Settings', 'wc-admin' ),
] }
/>
<SectionHeader title={ __( 'Analytics Settings', 'wc-admin' ) } />
<div className="woocommerce-settings__wrapper">
{ analyticsSettings.map( setting => (
<Setting
handleChange={ this.handleInputChange }
helpText={ setting.helpText }
inputType={ setting.inputType }
key={ setting.name }
label={ setting.label }
name={ setting.name }
options={ setting.options }
value={ this.state.settings[ setting.name ] }
/>
) ) }
<div className="woocommerce-settings__actions">
<Button isDefault onClick={ this.resetDefaults }>
{ __( 'Reset Defaults', 'wc-admin' ) }
</Button>
<Button isPrimary onClick={ this.saveChanges }>
{ __( 'Save Changes', 'wc-admin' ) }
</Button>
</div>
</div>
</Fragment>
);
}
}
export default compose(
withDispatch( dispatch => {
const { updateSettings } = dispatch( 'wc-api' );
return {
updateSettings,
};
} )
)( useFilters( SETTINGS_FILTER )( Settings ) );

View File

@ -0,0 +1,17 @@
/** @format */
.woocommerce-settings__wrapper {
@include breakpoint( '>782px' ) {
padding: 0 ($gap - 3);
}
}
.woocommerce-settings__actions {
@include breakpoint( '>1280px' ) {
margin-left: 15%;
}
button {
margin-right: $gap;
}
}

View File

@ -0,0 +1,141 @@
/** @format */
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import PropTypes from 'prop-types';
import { uniqueId } from 'lodash';
/**
* Internal dependencies
*/
import './setting.scss';
class Setting extends Component {
renderInput = () => {
const { handleChange, name, inputType, options, value } = this.props;
const id = uniqueId( name );
switch ( inputType ) {
case 'checkboxGroup':
return options.map(
optionGroup =>
optionGroup.options.length > 0 && (
<div
className="woocommerce-setting__options-group"
key={ optionGroup.key }
aria-labelledby={ name + '-label' }
>
{ optionGroup.label && (
<span className="woocommerce-setting__options-group-label">
{ optionGroup.label }
</span>
) }
{ this.renderCheckboxOptions( optionGroup.options ) }
</div>
)
);
case 'checkbox':
return this.renderCheckboxOptions( options );
case 'text':
default:
return (
<input id={ id } type="text" name={ name } onChange={ handleChange } value={ value } />
);
}
};
renderCheckboxOptions( options ) {
const { handleChange, name, value } = this.props;
return options.map( option => {
const id = uniqueId( name + '-' + option.value );
return (
<label htmlFor={ id } key={ option.value }>
<input
id={ id }
type="checkbox"
name={ name }
onChange={ handleChange }
aria-label={ option.description }
checked={ value && value.includes( option.value ) }
value={ option.value }
/>
{ option.label }
</label>
);
} );
}
render() {
const { helpText, label, name } = this.props;
return (
<div className="woocommerce-setting">
<div className="woocommerce-setting__label" id={ name + '-label' }>
{ label }
</div>
<div className="woocommerce-setting__options">
{ this.renderInput() }
{ helpText && <span className="woocommerce-setting__help">{ helpText }</span> }
</div>
</div>
);
}
}
Setting.propTypes = {
/**
* Function assigned to the onChange of all inputs.
*/
handleChange: PropTypes.func.isRequired,
/**
* Optional help text displayed underneath the setting.
*/
helpText: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
/**
* Type of input to use; defaults to a text input.
*/
inputType: PropTypes.oneOf( [ 'checkbox', 'checkboxGroup', 'text' ] ),
/**
* Label used for describing the setting.
*/
label: PropTypes.string.isRequired,
/**
* Setting slug applied to input names.
*/
name: PropTypes.string.isRequired,
/**
* Array of options used for when the `inputType` allows multiple selections.
*/
options: PropTypes.arrayOf(
PropTypes.shape( {
/**
* Input value for this option.
*/
value: PropTypes.string,
/**
* Label for this option or above a group for a group `inputType`.
*/
label: PropTypes.string,
/**
* Description used for screen readers.
*/
description: PropTypes.string,
/**
* Key used for a group `inputType`.
*/
key: PropTypes.string,
/**
* Nested options for a group `inputType`.
*/
options: PropTypes.array,
} )
),
/**
* The string value used for the input or array of items if the input allows multiselection.
*/
value: PropTypes.oneOfType( [ PropTypes.string, PropTypes.array ] ),
};
export default Setting;

View File

@ -0,0 +1,49 @@
/** @format */
.woocommerce-setting {
display: flex;
margin-bottom: $gap-large;
@include breakpoint( '<1280px' ) {
flex-direction: column;
}
}
.woocommerce-setting__label {
@include font-size(16);
margin-bottom: $gap;
padding-right: $gap;
font-weight: bold;
@include breakpoint( '>1280px' ) {
width: 15%;
}
}
.woocommerce-setting__options {
display: flex;
flex-direction: column;
@include breakpoint( '>1280px' ) {
width: 35%;
}
label {
width: 100%;
display: block;
margin-bottom: $gap-small;
color: $core-grey-dark-500;
}
input[type='checkbox'] {
margin-right: $gap-small;
}
}
.woocommerce-setting__options-group-label {
display: block;
font-weight: bold;
margin-bottom: $gap-small;
}
.woocommerce-setting__help {
font-style: italic;
color: $core-grey-dark-300;
}

View File

@ -16,6 +16,7 @@ import { getPersistedQuery, stringifyQuery } from '@woocommerce/navigation';
*/
import Analytics from 'analytics';
import AnalyticsReport from 'analytics/report';
import AnalyticsSettings from 'analytics/settings';
import Dashboard from 'dashboard';
import DevDocs from 'devdocs';
@ -33,6 +34,12 @@ const getPages = () => {
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
},
{
container: AnalyticsSettings,
path: '/analytics/settings',
wpOpenMenu: 'toplevel_page_wc-admin--analytics-revenue',
wpClosedMenu: 'toplevel_page_woocommerce',
},
{
container: AnalyticsReport,
path: '/analytics/:report',

View File

@ -0,0 +1,13 @@
/** @format */
/**
* Internal dependencies
*/
import operations from './operations';
import selectors from './selectors';
import mutations from './mutations';
export default {
operations,
selectors,
mutations,
};

View File

@ -0,0 +1,10 @@
/** @format */
const updateSettings = operations => settingFields => {
const resourceKey = 'settings';
operations.update( [ resourceKey ], { [ resourceKey ]: settingFields } );
};
export default {
updateSettings,
};

View File

@ -0,0 +1,71 @@
/** @format */
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { pick } from 'lodash';
/**
* Internal dependencies
*/
import { NAMESPACE } from '../constants';
function read( resourceNames, fetch = apiFetch ) {
return [ ...readSettings( resourceNames, fetch ) ];
}
function update( resourceNames, data, fetch = apiFetch ) {
return [ ...updateSettings( resourceNames, data, fetch ) ];
}
function readSettings( resourceNames, fetch ) {
if ( resourceNames.includes( 'settings' ) ) {
const url = NAMESPACE + '/settings/wc_admin';
return [
fetch( { path: url } )
.then( settingsToSettingsResource )
.catch( error => {
return { [ 'settings' ]: { error: String( error.message ) } };
} ),
];
}
return [];
}
function updateSettings( resourceNames, data, fetch ) {
const resourceName = 'settings';
const settingsFields = [ 'woocommerce_excluded_report_order_statuses' ];
if ( resourceNames.includes( resourceName ) ) {
const url = NAMESPACE + '/settings/wc_admin/';
const settingsData = pick( data[ resourceName ], settingsFields );
const promises = Object.keys( settingsData ).map( setting => {
return fetch( {
path: url + setting,
method: 'POST',
data: { value: settingsData[ setting ] },
} )
.then( settingsToSettingsResource )
.catch( error => {
return { [ resourceName ]: { error } };
} );
} );
return [ promises ];
}
return [];
}
function settingsToSettingsResource( settings ) {
const settingsData = {};
settings.forEach( setting => ( settingsData[ setting.id ] = setting.value ) );
return { [ 'settings' ]: { data: settingsData } };
}
export default {
read,
update,
};

View File

@ -0,0 +1,14 @@
/** @format */
/**
* Internal dependencies
*/
import { DEFAULT_REQUIREMENT } from '../constants';
const getSettings = ( getResource, requireResource ) => ( requirement = DEFAULT_REQUIREMENT ) => {
return requireResource( requirement, 'settings' ).data;
};
export default {
getSettings,
};

View File

@ -9,11 +9,13 @@ import orders from './orders';
import reportItems from './reports/items';
import reportStats from './reports/stats';
import reviews from './reviews';
import settings from './settings';
import user from './user';
function createWcApiSpec() {
return {
mutations: {
...settings.mutations,
...user.mutations,
},
selectors: {
@ -23,6 +25,7 @@ function createWcApiSpec() {
...reportItems.selectors,
...reportStats.selectors,
...reviews.selectors,
...settings.selectors,
...user.selectors,
},
operations: {
@ -34,11 +37,15 @@ function createWcApiSpec() {
...reportItems.operations.read( resourceNames ),
...reportStats.operations.read( resourceNames ),
...reviews.operations.read( resourceNames ),
...settings.operations.read( resourceNames ),
...user.operations.read( resourceNames ),
];
},
update( resourceNames, data ) {
return [ ...user.operations.update( resourceNames, data ) ];
return [
...settings.operations.update( resourceNames, data ),
...user.operations.update( resourceNames, data ),
];
},
},
};

View File

@ -0,0 +1,27 @@
<?php
/**
* REST API Setting Options Controller
*
* Handles requests to /settings/{option}
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* Setting Options controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_Setting_Options_Controller
*/
class WC_Admin_REST_Setting_Options_Controller extends WC_REST_Setting_Options_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v4';
}

View File

@ -167,6 +167,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-categories-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-reviews-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-setting-options-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-system-status-tools-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-categories-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-coupons-controller.php';
@ -202,6 +203,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Product_Categories_Controller',
'WC_Admin_REST_Product_Reviews_Controller',
'WC_Admin_REST_Reports_Controller',
'WC_Admin_REST_Setting_Options_Controller',
'WC_Admin_REST_System_Status_Tools_Controller',
'WC_Admin_REST_Reports_Products_Controller',
'WC_Admin_REST_Reports_Variations_Controller',
@ -380,6 +382,20 @@ class WC_Admin_Api_Init {
$endpoints['/wc/v4/taxes'][1] = $endpoints['/wc/v4/taxes'][3];
}
// Override /wc/v4/settings/$group_id.
if ( isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'] )
&& isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][5] )
&& isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][4] )
&& isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][3] )
&& $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][3]['callback'][0] instanceof WC_Admin_REST_Setting_Options_Controller
&& $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][4]['callback'][0] instanceof WC_Admin_REST_Setting_Options_Controller
&& $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][5]['callback'][0] instanceof WC_Admin_REST_Setting_Options_Controller
) {
$endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][0] = $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][3];
$endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][1] = $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][4];
$endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][2] = $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][5];
}
return $endpoints;
}

View File

@ -406,7 +406,9 @@ class WC_Admin_Reports_Data_Store {
* @return array
*/
protected static function get_excluded_report_order_statuses() {
return apply_filters( 'woocommerce_reports_excluded_order_statuses', array( 'refunded', 'pending', 'failed', 'cancelled' ) );
$excluded_statuses = WC_Admin_Settings::get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) );
$excluded_statuses[] = 'refunded';
return apply_filters( 'woocommerce_reports_excluded_order_statuses', $excluded_statuses );
}
/**

View File

@ -157,6 +157,14 @@ function wc_admin_register_pages() {
)
);
wc_admin_register_page(
array(
'title' => __( 'Settings', 'wc-admin' ),
'parent' => '/analytics/revenue',
'path' => '/analytics/settings',
)
);
if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
wc_admin_register_page(
array(
@ -447,3 +455,39 @@ function wc_admin_update_user_data_values( $values, $user, $field_id ) {
return $updates;
}
/**
* Register the admin settings for use in the WC REST API
*
* @param array $groups Array of setting groups.
* @return array
*/
function wc_admin_add_settings_group( $groups ) {
$groups[] = array(
'id' => 'wc_admin',
'label' => __( 'WooCommerce Admin', 'wc-admin' ),
'description' => __( 'Settings for WooCommerce admin reporting.', 'wc-admin' ),
);
return $groups;
}
add_filter( 'woocommerce_settings_groups', 'wc_admin_add_settings_group' );
/**
* Add WC Admin specific settings
*
* @param array $settings Array of settings in wc admin group.
* @return array
*/
function wc_admin_add_settings( $settings ) {
$settings[] = array(
'id' => 'woocommerce_excluded_report_order_statuses',
'option_key' => 'woocommerce_excluded_report_order_statuses',
'label' => __( 'Excluded report order statuses', 'wc-admin' ),
'description' => __( 'Statuses that should not be included when calculating report totals.', 'wc-admin' ),
'default' => '',
'type' => 'multiselect',
'options' => format_order_statuses( wc_get_order_statuses() ),
);
return $settings;
};
add_filter( 'woocommerce_settings-wc_admin', 'wc_admin_add_settings' );

View File

@ -206,10 +206,13 @@ function wc_admin_print_script_settings() {
),
'currentUserData' => $current_user_data,
);
$settings = wc_admin_add_custom_settings( $settings );
foreach ( $preload_data_endpoints as $key => $endpoint ) {
$settings['dataEndpoints'][ $key ] = $preload_data[ $endpoint ]['body'];
}
$settings = apply_filters( 'wc_admin_wc_settings', $settings );
?>
<script type="text/javascript">
<?php
@ -221,6 +224,23 @@ function wc_admin_print_script_settings() {
}
add_action( 'admin_print_footer_scripts', 'wc_admin_print_script_settings', 1 );
/**
* Add in custom settings used for WC Admin.
*
* @param array $settings Array of settings to merge into.
* @return array
*/
function wc_admin_add_custom_settings( $settings ) {
$wc_rest_settings_options_controller = new WC_REST_Setting_Options_Controller();
$wc_admin_group_settings = $wc_rest_settings_options_controller->get_group_settings( 'wc_admin' );
$settings['wcAdminSettings'] = array();
foreach ( $wc_admin_group_settings as $setting ) {
$settings['wcAdminSettings'][ $setting['id'] ] = $setting['value'];
}
return $settings;
}
/**
* Load plugin text domain for translations.
*/