fix merge conflicts

This commit is contained in:
Ron Rennick 2019-05-07 15:22:48 -03:00
commit 8744a8d6c9
72 changed files with 3147 additions and 1160 deletions

View File

@ -185,7 +185,7 @@ install_deps() {
if [ "$TRAVIS_PULL_REQUEST_BRANCH" != "" ]; then
BRANCH="$(sed 's/#/%23/' <<<$BRANCH)"
# Install wc-admin, the correct branch, if running from Travis CI.
# Install woocommerce-admin, the correct branch, if running from Travis CI.
php wp-cli.phar plugin install https://github.com/$REPO/archive/$BRANCH.zip --activate
fi

View File

@ -3,8 +3,9 @@
* External dependencies
*/
import { applyFilters } from '@wordpress/hooks';
import { Component } from '@wordpress/element';
import { Component, createRef, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { focus } from '@wordpress/dom';
import { withDispatch } from '@wordpress/data';
import { get } from 'lodash';
import PropTypes from 'prop-types';
@ -24,6 +25,8 @@ import { QUERY_DEFAULTS } from 'wc-api/constants';
import withSelect from 'wc-api/with-select';
import { extendTableData } from './utils';
import './style.scss';
const TABLE_FILTER = 'woocommerce_admin_report_table';
/**
@ -34,6 +37,8 @@ class ReportTable extends Component {
super( props );
this.onColumnsChange = this.onColumnsChange.bind( this );
this.onPageChange = this.onPageChange.bind( this );
this.scrollPointRef = createRef();
}
onColumnsChange( shownColumns ) {
@ -49,6 +54,18 @@ class ReportTable extends Component {
}
}
onPageChange() {
this.scrollPointRef.current.scrollIntoView();
const tableElement = this.scrollPointRef.current.nextSibling.querySelector(
'.woocommerce-table__table'
);
const focusableElements = focus.focusable.find( tableElement );
if ( focusableElements.length ) {
focusableElements[ 0 ].focus();
}
}
filterShownHeaders( headers, hiddenKeys ) {
if ( ! hiddenKeys || ! hiddenKeys.length ) {
return headers;
@ -102,19 +119,27 @@ class ReportTable extends Component {
const filteredHeaders = this.filterShownHeaders( headers, userPrefColumns );
return (
<TableCard
downloadable={ downloadable }
headers={ filteredHeaders }
ids={ ids }
isLoading={ isLoading }
onQueryChange={ onQueryChange }
onColumnsChange={ this.onColumnsChange }
rows={ rows }
rowsPerPage={ parseInt( query.per_page ) || QUERY_DEFAULTS.pageSize }
summary={ summary }
totalRows={ totalResults }
{ ...tableProps }
/>
<Fragment>
<div
className="woocommerce-report-table__scroll-point"
ref={ this.scrollPointRef }
aria-hidden
/>
<TableCard
downloadable={ downloadable }
headers={ filteredHeaders }
ids={ ids }
isLoading={ isLoading }
onQueryChange={ onQueryChange }
onColumnsChange={ this.onColumnsChange }
onPageChange={ this.onPageChange }
rows={ rows }
rowsPerPage={ parseInt( query.per_page ) || QUERY_DEFAULTS.pageSize }
summary={ summary }
totalRows={ totalResults }
{ ...tableProps }
/>
</Fragment>
);
}
}

View File

@ -0,0 +1,22 @@
/** @format */
.woocommerce-report-table__scroll-point {
position: relative;
top: -#{$adminbar-height + $gap};
@include breakpoint( '<782px' ) {
top: -#{$adminbar-height-mobile + $gap};
}
.woocommerce-feature-enabled-activity-panels & {
top: -#{$adminbar-height + $large-header-height + $gap};
@include breakpoint( '<782px' ) {
top: -#{$adminbar-height-mobile + $small-header-height + $gap};
}
@include breakpoint( '782px-960px' ) {
top: -#{$adminbar-height + $medium-header-height + $gap};
}
}
}

View File

@ -5,10 +5,10 @@ The core reports offered by WooCommerce live in this folder. The Header is added
## Extending Reports
New reports can be added by third-parties without altering `woocommerce-admin`, by hooking into the reports filter, `woocommerce-reports-list`. For example:
New reports can be added by third-parties without altering `woocommerce-admin`, by hooking into the reports filter, `woocommerce_admin_reports_list`. For example:
```js
addFilter( 'woocommerce-reports-list', 'wc-example/my-report', pages => {
addFilter( 'woocommerce_admin_reports_list', 'analytics/my-report', pages => {
return [
...pages,
{
@ -33,4 +33,4 @@ The component will get the following props:
- `pathMatch` (string): The route matched for this view, should always be `/analytics/:report`.
- `params` (object): This will contain the `report` from the path, which should match `report` in the page object.
**Note:** Adding your page to `woocommerce-reports-list` does not add the item to the admin menu, you'll need to do that in PHP with `wc_admin_register_page`.
**Note:** Adding your page to `woocommerce_admin_reports_list` does not add the item to the admin menu, you'll need to do that in PHP with the `woocommerce_admin_report_menu_items` filter.

View File

@ -144,7 +144,7 @@ export const advancedFilters = {
getLabels: getCouponLabels,
},
},
customer: {
customer_type: {
labels: {
add: __( 'Customer Type', 'woocommerce-admin' ),
remove: __( 'Remove customer filter', 'woocommerce-admin' ),

View File

@ -58,9 +58,9 @@ export const analyticsSettings = applyFilters( SETTINGS_FILTER, [
'woocommerce-admin'
);
apiFetch( { path: '/wc/v3/system_status/tools/rebuild_stats', method: 'PUT' } )
apiFetch( { path: '/wc/v4/reports/import', method: 'PUT' } )
.then( response => {
if ( response.success ) {
if ( 'success' === response.status ) {
addNotice( { status: 'success', message: response.message } );
// @todo This should be changed to detect when the lookup table population is complete.
setTimeout( () => resolve(), 300000 );

View File

@ -0,0 +1,87 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { Fragment } from '@wordpress/element';
function HistoricalDataActions( {
customersProgress,
customersTotal,
hasImportedData,
inProgress,
ordersProgress,
ordersTotal,
} ) {
const getActions = () => {
// An import is currently in progress
if ( inProgress ) {
return (
<Fragment>
<Button
className="woocommerce-settings-historical-data__action-button"
isPrimary
onClick={ () => null }
>
{ __( 'Stop Import', 'woocommerce-admin' ) }
</Button>
<div className="woocommerce-setting__help woocommerce-settings-historical-data__action-help">
{ __(
'Imported data will not be lost if the import is stopped.',
'woocommerce-admin'
) }
<br />
{ __(
'Navigating away from this page will not affect the import.',
'woocommerce-admin'
) }
</div>
</Fragment>
);
}
// Has no imported data
if ( ! hasImportedData ) {
return (
<Button isPrimary onClick={ () => null }>
{ __( 'Start', 'woocommerce-admin' ) }
</Button>
);
}
// Has imported all possible data
if ( customersProgress === customersTotal && ordersProgress === ordersTotal ) {
return (
<Fragment>
<Button isDefault onClick={ () => null }>
{ __( 'Re-import Data', 'woocommerce-admin' ) }
</Button>
<Button isDefault onClick={ () => null }>
{ __( 'Delete Previously Imported Data', 'woocommerce-admin' ) }
</Button>
</Fragment>
);
}
// It's not in progress and has some imported data
return (
<Fragment>
<Button isPrimary onClick={ () => null }>
{ __( 'Start', 'woocommerce-admin' ) }
</Button>
<Button isDefault onClick={ () => null }>
{ __( 'Delete Previously Imported Data', 'woocommerce-admin' ) }
</Button>
</Fragment>
);
};
return (
<div className="woocommerce-settings__actions woocommerce-settings-historical-data__actions">
{ getActions() }
</div>
);
}
export default HistoricalDataActions;

View File

@ -0,0 +1,174 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import moment from 'moment';
/**
* Internal dependencies
*/
import HistoricalDataActions from './actions';
import HistoricalDataPeriodSelector from './period-selector';
import HistoricalDataProgress from './progress';
import HistoricalDataStatus from './status';
import HistoricalDataSkipCheckbox from './skip-checkbox';
import withSelect from 'wc-api/with-select';
import './style.scss';
class HistoricalData extends Component {
constructor() {
super( ...arguments );
this.dateFormat = __( 'MM/DD/YYYY', 'woocommerce-admin' );
this.state = {
period: {
date: moment().format( this.dateFormat ),
label: 'all',
},
skipChecked: true,
};
this.onDateChange = this.onDateChange.bind( this );
this.onPeriodChange = this.onPeriodChange.bind( this );
this.onSkipChange = this.onSkipChange.bind( this );
}
onPeriodChange( val ) {
this.setState( {
period: {
...this.state.period,
label: val,
},
} );
}
onDateChange( val ) {
this.setState( {
period: {
date: val,
label: 'custom',
},
} );
}
onSkipChange( val ) {
this.setState( {
skipChecked: val,
} );
}
getStatus() {
const {
customersProgress,
customersTotal,
inProgress,
ordersProgress,
ordersTotal,
} = this.props;
if ( inProgress ) {
if ( customersProgress < customersTotal ) {
return 'customers';
}
if ( ordersProgress < ordersTotal ) {
return 'orders';
}
return 'finalizing';
}
if (
( customersTotal > 0 || ordersTotal > 0 ) &&
customersProgress === customersTotal &&
ordersProgress === ordersTotal
) {
return 'finished';
}
return 'ready';
}
render() {
const {
customersProgress,
customersTotal,
hasImportedData,
importDate,
inProgress,
ordersProgress,
ordersTotal,
} = this.props;
const { period, skipChecked } = this.state;
const hasImportedAllData =
! inProgress &&
hasImportedData &&
customersProgress === customersTotal &&
ordersProgress === ordersTotal;
return (
<Fragment>
<div className="woocommerce-setting">
<div className="woocommerce-setting__label" id="import-historical-data-label">
{ __( 'Import Historical Data:', 'woocommerce-admin' ) }
</div>
<div className="woocommerce-setting__input">
<span className="woocommerce-setting__help">
{ __(
'This tool populates historical analytics data by processing customers ' +
'and orders created prior to activating WooCommerce Admin.',
'woocommerce-admin'
) }
</span>
{ ! hasImportedAllData && (
<Fragment>
<HistoricalDataPeriodSelector
dateFormat={ this.dateFormat }
disabled={ inProgress }
onPeriodChange={ this.onPeriodChange }
onDateChange={ this.onDateChange }
value={ period }
/>
<HistoricalDataSkipCheckbox
disabled={ inProgress }
checked={ skipChecked }
onChange={ this.onSkipChange }
/>
<HistoricalDataProgress
label={ __( 'Registered Customers', 'woocommerce-admin' ) }
progress={ customersProgress }
total={ customersTotal }
/>
<HistoricalDataProgress
label={ __( 'Orders', 'woocommerce-admin' ) }
progress={ ordersProgress }
total={ ordersTotal }
/>
</Fragment>
) }
<HistoricalDataStatus importDate={ importDate } status={ this.getStatus() } />
</div>
</div>
<HistoricalDataActions
customersProgress={ customersProgress }
customersTotal={ customersTotal }
hasImportedData={ hasImportedData }
inProgress={ inProgress }
ordersProgress={ ordersProgress }
ordersTotal={ ordersTotal }
/>
</Fragment>
);
}
}
export default withSelect( () => {
return {
customersProgress: 0,
customersTotal: 0,
hasImportedData: false,
importDate: '2019-04-01',
inProgress: false,
ordersProgress: 0,
ordersTotal: 0,
};
} )( HistoricalData );

View File

@ -0,0 +1,85 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import moment from 'moment';
import { SelectControl } from '@wordpress/components';
/**
* WooCommerce dependencies
*/
import { DatePicker } from '@woocommerce/components';
import { dateValidationMessages } from '@woocommerce/date';
function HistoricalDataPeriodSelector( {
dateFormat,
disabled,
onDateChange,
onPeriodChange,
value,
} ) {
const onSelectChange = val => {
onPeriodChange( val );
};
const onDatePickerChange = val => {
if ( val.date && val.date.isValid ) {
onDateChange( val.date.format( dateFormat ) );
} else {
onDateChange( val.text );
}
};
const getDatePickerError = momentDate => {
if ( ! momentDate.isValid() || value.date.length !== dateFormat.length ) {
return dateValidationMessages.invalid;
}
if ( momentDate.isAfter( new Date(), 'day' ) ) {
return dateValidationMessages.future;
}
return null;
};
const getDatePicker = () => {
const momentDate = moment( value.date, dateFormat );
return (
<div className="woocommerce-settings-historical-data__column">
<div className="woocommerce-settings-historical-data__column-label">
{ __( 'Beginning on', 'woocommerce-admin' ) }
</div>
<DatePicker
date={ momentDate.isValid() ? momentDate.toDate() : null }
dateFormat={ dateFormat }
disabled={ disabled }
error={ getDatePickerError( momentDate ) }
isInvalidDate={ date => moment( date ).isAfter( new Date(), 'day' ) }
onUpdate={ onDatePickerChange }
text={ value.date }
/>
</div>
);
};
return (
<div className="woocommerce-settings-historical-data__columns">
<div className="woocommerce-settings-historical-data__column">
<SelectControl
label={ __( 'Import Historical Data', 'woocommerce-admin' ) }
value={ value.label }
disabled={ disabled }
onChange={ onSelectChange }
options={ [
{ label: 'All', value: 'all' },
{ label: 'Last 365 days', value: '365_days' },
{ label: 'Last 90 days', value: '90_days' },
{ label: 'Last 30 days', value: '30_days' },
{ label: 'Last 7 days', value: '7_days' },
{ label: 'Last 24 hours', value: '24_hours' },
{ label: 'Custom', value: 'custom' },
] }
/>
</div>
{ value.label === 'custom' && getDatePicker() }
</div>
);
}
export default HistoricalDataPeriodSelector;

View File

@ -0,0 +1,29 @@
/** @format */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
function HistoricalDataProgress( { label, progress, total } ) {
return (
<div className="woocommerce-settings-historical-data__progress">
<span className="woocommerce-settings-historical-data__progress-label">
{ sprintf( __( 'Imported %(label)s', 'woocommerce-admin' ), {
label,
} ) +
' ' +
sprintf( __( '%(progress)s of %(total)s', 'woocommerce-admin' ), {
progress,
total,
} ) }
</span>
<progress
className="woocommerce-settings-historical-data__progress-bar"
max={ total }
value={ progress }
/>
</div>
);
}
export default HistoricalDataProgress;

View File

@ -0,0 +1,20 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { CheckboxControl } from '@wordpress/components';
function HistoricalDataSkipCheckbox( { checked, disabled, onChange } ) {
return (
<CheckboxControl
className="woocommerce-settings-historical-data__skip-checkbox"
checked={ checked }
disabled={ disabled }
label={ __( 'Skip previously imported customers and orders', 'woocommerce-admin' ) }
onChange={ onChange }
/>
);
}
export default HistoricalDataSkipCheckbox;

View File

@ -0,0 +1,37 @@
/** @format */
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { applyFilters } from '@wordpress/hooks';
import moment from 'moment';
import { Spinner } from '@wordpress/components';
/**
* WooCommerce dependencies
*/
import { useFilters } from '@woocommerce/components';
const HISTORICAL_DATA_STATUS_FILTER = 'woocommerce_admin_import_status';
function HistoricalDataStatus( { importDate, status } ) {
const statusLabels = applyFilters( HISTORICAL_DATA_STATUS_FILTER, {
ready: __( 'Ready To Import', 'woocommerce-admin' ),
customers: [ __( 'Importing Customers', 'woocommerce-admin' ), <Spinner key="spinner" /> ],
orders: [ __( 'Importing Orders', 'woocommerce-admin' ), <Spinner key="spinner" /> ],
finalizing: [ __( 'Finalizing', 'woocommerce-admin' ), <Spinner key="spinner" /> ],
finished: sprintf(
__( 'Historical data from %s onward imported', 'woocommerce-admin' ),
moment( importDate ).format( 'll' )
),
} );
return (
<span className="woocommerce-settings-historical-data__status">
{ __( 'Status:', 'woocommerce-admin' ) + ' ' }
{ statusLabels[ status ] }
</span>
);
}
export default useFilters( HISTORICAL_DATA_STATUS_FILTER )( HistoricalDataStatus );

View File

@ -0,0 +1,111 @@
/** @format */
.woocommerce-settings-historical-data__columns {
display: grid;
grid-column-gap: $gap-large;
grid-template-columns: calc(50% - #{$gap-large/2}) calc(50% - #{$gap-large/2});
margin-top: $gap-small;
.woocommerce-settings-historical-data__column {
align-self: end;
// Auto-position fix for IE11.
@include set-grid-item-position( 2, 2 );
}
@include breakpoint( '<960px' ) {
grid-template-columns: 100%;
.woocommerce-settings-historical-data__column {
@include set-grid-item-position( 1, 2 );
}
}
.components-base-control__label,
.woocommerce-settings-historical-data__column-label {
margin-bottom: $gap-small;
}
.components-select-control__input {
height: 38px;
padding: 8px 2px;
}
.components-base-control__field {
margin-bottom: 0;
}
}
.woocommerce-settings-historical-data__skip-checkbox {
margin-top: $gap-large;
> .components-base-control__field {
margin-bottom: 0;
> .components-checkbox-control__label {
display: inline-block;
margin-bottom: 0;
width: auto;
}
}
}
.woocommerce-settings-historical-data__progress-label {
display: block;
font-weight: bold;
margin-bottom: $gap-small;
margin-top: $gap-large;
}
.woocommerce-settings-historical-data__progress-bar {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: 0;
height: 8px;
width: 100%;
// Firefox
& {
background-color: #c4c4c4;
}
&::-moz-progress-bar {
background-color: #0085ba;
}
// Chrome
&::-webkit-progress-bar {
background-color: #c4c4c4;
}
&::-webkit-progress-value {
background-color: #0085ba;
}
}
.woocommerce-settings-historical-data__status {
display: block;
font-weight: bold;
margin-top: $gap-large;
> .components-spinner {
float: none;
height: 12px;
margin-left: 6px;
margin-right: 6px;
width: 12px;
&::before {
left: 2px;
height: 3px;
top: 2px;
transform-origin: 4px 4px;
width: 3px;
}
}
}
.woocommerce-settings-historical-data__actions {
align-items: center;
display: flex;
}

View File

@ -21,6 +21,7 @@ import './index.scss';
import { analyticsSettings } from './config';
import Header from 'header';
import Setting from './setting';
import HistoricalData from './historical-data';
import withSelect from 'wc-api/with-select';
const SETTINGS_FILTER = 'woocommerce_admin_analytics_settings';
@ -140,6 +141,7 @@ class Settings extends Component {
{ __( 'Save Changes', 'woocommerce-admin' ) }
</Button>
</div>
<HistoricalData />
</div>
</Fragment>
);

View File

@ -0,0 +1,48 @@
Dashboard
=======
This folder contains the components used in the Dashboard page.
## Extending the Dashboard
New Dashboard sections can be added by hooking into the filter `woocommerce_dashboard_default_sections`. For example:
```js
addFilter( 'woocommerce_dashboard_default_sections', sections => {
return [
...sections,
{
key: 'example',
component: ExampleSection,
title: 'My Example Dashboard Section',
isVisible: true,
icon: 'arrow-right-alt',
hiddenBlocks: [],
},
];
} );
```
Each section is defined by an object containing the following properties.
- `key` (string): The key used internally to identify the section.
- `title` (string): The title shown in the Dashboard. It can be modified by users.
- `icon` (string|function|WPComponent|null): Icon to be used to identify the section.
- `component` (react component): The component containing the section content.
- `isVisible` (boolean): Whether the section is visible by default. Sections can be added/hidden by users.
- `hiddenBlocks` (array of strings): The keys of the blocks that must be hidden by default. Used in Sections that contain several blocks that can be shown or hidden. It can be modified by users.
The component will get the following props:
- `hiddenBlocks` (array of strings): Hidden blocks according to the default settings or the user preferences if they had made any modification.
- `isFirst` (boolean): Whether the component is the first one shown in the Dashboard.
- `isLast` (boolean): Whether the component is the last one shown in the Dashboard.
- `onMove` (boolean): Event to trigger when moving the section.
- `onRemove` (boolean): Event to trigger when removing the section.
- `onTitleBlur` (function): Event to trigger when the edit title input box is unfocused.
- `onTitleChange` (function): Event to trigger when the edit title input box receives a change event.
- `onToggleHiddenBlock` (function): Event to trigger when the user toggles one of the hidden blocks preferences.
- `titleInput` (string): Current string to be displayed in the edit title input box. Title is only updated on blur, so this value will be different than `title` when the user is modifying the input box.
- `path` (string): The exact path for this view.
- `query` (object): The query string for the current view, can be used to read current preferences for time periods or chart interval/type.
- `title` (string): Title of the section according to the default settings or the user preferences if they had made any modification.

View File

@ -0,0 +1,66 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon } from '@wordpress/components';
import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import { MenuItem } from '@woocommerce/components';
import './style.scss';
class SectionControls extends Component {
constructor( props ) {
super( props );
this.onMoveUp = this.onMoveUp.bind( this );
this.onMoveDown = this.onMoveDown.bind( this );
}
onMoveUp() {
const { onMove, onToggle } = this.props;
onMove( -1 );
// Close the dropdown
onToggle();
}
onMoveDown() {
const { onMove, onToggle } = this.props;
onMove( 1 );
// Close the dropdown
onToggle();
}
render() {
const { onRemove, isFirst, isLast } = this.props;
if ( ! window.wcAdminFeatures[ 'dashboard/customizable' ] ) {
return null;
}
return (
<div className="woocommerce-section-controls">
{ ! isFirst && (
<MenuItem isClickable onInvoke={ this.onMoveUp }>
<Icon icon={ 'arrow-up-alt2' } label={ __( 'Move up' ) } />
{ __( 'Move up', 'woocommerce-admin' ) }
</MenuItem>
) }
{ ! isLast && (
<MenuItem isClickable onInvoke={ this.onMoveDown }>
<Icon icon={ 'arrow-down-alt2' } label={ __( 'Move Down' ) } />
{ __( 'Move Down', 'woocommerce-admin' ) }
</MenuItem>
) }
<MenuItem isClickable onInvoke={ onRemove }>
<Icon icon={ 'trash' } label={ __( 'Remove block' ) } />
{ __( 'Remove section', 'woocommerce-admin' ) }
</MenuItem>
</div>
);
}
}
export default SectionControls;

View File

@ -0,0 +1,14 @@
/** @format */
.woocommerce-section-controls {
border-top: $border-width solid $core-grey-light-500;
.dashicon {
margin: 0 $gap-smaller 0 0;
vertical-align: bottom;
}
.woocommerce-ellipsis-menu__item {
padding-bottom: 10px;
}
}

View File

@ -0,0 +1,65 @@
/** @format */
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { xor } from 'lodash';
export default class Section extends Component {
constructor( props ) {
super( props );
const { title } = props;
this.state = {
titleInput: title,
};
this.onToggleHiddenBlock = this.onToggleHiddenBlock.bind( this );
this.onTitleChange = this.onTitleChange.bind( this );
this.onTitleBlur = this.onTitleBlur.bind( this );
}
onTitleChange( updatedTitle ) {
this.setState( { titleInput: updatedTitle } );
}
onTitleBlur() {
const { onTitleUpdate, title } = this.props;
const { titleInput } = this.state;
if ( titleInput === '' ) {
this.setState( { titleInput: title } );
} else if ( onTitleUpdate ) {
onTitleUpdate( titleInput );
}
}
onToggleHiddenBlock( key ) {
return () => {
const hiddenBlocks = xor( this.props.hiddenBlocks, [ key ] );
this.props.onChangeHiddenBlocks( hiddenBlocks );
};
}
render() {
const {
component: SectionComponent,
onChangeHiddenBlocks,
onTitleUpdate,
...props
} = this.props;
const { titleInput } = this.state;
return (
<div className="woocommerce-dashboard-section">
<SectionComponent
onTitleChange={ this.onTitleChange }
onTitleBlur={ this.onTitleBlur }
onToggleHiddenBlock={ this.onToggleHiddenBlock }
titleInput={ titleInput }
{ ...props }
/>
</div>
);
}
}

View File

@ -2,30 +2,211 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { __, sprintf } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { partial } from 'lodash';
import { IconButton, Icon, Dropdown, Button } from '@wordpress/components';
import { withDispatch } from '@wordpress/data';
/**
* WooCommerce dependencies
*/
import { H, ReportFilters } from '@woocommerce/components';
/**
* Internal dependencies
*/
import './style.scss';
import DashboardCharts from './dashboard-charts';
import Leaderboards from './leaderboards';
import { ReportFilters, H } from '@woocommerce/components';
import StorePerformance from './store-performance';
import defaultSections from './default-sections';
import Section from './components/section';
import withSelect from 'wc-api/with-select';
class CustomizableDashboard extends Component {
constructor( props ) {
super( props );
this.state = {
sections: this.mergeSectionsWithDefaults( props.userPrefSections ),
};
this.onMove = this.onMove.bind( this );
this.updateSection = this.updateSection.bind( this );
}
mergeSectionsWithDefaults( prefSections ) {
if ( ! prefSections || prefSections.length === 0 ) {
return defaultSections;
}
const defaultKeys = defaultSections.map( section => section.key );
const prefKeys = prefSections.map( section => section.key );
const keys = new Set( [ ...prefKeys, ...defaultKeys ] );
const sections = [];
keys.forEach( key => {
const defaultSection = defaultSections.find( section => section.key === key );
if ( ! defaultSection ) {
return;
}
const prefSection = prefSections.find( section => section.key === key );
sections.push( {
...defaultSection,
...prefSection,
} );
} );
return sections;
}
updateSections( newSections ) {
this.setState( { sections: newSections } );
this.props.updateCurrentUserData( { dashboard_sections: newSections } );
}
updateSection( updatedKey, newSettings ) {
const newSections = this.state.sections.map( section => {
if ( section.key === updatedKey ) {
return {
...section,
...newSettings,
};
}
return section;
} );
this.updateSections( newSections );
}
onChangeHiddenBlocks( updatedKey ) {
return updatedHiddenBlocks => {
this.updateSection( updatedKey, { hiddenBlocks: updatedHiddenBlocks } );
};
}
onSectionTitleUpdate( updatedKey ) {
return updatedTitle => {
this.updateSection( updatedKey, { title: updatedTitle } );
};
}
toggleVisibility( key, onToggle ) {
return () => {
if ( onToggle ) {
// Close the dropdown before setting state so an action is not performed on an unmounted component.
onToggle();
}
// When toggling visibility, place section at the end of the array.
const sections = [ ...this.state.sections ];
const index = sections.findIndex( s => key === s.key );
const toggledSection = sections.splice( index, 1 ).shift();
toggledSection.isVisible = ! toggledSection.isVisible;
sections.push( toggledSection );
this.updateSections( sections );
};
}
onMove( index, change ) {
const sections = [ ...this.state.sections ];
const movedSection = sections.splice( index, 1 ).shift();
sections.splice( index + change, 0, movedSection );
this.updateSections( sections );
}
renderAddMore() {
const { sections } = this.state;
const hiddenSections = sections.filter( section => false === section.isVisible );
if ( 0 === hiddenSections.length ) {
return null;
}
return (
<Dropdown
position="top center"
className="woocommerce-dashboard-section__add-more"
renderToggle={ ( { onToggle, isOpen } ) => (
<IconButton
onClick={ onToggle }
icon="plus-alt"
title={ __( 'Add more sections', 'woocommerce-admin' ) }
aria-expanded={ isOpen }
/>
) }
renderContent={ ( { onToggle } ) => (
<Fragment>
<H>{ __( 'Dashboard Sections', 'woocommerce-admin' ) }</H>
<div className="woocommerce-dashboard-section__add-more-choices">
{ hiddenSections.map( section => {
return (
<Button
key={ section.key }
onClick={ this.toggleVisibility( section.key, onToggle ) }
className="woocommerce-dashboard-section__add-more-btn"
title={ sprintf( __( 'Add %s section', 'woocommerce-admin' ), section.title ) }
>
<Icon icon={ section.icon } size={ 30 } />
<span className="woocommerce-dashboard-section__add-more-btn-title">
{ section.title }
</span>
</Button>
);
} ) }
</div>
</Fragment>
) }
/>
);
}
// @todo Replace dashboard-charts, leaderboards, and store-performance sections as neccessary with customizable equivalents.
export default class CustomizableDashboard extends Component {
render() {
const { query, path } = this.props;
const { sections } = this.state;
const visibleSections = sections.filter( section => section.isVisible );
return (
<Fragment>
<H>{ __( 'Customizable Dashboard', 'woocommerce-admin' ) }</H>
<ReportFilters query={ query } path={ path } />
<StorePerformance query={ query } />
<DashboardCharts query={ query } path={ path } />
<Leaderboards query={ query } />
{ visibleSections.map( ( section, index ) => {
return (
<Section
component={ section.component }
hiddenBlocks={ section.hiddenBlocks }
key={ section.key }
onChangeHiddenBlocks={ this.onChangeHiddenBlocks( section.key ) }
onTitleUpdate={ this.onSectionTitleUpdate( section.key ) }
path={ path }
query={ query }
title={ section.title }
onMove={ partial( this.onMove, index ) }
onRemove={ this.toggleVisibility( section.key ) }
isFirst={ 0 === index }
isLast={ visibleSections.length === index + 1 }
/>
);
} ) }
{ this.renderAddMore() }
</Fragment>
);
}
}
export default compose(
withSelect( select => {
const { getCurrentUserData } = select( 'wc-api' );
const userData = getCurrentUserData();
return {
userPrefSections: userData.dashboard_sections,
};
} ),
withDispatch( dispatch => {
const { updateCurrentUserData } = dispatch( 'wc-api' );
return {
updateCurrentUserData,
};
} )
)( CustomizableDashboard );

View File

@ -7,15 +7,14 @@ import classNames from 'classnames';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import Gridicon from 'gridicons';
import { xor } from 'lodash';
import PropTypes from 'prop-types';
import { IconButton, NavigableMenu, SelectControl } from '@wordpress/components';
import { IconButton, NavigableMenu, SelectControl, TextControl } from '@wordpress/components';
import { withDispatch } from '@wordpress/data';
/**
* WooCommerce dependencies
*/
import { EllipsisMenu, MenuItem, SectionHeader } from '@woocommerce/components';
import { EllipsisMenu, MenuItem, MenuTitle, SectionHeader } from '@woocommerce/components';
import { getAllowedIntervalsForQuery } from '@woocommerce/date';
/**
@ -25,40 +24,15 @@ import ChartBlock from './block';
import { getChartFromKey, uniqCharts } from './config';
import withSelect from 'wc-api/with-select';
import './style.scss';
import SectionControls from 'dashboard/components/section-controls';
class DashboardCharts extends Component {
constructor( props ) {
super( ...arguments );
this.state = {
chartType: props.userPrefChartType || 'line',
hiddenChartKeys: props.userPrefCharts || [
'avg_order_value',
'avg_items_per_order',
'items_sold',
'gross_revenue',
'refunds',
'coupons',
'taxes',
'shipping',
'amount',
'total_tax',
'order_tax',
'shipping_tax',
],
interval: props.userPrefIntervals || 'day',
};
this.toggle = this.toggle.bind( this );
}
toggle( key ) {
return () => {
const hiddenChartKeys = xor( this.state.hiddenChartKeys, [ key ] );
this.setState( { hiddenChartKeys } );
const userDataFields = {
[ 'dashboard_charts' ]: hiddenChartKeys,
};
this.props.updateCurrentUserData( userDataFields );
interval: props.userPrefChartInterval || 'day',
};
}
@ -73,24 +47,58 @@ class DashboardCharts extends Component {
}
renderMenu() {
const { hiddenChartKeys } = this.state;
const {
hiddenBlocks,
isFirst,
isLast,
onMove,
onRemove,
onTitleBlur,
onTitleChange,
onToggleHiddenBlock,
titleInput,
} = this.props;
return (
<EllipsisMenu label={ __( 'Choose which charts to display', 'woocommerce-admin' ) }>
{ uniqCharts.map( chart => {
return (
<MenuItem
checked={ ! hiddenChartKeys.includes( chart.key ) }
isCheckbox
isClickable
key={ chart.key }
onInvoke={ this.toggle( chart.key ) }
>
{ __( `${ chart.label }`, 'woocommerce-admin' ) }
</MenuItem>
);
} ) }
</EllipsisMenu>
<EllipsisMenu
label={ __( 'Choose which charts to display', 'woocommerce-admin' ) }
renderContent={ ( { onToggle } ) => (
<Fragment>
{ window.wcAdminFeatures[ 'dashboard/customizable' ] && (
<div className="woocommerce-ellipsis-menu__item">
<TextControl
label={ __( 'Section Title', 'woocommerce-admin' ) }
onBlur={ onTitleBlur }
onChange={ onTitleChange }
required
value={ titleInput }
/>
</div>
) }
<MenuTitle>{ __( 'Charts', 'woocommerce-admin' ) }</MenuTitle>
{ uniqCharts.map( chart => {
return (
<MenuItem
checked={ ! hiddenBlocks.includes( chart.key ) }
isCheckbox
isClickable
key={ chart.key }
onInvoke={ () => onToggleHiddenBlock( chart.key )() }
>
{ __( `${ chart.label }`, 'woocommerce-admin' ) }
</MenuItem>
);
} ) }
<SectionControls
onToggle={ onToggle }
onMove={ onMove }
onRemove={ onRemove }
isFirst={ isFirst }
isLast={ isLast }
/>
</Fragment>
) }
/>
);
}
@ -132,14 +140,14 @@ class DashboardCharts extends Component {
};
render() {
const { path } = this.props;
const { chartType, hiddenChartKeys, interval } = this.state;
const { hiddenBlocks, path, title } = this.props;
const { chartType, interval } = this.state;
const query = { ...this.props.query, chartType, interval };
return (
<Fragment>
<div className="woocommerce-dashboard__dashboard-charts">
<SectionHeader
title={ __( 'Charts', 'woocommerce-admin' ) }
title={ title || __( 'Charts', 'woocommerce-admin' ) }
menu={ this.renderMenu() }
className={ 'has-interval-select' }
>
@ -176,7 +184,7 @@ class DashboardCharts extends Component {
</SectionHeader>
<div className="woocommerce-dashboard__columns">
{ uniqCharts.map( chart => {
return hiddenChartKeys.includes( chart.key ) ? null : (
return hiddenBlocks.includes( chart.key ) ? null : (
<ChartBlock
charts={ getChartFromKey( chart.key ) }
endpoint={ chart.endpoint }
@ -204,7 +212,6 @@ export default compose(
const userData = getCurrentUserData();
return {
userPrefCharts: userData.dashboard_charts,
userPrefChartType: userData.dashboard_chart_type,
userPrefChartInterval: userData.dashboard_chart_interval,
};

View File

@ -0,0 +1,63 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { applyFilters } from '@wordpress/hooks';
/**
* Internal dependencies
*/
import DashboardCharts from './dashboard-charts';
import Leaderboards from './leaderboards';
import StorePerformance from './store-performance';
const DEFAULT_SECTIONS_FILTER = 'woocommerce_dashboard_default_sections';
export default applyFilters( DEFAULT_SECTIONS_FILTER, [
{
key: 'store-performance',
component: StorePerformance,
title: __( 'Performance', 'woocommerce-admin' ),
isVisible: true,
icon: 'arrow-right-alt',
hiddenBlocks: [
'coupons/amount',
'coupons/orders_count',
'downloads/download_count',
'taxes/order_tax',
'taxes/total_tax',
'taxes/shipping_tax',
'revenue/shipping',
],
},
{
key: 'charts',
component: DashboardCharts,
title: __( 'Charts', 'woocommerce-admin' ),
isVisible: true,
icon: 'chart-bar',
hiddenBlocks: [
'avg_order_value',
'avg_items_per_order',
'items_sold',
'gross_revenue',
'refunds',
'coupons',
'taxes',
'shipping',
'amount',
'total_tax',
'order_tax',
'shipping_tax',
],
},
{
key: 'leaderboards',
component: Leaderboards,
title: __( 'Leaderboards', 'woocommerce-admin' ),
isVisible: true,
icon: 'editor-ol',
hiddenBlocks: [ 'coupons', 'customers' ],
},
] );

View File

@ -5,9 +5,8 @@
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { xor } from 'lodash';
import PropTypes from 'prop-types';
import { SelectControl } from '@wordpress/components';
import { SelectControl, TextControl } from '@wordpress/components';
import { withDispatch } from '@wordpress/data';
/**
@ -20,28 +19,16 @@ import { EllipsisMenu, MenuItem, MenuTitle, SectionHeader } from '@woocommerce/c
*/
import Leaderboard from 'analytics/components/leaderboard';
import withSelect from 'wc-api/with-select';
import SectionControls from 'dashboard/components/section-controls';
import './style.scss';
class Leaderboards extends Component {
constructor( props ) {
super( ...arguments );
this.state = {
hiddenLeaderboardKeys: props.userPrefLeaderboards || [ 'coupons', 'customers' ],
rowsPerTable: parseInt( props.userPrefLeaderboardRows ) || 5,
};
this.toggle = this.toggle.bind( this );
}
toggle( key ) {
return () => {
const hiddenLeaderboardKeys = xor( this.state.hiddenLeaderboardKeys, [ key ] );
this.setState( { hiddenLeaderboardKeys } );
const userDataFields = {
[ 'dashboard_leaderboards' ]: hiddenLeaderboardKeys,
};
this.props.updateCurrentUserData( userDataFields );
};
}
setRowsPerTable = rows => {
@ -53,52 +40,82 @@ class Leaderboards extends Component {
};
renderMenu() {
const { hiddenLeaderboardKeys, rowsPerTable } = this.state;
const { allLeaderboards } = this.props;
const {
allLeaderboards,
isFirst,
isLast,
hiddenBlocks,
onMove,
onRemove,
onTitleBlur,
onTitleChange,
onToggleHiddenBlock,
titleInput,
} = this.props;
const { rowsPerTable } = this.state;
return (
<EllipsisMenu
label={ __(
'Choose which leaderboards to display and the number of rows',
'Choose which leaderboards to display and other settings',
'woocommerce-admin'
) }
>
<Fragment>
<MenuTitle>{ __( 'Leaderboards', 'woocommerce-admin' ) }</MenuTitle>
{ allLeaderboards.map( leaderboard => {
return (
<MenuItem
checked={ ! hiddenLeaderboardKeys.includes( leaderboard.id ) }
isCheckbox
isClickable
key={ leaderboard.id }
onInvoke={ this.toggle( leaderboard.id ) }
>
{ leaderboard.label }
</MenuItem>
);
} ) }
<SelectControl
className="woocommerce-dashboard__dashboard-leaderboards__select"
label={ <MenuTitle>{ __( 'Rows Per Table', 'woocommerce-admin' ) }</MenuTitle> }
value={ rowsPerTable }
options={ Array.from( { length: 20 }, ( v, key ) => ( {
v: key + 1,
label: key + 1,
} ) ) }
onChange={ this.setRowsPerTable }
/>
</Fragment>
</EllipsisMenu>
renderContent={ ( { onToggle } ) => (
<Fragment>
{ window.wcAdminFeatures[ 'dashboard/customizable' ] && (
<div className="woocommerce-ellipsis-menu__item">
<TextControl
label={ __( 'Section Title', 'woocommerce-admin' ) }
onBlur={ onTitleBlur }
onChange={ onTitleChange }
required
value={ titleInput }
/>
</div>
) }
<MenuTitle>{ __( 'Leaderboards', 'woocommerce-admin' ) }</MenuTitle>
{ allLeaderboards.map( leaderboard => {
return (
<MenuItem
checked={ ! hiddenBlocks.includes( leaderboard.id ) }
isCheckbox
isClickable
key={ leaderboard.id }
onInvoke={ () => onToggleHiddenBlock( leaderboard.id )() }
>
{ leaderboard.label }
</MenuItem>
);
} ) }
<SelectControl
className="woocommerce-dashboard__dashboard-leaderboards__select"
label={ <MenuTitle>{ __( 'Rows Per Table', 'woocommerce-admin' ) }</MenuTitle> }
value={ rowsPerTable }
options={ Array.from( { length: 20 }, ( v, key ) => ( {
v: key + 1,
label: key + 1,
} ) ) }
onChange={ this.setRowsPerTable }
/>
<SectionControls
onToggle={ onToggle }
onMove={ onMove }
onRemove={ onRemove }
isFirst={ isFirst }
isLast={ isLast }
/>
</Fragment>
) }
/>
);
}
renderLeaderboards() {
const { hiddenLeaderboardKeys, rowsPerTable } = this.state;
const { allLeaderboards, query } = this.props;
const { rowsPerTable } = this.state;
const { allLeaderboards, hiddenBlocks, query } = this.props;
return allLeaderboards.map( leaderboard => {
if ( hiddenLeaderboardKeys.includes( leaderboard.id ) ) {
if ( hiddenBlocks.includes( leaderboard.id ) ) {
return;
}
@ -116,11 +133,13 @@ class Leaderboards extends Component {
}
render() {
const { title } = this.props;
return (
<Fragment>
<div className="woocommerce-dashboard__dashboard-leaderboards">
<SectionHeader
title={ __( 'Leaderboards', 'woocommerce-admin' ) }
title={ title || __( 'Leaderboards', 'woocommerce-admin' ) }
menu={ this.renderMenu() }
/>
<div className="woocommerce-dashboard__columns">{ this.renderLeaderboards() }</div>
@ -147,7 +166,6 @@ export default compose(
getItems,
getItemsError,
isGetItemsRequesting,
userPrefLeaderboards: userData.dashboard_leaderboards,
userPrefLeaderboardRows: userData.dashboard_leaderboard_rows,
};
} ),

View File

@ -8,6 +8,7 @@ import { compose } from '@wordpress/compose';
import { withDispatch } from '@wordpress/data';
import moment from 'moment';
import { find } from 'lodash';
import { TextControl } from '@wordpress/components';
/**
* WooCommerce dependencies
@ -30,58 +31,68 @@ import {
SummaryNumber,
} from '@woocommerce/components';
import withSelect from 'wc-api/with-select';
import SectionControls from 'dashboard/components/section-controls';
import './style.scss';
class StorePerformance extends Component {
constructor( props ) {
super( props );
this.state = {
hiddenIndicators: props.hiddenIndicators || [],
};
this.toggle = this.toggle.bind( this );
}
toggle( statKey ) {
return () => {
this.setState( state => {
const indicators = [ ...state.hiddenIndicators ];
let newHiddenIndicators = [];
if ( ! indicators.includes( statKey ) ) {
indicators.push( statKey );
newHiddenIndicators = indicators;
} else {
newHiddenIndicators = indicators.filter( indicator => indicator !== statKey );
}
this.props.updateCurrentUserData( {
dashboard_performance_indicators: newHiddenIndicators,
} );
return {
hiddenIndicators: newHiddenIndicators,
};
} );
};
}
renderMenu() {
const { indicators } = this.props;
const {
hiddenBlocks,
indicators,
isFirst,
isLast,
onMove,
onRemove,
onTitleBlur,
onTitleChange,
onToggleHiddenBlock,
titleInput,
} = this.props;
return (
<EllipsisMenu label={ __( 'Choose which analytics to display', 'woocommerce-admin' ) }>
<MenuTitle>{ __( 'Display Stats:', 'woocommerce-admin' ) }</MenuTitle>
{ indicators.map( ( indicator, i ) => {
const checked = ! this.state.hiddenIndicators.includes( indicator.stat );
return (
<MenuItem
checked={ checked }
isCheckbox
isClickable
key={ i }
onInvoke={ this.toggle( indicator.stat ) }
>
{ sprintf( __( 'Show %s', 'woocommerce-admin' ), indicator.label ) }
</MenuItem>
);
} ) }
</EllipsisMenu>
<EllipsisMenu
label={ __(
'Choose which analytics to display and the section name',
'woocommerce-admin'
) }
renderContent={ ( { onToggle } ) => (
<Fragment>
{ window.wcAdminFeatures[ 'dashboard/customizable' ] && (
<div className="woocommerce-ellipsis-menu__item">
<TextControl
label={ __( 'Section Title', 'woocommerce-admin' ) }
onBlur={ onTitleBlur }
onChange={ onTitleChange }
required
value={ titleInput }
/>
</div>
) }
<MenuTitle>{ __( 'Display Stats:', 'woocommerce-admin' ) }</MenuTitle>
{ indicators.map( ( indicator, i ) => {
const checked = ! hiddenBlocks.includes( indicator.stat );
return (
<MenuItem
checked={ checked }
isCheckbox
isClickable
key={ i }
onInvoke={ () => onToggleHiddenBlock( indicator.stat )() }
>
{ sprintf( __( 'Show %s', 'woocommerce-admin' ), indicator.label ) }
</MenuItem>
);
} ) }
<SectionControls
onToggle={ onToggle }
onMove={ onMove }
onRemove={ onRemove }
isFirst={ isFirst }
isLast={ isLast }
/>
</Fragment>
) }
/>
);
}
@ -157,42 +168,24 @@ class StorePerformance extends Component {
}
render() {
const { userIndicators, title } = this.props;
return (
<Fragment>
<SectionHeader
title={ __( 'Store Performance', 'woocommerce-admin' ) }
title={ title || __( 'Store Performance', 'woocommerce-admin' ) }
menu={ this.renderMenu() }
/>
<div className="woocommerce-dashboard__store-performance">{ this.renderList() }</div>
{ userIndicators.length > 0 && (
<div className="woocommerce-dashboard__store-performance">{ this.renderList() }</div>
) }
</Fragment>
);
}
}
export default compose(
withSelect( ( select, props ) => {
const { query } = props;
const {
getCurrentUserData,
getReportItems,
getReportItemsError,
isReportItemsRequesting,
} = select( 'wc-api' );
const userData = getCurrentUserData();
let hiddenIndicators = userData.dashboard_performance_indicators;
// Set default values for user preferences if none is set.
// These columns are HIDDEN by default.
if ( ! hiddenIndicators ) {
hiddenIndicators = [
'coupons/amount',
'coupons/orders_count',
'downloads/download_count',
'taxes/order_tax',
'taxes/total_tax',
'taxes/shipping_tax',
'revenue/shipping',
];
}
const { hiddenBlocks, query } = props;
const { getReportItems, getReportItemsError, isReportItemsRequesting } = select( 'wc-api' );
const datesFromQuery = getCurrentDates( query );
const endPrimary = datesFromQuery.primary.before;
@ -200,10 +193,18 @@ export default compose(
const indicators = wcSettings.dataEndpoints.performanceIndicators;
const userIndicators = indicators.filter(
indicator => ! hiddenIndicators.includes( indicator.stat )
indicator => ! hiddenBlocks.includes( indicator.stat )
);
const statKeys = userIndicators.map( indicator => indicator.stat ).join( ',' );
if ( statKeys.length === 0 ) {
return {
hiddenIndicators,
userIndicators,
indicators,
};
}
const primaryQuery = {
after: appendTimestamp( datesFromQuery.primary.after, 'start' ),
before: appendTimestamp( endPrimary, endPrimary.isSame( moment(), 'day' ) ? 'now' : 'end' ),
@ -228,7 +229,7 @@ export default compose(
const secondaryRequesting = isReportItemsRequesting( 'performance-indicators', secondaryQuery );
return {
hiddenIndicators,
hiddenBlocks,
userIndicators,
indicators,
primaryData,

View File

@ -28,3 +28,35 @@
.woocommerce-dashboard__widget-item {
flex: 1;
}
.woocommerce-dashboard-section__add-more {
margin: 0 auto;
width: 84px;
padding: 0 $gap-large $gap-large;
.components-popover__content {
padding: 0 $gap $gap-smaller;
}
}
.woocommerce-dashboard-section__add-more-choices {
display: flex;
justify-content: center;
}
.woocommerce-dashboard-section__add-more-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: $gap;
margin: $gap-smaller;
.dashicons-arrow-right-alt {
transform: rotate(-45deg);
}
}
.woocommerce-dashboard-section__add-more-btn-title {
color: $core-grey-dark-300;
padding-top: 8px;
}

View File

@ -101,6 +101,14 @@
.woocommerce-activity-card__body {
grid-area: body;
& > p:first-child {
margin-top: 0;
}
& > p:last-child {
margin-bottom: 0;
}
}
.woocommerce-activity-card__actions {
@ -332,3 +340,7 @@
@include font-size( 12 );
}
}
.woocommerce-empty-review-activity-card {
grid-template-columns: 72px 1fr;
}

View File

@ -19,7 +19,7 @@ import { H, Section } from '@woocommerce/components';
import {
getUnreadNotes,
getUnreadOrders,
getUnreadReviews,
getUnapprovedReviews,
getUnreadStock,
} from './unread-indicators';
import InboxPanel from './panels/inbox';
@ -98,7 +98,7 @@ class ActivityPanel extends Component {
// @todo Pull in dynamic unread status/count
getTabs() {
const { hasUnreadNotes, hasUnreadOrders, hasUnreadReviews, hasUnreadStock } = this.props;
const { hasUnreadNotes, hasUnreadOrders, hasUnapprovedReviews, hasUnreadStock } = this.props;
return [
{
name: 'inbox',
@ -125,7 +125,7 @@ class ActivityPanel extends Component {
name: 'reviews',
title: __( 'Reviews', 'woocommerce-admin' ),
icon: <Gridicon icon="star" />,
unread: hasUnreadReviews,
unread: hasUnapprovedReviews,
}
: null,
].filter( Boolean );
@ -141,8 +141,8 @@ class ActivityPanel extends Component {
case 'stock':
return <StockPanel />;
case 'reviews':
const { numberOfReviews } = this.props;
return <ReviewsPanel numberOfReviews={ numberOfReviews } />;
const { hasUnapprovedReviews } = this.props;
return <ReviewsPanel hasUnapprovedReviews={ hasUnapprovedReviews } />;
default:
return null;
}
@ -272,7 +272,7 @@ export default withSelect( select => {
const hasUnreadNotes = getUnreadNotes( select );
const hasUnreadOrders = getUnreadOrders( select );
const hasUnreadStock = getUnreadStock( select );
const { numberOfReviews, hasUnreadReviews } = getUnreadReviews( select );
const hasUnapprovedReviews = getUnapprovedReviews( select );
return { hasUnreadNotes, hasUnreadOrders, hasUnreadReviews, hasUnreadStock, numberOfReviews };
return { hasUnreadNotes, hasUnreadOrders, hasUnreadStock, hasUnapprovedReviews };
} )( clickOutside( ActivityPanel ) );

View File

@ -235,10 +235,15 @@ class OrdersPanel extends Component {
}
const menu = (
<EllipsisMenu label="Demo Menu">
<MenuTitle>Test</MenuTitle>
<MenuItem onInvoke={ noop }>Test</MenuItem>
</EllipsisMenu>
<EllipsisMenu
label="Demo Menu"
renderContent={ () => (
<Fragment>
<MenuTitle>Test</MenuTitle>
<MenuItem onInvoke={ noop }>Test</MenuItem>
</Fragment>
) }
/>
);
const title =

View File

@ -5,12 +5,11 @@
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { Button } from '@wordpress/components';
import Gridicon from 'gridicons';
import interpolateComponents from 'interpolate-components';
import { get, noop, isNull } from 'lodash';
import PropTypes from 'prop-types';
import { withDispatch } from '@wordpress/data';
/**
* WooCommerce dependencies
@ -24,13 +23,14 @@ import {
Section,
SplitButton,
} from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { ActivityCard, ActivityCardPlaceholder } from '../activity-card';
import ActivityHeader from '../activity-header';
import { DEFAULT_REVIEW_STATUSES, QUERY_DEFAULTS } from 'wc-api/constants';
import { QUERY_DEFAULTS } from 'wc-api/constants';
import sanitizeHTML from 'lib/sanitize-html';
import withSelect from 'wc-api/with-select';
@ -41,13 +41,6 @@ class ReviewsPanel extends Component {
this.mountTime = new Date().getTime();
}
componentWillUnmount() {
const userDataFields = {
[ 'activity_panel_reviews_last_read' ]: this.mountTime,
};
this.props.updateCurrentUserData( userDataFields );
}
renderReview( review, props ) {
const { lastRead } = props;
const product =
@ -149,20 +142,85 @@ class ReviewsPanel extends Component {
);
}
renderPlaceholders() {
const { numberOfReviews } = this.props;
const placeholders = new Array( numberOfReviews );
return placeholders
.fill( 0 )
.map( ( p, i ) => (
<ActivityCardPlaceholder
className="woocommerce-review-activity-card"
key={ i }
hasAction
hasDate
lines={ 2 }
/>
) );
renderEmptyMessage() {
const { lastApprovedReviewTime } = this.props;
const title = __( 'You have no reviews to moderate', 'woocommerce-admin' );
let buttonUrl = '';
let buttonTarget = '';
let buttonText = '';
let content = '';
if ( lastApprovedReviewTime ) {
const now = new Date();
const DAY = 24 * 60 * 60 * 1000;
if ( ( now.getTime() - lastApprovedReviewTime ) / DAY > 30 ) {
buttonUrl = 'https://woocommerce.com/posts/reviews-woocommerce-best-practices/';
buttonTarget = '_blank';
buttonText = __( 'Learn more', 'woocommerce-admin' );
content = (
<Fragment>
<p>
{ __(
"We noticed that it's been a while since your products had any reviews.",
'woocommerce-admin'
) }
</p>
<p>
{ __(
'Take some time to learn about best practices for collecting and using your reviews.',
'woocommerce-admin'
) }
</p>
</Fragment>
);
} else {
buttonUrl = getAdminLink( 'edit-comments.php' );
buttonText = __( 'View all Reviews', 'woocommerce-admin' );
content = (
<p>
{ __(
/* eslint-disable max-len */
"Awesome, you've moderated all of your product reviews. How about responding to some of those negative reviews?",
'woocommerce-admin'
/* eslint-enable */
) }
</p>
);
}
} else {
buttonUrl = 'https://woocommerce.com/posts/reviews-woocommerce-best-practices/';
buttonTarget = '_blank';
buttonText = __( 'Learn more', 'woocommerce-admin' );
content = (
<Fragment>
<p>
{ __( "Your customers haven't started reviewing your products.", 'woocommerce-admin' ) }
</p>
<p>
{ __(
'Take some time to learn about best practices for collecting and using your reviews.',
'woocommerce-admin'
) }
</p>
</Fragment>
);
}
return (
<ActivityCard
className="woocommerce-empty-review-activity-card"
title={ title }
icon={ <Gridicon icon="time" size={ 48 } /> }
actions={
<Button href={ buttonUrl } target={ buttonTarget } isDefault>
{ buttonText }
</Button>
}
>
{ content }
</ActivityCard>
);
}
render() {
@ -190,15 +248,27 @@ class ReviewsPanel extends Component {
);
}
const title =
isRequesting || reviews.length
? __( 'Reviews', 'woocommerce-admin' )
: __( 'No reviews to moderate', 'woocommerce-admin' );
return (
<Fragment>
<ActivityHeader title={ __( 'Reviews', 'woocommerce-admin' ) } />
<ActivityHeader title={ title } />
<Section>
{ isRequesting ? (
this.renderPlaceholders()
<ActivityCardPlaceholder
className="woocommerce-review-activity-card"
hasAction
hasDate
lines={ 2 }
/>
) : (
<Fragment>
{ reviews.map( review => this.renderReview( review, this.props ) ) }
{ reviews.length
? reviews.map( review => this.renderReview( review, this.props ) )
: this.renderEmptyMessage() }
</Fragment>
) }
</Section>
@ -211,44 +281,55 @@ ReviewsPanel.propTypes = {
reviews: PropTypes.array.isRequired,
isError: PropTypes.bool,
isRequesting: PropTypes.bool,
numberOfReviews: PropTypes.number,
};
ReviewsPanel.defaultProps = {
reviews: [],
isError: false,
isRequesting: false,
numberOfReviews: 0,
};
export default compose(
withSelect( ( select, props ) => {
const { numberOfReviews } = props;
const { getCurrentUserData, getReviews, getReviewsError, isGetReviewsRequesting } = select(
'wc-api'
);
if ( numberOfReviews === 0 ) {
return {};
}
const userData = getCurrentUserData();
export default withSelect( ( select, props ) => {
const { hasUnapprovedReviews } = props;
const { getReviews, getReviewsError, isGetReviewsRequesting } = select( 'wc-api' );
let reviews = [];
let isError = false;
let isRequesting = false;
let lastApprovedReviewTime = null;
if ( hasUnapprovedReviews ) {
const reviewsQuery = {
page: 1,
per_page: QUERY_DEFAULTS.pageSize,
status: DEFAULT_REVIEW_STATUSES,
status: 'hold',
_embed: 1,
};
const reviews = getReviews( reviewsQuery );
const isError = Boolean( getReviewsError( reviewsQuery ) );
const isRequesting = isGetReviewsRequesting( reviewsQuery );
return { reviews, isError, isRequesting, lastRead: userData.activity_panel_reviews_last_read };
} ),
withDispatch( dispatch => {
const { updateCurrentUserData } = dispatch( 'wc-api' );
return {
updateCurrentUserData,
reviews = getReviews( reviewsQuery );
isError = Boolean( getReviewsError( reviewsQuery ) );
isRequesting = isGetReviewsRequesting( reviewsQuery );
} else {
const approvedReviewsQuery = {
page: 1,
per_page: 1,
status: 'approved',
_embed: 1,
};
} )
)( ReviewsPanel );
const approvedReviews = getReviews( approvedReviewsQuery );
if ( approvedReviews.length ) {
const lastApprovedReview = approvedReviews[ 0 ];
if ( lastApprovedReview.date_created_gmt ) {
const creationDate = new Date( lastApprovedReview.date_created_gmt );
lastApprovedReviewTime = creationDate.getTime();
}
}
isError = Boolean( getReviewsError( approvedReviewsQuery ) );
isRequesting = isGetReviewsRequesting( approvedReviewsQuery );
}
return {
reviews,
isError,
isRequesting,
lastApprovedReviewTime,
};
} )( ReviewsPanel );

View File

@ -116,7 +116,10 @@ class ProductStockCard extends Component {
render() {
const { product } = this.props;
const title = (
<Link href={ 'post.php?action=edit&post=' + ( product.parent_id || product.id ) } type="wp-admin">
<Link
href={ 'post.php?action=edit&post=' + ( product.parent_id || product.id ) }
type="wp-admin"
>
{ product.name }
</Link>
);

View File

@ -6,26 +6,24 @@
align-items: center;
position: fixed;
right: 0;
height: 80px;
height: $medium-header-height;
@include breakpoint( '<782px' ) {
position: relative;
background: $white;
margin: 0;
padding: 0;
top: -3px;
width: 100vw;
display: none;
height: 60px;
flex: 1 100%;
}
@include breakpoint( '782px-960px' ) {
max-width: 280px;
height: 60px;
}
@include breakpoint( '>960px' ) {
height: $large-header-height;
max-width: 400px;
}
@ -37,11 +35,11 @@
.woocommerce-layout__activity-panel-tabs {
width: 100%;
display: flex;
height: 60px;
height: $medium-header-height;
justify-content: flex-end;
@include breakpoint( '>960px' ) {
height: 80px;
height: $large-header-height;
}
.dashicon,
@ -78,10 +76,10 @@
min-width: 80px;
width: 100%;
font-size: 11px;
height: 60px;
height: $medium-header-height;
@include breakpoint( '>960px' ) {
font-size: 13px;
height: 80px;
height: $large-header-height;
}
&.is-active {
@ -149,9 +147,9 @@
.woocommerce-layout__activity-panel-mobile-toggle {
margin-right: 10px;
max-width: 48px;
height: 46px;
height: $small-header-height;
position: fixed;
top: 50px;
top: $adminbar-height-mobile;
right: 0;
@include breakpoint( '>782px' ) {
display: none;
@ -194,31 +192,26 @@
}
.woocommerce-layout__activity-panel-wrapper {
height: calc(100vh - 80px);
min-height: calc(100vh - 80px);
height: calc(100vh - #{$medium-header-height + $small-header-height + $adminbar-height-mobile});
background: $core-grey-light-200;
box-shadow: 0 12px 12px 0 rgba(85, 93, 102, 0.3);
width: 0;
@include activity-panel-slide();
position: fixed;
right: 0;
top: 92px;
top: #{$medium-header-height + $small-header-height + $adminbar-height-mobile};
z-index: 1000;
overflow-x: hidden;
overflow-y: auto;
// Extra padding is needed at the bottom of the wrapper because of our positioning, height, and overflow rules. Otherwise, some content can get cut off.
padding-bottom: $gap-small * 6;
@include breakpoint( '782px-960px' ) {
padding-bottom: $gap-small;
}
@include breakpoint( '>960px' ) {
top: 112px;
padding-bottom: $gap * 2;
height: calc(100vh - #{$medium-header-height + $adminbar-height});
top: #{$medium-header-height + $adminbar-height};
}
@include breakpoint( '<782px' ) {
top: 153px;
@include breakpoint( '>960px' ) {
height: calc(100vh - #{$large-header-height + $adminbar-height});
top: #{$large-header-height + $adminbar-height};
}
&.is-open {

View File

@ -3,7 +3,7 @@
/**
* Internal dependencies
*/
import { DEFAULT_ACTIONABLE_STATUSES, DEFAULT_REVIEW_STATUSES } from 'wc-api/constants';
import { DEFAULT_ACTIONABLE_STATUSES } from 'wc-api/constants';
export function getUnreadNotes( select ) {
const { getCurrentUserData, getNotes, getNotesError, isGetNotesRequesting } = select( 'wc-api' );
@ -57,60 +57,26 @@ export function getUnreadOrders( select ) {
return totalOrders > 0;
}
export function getUnreadReviews( select ) {
const {
getCurrentUserData,
getReviews,
getReviewsTotalCount,
getReviewsError,
isGetReviewsRequesting,
} = select( 'wc-api' );
const userData = getCurrentUserData();
let numberOfReviews = null;
let hasUnreadReviews = false;
if ( 'yes' === wcSettings.reviewsEnabled ) {
const reviewsQuery = {
order: 'desc',
orderby: 'date_gmt',
export function getUnapprovedReviews( select ) {
const { getReviewsTotalCount, getReviewsError, isGetReviewsRequesting } = select( 'wc-api' );
if ( 'yes' === wcSettings.reviewsEnabled && '1' === wcSettings.commentModeration ) {
const actionableReviewsQuery = {
page: 1,
// @todo we are not using this review, so when the endpoint supports it,
// it could be replaced with `per_page: 0`
per_page: 1,
status: DEFAULT_REVIEW_STATUSES,
status: 'hold',
};
const reviews = getReviews( reviewsQuery );
const totalReviews = getReviewsTotalCount( reviewsQuery );
const isReviewsError = Boolean( getReviewsError( reviewsQuery ) );
const isReviewsRequesting = isGetReviewsRequesting( reviewsQuery );
const totalActionableReviews = getReviewsTotalCount( actionableReviewsQuery );
const isActionableReviewsError = Boolean( getReviewsError( actionableReviewsQuery ) );
const isActionableReviewsRequesting = isGetReviewsRequesting( actionableReviewsQuery );
if ( ! isReviewsError && ! isReviewsRequesting ) {
numberOfReviews = totalReviews;
hasUnreadReviews = Boolean(
reviews.length &&
reviews[ 0 ].date_created_gmt &&
new Date( reviews[ 0 ].date_created_gmt + 'Z' ).getTime() >
userData.activity_panel_reviews_last_read
);
}
if ( ! hasUnreadReviews && '1' === wcSettings.commentModeration ) {
const actionableReviewsQuery = {
page: 1,
// @todo we are not using this review, so when the endpoint supports it,
// it could be replaced with `per_page: 0`
per_page: 1,
status: 'hold',
};
const totalActionableReviews = getReviewsTotalCount( actionableReviewsQuery );
const isActionableReviewsError = Boolean( getReviewsError( actionableReviewsQuery ) );
const isActionableReviewsRequesting = isGetReviewsRequesting( actionableReviewsQuery );
if ( ! isActionableReviewsError && ! isActionableReviewsRequesting ) {
hasUnreadReviews = totalActionableReviews > 0;
}
if ( ! isActionableReviewsError && ! isActionableReviewsRequesting ) {
return totalActionableReviews > 0;
}
}
return { numberOfReviews, hasUnreadReviews };
return false;
}
export function getUnreadStock( select ) {

View File

@ -8,7 +8,7 @@
box-sizing: border-box;
border-bottom: 1px solid $white;
padding: 0;
height: 80px;
height: $large-header-height;
position: fixed;
width: 100%;
top: $adminbar-height;
@ -20,12 +20,12 @@
@include breakpoint( '<782px' ) {
top: $adminbar-height-mobile;
height: 50px;
height: $small-header-height;
flex-flow: row wrap;
}
@include breakpoint( '782px-960px' ) {
height: 60px;
height: $medium-header-height;
}
.woocommerce-layout__header-breadcrumbs {
@ -34,18 +34,18 @@
padding: 0 0 0 $fallback-gutter-large;
padding: 0 0 0 $gutter-large;
flex: 1 auto;
height: 50px;
line-height: 50px;
height: $small-header-height;
line-height: $small-header-height;
background: $white;
@include breakpoint( '782px-960px' ) {
height: 60px;
line-height: 60px;
height: $medium-header-height;
line-height: $medium-header-height;
}
@include breakpoint( '>960px' ) {
height: 80px;
line-height: 80px;
height: $large-header-height;
line-height: $large-header-height;
}
span + span::before {

View File

@ -13,6 +13,11 @@ $gap-small: 12px;
$gap-smaller: 8px;
$gap-smallest: 4px;
// Header
$small-header-height: 50px;
$medium-header-height: 60px;
$large-header-height: 80px;
// @todo Remove this spacing variable
$spacing: 16px;

View File

@ -14,8 +14,6 @@ export const DEFAULT_REQUIREMENT = {
// WordPress & WooCommerce both set a hard limit of 100 for the per_page parameter
export const MAX_PER_PAGE = 100;
export const DEFAULT_REVIEW_STATUSES = 'approved,hold';
export const DEFAULT_ACTIONABLE_STATUSES = [ 'processing', 'on-hold' ];
export const QUERY_DEFAULTS = {

View File

@ -40,14 +40,11 @@ function updateCurrentUserData( resourceNames, data, fetch ) {
'revenue_report_columns',
'taxes_report_columns',
'variations_report_columns',
'dashboard_performance_indicators',
'dashboard_charts',
'dashboard_sections',
'dashboard_chart_type',
'dashboard_chart_interval',
'dashboard_leaderboards',
'dashboard_leaderboard_rows',
'activity_panel_inbox_last_read',
'activity_panel_reviews_last_read',
];
if ( resourceNames.includes( resourceName ) ) {

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { addFilter } from '@wordpress/hooks';
import { Fragment } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
@ -8,16 +9,43 @@ import { __ } from '@wordpress/i18n';
/**
* WooCommerce dependencies
*/
import { ReportFilters } from '@woocommerce/components';
import { ReportFilters, TableCard } from '@woocommerce/components';
const Report = ( { path, query } ) => {
return (
<Fragment>
<ReportFilters
query={ query }
path={ path }
filters={ [] }
advancedFilters={ {} }
<ReportFilters query={ query } path={ path } filters={ [] } advancedFilters={ {} } />
<TableCard
title="Apples"
headers={ [
{ label: 'Type', key: 'type' },
{ label: 'Color', key: 'color' },
{ label: 'Taste', key: 'taste' },
] }
rows={ [
[
{ display: 'Granny Smith', value: 'type' },
{ display: 'Green', value: 'color' },
{ display: 'Tangy and sweet', value: 'taste' },
],
[
{ display: 'Golden Delicious', value: 'type' },
{ display: 'Gold', value: 'color' },
{ display: 'Sweet and cheery', value: 'taste' },
],
[
{ display: 'Gala', value: 'type' },
{ display: 'Red', value: 'color' },
{ display: 'Mild, sweet and crisp', value: 'taste' },
],
[
{ display: 'Braeburn', value: 'type' },
{ display: 'Red', value: 'color' },
{ display: 'Firm, crisp and pleasing', value: 'taste' },
],
] }
rowsPerPage={ 100 }
totalRows={ 1 }
/>
</Fragment>
);

View File

@ -5,6 +5,7 @@
const path = require( 'path' );
const CopyWebpackPlugin = require( 'copy-webpack-plugin' );
const fs = require( 'fs' );
const woocommerceAdminConfig = require( path.resolve( __dirname, '../../../webpack.config.js' ) );
const extArg = process.argv.find( arg => arg.startsWith( '--ext=' ) );
@ -19,31 +20,6 @@ if ( ! fs.existsSync( extensionPath ) ) {
throw new Error( 'Extension example does not exist.' );
}
const externals = {
'@wordpress/api-fetch': 'window.wp.apiFetch',
'@wordpress/blocks': 'window.wp.blocks',
'@wordpress/components': 'window.wp.components',
'@wordpress/compose': 'window.wp.compose',
'@wordpress/data': 'window.wp.data',
'@wordpress/editor': 'window.wp.editor',
'@wordpress/element': 'window.wp.element',
'@wordpress/hooks': 'window.wp.hooks',
'@wordpress/html-entities': 'window.wp.htmlEntities',
'@wordpress/i18n': 'window.wp.i18n',
'@wordpress/keycodes': 'window.wp.keycodes',
tinymce: 'tinymce',
moment: 'moment',
react: 'React',
lodash: 'lodash',
'react-dom': 'ReactDOM',
'@woocommerce/components': 'window.wc.components',
'@woocommerce/csv-export': 'window.wc.csvExport',
'@woocommerce/currency': 'window.wc.currency',
'@woocommerce/date': 'window.wc.date',
'@woocommerce/navigation': 'window.wc.navigation',
'@woocommerce/number': 'window.wc.number',
};
const webpackConfig = {
mode: 'development',
entry: {
@ -52,8 +28,9 @@ const webpackConfig = {
output: {
filename: '[name]/dist/index.js',
path: path.resolve( __dirname ),
libraryTarget: 'this',
},
externals,
externals: woocommerceAdminConfig.externals,
module: {
rules: [
{

View File

@ -1,6 +1,6 @@
# Feature Flags
Features inside the `wc-admin` repository can be in various states of completeness. In addition to the development copy of `wc-admin`, feature plugin versions are bundled, and code is merged to WooCommerce core. To provide a way for improved control over how these features are released in these different environments, `wc-admin` has a system for feature flags.
Features inside the `woocommerce-admin` repository can be in various states of completeness. In addition to the development copy of `woocommerce-admin`, feature plugin versions are bundled, and code is merged to WooCommerce core. To provide a way for improved control over how these features are released in these different environments, `woocommerce-admin` has a system for feature flags.
We currently support the following environments:
@ -46,4 +46,4 @@ if ( WC_Admin_Loader::is_feature_enabled( 'activity-panels' ) ) {
}
```
If you name a directory after the flag in `includes/` with a `class-wc-admin-FEATURE.php` file, this code will also automatically be loaded.
If you name a directory after the flag in `includes/` with a `class-wc-admin-FEATURE.php` file, this code will also automatically be loaded.

View File

@ -29,11 +29,11 @@ class WC_Admin_REST_Orders_Controller extends WC_REST_Orders_Controller {
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params = parent::get_collection_params();
// This needs to remain a string to support extensions that filter Order Number.
$params['number'] = array(
'description' => __( 'Limit result set to orders matching part of an order number.', 'woocommerce-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
@ -51,15 +51,22 @@ class WC_Admin_REST_Orders_Controller extends WC_REST_Orders_Controller {
// Search by partial order number.
if ( ! empty( $request['number'] ) ) {
$order_ids = $wpdb->get_col(
$partial_number = trim( $request['number'] );
$limit = intval( $args['posts_per_page'] );
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->prefix}posts WHERE post_type = 'shop_order' AND ID LIKE %s",
intval( $request['number'] ) . '%'
"SELECT ID
FROM {$wpdb->prefix}posts
WHERE post_type = 'shop_order'
AND ID LIKE %s
LIMIT %d",
$wpdb->esc_like( absint( $partial_number ) ) . '%',
$limit
)
);
// Force WP_Query return empty if don't found any order.
$order_ids = ! empty( $order_ids ) ? $order_ids : array( 0 );
$order_ids = empty( $order_ids ) ? array( 0 ) : $order_ids;
$args['post__in'] = $order_ids;
}

View File

@ -0,0 +1,252 @@
<?php
/**
* REST API Reports Import Controller
*
* Handles requests to /reports/import
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* Reports Imports controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_Data_Controller
*/
class WC_Admin_REST_Reports_Import_Controller extends WC_Admin_REST_Reports_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v4';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'reports/import';
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'import_items' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
'args' => $this->get_import_collection_params(),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/cancel',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'cancel_import' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/delete',
array(
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'delete_imported_items' ),
'permission_callback' => array( $this, 'import_permissions_check' ),
),
'schema' => array( $this, 'get_import_public_schema' ),
)
);
}
/**
* Makes sure the current user has access to WRITE the settings APIs.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_Error|bool
*/
public function import_permissions_check( $request ) {
if ( ! wc_rest_check_manager_permissions( 'settings', 'edit' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_edit', __( 'Sorry, you cannot edit this resource.', 'woocommerce-admin' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Import data based on user request params.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function import_items( $request ) {
$query_args = $this->prepare_objects_query( $request );
$import = WC_Admin_Reports_Sync::regenerate_report_data( $query_args['days'], $query_args['skip_existing'] );
if ( is_wp_error( $import ) ) {
$result = array(
'status' => 'error',
'message' => $import->get_error_message(),
);
} else {
$result = array(
'status' => 'success',
'message' => $import,
);
}
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Prepare request object as query args.
*
* @param WP_REST_Request $request Request data.
* @return array
*/
protected function prepare_objects_query( $request ) {
$args = array();
$args['skip_existing'] = $request['skip_existing'];
$args['days'] = $request['days'];
return $args;
}
/**
* Prepare the data object for response.
*
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
/**
* Filter the list returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_reports_import', $response, $item, $request );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_import_collection_params() {
$params = array();
$params['days'] = array(
'description' => __( 'Number of days to import.', 'woocommerce-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['skip_existing'] = array(
'description' => __( 'Skip importing existing order data.', 'woocommerce-admin' ),
'type' => 'boolean',
'default' => false,
'sanitize_callback' => 'wc_string_to_bool',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_import_public_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_import',
'type' => 'object',
'properties' => array(
'status' => array(
'description' => __( 'Regeneration status.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'message' => array(
'description' => __( 'Regenerate data message.', 'woocommerce-admin' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Cancel all queued import actions.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function cancel_import( $request ) {
WC_Admin_Reports_Sync::clear_queued_actions();
$result = array(
'status' => 'success',
'message' => __( 'All pending and in-progress import actions have been cancelled.', 'woocommerce-admin' ),
);
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
/**
* Delete all imported items.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function delete_imported_items( $request ) {
$delete = WC_Admin_Reports_Sync::delete_report_data();
if ( is_wp_error( $delete ) ) {
$result = array(
'status' => 'error',
'message' => $delete->get_error_message(),
);
} else {
$result = array(
'status' => 'success',
'message' => $delete,
);
}
$response = $this->prepare_item_for_response( $result, $request );
$data = $this->prepare_response_for_collection( $response );
return rest_ensure_response( $data );
}
}

View File

@ -1,70 +0,0 @@
<?php
/**
* REST API WC System Status Tools Controller
*
* Handles requests to the /system_status/tools/* endpoints.
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* System status tools controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_System_Status_Tools_Controller
*/
class WC_Admin_REST_System_Status_Tools_Controller extends WC_REST_System_Status_Tools_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v4';
/**
* A list of available tools for use in the system status section.
* 'button' becomes 'action' in the API.
*
* @return array
*/
public function get_tools() {
return array_merge(
parent::get_tools(),
array(
'rebuild_stats' => array(
'name' => __( 'Rebuild reports data', 'woocommerce-admin' ),
'button' => __( 'Rebuild reports', 'woocommerce-admin' ),
'desc' => __( 'This tool will rebuild all of the information used by the reports.', 'woocommerce-admin' ),
),
)
);
}
/**
* Actually executes a tool.
*
* @param string $tool Tool.
* @return array
*/
public function execute_tool( $tool ) {
$ran = true;
$message = '';
switch ( $tool ) {
case 'rebuild_stats':
WC_Admin_Api_Init::regenerate_report_data();
$message = __( 'Rebuilding reports data in the background . . .', 'woocommerce-admin' );
break;
default:
return parent::execute_tool( $tool );
}
return array(
'success' => $ran,
'message' => $message,
);
}
}

View File

@ -47,10 +47,10 @@ class WC_Admin_ActionScheduler_WPPostStore extends ActionScheduler_wpPostStore {
$action_types = array(
WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION,
WC_Admin_Reports_Sync::QUEUE_DEPEDENT_ACTION,
WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION,
WC_Admin_Reports_Sync::ORDERS_BATCH_ACTION,
WC_Admin_Reports_Sync::ORDERS_LOOKUP_BATCH_INIT,
WC_Admin_Reports_Sync::SINGLE_ORDER_ACTION,
WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION,
WC_Admin_Reports_Sync::ORDERS_IMPORT_BATCH_ACTION,
WC_Admin_Reports_Sync::ORDERS_IMPORT_BATCH_INIT,
WC_Admin_Reports_Sync::SINGLE_ORDER_IMPORT_ACTION,
);
foreach ( $action_types as $action_type ) {

View File

@ -115,7 +115,6 @@ class WC_Admin_Api_Init {
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-product-variations-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-setting-options-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-system-status-tools-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-categories-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-coupons-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-coupons-stats-controller.php';
@ -124,6 +123,7 @@ class WC_Admin_Api_Init {
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-downloads-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-downloads-files-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-downloads-stats-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-import-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-orders-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-orders-stats-controller.php';
require_once WC_ADMIN_ABSPATH . 'includes/api/class-wc-admin-rest-reports-products-controller.php';
@ -157,7 +157,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Product_Variations_Controller',
'WC_Admin_REST_Reports_Controller',
'WC_Admin_REST_Setting_Options_Controller',
'WC_Admin_REST_System_Status_Tools_Controller',
'WC_Admin_REST_Reports_Import_Controller',
'WC_Admin_REST_Reports_Products_Controller',
'WC_Admin_REST_Reports_Variations_Controller',
'WC_Admin_REST_Reports_Products_Stats_Controller',

View File

@ -187,7 +187,7 @@ class WC_Admin_Loader {
public static function update_link_structure() {
global $submenu;
// User does not have capabilites to see the submenu.
if ( ! current_user_can( 'manage_woocommerce' ) ) {
if ( ! current_user_can( 'manage_woocommerce' ) || empty( $submenu['woocommerce'] ) ) {
return;
}

View File

@ -22,24 +22,44 @@ class WC_Admin_Reports_Sync {
const QUEUE_DEPEDENT_ACTION = 'wc-admin_queue_dependent_action';
/**
* Action hook for processing a batch of customers.
* Action hook for importing a batch of customers.
*/
const CUSTOMERS_BATCH_ACTION = 'wc-admin_process_customers_batch';
/**
* Action hook for processing a batch of orders.
*/
const ORDERS_BATCH_ACTION = 'wc-admin_process_orders_batch';
const CUSTOMERS_IMPORT_BATCH_ACTION = 'wc-admin_import_customers_batch';
/**
* Action hook for initializing the orders lookup batch creation.
*/
const ORDERS_LOOKUP_BATCH_INIT = 'wc-admin_orders_lookup_batch_init';
const CUSTOMERS_DELETE_BATCH_INIT = 'wc-admin_delete_customers_batch_init';
/**
* Action hook for processing a batch of orders.
* Action hook for deleting a batch of customers.
*/
const SINGLE_ORDER_ACTION = 'wc-admin_process_order';
const CUSTOMERS_DELETE_BATCH_ACTION = 'wc-admin_delete_customers_batch';
/**
* Action hook for importing a batch of orders.
*/
const ORDERS_IMPORT_BATCH_ACTION = 'wc-admin_import_orders_batch';
/**
* Action hook for initializing the orders lookup batch creation.
*/
const ORDERS_IMPORT_BATCH_INIT = 'wc-admin_orders_lookup_import_batch_init';
/**
* Action hook for initializing the orders lookup batch deletion.
*/
const ORDERS_DELETE_BATCH_INIT = 'wc-admin_orders_lookup_delete_batch_init';
/**
* Action hook for deleting a batch of orders.
*/
const ORDERS_DELETE_BATCH_ACTION = 'wc-admin_delete_orders_batch';
/**
* Action hook for importing a batch of orders.
*/
const SINGLE_ORDER_IMPORT_ACTION = 'wc-admin_import_order';
/**
* Action scheduler group.
@ -79,30 +99,32 @@ class WC_Admin_Reports_Sync {
* Hook in sync methods.
*/
public static function init() {
// Add report regeneration to tools REST API.
add_filter( 'woocommerce_debug_tools', array( __CLASS__, 'add_regenerate_tool' ) );
// Initialize syncing hooks.
add_action( 'wp_loaded', array( __CLASS__, 'orders_lookup_update_init' ) );
// Initialize scheduled action handlers.
add_action( self::QUEUE_BATCH_ACTION, array( __CLASS__, 'queue_batches' ), 10, 3 );
add_action( self::QUEUE_BATCH_ACTION, array( __CLASS__, 'queue_batches' ), 10, 4 );
add_action( self::QUEUE_DEPEDENT_ACTION, array( __CLASS__, 'queue_dependent_action' ), 10, 3 );
add_action( self::CUSTOMERS_BATCH_ACTION, array( __CLASS__, 'customer_lookup_process_batch' ) );
add_action( self::ORDERS_BATCH_ACTION, array( __CLASS__, 'orders_lookup_process_batch' ) );
add_action( self::ORDERS_LOOKUP_BATCH_INIT, array( __CLASS__, 'orders_lookup_batch_init' ) );
add_action( self::SINGLE_ORDER_ACTION, array( __CLASS__, 'orders_lookup_process_order' ) );
add_action( self::CUSTOMERS_IMPORT_BATCH_ACTION, array( __CLASS__, 'customer_lookup_import_batch' ), 10, 3 );
add_action( self::CUSTOMERS_DELETE_BATCH_INIT, array( __CLASS__, 'customer_lookup_delete_batch_init' ) );
add_action( self::CUSTOMERS_DELETE_BATCH_ACTION, array( __CLASS__, 'customer_lookup_delete_batch' ) );
add_action( self::ORDERS_IMPORT_BATCH_ACTION, array( __CLASS__, 'orders_lookup_import_batch' ), 10, 4 );
add_action( self::ORDERS_IMPORT_BATCH_INIT, array( __CLASS__, 'orders_lookup_import_batch_init' ), 10, 3 );
add_action( self::ORDERS_DELETE_BATCH_ACTION, array( __CLASS__, 'orders_lookup_delete_batch' ), 10, 4 );
add_action( self::ORDERS_DELETE_BATCH_INIT, array( __CLASS__, 'orders_lookup_delete_batch_init' ), 10, 3 );
add_action( self::SINGLE_ORDER_IMPORT_ACTION, array( __CLASS__, 'orders_lookup_import_order' ) );
}
/**
* Regenerate data for reports.
*
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip exisiting records.
* @return string
*/
public static function regenerate_report_data() {
// Add registered customers to the lookup table before updating order stats
// so that the orders can be associated with the `customer_id` column.
self::customer_lookup_batch_init();
// Queue orders lookup to occur after customers lookup generation is done.
self::queue_dependent_action( self::ORDERS_LOOKUP_BATCH_INIT, array(), self::CUSTOMERS_BATCH_ACTION );
public static function regenerate_report_data( $days, $skip_existing ) {
self::customer_lookup_import_batch_init( $days, $skip_existing );
self::queue_dependent_action( self::ORDERS_IMPORT_BATCH_INIT, array( $days, $skip_existing ), self::CUSTOMERS_IMPORT_BATCH_ACTION );
return __( 'Report table data is being rebuilt. Please allow some time for data to fully populate.', 'woocommerce-admin' );
}
@ -122,42 +144,36 @@ class WC_Admin_Reports_Sync {
}
/**
* Adds regenerate tool to WC system status tools API.
* Delete all data for reports.
*
* @param array $tools List of tools.
* @return array
* @return string
*/
public static function add_regenerate_tool( $tools ) {
if ( isset( $_GET['page'] ) && 'wc-status' === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification
return $tools;
}
public static function delete_report_data() {
// Cancel all pending import jobs.
self::clear_queued_actions();
return array_merge(
$tools,
array(
'rebuild_stats' => array(
'name' => __( 'Rebuild reports data', 'woocommerce-admin' ),
'button' => __( 'Rebuild reports', 'woocommerce-admin' ),
'desc' => __( 'This tool will rebuild all of the information used by the reports.', 'woocommerce-admin' ),
'callback' => array( __CLASS__, 'regenerate_report_data' ),
),
)
);
// Delete orders in batches.
self::queue()->schedule_single( time() + 5, self::ORDERS_DELETE_BATCH_INIT, array(), self::QUEUE_GROUP );
// Delete customers after order data is deleted.
self::queue_dependent_action( self::CUSTOMERS_DELETE_BATCH_INIT, array(), self::ORDERS_DELETE_BATCH_INIT );
return __( 'Report table data is being deleted.', 'woocommerce-admin' );
}
/**
* Schedule an action to process a single Order.
* Schedule an action to import a single Order.
*
* @param int $order_id Order ID.
* @return void
*/
public static function schedule_single_order_process( $order_id ) {
public static function schedule_single_order_import( $order_id ) {
if ( 'shop_order' !== get_post_type( $order_id ) ) {
return;
}
if ( apply_filters( 'woocommerce_disable_order_scheduling', false ) ) {
self::orders_lookup_process_order( $order_id );
self::orders_lookup_import_order( $order_id );
return;
}
@ -178,10 +194,10 @@ class WC_Admin_Reports_Sync {
// Bail out if there's a pending single order action, or a pending dependent action.
if (
( self::SINGLE_ORDER_ACTION === $existing_job->get_hook() ) ||
( self::SINGLE_ORDER_IMPORT_ACTION === $existing_job->get_hook() ) ||
(
self::QUEUE_DEPEDENT_ACTION === $existing_job->get_hook() &&
in_array( self::SINGLE_ORDER_ACTION, $existing_job->get_args() )
in_array( self::SINGLE_ORDER_IMPORT_ACTION, $existing_job->get_args() )
)
) {
return;
@ -189,7 +205,7 @@ class WC_Admin_Reports_Sync {
}
// We want to ensure that customer lookup updates are scheduled before order updates.
self::queue_dependent_action( self::SINGLE_ORDER_ACTION, array( $order_id ), self::CUSTOMERS_BATCH_ACTION );
self::queue_dependent_action( self::SINGLE_ORDER_IMPORT_ACTION, array( $order_id ), self::CUSTOMERS_IMPORT_BATCH_ACTION );
}
/**
@ -199,8 +215,8 @@ class WC_Admin_Reports_Sync {
// Activate WC_Order extension.
WC_Admin_Order::add_filters();
add_action( 'save_post', array( __CLASS__, 'schedule_single_order_process' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'schedule_single_order_process' ) );
add_action( 'save_post', array( __CLASS__, 'schedule_single_order_import' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'schedule_single_order_import' ) );
WC_Admin_Reports_Orders_Stats_Data_Store::init();
WC_Admin_Reports_Customers_Data_Store::init();
@ -211,59 +227,98 @@ class WC_Admin_Reports_Sync {
/**
* Init order/product lookup tables update (in batches).
*
* @param integer|boolean $days Number of days to import.
* @param boolean $skip_existing Skip exisiting records.
*/
public static function orders_lookup_batch_init() {
$batch_size = self::get_batch_size( self::ORDERS_BATCH_ACTION );
$order_query = new WC_Order_Query(
array(
'return' => 'ids',
'limit' => 1,
'paginate' => true,
)
);
$result = $order_query->get_orders();
public static function orders_lookup_import_batch_init( $days, $skip_existing ) {
$batch_size = self::get_batch_size( self::ORDERS_IMPORT_BATCH_ACTION );
$orders = self::get_orders( 1, 1, $days, $skip_existing );
if ( 0 === $result->total ) {
if ( 0 === $orders->total ) {
return;
}
$num_batches = ceil( $result->total / $batch_size );
$num_batches = ceil( $orders->total / $batch_size );
self::queue_batches( 1, $num_batches, self::ORDERS_BATCH_ACTION );
self::queue_batches( 1, $num_batches, self::ORDERS_IMPORT_BATCH_ACTION, array( $days, $skip_existing ) );
}
/**
* Process a batch of orders to update (stats and products).
* Get the order IDs and total count that need to be synced.
*
* @param int $batch_number Batch number to process (essentially a query page number).
* @param int $limit Number of records to retrieve.
* @param int $page Page number.
* @param int|bool $days Number of days prior to current date to limit search results.
* @param bool $skip_existing Skip already imported orders.
*/
public static function get_orders( $limit = 10, $page = 1, $days = false, $skip_existing = false ) {
global $wpdb;
$where_clause = '';
$offset = $page > 1 ? $page * $limit : 0;
if ( $days ) {
$days_ago = date( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) );
$where_clause .= " AND post_date >= '{$days_ago}'";
}
if ( $skip_existing ) {
$where_clause .= " AND NOT EXISTS (
SELECT 1 FROM {$wpdb->prefix}wc_order_stats
WHERE {$wpdb->prefix}wc_order_stats.order_id = {$wpdb->posts}.ID
)";
}
$count = $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->posts}
WHERE post_type = 'shop_order'
{$where_clause}"
); // WPCS: unprepared SQL ok.
$order_ids = absint( $count ) > 0 ? $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->posts}
WHERE post_type = 'shop_order'
{$where_clause}
ORDER BY post_date ASC
LIMIT %d
OFFSET %d",
$limit,
$offset
)
) : array(); // WPCS: unprepared SQL ok.
return (object) array(
'total' => absint( $count ),
'order_ids' => $order_ids,
);
}
/**
* Imports a batch of orders to update (stats and products).
*
* @param int $batch_number Batch number to import (essentially a query page number).
* @param int|bool $days Number of days to import.
* @param bool $skip_existing Skip exisiting records.
* @return void
*/
public static function orders_lookup_process_batch( $batch_number ) {
$batch_size = self::get_batch_size( self::ORDERS_BATCH_ACTION );
$order_query = new WC_Order_Query(
array(
'return' => 'ids',
'limit' => $batch_size,
'page' => $batch_number,
'orderby' => 'ID',
'order' => 'ASC',
)
);
$order_ids = $order_query->get_orders();
public static function orders_lookup_import_batch( $batch_number, $days, $skip_existing ) {
$batch_size = self::get_batch_size( self::ORDERS_IMPORT_BATCH_ACTION );
$orders = self::get_orders( $batch_size, $batch_number, $days, $skip_existing );
foreach ( $order_ids as $order_id ) {
self::orders_lookup_process_order( $order_id );
foreach ( $orders->order_ids as $order_id ) {
self::orders_lookup_import_order( $order_id );
}
}
/**
* Process a single order to update lookup tables for.
* Imports a single order to update lookup tables for.
* If an error is encountered in one of the updates, a retry action is scheduled.
*
* @param int $order_id Order ID.
* @return void
*/
public static function orders_lookup_process_order( $order_id ) {
public static function orders_lookup_import_order( $order_id ) {
$result = array_sum(
array(
WC_Admin_Reports_Orders_Stats_Data_Store::sync_order( $order_id ),
@ -280,7 +335,7 @@ class WC_Admin_Reports_Sync {
}
// Otherwise assume an error occurred and reschedule.
self::schedule_single_order_process( $order_id );
self::schedule_single_order_import( $order_id );
}
/**
@ -292,9 +347,11 @@ class WC_Admin_Reports_Sync {
*/
public static function get_batch_size( $action ) {
$batch_sizes = array(
self::QUEUE_BATCH_ACTION => 100,
self::CUSTOMERS_BATCH_ACTION => 25,
self::ORDERS_BATCH_ACTION => 10,
self::QUEUE_BATCH_ACTION => 100,
self::CUSTOMERS_IMPORT_BATCH_ACTION => 25,
self::CUSTOMERS_DELETE_BATCH_ACTION => 25,
self::ORDERS_IMPORT_BATCH_ACTION => 10,
self::ORDERS_DELETE_BATCH_ACTION => 10,
);
$batch_size = isset( $batch_sizes[ $action ] ) ? $batch_sizes[ $action ] : 25;
@ -314,9 +371,10 @@ class WC_Admin_Reports_Sync {
* @param int $range_start Starting batch number.
* @param int $range_end Ending batch number.
* @param string $single_batch_action Action to schedule for a single batch.
* @param array $action_args Action arguments.
* @return void
*/
public static function queue_batches( $range_start, $range_end, $single_batch_action ) {
public static function queue_batches( $range_start, $range_end, $single_batch_action, $action_args = array() ) {
$batch_size = self::get_batch_size( self::QUEUE_BATCH_ACTION );
$range_size = 1 + ( $range_end - $range_start );
$action_timestamp = time() + 5;
@ -333,14 +391,15 @@ class WC_Admin_Reports_Sync {
self::queue()->schedule_single(
$action_timestamp,
self::QUEUE_BATCH_ACTION,
array( $batch_start, $batch_end, $single_batch_action ),
array( $batch_start, $batch_end, $single_batch_action, $action_args ),
self::QUEUE_GROUP
);
}
} else {
// Otherwise, queue the single batches.
for ( $i = $range_start; $i <= $range_end; $i++ ) {
self::queue()->schedule_single( $action_timestamp, $single_batch_action, array( $i ), self::QUEUE_GROUP );
$batch_action_args = array_merge( array( $i ), $action_args );
self::queue()->schedule_single( $action_timestamp, $single_batch_action, $batch_action_args, self::QUEUE_GROUP );
}
}
}
@ -393,11 +452,64 @@ class WC_Admin_Reports_Sync {
}
/**
* Init customer lookup table update (in batches).
* Exclude users that already exist in our customer lookup table.
*
* Meant to be hooked into 'pre_user_query' action.
*
* @param WP_User_Query $wp_user_query WP_User_Query to modify.
*/
public static function customer_lookup_batch_init() {
$batch_size = self::get_batch_size( self::CUSTOMERS_BATCH_ACTION );
$customer_query = new WP_User_Query(
public static function exclude_existing_customers_from_query( $wp_user_query ) {
global $wpdb;
$wp_user_query->query_where .= " AND NOT EXISTS (
SELECT ID FROM {$wpdb->prefix}wc_customer_lookup
WHERE {$wpdb->prefix}wc_customer_lookup.user_id = {$wpdb->users}.ID
)";
}
/**
* Retrieve user IDs given import criteria.
*
* @param int|bool $days Number of days to process.
* @param bool $skip_existing Skip exisiting records.
* @param array $query_args Optional. WP_User_Query args.
* @return WP_User_Query
*/
public static function get_user_ids_for_batch( $days, $skip_existing, $query_args = array() ) {
if ( ! is_array( $query_args ) ) {
$query_args = array();
}
if ( $days ) {
$query_args['date_query'] = array(
'after' => date( 'Y-m-d 00:00:00', time() - ( DAY_IN_SECONDS * $days ) ),
);
}
if ( $skip_existing ) {
add_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
}
$customer_query = new WP_User_Query( $query_args );
remove_action( 'pre_user_query', array( __CLASS__, 'exclude_existing_customers_from_query' ) );
return $customer_query;
}
/**
* Init customer lookup table update (in batches).
*
* @param int|bool $days Number of days to process.
* @param bool $skip_existing Skip exisiting records.
*/
public static function customer_lookup_import_batch_init( $days, $skip_existing ) {
$batch_size = self::get_batch_size( self::CUSTOMERS_IMPORT_BATCH_ACTION );
$customer_query = self::get_user_ids_for_batch(
$days,
$skip_existing,
array(
'fields' => 'ID',
'number' => 1,
@ -411,18 +523,22 @@ class WC_Admin_Reports_Sync {
$num_batches = ceil( $total_customers / $batch_size );
self::queue_batches( 1, $num_batches, self::CUSTOMERS_BATCH_ACTION );
self::queue_batches( 1, $num_batches, self::CUSTOMERS_IMPORT_BATCH_ACTION, array( $days, $skip_existing ) );
}
/**
* Process a batch of customers to update.
*
* @param int $batch_number Batch number to process (essentially a query page number).
* @param int $batch_number Batch number to process (essentially a query page number).
* @param int|bool $days Number of days to process.
* @param bool $skip_existing Skip exisiting records.
* @return void
*/
public static function customer_lookup_process_batch( $batch_number ) {
$batch_size = self::get_batch_size( self::CUSTOMERS_BATCH_ACTION );
$customer_query = new WP_User_Query(
public static function customer_lookup_import_batch( $batch_number, $days, $skip_existing ) {
$batch_size = self::get_batch_size( self::CUSTOMERS_IMPORT_BATCH_ACTION );
$customer_query = self::get_user_ids_for_batch(
$days,
$skip_existing,
array(
'fields' => 'ID',
'orderby' => 'ID',
@ -439,6 +555,79 @@ class WC_Admin_Reports_Sync {
WC_Admin_Reports_Customers_Data_Store::update_registered_customer( $customer_id );
}
}
/**
* Delete customer lookup table rows (in batches).
*/
public static function customer_lookup_delete_batch_init() {
global $wpdb;
$batch_size = self::get_batch_size( self::CUSTOMERS_DELETE_BATCH_ACTION );
$count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_customer_lookup" );
if ( 0 === $count ) {
return;
}
$num_batches = ceil( $count / $batch_size );
self::queue_batches( 1, $num_batches, self::CUSTOMERS_DELETE_BATCH_ACTION );
}
/**
* Delete a batch of customers.
*/
public static function customer_lookup_delete_batch() {
global $wpdb;
$batch_size = self::get_batch_size( self::CUSTOMERS_DELETE_BATCH_ACTION );
$customer_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT customer_id FROM {$wpdb->prefix}wc_customer_lookup ORDER BY customer_id ASC LIMIT %d",
$batch_size
)
);
foreach ( $customer_ids as $customer_id ) {
WC_Admin_Reports_Customers_Data_Store::delete_customer( $customer_id );
}
}
/**
* Delete orders lookup table rows (in batches).
*/
public static function orders_lookup_delete_batch_init() {
global $wpdb;
$batch_size = self::get_batch_size( self::ORDERS_DELETE_BATCH_ACTION );
$count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_stats" );
if ( 0 === $count ) {
return;
}
$num_batches = ceil( $count / $batch_size );
self::queue_batches( 1, $num_batches, self::ORDERS_DELETE_BATCH_ACTION );
}
/**
* Delete a batch of orders.
*
* @return void
*/
public static function orders_lookup_delete_batch() {
global $wpdb;
$batch_size = self::get_batch_size( self::ORDERS_DELETE_BATCH_ACTION );
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT order_id FROM {$wpdb->prefix}wc_order_stats ORDER BY order_id ASC LIMIT %d",
$batch_size
)
);
foreach ( $order_ids as $order_id ) {
WC_Admin_Reports_Orders_Stats_Data_Store::delete_order( $order_id );
}
}
}
WC_Admin_Reports_Sync::init();

View File

@ -520,8 +520,8 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* @return array
*/
public static function get_customer_name( $user_id = 0, $order = null ) {
$first_name = null;
$last_name = null;
$first_name = '';
$last_name = '';
if (
$user_id &&
@ -683,7 +683,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
protected static function is_valid_customer( $user_id ) {
$customer = new WC_Customer( $user_id );
if ( $customer->get_id() !== $user_id ) {
if ( $customer->get_id() != $user_id ) {
return false;
}
@ -694,6 +694,31 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
return true;
}
/**
* Delete a customer lookup row.
*
* @param int $customer_id Customer ID.
*/
public static function delete_customer( $customer_id ) {
global $wpdb;
$customer_id = (int) $customer_id;
$table_name = $wpdb->prefix . self::TABLE_NAME;
$wpdb->query(
$wpdb->prepare(
"DELETE FROM ${table_name} WHERE customer_id = %d",
$customer_id
)
);
/**
* Fires when a customer is deleted.
*
* @param int $order_id Order ID.
*/
do_action( 'woocommerce_reports_delete_customer', $customer_id );
}
/**
* Returns string to be used as cache key for the data.
*

View File

@ -36,7 +36,7 @@ class WC_Admin_Dashboard {
}
/**
* Preload data from the performance indiciators endpoint.
* Preload data from the performance indicators endpoint.
*
* @param array $endpoints Array of preloaded endpoints.
* @return array
@ -48,7 +48,7 @@ class WC_Admin_Dashboard {
}
/**
* Adds fields so that we can store performance indiciators, row settings, and chart type settings for users.
* Adds fields so that we can store performance indicators, row settings, and chart type settings for users.
*
* @param array $user_data_fields User data fields.
* @return array
@ -57,11 +57,9 @@ class WC_Admin_Dashboard {
return array_merge(
$user_data_fields,
array(
'dashboard_performance_indicators',
'dashboard_charts',
'dashboard_sections',
'dashboard_chart_type',
'dashboard_chart_interval',
'dashboard_leaderboards',
'dashboard_leaderboard_rows',
)
);

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,7 @@
}
},
"dependencies": {
"@fresh-data/framework": "0.6.0",
"@fresh-data/framework": "0.6.1",
"@wordpress/api-fetch": "2.2.6",
"@wordpress/components": "7.0.5",
"@wordpress/data": "4.2.0",
@ -107,11 +107,11 @@
"redux": "4.0.1"
},
"devDependencies": {
"@babel/cli": "7.4.3",
"@babel/core": "7.4.3",
"@babel/plugin-transform-async-to-generator": "7.4.0",
"@babel/cli": "7.4.4",
"@babel/core": "7.4.4",
"@babel/plugin-transform-async-to-generator": "7.4.4",
"@babel/plugin-transform-react-jsx": "7.3.0",
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"@wordpress/babel-plugin-import-jsx-pragma": "1.1.2",
"@wordpress/babel-plugin-makepot": "2.1.2",
"@wordpress/babel-preset-default": "3.0.1",
@ -119,7 +119,7 @@
"@wordpress/custom-templated-path-webpack-plugin": "1.1.5",
"@wordpress/jest-preset-default": "4.0.0",
"@wordpress/postcss-themes": "1.0.4",
"ast-types": "0.12.3",
"ast-types": "0.12.4",
"autoprefixer": "9.5.1",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "10.0.1",
@ -137,7 +137,7 @@
"eslint": "5.16.0",
"eslint-config-wpcalypso": "4.0.1",
"eslint-loader": "2.1.2",
"eslint-plugin-jest": "22.4.1",
"eslint-plugin-jest": "22.5.1",
"eslint-plugin-jsx-a11y": "6.2.1",
"eslint-plugin-react": "7.12.4",
"eslint-plugin-wpcalypso": "4.0.2",
@ -145,10 +145,10 @@
"grunt-checktextdomain": "1.0.1",
"grunt-wp-i18n": "1.0.3",
"husky": "1.3.1",
"lerna": "3.13.3",
"lerna": "3.13.4",
"locutus": "2.0.10",
"mini-css-extract-plugin": "0.6.0",
"node-sass": "4.11.0",
"node-sass": "4.12.0",
"node-watch": "0.6.2",
"postcss-color-function": "4.1.0",
"postcss-loader": "3.0.0",

View File

@ -1,7 +1,10 @@
# unreleased
# 3.0.0 (unreleased)
- <DateInput> and <DatePicker> got a `disabled` prop.
- TableCard component: new `onPageChange` prop.
- TableCard now has a `defaultOrder` parameter to specify default sort column sort order.
- Pagination no longer considers `0` a valid input and triggers `onPageChange` on the input blur event.
- Tweaks to SummaryListPlaceholder height in order to better match SummaryNumber.
- EllipsisMenu component (breaking change): Remove `children` prop in favor of a render prop `renderContent` so that function arguments `isOpen`, `onToggle`, and `onClose` can be passed down.
# 2.0.0
- Chart legend component now uses withInstanceId HOC so the ids used in several HTML elements are unique.

View File

@ -10,4 +10,4 @@ Install the module
npm install @woocommerce/components --save
```
View [the full Component documentation](https://woocommerce.github.io/wc-admin/#/components/) for usage information.
View [the full Component documentation](https://woocommerce.github.io/woocommerce-admin/#/components/) for usage information.

View File

@ -21,7 +21,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"@woocommerce/csv-export": "1.1.0",
"@woocommerce/currency": "1.1.1",
"@woocommerce/date": "1.0.7",

View File

@ -11,11 +11,15 @@ import { partial } from 'lodash';
import { TAB } from '@wordpress/keycodes';
import moment from 'moment';
/**
* WooCommerce dependencies
*/
import { dateValidationMessages, toMoment } from '@woocommerce/date';
/**
* Internal dependencies
*/
import DateInput from './input';
import { toMoment } from '@woocommerce/date';
import { H, Section } from '../section';
import PropTypes from 'prop-types';
@ -54,7 +58,7 @@ class DatePicker extends Component {
const value = event.target.value;
const { dateFormat } = this.props;
const date = toMoment( dateFormat, value );
const error = date ? null : __( 'Invalid date', 'woocommerce-admin' );
const error = date ? null : dateValidationMessages.invalid;
this.props.onUpdate( {
date,
@ -64,7 +68,7 @@ class DatePicker extends Component {
}
render() {
const { date, text, dateFormat, error, isInvalidDate } = this.props;
const { date, disabled, text, dateFormat, error, isInvalidDate } = this.props;
return (
<Dropdown
@ -72,6 +76,7 @@ class DatePicker extends Component {
focusOnMount={ false }
renderToggle={ ( { isOpen, onToggle } ) => (
<DateInput
disabled={ disabled }
value={ text }
onChange={ this.onInputChange }
dateFormat={ dateFormat }
@ -112,6 +117,10 @@ DatePicker.propTypes = {
* A moment date object representing the selected date. `null` for no selection.
*/
date: PropTypes.object,
/**
* Whether the input is disabled.
*/
disabled: PropTypes.bool,
/**
* The date in human-readable format. Displayed in the text input.
*/

View File

@ -8,6 +8,7 @@ import { uniqueId, noop } from 'lodash';
import PropTypes from 'prop-types';
const DateInput = ( {
disabled,
value,
onChange,
dateFormat,
@ -36,6 +37,7 @@ const DateInput = ( {
placeholder={ dateFormat.toLowerCase() }
onFocus={ onFocus }
onKeyDown={ onKeyDown }
disabled={ disabled }
/>
{ error && (
<Popover
@ -55,6 +57,7 @@ const DateInput = ( {
};
DateInput.propTypes = {
disabled: PropTypes.bool,
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
dateFormat: PropTypes.string.isRequired,
@ -67,6 +70,7 @@ DateInput.propTypes = {
};
DateInput.defaultProps = {
disabled: false,
onFocus: () => {},
errorPosition: 'bottom center',
onKeyDown: noop,

View File

@ -97,6 +97,7 @@
.woocommerce-chart__types {
padding: 0 8px;
white-space: nowrap;
}
.woocommerce-chart__type-button {

View File

@ -1,26 +1,40 @@
```jsx
import { EllipsisMenu, MenuItem, MenuTitle } from '@woocommerce/components';
import { EllipsisMenu, MenuItem, MenuTitle, Button } from '@woocommerce/components';
const MyEllipsisMenu = withState( {
showCustomers: true,
showOrders: true,
} )( ( { setState, showCustomers, showOrders } ) => (
<EllipsisMenu label="Choose which analytics to display">
<MenuTitle>Display Stats</MenuTitle>
<MenuItem onInvoke={ () => setState( { showCustomers: ! showCustomers } ) }>
<ToggleControl
label="Show Customers"
checked={ showCustomers }
onChange={ () => setState( { showCustomers: ! showCustomers } ) }
/>
</MenuItem>
<MenuItem onInvoke={ () => setState( { showOrders: ! showOrders } ) }>
<ToggleControl
label="Show Orders"
checked={ showOrders }
onChange={ () => setState( { showOrders: ! showOrders } ) }
/>
</MenuItem>
</EllipsisMenu>
<EllipsisMenu label="Choose which analytics to display"
renderContent={ ( { onToggle } )=> {
return (
<div>
<MenuTitle>Display Stats</MenuTitle>
<MenuItem onInvoke={ () => setState( { showCustomers: ! showCustomers } ) }>
<ToggleControl
label="Show Customers"
checked={ showCustomers }
onChange={ () => setState( { showCustomers: ! showCustomers } ) }
/>
</MenuItem>
<MenuItem onInvoke={ () => setState( { showOrders: ! showOrders } ) }>
<ToggleControl
label="Show Orders"
checked={ showOrders }
onChange={ () => setState( { showOrders: ! showOrders } ) }
/>
</MenuItem>
<MenuItem onInvoke={ onToggle }>
<Button
label="Close menu"
onClick={ onToggle }
>
Close Menu
</Button>
</MenuItem>
</div>
);
} }
/>
) );
```

View File

@ -12,12 +12,12 @@ import PropTypes from 'prop-types';
*/
class EllipsisMenu extends Component {
render() {
const { children, label } = this.props;
if ( ! children ) {
const { label, renderContent } = this.props;
if ( ! renderContent ) {
return null;
}
const renderToggle = ( { onToggle, isOpen } ) => {
const renderEllipsis = ( { onToggle, isOpen } ) => {
const toggleClassname = classnames( 'woocommerce-ellipsis-menu__toggle', {
'is-opened': isOpen,
} );
@ -33,8 +33,10 @@ class EllipsisMenu extends Component {
);
};
const renderContent = () => (
<NavigableMenu className="woocommerce-ellipsis-menu__content">{ children }</NavigableMenu>
const renderMenu = renderContentArgs => (
<NavigableMenu className="woocommerce-ellipsis-menu__content">
{ renderContent( renderContentArgs ) }
</NavigableMenu>
);
return (
@ -42,8 +44,8 @@ class EllipsisMenu extends Component {
<Dropdown
contentClassName="woocommerce-ellipsis-menu__popover"
position="bottom left"
renderToggle={ renderToggle }
renderContent={ renderContent }
renderToggle={ renderEllipsis }
renderContent={ renderMenu }
/>
</div>
);
@ -56,9 +58,9 @@ EllipsisMenu.propTypes = {
*/
label: PropTypes.string.isRequired,
/**
* A list of `MenuTitle`/`MenuItem` components
* A function returning `MenuTitle`/`MenuItem` components as a render prop. Arguments from Dropdown passed as function arguments.
*/
children: PropTypes.node,
renderContent: PropTypes.func,
};
export default EllipsisMenu;

View File

@ -47,6 +47,7 @@
}
}
.components-base-control__label,
.woocommerce-ellipsis-menu__title {
color: #6c7781;
padding-bottom: 8px;

View File

@ -67,10 +67,10 @@ class Pagination extends Component {
}
onInputBlur( event ) {
const { onPageChange } = this.props;
const { onPageChange, page } = this.props;
const newPage = parseInt( event.target.value, 10 );
if ( isFinite( newPage ) && newPage > 0 && this.pageCount && this.pageCount >= newPage ) {
if ( newPage !== page && isFinite( newPage ) && newPage > 0 && this.pageCount && this.pageCount >= newPage ) {
onPageChange( newPage );
}
}

View File

@ -3,7 +3,6 @@
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { isNaN } from 'lodash';
/**
* WooCommerce dependencies
@ -24,18 +23,11 @@ import { computeSuggestionMatch } from './utils';
export default {
name: 'orders',
className: 'woocommerce-search__order-result',
inputType: 'number',
options( search ) {
let payload = '';
if ( search ) {
const number = parseInt( search );
if ( isNaN( number ) ) {
return;
}
const query = {
number,
number: search,
per_page: 10,
};
payload = stringifyQuery( query );

View File

@ -53,6 +53,7 @@ class TableCard extends Component {
this.onColumnToggle = this.onColumnToggle.bind( this );
this.onClickDownload = this.onClickDownload.bind( this );
this.onCompare = this.onCompare.bind( this );
this.onPageChange = this.onPageChange.bind( this );
this.onSearch = this.onSearch.bind( this );
this.selectRow = this.selectRow.bind( this );
this.selectAllRows = this.selectAllRows.bind( this );
@ -168,6 +169,16 @@ class TableCard extends Component {
}
}
onPageChange( ...params ) {
const { onPageChange, onQueryChange } = this.props;
if ( onPageChange ) {
onPageChange( ...params );
}
if ( onQueryChange ) {
onQueryChange( 'page' )( ...params );
}
}
onSearch( values ) {
const { compareParam, searchBy, baseSearchQuery } = this.props;
// A comma is used as a separator between search terms, so we want to escape
@ -329,25 +340,29 @@ class TableCard extends Component {
),
] }
menu={
showMenu && <EllipsisMenu label={ __( 'Choose which values to display', 'woocommerce-admin' ) }>
<MenuTitle>{ __( 'Columns:', 'woocommerce-admin' ) }</MenuTitle>
{ allHeaders.map( ( { key, label, required } ) => {
if ( required ) {
return null;
}
return (
<MenuItem
checked={ showCols.includes( key ) }
isCheckbox
isClickable
key={ key }
onInvoke={ this.onColumnToggle( key ) }
>
{ label }
</MenuItem>
);
} ) }
</EllipsisMenu>
showMenu && <EllipsisMenu label={ __( 'Choose which values to display', 'woocommerce-admin' ) }
renderContent={ () => (
<Fragment>
<MenuTitle>{ __( 'Columns:', 'woocommerce-admin' ) }</MenuTitle>
{ allHeaders.map( ( { key, label, required } ) => {
if ( required ) {
return null;
}
return (
<MenuItem
checked={ showCols.includes( key ) }
isCheckbox
isClickable
key={ key }
onInvoke={ this.onColumnToggle( key ) }
>
{ label }
</MenuItem>
);
} ) }
</Fragment>
) }
/>
}
>
{ isLoading ? (
@ -380,7 +395,7 @@ class TableCard extends Component {
page={ parseInt( query.page ) || 1 }
perPage={ rowsPerPage }
total={ totalRows }
onPageChange={ onQueryChange( 'page' ) }
onPageChange={ this.onPageChange }
onPerPageChange={ onQueryChange( 'per_page' ) }
/>

View File

@ -21,7 +21,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"browser-filesaver": "1.1.1",
"moment": "2.22.2"
},

View File

@ -21,7 +21,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"@woocommerce/number": "1.0.2",
"lodash": "4.17.11"
},

View File

@ -21,7 +21,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"@wordpress/date": "3.0.1",
"@wordpress/i18n": "3.1.0",
"lodash": "4.17.11",

View File

@ -21,7 +21,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"history": "4.9.0",
"lodash": "4.17.11",
"qs": "6.7.0"

View File

@ -20,7 +20,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.4.3",
"@babel/runtime-corejs2": "7.4.4",
"locutus": "2.0.10",
"lodash": "4.17.11"
},

View File

@ -72,13 +72,13 @@ class WC_Tests_API_Init extends WC_REST_Unit_Test_Case {
add_filter( 'query', array( $this, 'filter_order_query' ) );
// Initiate sync.
WC_Admin_Reports_Sync::orders_lookup_process_order( $order->get_id() );
WC_Admin_Reports_Sync::orders_lookup_import_order( $order->get_id() );
// Verify that a retry job was scheduled.
$this->assertCount( 1, $this->queue->actions );
$this->assertArraySubset(
array(
'hook' => WC_Admin_Reports_Sync::SINGLE_ORDER_ACTION,
'hook' => WC_Admin_Reports_Sync::SINGLE_ORDER_IMPORT_ACTION,
'args' => array( $order->get_id() ),
),
$this->queue->actions[0]

View File

@ -46,7 +46,7 @@ class WC_Tests_API_Orders extends WC_REST_Unit_Test_Case {
$request = new WP_REST_Request( 'GET', $this->endpoint );
$request->set_query_params(
array(
'number' => $order->get_id(),
'number' => (string) $order->get_id(),
)
);

View File

@ -0,0 +1,288 @@
<?php
/**
* Reports Import REST API Test
*
* @package WooCommerce\Tests\API
*/
/**
* Reports Import REST API Test Class
*
* @package WooCommerce\Tests\API
*/
class WC_Tests_API_Reports_Import extends WC_REST_Unit_Test_Case {
/**
* Endpoint.
*
* @var string
*/
protected $endpoint = '/wc/v4/reports/import';
/**
* Setup test reports products data.
*/
public function setUp() {
parent::setUp();
$this->user = $this->factory->user->create(
array(
'role' => 'administrator',
)
);
$this->customer = $this->factory->user->create(
array(
'first_name' => 'Steve',
'last_name' => 'User',
'role' => 'customer',
)
);
}
/**
* Test route registration.
*/
public function test_register_routes() {
$routes = $this->server->get_routes();
$this->assertArrayHasKey( $this->endpoint, $routes );
}
/**
* Asserts the report item schema is correct.
*
* @param array $schema Item to check schema.
*/
public function assert_report_item_schema( $schema ) {
$this->assertArrayHasKey( 'status', $schema );
$this->assertArrayHasKey( 'message', $schema );
}
/**
* Test reports schema.
*/
public function test_reports_schema() {
wp_set_current_user( $this->user );
$request = new WP_REST_Request( 'OPTIONS', $this->endpoint );
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$properties = $data['schema']['properties'];
$this->assertCount( 2, $properties );
$this->assert_report_item_schema( $properties );
}
/**
* Test getting reports without valid permissions.
*/
public function test_get_reports_without_permission() {
wp_set_current_user( 0 );
$response = $this->server->dispatch( new WP_REST_Request( 'POST', $this->endpoint ) );
$this->assertEquals( 401, $response->get_status() );
}
/**
* Test the import paramaters.
*/
public function test_import_params() {
global $wpdb;
wp_set_current_user( $this->user );
// Populate all of the data.
$product = new WC_Product_Simple();
$product->set_name( 'Test Product' );
$product->set_regular_price( 25 );
$product->save();
$order_1 = WC_Helper_Order::create_order( 1, $product );
$order_1->set_status( 'completed' );
$order_1->set_date_created( time() - ( 3 * DAY_IN_SECONDS ) );
$order_1->save();
$order_2 = WC_Helper_Order::create_order( 1, $product );
$order_2->set_total( 100 );
$order_2->set_status( 'completed' );
$order_2->save();
// Delete order stats so we can test import API.
$wpdb->query( "DELETE FROM {$wpdb->posts} WHERE post_type = 'scheduled-action'" );
$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_order_stats" );
// Use the days param to only process orders in the last day.
$request = new WP_REST_Request( 'POST', $this->endpoint );
$request->set_query_params( array( 'days' => '1' ) );
$response = $this->server->dispatch( $request );
$report = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'success', $report['status'] );
// Run pending thrice to process batch and order.
WC_Helper_Queue::run_all_pending();
WC_Helper_Queue::run_all_pending();
WC_Helper_Queue::run_all_pending();
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/customers' );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 2, $reports );
$this->assertEquals( $this->customer, $reports[0]['user_id'] );
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/orders' );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 1, $reports );
$this->assertEquals( $order_2->get_id(), $reports[0]['order_id'] );
// Use the skip existing params to skip processing customers/orders.
// Compare against order status to make sure previously imported order was skipped.
$order_2->set_status( 'processing' );
$order_2->save();
// Compare against name to make sure previously imported customer was skipped.
wp_update_user(
array(
'ID' => $this->customer,
'first_name' => 'Changed',
)
);
// Delete scheduled actions to avoid default order processing.
$wpdb->query( "DELETE FROM {$wpdb->posts} WHERE post_type = 'scheduled-action'" );
$request = new WP_REST_Request( 'POST', $this->endpoint );
$request->set_query_params( array( 'skip_existing' => '1' ) );
$response = $this->server->dispatch( $request );
$report = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'success', $report['status'] );
// Run pending thrice to process batch and order.
WC_Helper_Queue::run_all_pending();
WC_Helper_Queue::run_all_pending();
WC_Helper_Queue::run_all_pending();
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/customers' );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 2, $reports );
$this->assertEquals( 'Steve User', $reports[0]['name'] );
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/orders' );
$request->set_query_params( array( 'per_page' => 5 ) );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 2, $reports );
$this->assertEquals( 'completed', $reports[0]['status'] );
}
/**
* Test cancelling import actions.
*/
public function test_cancel_import() {
wp_set_current_user( $this->user );
// Populate all of the data.
$product = new WC_Product_Simple();
$product->set_name( 'Test Product' );
$product->set_regular_price( 25 );
$product->save();
$order = WC_Helper_Order::create_order( 1, $product );
$order->set_status( 'completed' );
$order->set_date_created( time() - ( 3 * DAY_IN_SECONDS ) );
$order->save();
// Verify there are actions to cancel.
$pending_actions = WC_Helper_Queue::get_all_pending();
$this->assertCount( 1, $pending_actions );
// Cancel outstanding actions.
$request = new WP_REST_Request( 'POST', $this->endpoint . '/cancel' );
$response = $this->server->dispatch( $request );
$report = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'success', $report['status'] );
// Verify there are no pending actions.
$pending_actions = WC_Helper_Queue::get_all_pending();
$this->assertCount( 0, $pending_actions );
}
/**
* Test import deletion.
*/
public function test_delete_stats() {
global $wpdb;
wp_set_current_user( $this->user );
// Populate all of the data.
$product = new WC_Product_Simple();
$product->set_name( 'Test Product' );
$product->set_regular_price( 25 );
$product->save();
for ( $i = 0; $i < 25; $i++ ) {
$order = WC_Helper_Order::create_order( 1, $product );
$order->set_status( 'completed' );
$order->save();
}
// Check that stats exist before deleting.
WC_Helper_Queue::run_all_pending();
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/orders' );
$request->set_query_params( array( 'per_page' => 25 ) );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 25, $reports );
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/customers' );
$request->set_query_params( array( 'per_page' => 25 ) );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 1, $reports );
// Delete all stats.
$request = new WP_REST_Request( 'POST', $this->endpoint . '/delete' );
$response = $this->server->dispatch( $request );
$report = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( 'success', $report['status'] );
// Run pending three times to process batches and dependent actions.
WC_Helper_Queue::run_all_pending();
WC_Helper_Queue::run_all_pending();
WC_Helper_Queue::run_all_pending();
// Check that stats have been deleted.
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/orders' );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 0, $reports );
$request = new WP_REST_Request( 'GET', '/wc/v4/reports/customers' );
$response = $this->server->dispatch( $request );
$reports = $response->get_data();
$this->assertEquals( 200, $response->get_status() );
$this->assertCount( 0, $reports );
}
}

View File

@ -45,7 +45,7 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case {
switch ( $action ) {
case WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION:
return $this->queue_batch_size;
case WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION:
case WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION:
return $this->customers_batch_size;
case WC_Admin_Reports_Sync::ORDERS_BATCH_ACTION:
return $this->orders_batch_size;
@ -81,20 +81,20 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case {
$num_customers = 1234; // 1234 / 5 = 247 batches
$num_batches = ceil( $num_customers / $this->customers_batch_size );
WC_Admin_Reports_Sync::queue_batches( 1, $num_batches, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION );
WC_Admin_Reports_Sync::queue_batches( 1, $num_batches, WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION );
$this->assertCount( $this->queue_batch_size, $this->queue->actions );
$this->assertArraySubset(
array(
'hook' => WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION,
'args' => array( 1, 25, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION ),
'args' => array( 1, 25, WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION ),
),
$this->queue->actions[0]
);
$this->assertArraySubset(
array(
'hook' => WC_Admin_Reports_Sync::QUEUE_BATCH_ACTION,
'args' => array( 226, 247, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION ),
'args' => array( 226, 247, WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION ),
),
$this->queue->actions[ $this->queue_batch_size - 1 ]
);
@ -107,19 +107,19 @@ class WC_Tests_Reports_Regenerate_Batching extends WC_REST_Unit_Test_Case {
$num_customers = 45; // 45 / 5 = 9 batches (which is less than the batch queue size)
$num_batches = ceil( $num_customers / $this->customers_batch_size );
WC_Admin_Reports_Sync::queue_batches( 1, $num_batches, WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION );
WC_Admin_Reports_Sync::queue_batches( 1, $num_batches, WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION );
$this->assertCount( 9, $this->queue->actions );
$this->assertArraySubset(
array(
'hook' => WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION,
'hook' => WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION,
'args' => array( 1 ),
),
$this->queue->actions[0]
);
$this->assertArraySubset(
array(
'hook' => WC_Admin_Reports_Sync::CUSTOMERS_BATCH_ACTION,
'hook' => WC_Admin_Reports_Sync::CUSTOMERS_IMPORT_BATCH_ACTION,
'args' => array( 9 ),
),
$this->queue->actions[8]

View File

@ -12,11 +12,11 @@
*/
class WC_Helper_Queue {
/**
* Run all pending queued actions.
* Get all pending queued actions.
*
* @return void
* @return array Pending jobs.
*/
public static function run_all_pending() {
public static function get_all_pending() {
$jobs = WC()->queue()->search(
array(
'per_page' => -1,
@ -25,6 +25,17 @@ class WC_Helper_Queue {
)
);
return $jobs;
}
/**
* Run all pending queued actions.
*
* @return void
*/
public static function run_all_pending() {
$jobs = self::get_all_pending();
foreach ( $jobs as $job ) {
$job->execute();
}

View File

@ -37,13 +37,13 @@ class WC_Tests_Reports_Queue_Prioritization extends WC_REST_Unit_Test_Case {
* Test that we're setting a priority on our actions.
*/
public function test_queue_action_sets_priority() {
WC_Admin_Reports_Sync::queue()->schedule_single( time(), WC_Admin_Reports_Sync::SINGLE_ORDER_ACTION );
WC_Admin_Reports_Sync::queue()->schedule_single( time(), WC_Admin_Reports_Sync::SINGLE_ORDER_IMPORT_ACTION );
$actions = WC_Admin_Reports_Sync::queue()->search(
array(
'status' => 'pending',
'claimed' => false,
'hook' => WC_Admin_Reports_Sync::SINGLE_ORDER_ACTION,
'hook' => WC_Admin_Reports_Sync::SINGLE_ORDER_IMPORT_ACTION,
)
);
@ -55,7 +55,7 @@ class WC_Tests_Reports_Queue_Prioritization extends WC_REST_Unit_Test_Case {
$this->assertEquals( WC_Admin_ActionScheduler_wpPostStore::JOB_PRIORITY, $action->menu_order );
WC_Admin_Reports_Sync::queue()->cancel_all( WC_Admin_Reports_Sync::SINGLE_ORDER_ACTION );
WC_Admin_Reports_Sync::queue()->cancel_all( WC_Admin_Reports_Sync::SINGLE_ORDER_IMPORT_ACTION );
}
}