Merge branch 'master' into fix/1307

This commit is contained in:
Claudio Sanches 2019-02-01 18:24:26 -02:00 committed by GitHub
commit 31e8dc9332
114 changed files with 6353 additions and 2287 deletions

View File

@ -0,0 +1,398 @@
### WooCommerce - eCommerce for WordPress
Copyright 2015 by the contributors
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
This program incorporates work covered by the following copyright and
permission notices:
Jigoshop is Copyright (c) 2011 Jigowatt Ltd.
http://jigowatt.com - http://jigoshop.com
Jigoshop is released under the GPL
and
WooCommerce - eCommerce for WordPress
Copyright 2015 by the contributors
WooCommerce is released under the GPL
---
### GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
### Preamble
The licenses for most software are designed to take away your freedom
to share and change it. By contrast, the GNU General Public License is
intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if
you distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on,
we want its recipients to know that what they have is not the
original, so that any problems introduced by others will not reflect
on the original authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at
all.
The precise terms and conditions for copying, distribution and
modification follow.
### TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
**0.** This License applies to any program or other work which
contains a notice placed by the copyright holder saying it may be
distributed under the terms of this General Public License. The
"Program", below, refers to any such program or work, and a "work
based on the Program" means either the Program or any derivative work
under copyright law: that is to say, a work containing the Program or
a portion of it, either verbatim or with modifications and/or
translated into another language. (Hereinafter, translation is
included without limitation in the term "modification".) Each licensee
is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the Program
(independent of having been made by running the Program). Whether that
is true depends on what the Program does.
**1.** You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a
fee.
**2.** You may modify your copy or copies of the Program or any
portion of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
**a)** You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
**b)** You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any part
thereof, to be licensed as a whole at no charge to all third parties
under the terms of this License.
**c)** If the modified program normally reads commands interactively
when run, you must cause it, when started running for such interactive
use in the most ordinary way, to print or display an announcement
including an appropriate copyright notice and a notice that there is
no warranty (or else, saying that you provide a warranty) and that
users may redistribute the program under these conditions, and telling
the user how to view a copy of this License. (Exception: if the
Program itself is interactive but does not normally print such an
announcement, your work based on the Program is not required to print
an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
**3.** You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
**a)** Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections 1
and 2 above on a medium customarily used for software interchange; or,
**b)** Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your cost of
physically performing source distribution, a complete machine-readable
copy of the corresponding source code, to be distributed under the
terms of Sections 1 and 2 above on a medium customarily used for
software interchange; or,
**c)** Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is allowed
only for noncommercial distribution and only if you received the
program in object code or executable form with such an offer, in
accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
**4.** You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt otherwise
to copy, modify, sublicense or distribute the Program is void, and
will automatically terminate your rights under this License. However,
parties who have received copies, or rights, from you under this
License will not have their licenses terminated so long as such
parties remain in full compliance.
**5.** You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
**6.** Each time you redistribute the Program (or any work based on
the Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
**7.** If, as a consequence of a court judgment or allegation of
patent infringement or for any other reason (not limited to patent
issues), conditions are imposed on you (whether by court order,
agreement or otherwise) that contradict the conditions of this
License, they do not excuse you from the conditions of this License.
If you cannot distribute so as to satisfy simultaneously your
obligations under this License and any other pertinent obligations,
then as a consequence you may not distribute the Program at all. For
example, if a patent license would not permit royalty-free
redistribution of the Program by all those who receive copies directly
or indirectly through you, then the only way you could satisfy both it
and this License would be to refrain entirely from distribution of the
Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
**8.** If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
**9.** The Free Software Foundation may publish revised and/or new
versions of the General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Program does not specify a
version number of this License, you may choose any version ever
published by the Free Software Foundation.
**10.** If you wish to incorporate parts of the Program into other
free programs whose distribution conditions are different, write to
the author to ask for permission. For software which is copyrighted by
the Free Software Foundation, write to the Free Software Foundation;
we sometimes make exceptions for this. Our decision will be guided by
the two goals of preserving the free status of all derivatives of our
free software and of promoting the sharing and reuse of software
generally.
**NO WARRANTY**
**11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
**12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
### END OF TERMS AND CONDITIONS
### How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest to
attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
one line to give the program's name and an idea of what it does.
Copyright (C) yyyy name of author
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
Also add information on how to contact you by electronic and paper
mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details
type `show w'. This is free software, and you are welcome
to redistribute it under certain conditions; type `show c'
for details.
The hypothetical commands \`show w' and \`show c' should show the
appropriate parts of the General Public License. Of course, the
commands you use may be called something other than \`show w' and
\`show c'; they could even be mouse-clicks or menu items--whatever
suits your program.
You should also get your employer (if you work as a programmer) or
your school, if any, to sign a "copyright disclaimer" for the program,
if necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright
interest in the program `Gnomovision'
(which makes passes at compilers) written
by James Hacker.
signature of Ty Coon, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library,
you may consider it more useful to permit linking proprietary
applications with the library. If this is what you want to do, use the
[GNU Lesser General Public
License](http://www.gnu.org/licenses/lgpl.html) instead of this
License.

View File

@ -17,7 +17,7 @@ import { Card, EmptyTable, TableCard } from '@woocommerce/components';
* Internal dependencies
*/
import ReportError from 'analytics/components/report-error';
import { getReportTableData } from 'store/reports/utils';
import { getReportTableData } from 'wc-api/reports/utils';
import withSelect from 'wc-api/with-select';
import './style.scss';

View File

@ -18,7 +18,7 @@ import { numberFormat } from '@woocommerce/number';
* Internal dependencies
*/
import LeaderboardWithSelect, { Leaderboard } from '../';
import { NAMESPACE } from 'store/constants';
import { NAMESPACE } from 'wc-api/constants';
import mockData from '../__mocks__/top-selling-products-mock-data';
// Mock <Table> to avoid tests failing due to it using DOM properties that

View File

@ -25,7 +25,7 @@ import { Chart } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { getReportChartData, getTooltipValueFormat } from 'store/reports/utils';
import { getReportChartData, getTooltipValueFormat } from 'wc-api/reports/utils';
import ReportError from 'analytics/components/report-error';
import withSelect from 'wc-api/with-select';

View File

@ -19,7 +19,7 @@ import { formatCurrency } from '@woocommerce/currency';
/**
* Internal dependencies
*/
import { getSummaryNumbers } from 'store/reports/utils';
import { getSummaryNumbers } from 'wc-api/reports/utils';
import ReportError from 'analytics/components/report-error';
import withSelect from 'wc-api/with-select';

View File

@ -19,7 +19,7 @@ import { onQueryChange } from '@woocommerce/navigation';
* Internal dependencies
*/
import ReportError from 'analytics/components/report-error';
import { getReportChartData, getReportTableData } from 'store/reports/utils';
import { getReportChartData, getReportTableData } from 'wc-api/reports/utils';
import withSelect from 'wc-api/with-select';
import { extendTableData } from './utils';

View File

@ -18,7 +18,7 @@ export default class CategoryBreadcrumbs extends Component {
let parent = category.parent;
while ( parent ) {
ancestors.unshift( parent );
parent = categories[ parent ].parent;
parent = categories.get( parent ).parent;
}
return ancestors;
}
@ -30,20 +30,20 @@ export default class CategoryBreadcrumbs extends Component {
return;
}
if ( ancestorIds.length === 1 ) {
return categories[ first( ancestorIds ) ].name + ' ';
return categories.get( first( ancestorIds ) ).name + ' ';
}
if ( ancestorIds.length === 2 ) {
return (
categories[ first( ancestorIds ) ].name +
categories.get( first( ancestorIds ) ).name +
' ' +
categories[ last( ancestorIds ) ].name +
categories.get( last( ancestorIds ) ).name +
' '
);
}
return (
categories[ first( ancestorIds ) ].name +
categories.get( first( ancestorIds ) ).name +
' … ' +
categories[ last( ancestorIds ) ].name +
categories.get( last( ancestorIds ) ).name +
' '
);
}

View File

@ -71,7 +71,7 @@ class CategoriesReportTable extends Component {
return map( categoryStats, categoryStat => {
const { category_id, items_sold, net_revenue, products_count, orders_count } = categoryStat;
const { categories, query } = this.props;
const category = categories[ category_id ];
const category = categories.get( category_id );
const persistedQuery = getPersistedQuery( query );
return [
@ -153,6 +153,7 @@ class CategoriesReportTable extends Component {
getSummary={ this.getSummary }
itemIdField="category_id"
query={ query }
searchBy="categories"
labels={ labels }
tableQuery={ {
orderby: query.orderby || 'items_sold',
@ -168,14 +169,14 @@ class CategoriesReportTable extends Component {
export default compose(
withSelect( select => {
const { getCategories, getCategoriesError, isGetCategoriesRequesting } = select( 'wc-api' );
const { getItems, getItemsError, isGetItemsRequesting } = select( 'wc-api' );
const tableQuery = {
per_page: -1,
};
const categories = getCategories( tableQuery );
const isError = Boolean( getCategoriesError( tableQuery ) );
const isRequesting = isGetCategoriesRequesting( tableQuery );
const categories = getItems( 'categories', tableQuery );
const isError = Boolean( getItemsError( 'categories', tableQuery ) );
const isRequesting = isGetItemsRequesting( 'categories', tableQuery );
return { categories, isError, isRequesting };
} )

View File

@ -169,6 +169,7 @@ export default class CouponsReportTable extends Component {
getSummary={ this.getSummary }
itemIdField="coupon_id"
query={ query }
searchBy="coupons"
tableQuery={ {
orderby: query.orderby || 'orders_count',
order: query.order || 'desc',

View File

@ -9,7 +9,7 @@ import { decodeEntities } from '@wordpress/html-entities';
* Internal dependencies
*/
import { getCustomerLabels, getRequestByIdString } from 'lib/async-requests';
import { NAMESPACE } from 'store/constants';
import { NAMESPACE } from 'wc-api/constants';
export const filters = [
{
@ -163,7 +163,7 @@ export const advancedFilters = {
} ) ),
},
},
order_count: {
orders_count: {
labels: {
add: __( 'No. of Orders', 'wc-admin' ),
remove: __( 'Remove order filter', 'wc-admin' ),

View File

@ -227,7 +227,6 @@ export default class CustomersReportTable extends Component {
query={ query }
labels={ { placeholder: __( 'Search by customer name', 'wc-admin' ) } }
searchBy="customers"
searchParam="name_includes"
title={ __( 'Customers', 'wc-admin' ) }
columnPrefsKey="customers_report_columns"
/>

View File

@ -5,6 +5,7 @@
import { __ } from '@wordpress/i18n';
import { applyFilters } from '@wordpress/hooks';
import { Component, Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types';
import { find } from 'lodash';
@ -12,6 +13,7 @@ import { find } from 'lodash';
* WooCommerce dependencies
*/
import { useFilters } from '@woocommerce/components';
import { getQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
@ -27,6 +29,8 @@ import TaxesReport from './taxes';
import DownloadsReport from './downloads';
import StockReport from './stock';
import CustomersReport from './customers';
import { searchItemsByString } from 'wc-api/items/utils';
import withSelect from 'wc-api/with-select';
const REPORTS_FILTER = 'woocommerce-reports-list';
@ -131,4 +135,27 @@ Report.propTypes = {
params: PropTypes.object.isRequired,
};
export default useFilters( REPORTS_FILTER )( Report );
export default compose(
useFilters( REPORTS_FILTER ),
withSelect( ( select, props ) => {
const { search } = getQuery();
if ( ! search ) {
return {};
}
const { report } = props.params;
const items = searchItemsByString( select, report, search );
const ids = Object.keys( items );
if ( ! ids.length ) {
return {}; // @TODO if no results were found, we should avoid making a server request.
}
return {
query: {
...props.query,
[ report ]: ids.join( ',' ),
},
};
} )
)( Report );

View File

@ -24,7 +24,7 @@ import VariationsReportTable from './table-variations';
export default class ProductsReport extends Component {
render() {
const { path, query } = this.props;
const isProductDetailsView = query.products && 1 === query.products.split( ',' ).length;
const isProductDetailsView = query.filter === 'single_product';
const itemsLabel = isProductDetailsView
? __( '%s variations', 'wc-admin' )

View File

@ -36,6 +36,12 @@ export default class VariationsReportTable extends Component {
required: true,
isLeftAligned: true,
},
{
label: __( 'SKU', 'wc-admin' ),
key: 'sku',
hiddenByDefault: true,
isSortable: true,
},
{
label: __( 'Items Sold', 'wc-admin' ),
key: 'items_sold',
@ -77,7 +83,7 @@ export default class VariationsReportTable extends Component {
return map( data, row => {
const { items_sold, net_revenue, orders_count, extended_info, product_id } = row;
const { stock_status, stock_quantity, low_stock_amount } = extended_info;
const { stock_status, stock_quantity, low_stock_amount, sku } = extended_info;
const name = get( row, [ 'extended_info', 'name' ], '' ).replace( ' - ', ' / ' );
const ordersLink = getNewPath( persistedQuery, 'orders', {
filter: 'advanced',
@ -94,6 +100,10 @@ export default class VariationsReportTable extends Component {
),
value: name,
},
{
display: sku,
value: sku,
},
{
display: items_sold,
value: items_sold,
@ -172,6 +182,7 @@ export default class VariationsReportTable extends Component {
labels={ labels }
query={ query }
getSummary={ this.getSummary }
searchBy="variations"
tableQuery={ {
orderby: query.orderby || 'items_sold',
order: query.order || 'desc',

View File

@ -119,11 +119,11 @@ class ProductsReportTable extends Component {
filter: 'single_product',
products: product_id,
} );
const categories = this.props.categories;
const { categories } = this.props;
const productCategories =
( category_ids &&
category_ids.map( category_id => categories[ category_id ] ).filter( Boolean ) ) ||
category_ids.map( category_id => categories.get( category_id ) ).filter( Boolean ) ) ||
[];
return [
@ -245,6 +245,7 @@ class ProductsReportTable extends Component {
itemIdField="product_id"
labels={ labels }
query={ query }
searchBy="products"
tableQuery={ {
orderby: query.orderby || 'items_sold',
order: query.order || 'desc',
@ -259,14 +260,14 @@ class ProductsReportTable extends Component {
export default compose(
withSelect( select => {
const { getCategories, getCategoriesError, isGetCategoriesRequesting } = select( 'wc-api' );
const { getItems, getItemsError, isGetItemsRequesting } = select( 'wc-api' );
const tableQuery = {
per_page: -1,
};
const categories = getCategories( tableQuery );
const isError = Boolean( getCategoriesError( tableQuery ) );
const isRequesting = isGetCategoriesRequesting( tableQuery );
const categories = getItems( 'categories', tableQuery );
const isError = Boolean( getItemsError( 'categories', tableQuery ) );
const isRequesting = isGetItemsRequesting( 'categories', tableQuery );
return { categories, isError, isRequesting };
} )

View File

@ -19,7 +19,7 @@ import { numberFormat } from '@woocommerce/number';
/**
* Internal dependencies
*/
import { QUERY_DEFAULTS } from 'store/constants';
import { QUERY_DEFAULTS } from 'wc-api/constants';
import ReportTable from 'analytics/components/report-table';
import withSelect from 'wc-api/with-select';

View File

@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n';
*/
import { getRequestByIdString } from 'lib/async-requests';
import { getTaxCode } from './utils';
import { NAMESPACE } from 'store/constants';
import { NAMESPACE } from 'wc-api/constants';
export const charts = [
{

View File

@ -150,6 +150,7 @@ export default class TaxesReportTable extends Component {
getSummary={ this.getSummary }
itemIdField="tax_rate_id"
query={ query }
searchBy="taxes"
tableQuery={ {
orderby: query.orderby || 'tax_rate_id',
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import { ActivityCard, ActivityCardPlaceholder } from '../activity-card';
import ActivityHeader from '../activity-header';
import { EmptyContent, Section } from '@woocommerce/components';
import sanitizeHTML from 'lib/sanitize-html';
import { QUERY_DEFAULTS } from 'store/constants';
import { QUERY_DEFAULTS } from 'wc-api/constants';
class InboxPanel extends Component {
render() {

View File

@ -33,7 +33,7 @@ import { ActivityCard, ActivityCardPlaceholder } from '../activity-card';
import ActivityHeader from '../activity-header';
import ActivityOutboundLink from '../activity-outbound-link';
import { getOrderRefundTotal } from 'lib/order-values';
import { QUERY_DEFAULTS } from 'store/constants';
import { QUERY_DEFAULTS } from 'wc-api/constants';
import withSelect from 'wc-api/with-select';
function OrdersPanel( { orders, isRequesting, isError } ) {
@ -92,6 +92,51 @@ function OrdersPanel( { orders, isRequesting, isError } ) {
);
};
const cards = [];
orders.forEach( ( order, id ) => {
// We want the billing address, but shipping can be used as a fallback.
const address = { ...order.shipping, ...order.billing };
const productsCount = order.line_items.reduce( ( total, line ) => total + line.quantity, 0 );
const total = order.total;
const refundValue = getOrderRefundTotal( order );
const remainingTotal = getCurrencyFormatDecimal( order.total ) + refundValue;
cards.push(
<ActivityCard
key={ id }
className="woocommerce-order-activity-card"
title={ orderCardTitle( order, address ) }
date={ order.date_created }
subtitle={
<div>
<span>
{ sprintf(
_n( '%d product', '%d products', productsCount, 'wc-admin' ),
productsCount
) }
</span>
{ refundValue ? (
<span>
<s>{ formatCurrency( total, order.currency_symbol ) }</s>{' '}
{ formatCurrency( remainingTotal, order.currency_symbol ) }
</span>
) : (
<span>{ formatCurrency( total, order.currency_symbol ) }</span>
) }
</div>
}
actions={
<Button isDefault href={ getAdminLink( 'post.php?action=edit&post=' + order.id ) }>
{ __( 'Begin fulfillment' ) }
</Button>
}
>
<OrderStatus order={ order } />
</ActivityCard>
);
} );
return (
<Fragment>
<ActivityHeader title={ __( 'Orders', 'wc-admin' ) } menu={ menu } />
@ -105,55 +150,7 @@ function OrdersPanel( { orders, isRequesting, isError } ) {
/>
) : (
<Fragment>
{ orders.map( ( order, i ) => {
// We want the billing address, but shipping can be used as a fallback.
const address = { ...order.shipping, ...order.billing };
const productsCount = order.line_items.reduce(
( total, line ) => total + line.quantity,
0
);
const total = order.total;
const refundValue = getOrderRefundTotal( order );
const remainingTotal = getCurrencyFormatDecimal( order.total ) + refundValue;
return (
<ActivityCard
key={ i }
className="woocommerce-order-activity-card"
title={ orderCardTitle( order, address ) }
date={ order.date_created }
subtitle={
<div>
<span>
{ sprintf(
_n( '%d product', '%d products', productsCount, 'wc-admin' ),
productsCount
) }
</span>
{ refundValue ? (
<span>
<s>{ formatCurrency( total, order.currency_symbol ) }</s>{' '}
{ formatCurrency( remainingTotal, order.currency_symbol ) }
</span>
) : (
<span>{ formatCurrency( total, order.currency_symbol ) }</span>
) }
</div>
}
actions={
<Button
isDefault
href={ getAdminLink( 'post.php?action=edit&post=' + order.id ) }
>
{ __( 'Begin fulfillment' ) }
</Button>
}
>
<OrderStatus order={ order } />
</ActivityCard>
);
} ) }
{ cards }
<ActivityOutboundLink href={ 'edit.php?post_type=shop_order' }>
{ __( 'Manage all orders' ) }
</ActivityOutboundLink>
@ -165,29 +162,29 @@ function OrdersPanel( { orders, isRequesting, isError } ) {
}
OrdersPanel.propTypes = {
orders: PropTypes.array.isRequired,
orders: PropTypes.instanceOf( Map ).isRequired,
isError: PropTypes.bool,
isRequesting: PropTypes.bool,
};
OrdersPanel.defaultProps = {
orders: [],
orders: new Map(),
isError: false,
isRequesting: false,
};
export default compose(
withSelect( select => {
const { getOrders, getOrdersError, isGetOrdersRequesting } = select( 'wc-api' );
const { getItems, getItemsError, isGetItemsRequesting } = select( 'wc-api' );
const ordersQuery = {
page: 1,
per_page: QUERY_DEFAULTS.pageSize,
status: 'processing',
};
const orders = getOrders( ordersQuery );
const isError = Boolean( getOrdersError( ordersQuery ) );
const isRequesting = isGetOrdersRequesting( ordersQuery );
const orders = getItems( 'orders', ordersQuery );
const isError = Boolean( getItemsError( 'orders', ordersQuery ) );
const isRequesting = isGetItemsRequesting( 'orders', ordersQuery );
return { orders, isError, isRequesting };
} )

View File

@ -28,7 +28,7 @@ import {
*/
import { ActivityCard, ActivityCardPlaceholder } from '../activity-card';
import ActivityHeader from '../activity-header';
import { QUERY_DEFAULTS } from 'store/constants';
import { QUERY_DEFAULTS } from 'wc-api/constants';
import sanitizeHTML from 'lib/sanitize-html';
import withSelect from 'wc-api/with-select';

View File

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

View File

@ -13,7 +13,7 @@ import { getIdsFromQuery, stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { NAMESPACE } from 'store/constants';
import { NAMESPACE } from 'wc-api/constants';
/**
* Get a function that accepts ids as they are found in url parameter and

View File

@ -1,16 +0,0 @@
/**
* @format
*/
export const NAMESPACE = '/wc/v4/';
export const SWAGGERNAMESPACE = 'https://virtserver.swaggerhub.com/peterfabian/wc-v3-api/1.0.0/';
export const ERROR = 'ERROR';
// WordPress & WooCommerce both set a hard limit of 100 for the per_page parameter
export const MAX_PER_PAGE = 100;
export const QUERY_DEFAULTS = {
pageSize: 25,
period: 'month',
compare: 'previous_year',
};

View File

@ -1,11 +0,0 @@
/** @format */
/**
* Returns a string representation of a sorted query object.
*
* @param {Object} query Current state
* @return {String} Query Key
*/
export function getJsonString( query = {} ) {
return JSON.stringify( query, Object.keys( query ).sort() );
}

View File

@ -1,52 +0,0 @@
/** @format */
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { isResourcePrefix, getResourceIdentifier, getResourceName } from '../utils';
import { NAMESPACE } from '../constants';
function read( resourceNames, fetch = apiFetch ) {
const filteredNames = resourceNames.filter( name => isResourcePrefix( name, 'category-query' ) );
return filteredNames.map( async resourceName => {
const query = getResourceIdentifier( resourceName );
const url = NAMESPACE + `/products/categories${ stringifyQuery( query ) }`;
try {
const categories = await fetch( {
path: url,
} );
const ids = categories.map( category => category.id );
const categoryResources = categories.reduce( ( resources, category ) => {
resources[ getResourceName( 'category', category.id ) ] = { data: category };
return resources;
}, {} );
return {
[ resourceName ]: {
data: ids,
totalCount: ids.length,
},
...categoryResources,
};
} catch ( error ) {
return { [ resourceName ]: { error } };
}
} );
}
export default {
read,
};

View File

@ -1,56 +0,0 @@
/** @format */
/**
* External dependencies
*/
import { isNil } from 'lodash';
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
import { DEFAULT_REQUIREMENT } from '../constants';
const getCategories = ( getResource, requireResource ) => (
query = {},
requirement = DEFAULT_REQUIREMENT
) => {
const resourceName = getResourceName( 'category-query', query );
const ids = requireResource( requirement, resourceName ).data || [];
const categories = ids.reduce(
( acc, id ) => ( {
...acc,
[ id ]: getResource( getResourceName( 'category', id ) ).data || {},
} ),
{}
);
return categories;
};
const getCategoriesTotalCount = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'category-query', query );
return getResource( resourceName ).totalCount || 0;
};
const getCategoriesError = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'category-query', query );
return getResource( resourceName ).error;
};
const isGetCategoriesRequesting = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'category-query', query );
const { lastRequested, lastReceived } = getResource( resourceName );
if ( isNil( lastRequested ) || isNil( lastReceived ) ) {
return true;
}
return lastRequested > lastReceived;
};
export default {
getCategories,
getCategoriesError,
getCategoriesTotalCount,
isGetCategoriesRequesting,
};

View File

@ -6,6 +6,9 @@ import { MINUTE } from '@fresh-data/framework';
export const NAMESPACE = '/wc/v4';
// TODO: Remove once swagger endpoints are phased out.
export const SWAGGERNAMESPACE = 'https://virtserver.swaggerhub.com/peterfabian/wc-v3-api/1.0.0/';
export const DEFAULT_REQUIREMENT = {
timeout: 1 * MINUTE,
freshness: 5 * MINUTE,

View File

@ -0,0 +1,66 @@
/** @format */
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { getResourceIdentifier, getResourcePrefix, getResourceName } from '../utils';
import { NAMESPACE } from '../constants';
const typeEndpointMap = {
'items-query-categories': 'products/categories',
'items-query-customers': 'customers',
'items-query-coupons': 'coupons',
'items-query-orders': 'orders',
'items-query-products': 'products',
'items-query-taxes': 'taxes',
};
function read( resourceNames, fetch = apiFetch ) {
const filteredNames = resourceNames.filter( name => {
const prefix = getResourcePrefix( name );
return Boolean( typeEndpointMap[ prefix ] );
} );
return filteredNames.map( async resourceName => {
const prefix = getResourcePrefix( resourceName );
const endpoint = typeEndpointMap[ prefix ];
const query = getResourceIdentifier( resourceName );
const url = NAMESPACE + `/${ endpoint }${ stringifyQuery( query ) }`;
try {
const items = await fetch( {
path: url,
} );
const ids = items.map( item => item.id );
const itemResources = items.reduce( ( resources, item ) => {
resources[ getResourceName( `${ prefix }-item`, item.id ) ] = { data: item };
return resources;
}, {} );
return {
[ resourceName ]: {
data: ids,
totalCount: ids.length,
},
...itemResources,
};
} catch ( error ) {
return { [ resourceName ]: { error } };
}
} );
}
export default {
read,
};

View File

@ -0,0 +1,54 @@
/** @format */
/**
* External dependencies
*/
import { isNil } from 'lodash';
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
import { DEFAULT_REQUIREMENT } from '../constants';
const getItems = ( getResource, requireResource ) => (
type,
query = {},
requirement = DEFAULT_REQUIREMENT
) => {
const resourceName = getResourceName( `items-query-${ type }`, query );
const ids = requireResource( requirement, resourceName ).data || [];
const items = new Map();
ids.forEach( id => {
items.set( id, getResource( getResourceName( `items-query-${ type }-item`, id ) ).data );
} );
return items;
};
const getItemsTotalCount = getResource => ( type, query = {} ) => {
const resourceName = getResourceName( `items-query-${ type }`, query );
return getResource( resourceName ).totalCount || 0;
};
const getItemsError = getResource => ( type, query = {} ) => {
const resourceName = getResourceName( `items-query-${ type }`, query );
return getResource( resourceName ).error;
};
const isGetItemsRequesting = getResource => ( type, query = {} ) => {
const resourceName = getResourceName( `items-query-${ type }`, query );
const { lastRequested, lastReceived } = getResource( resourceName );
if ( isNil( lastRequested ) || isNil( lastReceived ) ) {
return true;
}
return lastRequested > lastReceived;
};
export default {
getItems,
getItemsError,
getItemsTotalCount,
isGetItemsRequesting,
};

View File

@ -0,0 +1,31 @@
/** @format */
/**
* External dependencies
*/
/**
* Returns items based on a search query.
*
* @param {Object} select Instance of @wordpress/select
* @param {String} endpoint Report API Endpoint
* @param {String} search Search strings separated by commas.
* @return {Object} Object Object containing the matching items.
*/
export function searchItemsByString( select, endpoint, search ) {
const { getItems } = select( 'wc-api' );
const searchWords = search.split( ',' );
const items = {};
searchWords.forEach( searchWord => {
const newItems = getItems( endpoint, {
search: searchWord,
per_page: 10,
} );
newItems.forEach( ( item, id ) => {
items[ id ] = item;
} );
} );
return items;
}

View File

@ -1,76 +0,0 @@
/** @format */
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { isResourcePrefix, getResourceIdentifier, getResourceName } from '../utils';
import { NAMESPACE } from '../constants';
function read( resourceNames, fetch = apiFetch ) {
return [ ...readOrders( resourceNames, fetch ), ...readOrderQueries( resourceNames, fetch ) ];
}
function readOrderQueries( resourceNames, fetch ) {
const filteredNames = resourceNames.filter( name => isResourcePrefix( name, 'order-query' ) );
return filteredNames.map( async resourceName => {
const query = getResourceIdentifier( resourceName );
const url = `${ NAMESPACE }/orders${ stringifyQuery( query ) }`;
try {
const response = await fetch( {
parse: false,
path: url,
} );
const orders = await response.json();
const totalCount = parseInt( response.headers.get( 'x-wp-total' ) );
const ids = orders.map( order => order.id );
const orderResources = orders.reduce( ( resources, order ) => {
resources[ getResourceName( 'order', order.id ) ] = { data: order };
return resources;
}, {} );
return {
[ resourceName ]: {
data: ids,
totalCount,
},
...orderResources,
};
} catch ( error ) {
return { [ resourceName ]: { error } };
}
} );
}
function readOrders( resourceNames, fetch ) {
const filteredNames = resourceNames.filter( name => isResourcePrefix( name, 'order' ) );
return filteredNames.map( resourceName => readOrder( resourceName, fetch ) );
}
function readOrder( resourceName, fetch ) {
const id = getResourceIdentifier( resourceName );
const url = `${ NAMESPACE }/orders/${ id }`;
return fetch( { path: url } )
.then( order => {
return { [ resourceName ]: { data: order } };
} )
.catch( error => {
return { [ resourceName ]: { error } };
} );
}
export default {
read,
};

View File

@ -1,53 +0,0 @@
/** @format */
/**
* External dependencies
*/
import { isNil } from 'lodash';
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
import { DEFAULT_REQUIREMENT } from '../constants';
const getOrders = ( getResource, requireResource ) => (
query = {},
requirement = DEFAULT_REQUIREMENT
) => {
const resourceName = getResourceName( 'order-query', query );
const ids = requireResource( requirement, resourceName ).data || [];
const orders = ids.map( id => getResource( getResourceName( 'order', id ) ).data || {} );
return orders;
};
const getOrdersError = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'order-query', query );
return getResource( resourceName ).error;
};
const getOrdersTotalCount = ( getResource, requireResource ) => (
query = {},
requirement = DEFAULT_REQUIREMENT
) => {
const resourceName = getResourceName( 'order-query', query );
return requireResource( requirement, resourceName ).totalCount || 0;
};
const isGetOrdersRequesting = getResource => ( query = {} ) => {
const resourceName = getResourceName( 'order-query', query );
const { lastRequested, lastReceived } = getResource( resourceName );
if ( isNil( lastRequested ) || isNil( lastReceived ) ) {
return true;
}
return lastRequested > lastReceived;
};
export default {
getOrders,
getOrdersError,
getOrdersTotalCount,
isGetOrdersRequesting,
};

View File

@ -12,9 +12,8 @@ import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { getResourceIdentifier, getResourcePrefix } from '../../utils';
import { NAMESPACE } from '../../constants';
import { SWAGGERNAMESPACE } from 'store/constants';
import { getResourceIdentifier, getResourcePrefix } from 'wc-api/utils';
import { NAMESPACE, SWAGGERNAMESPACE } from 'wc-api/constants';
const statEndpoints = [
'coupons',

View File

@ -16,7 +16,7 @@ import { formatCurrency } from '@woocommerce/currency';
/**
* Internal dependencies
*/
import { MAX_PER_PAGE, QUERY_DEFAULTS } from 'store/constants';
import { MAX_PER_PAGE, QUERY_DEFAULTS } from 'wc-api/constants';
import * as categoriesConfig from 'analytics/report/categories/config';
import * as couponsConfig from 'analytics/report/coupons/config';
import * as customersConfig from 'analytics/report/customers/config';
@ -37,6 +37,12 @@ const reportConfigs = {
};
export function getFilterQuery( endpoint, query ) {
if ( query.search ) {
return {
[ endpoint ]: query[ endpoint ],
};
}
if ( reportConfigs[ endpoint ] ) {
const { filters = [], advancedFilters = {} } = reportConfigs[ endpoint ];
return filters
@ -335,7 +341,7 @@ export function getReportTableQuery( endpoint, urlQuery, query ) {
*
* @param {String} endpoint Report API Endpoint
* @param {Object} urlQuery Query parameters in the url
* @param {object} select Instance of @wordpress/select
* @param {Object} select Instance of @wordpress/select
* @param {Object} query Query parameters specific for that endpoint
* @return {Object} Object Table data response
*/

View File

@ -4,8 +4,10 @@
*/
import operations from './operations';
import selectors from './selectors';
import mutations from './mutations';
export default {
operations,
selectors,
mutations,
};

View File

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

View File

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

View File

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

View File

@ -3,42 +3,46 @@
/**
* Internal dependencies
*/
import categories from './categories';
import items from './items';
import notes from './notes';
import orders from './orders';
import reportItems from './reports/items';
import reportStats from './reports/stats';
import reviews from './reviews';
import settings from './settings';
import user from './user';
function createWcApiSpec() {
return {
mutations: {
...settings.mutations,
...user.mutations,
},
selectors: {
...categories.selectors,
...items.selectors,
...notes.selectors,
...orders.selectors,
...reportItems.selectors,
...reportStats.selectors,
...reviews.selectors,
...settings.selectors,
...user.selectors,
},
operations: {
read( resourceNames ) {
return [
...categories.operations.read( resourceNames ),
...items.operations.read( resourceNames ),
...notes.operations.read( resourceNames ),
...orders.operations.read( resourceNames ),
...reportItems.operations.read( resourceNames ),
...reportStats.operations.read( resourceNames ),
...reviews.operations.read( resourceNames ),
...settings.operations.read( resourceNames ),
...user.operations.read( resourceNames ),
];
},
update( resourceNames, data ) {
return [ ...user.operations.update( resourceNames, data ) ];
return [
...settings.operations.update( resourceNames, data ),
...user.operations.update( resourceNames, data ),
];
},
},
};

View File

@ -1,9 +1,9 @@
`Flag` (component)
==================
Use the `Flag` component to display a country's flag.
Use the `Flag` component to display a country's flag using the operating system's emojis.
React component.
Props
-----
@ -22,27 +22,6 @@ Two letter, three letter or three digit country code.
An order can be passed instead of `code` and the code will automatically be pulled from the billing or shipping data.
### `round`
- Type: Boolean
- Default: `true`
True to display a rounded flag.
### `height`
- Type: Number
- Default: `24`
Flag image height.
### `width`
- Type: Number
- Default: `24`
Flag image width.
### `className`
- Type: String
@ -50,3 +29,10 @@ Flag image width.
Additional CSS classes.
### `size`
- Type: Number
- Default: null
Supply a font size to be applied to the emoji flag.

View File

@ -12,17 +12,17 @@ Props
### `children`
- **Required**
- Type: ReactNode
- Type: Function
- Default: null
A list of `<SummaryNumber />`s
A function returning a list of `<SummaryNumber />`s
### `label`
- Type: String
- Default: null
- Default: `__( 'Performance Indicators', 'wc-admin' )`
An optional label of this group, read to screen reader users. Defaults to "Performance Indicators".
An optional label of this group, read to screen reader users.
`SummaryNumber` (component)
===========================
@ -46,7 +46,7 @@ If omitted, no change value will display.
### `href`
- Type: String
- Default: `'/analytics'`
- Default: `''`
An internal link to the report focused on this number.
@ -109,6 +109,13 @@ A boolean used to show a highlight style on this number.
A string or number value to display - a string is allowed so we can accept currency formatting.
### `onLinkClickCallback`
- Type: Function
- Default: `noop`
A function to be called after a SummaryNumber, rendered as a link, is clicked.
`SummaryListPlaceholder` (component)
====================================

View File

@ -17,7 +17,7 @@ defined( 'ABSPATH' ) || exit;
*/
class WC_Admin_REST_Customers_Controller extends WC_REST_Customers_Controller {
// TODO Add support for guests here. See https://wp.me/p7bje6-1dM.
// @todo Add support for guests here. See https://wp.me/p7bje6-1dM.
/**
* Endpoint namespace.

View File

@ -39,15 +39,16 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['coupons'] = (array) $request['coupons'];
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['coupons'] = (array) $request['coupons'];
$args['segmentby'] = $request['segmentby'];
return $args;
}
@ -61,7 +62,11 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$coupons_query = new WC_Admin_Reports_Coupons_Stats_Query( $query_args );
$report_data = $coupons_query->get_data();
try {
$report_data = $coupons_query->get_data();
} catch ( WC_Admin_Reports_Parameter_Exception $e ) {
return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
@ -132,7 +137,7 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con
* @return array
*/
public function get_item_schema() {
$totals = array(
$data_values = array(
'amount' => array(
'description' => __( 'Net discount amount.', 'wc-admin' ),
'type' => 'number',
@ -156,6 +161,35 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con
),
);
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_coupons_stats',
@ -302,6 +336,17 @@ class WC_Admin_REST_Reports_Coupons_Stats_Controller extends WC_REST_Reports_Con
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ),
'type' => 'string',
'enum' => array(
'product',
'variation',
'category',
'coupon',
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}

View File

@ -61,9 +61,11 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
$args['last_order_before'] = $request['last_order_before'];
$args['last_order_after'] = $request['last_order_after'];
$between_params = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params );
$args = array_merge( $args, $normalized );
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params_numeric, false );
$between_params_date = array( 'last_active', 'registered' );
$normalized_params_date = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params_date, true );
$args = array_merge( $args, $normalized_params_numeric, $normalized_params_date );
return $args;
}
@ -296,14 +298,14 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'wc-admin' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'wc-admin' ),
'type' => 'string',
'default' => 'date_registered',
@ -321,7 +323,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'wc-admin' ),
'type' => 'string',
'default' => 'all',
@ -331,7 +333,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['name'] = array(
$params['name'] = array(
'description' => __( 'Limit response to objects with a specfic customer name.', 'wc-admin' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
@ -363,34 +365,44 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_before'] = array(
$params['last_active_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_date_arg' ),
);
$params['registered_before'] = array(
'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_after'] = array(
$params['registered_after'] = array(
'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_min'] = array(
$params['registered_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_date_arg' ),
);
$params['orders_count_min'] = array(
'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'wc-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_max'] = array(
$params['orders_count_max'] = array(
'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'wc-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_between'] = array(
$params['orders_count_between'] = array(
'description' => __( 'Limit response to objects with an order count between two given integers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_arg' ),
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_numeric_arg' ),
);
$params['total_spend_min'] = array(
'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'wc-admin' ),
@ -405,7 +417,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
$params['total_spend_between'] = array(
'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_arg' ),
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_numeric_arg' ),
);
$params['avg_order_value_min'] = array(
'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'wc-admin' ),
@ -420,7 +432,7 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
$params['avg_order_value_between'] = array(
'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_arg' ),
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_numeric_arg' ),
);
$params['last_order_before'] = array(
'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'wc-admin' ),

View File

@ -56,9 +56,11 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$args['last_order_before'] = $request['last_order_before'];
$args['last_order_after'] = $request['last_order_after'];
$between_params = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params );
$args = array_merge( $args, $normalized );
$between_params_numeric = array( 'orders_count', 'total_spend', 'avg_order_value' );
$normalized_params_numeric = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params_numeric, false );
$between_params_date = array( 'last_active', 'registered' );
$normalized_params_date = WC_Admin_Reports_Interval::normalize_between_params( $request, $between_params_date, true );
$args = array_merge( $args, $normalized_params_numeric, $normalized_params_date );
return $args;
}
@ -75,7 +77,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$report_data = $customers_query->get_data();
$out_data = array(
'totals' => $report_data,
// TODO: is this needed? the single element array tricks the isReportDataEmpty() selector.
// @todo: is this needed? the single element array tricks the isReportDataEmpty() selector.
'intervals' => array( (object) array() ),
);
@ -117,9 +119,9 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
* @return array
*/
public function get_item_schema() {
// TODO: should any of these be 'indicator's?
// @todo: should any of these be 'indicator's?
$totals = array(
'customers_count' => array(
'customers_count' => array(
'description' => __( 'Number of customers.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
@ -131,7 +133,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'avg_total_spend' => array(
'avg_total_spend' => array(
'description' => __( 'Average total spend per customer.', 'wc-admin' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
@ -159,7 +161,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
'readonly' => true,
'properties' => $totals,
),
'intervals' => array( // TODO: remove this?
'intervals' => array( // @todo: remove this?
'description' => __( 'Reports data grouped by intervals.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
@ -234,7 +236,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['match'] = array(
$params['match'] = array(
'description' => __( 'Indicates whether all the conditions should be true for the resulting set, or if any one of them is sufficient. Match affects the following parameters: status_is, status_is_not, product_includes, product_excludes, coupon_includes, coupon_excludes, customer, categories', 'wc-admin' ),
'type' => 'string',
'default' => 'all',
@ -244,7 +246,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['name'] = array(
$params['name'] = array(
'description' => __( 'Limit response to objects with a specfic customer name.', 'wc-admin' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
@ -276,34 +278,44 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_before'] = array(
$params['last_active_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_date_arg' ),
);
$params['registered_before'] = array(
'description' => __( 'Limit response to objects registered before (or at) a given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['registered_after'] = array(
$params['registered_after'] = array(
'description' => __( 'Limit response to objects registered after (or at) a given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_min'] = array(
$params['registered_between'] = array(
'description' => __( 'Limit response to objects last active between two given ISO8601 compliant datetime.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_date_arg' ),
);
$params['orders_count_min'] = array(
'description' => __( 'Limit response to objects with an order count greater than or equal to given integer.', 'wc-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_max'] = array(
$params['orders_count_max'] = array(
'description' => __( 'Limit response to objects with an order count less than or equal to given integer.', 'wc-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['orders_count_between'] = array(
$params['orders_count_between'] = array(
'description' => __( 'Limit response to objects with an order count between two given integers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_arg' ),
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_numeric_arg' ),
);
$params['total_spend_min'] = array(
'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'wc-admin' ),
@ -318,7 +330,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$params['total_spend_between'] = array(
'description' => __( 'Limit response to objects with a total order spend between two given numbers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_arg' ),
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_numeric_arg' ),
);
$params['avg_order_value_min'] = array(
'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'wc-admin' ),
@ -333,7 +345,7 @@ class WC_Admin_REST_Reports_Customers_Stats_Controller extends WC_REST_Reports_C
$params['avg_order_value_between'] = array(
'description' => __( 'Limit response to objects with an average order spend between two given numbers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_arg' ),
'validate_callback' => array( 'WC_Admin_Reports_Interval', 'rest_validate_between_numeric_arg' ),
);
$params['last_order_before'] = array(
'description' => __( 'Limit response to objects with last order before (or at) a given ISO8601 compliant datetime.', 'wc-admin' ),

View File

@ -56,6 +56,7 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
$args['coupon_excludes'] = (array) $request['coupon_excludes'];
$args['customer'] = $request['customer'];
$args['categories'] = (array) $request['categories'];
$args['segmentby'] = $request['segmentby'];
return $args;
}
@ -69,7 +70,11 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$orders_query = new WC_Admin_Reports_Orders_Stats_Query( $query_args );
$report_data = $orders_query->get_data();
try {
$report_data = $orders_query->get_data();
} catch ( WC_Admin_Reports_Parameter_Exception $e ) {
return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
@ -140,15 +145,15 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
* @return array
*/
public function get_item_schema() {
$totals = array(
'net_revenue' => array(
$data_values = array(
'net_revenue' => array(
'description' => __( 'Net revenue.', 'wc-admin' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'format' => 'currency',
),
'orders_count' => array(
'orders_count' => array(
'description' => __( 'Amount of orders', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
@ -163,14 +168,78 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
'indicator' => true,
'format' => 'currency',
),
'avg_items_per_order' => array(
'avg_items_per_order' => array(
'description' => __( 'Average items per order', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'num_items_sold' => array(
'description' => __( 'Number of items sold', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'coupons' => array(
'description' => __( 'Amount discounted by coupons', 'wc-admin' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'num_returning_customers' => array(
'description' => __( 'Number of orders done by returning customers', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'num_new_customers' => array(
'description' => __( 'Number of orders done by new customers', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'products' => array(
'description' => __( 'Number of distinct products sold.', 'wc-admin' ),
'type' => 'number',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
);
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
// Products is not shown in intervals.
unset( $data_values['products'] );
$intervals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_orders_stats',
@ -227,7 +296,7 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $totals,
'properties' => $intervals,
),
),
),
@ -358,7 +427,7 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_includes'] = array(
$params['coupon_includes'] = array(
'description' => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'wc-admin' ),
'type' => 'array',
'items' => array(
@ -367,7 +436,7 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
);
$params['coupon_excludes'] = array(
$params['coupon_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'wc-admin' ),
'type' => 'array',
'items' => array(
@ -385,6 +454,18 @@ class WC_Admin_REST_Reports_Orders_Stats_Controller extends WC_Admin_REST_Report
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
'coupon',
'customer_type', // new vs returning.
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}

View File

@ -74,8 +74,12 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
}
}
$query = new WC_Admin_Reports_Products_Stats_Query( $query_args );
$report_data = $query->get_data();
$query = new WC_Admin_Reports_Products_Stats_Query( $query_args );
try {
$report_data = $query->get_data();
} catch ( WC_Admin_Reports_Parameter_Exception $e ) {
return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
@ -146,7 +150,7 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
* @return array
*/
public function get_item_schema() {
$totals = array(
$data_values = array(
'items_sold' => array(
'description' => __( 'Number of items sold.', 'wc-admin' ),
'type' => 'integer',
@ -169,6 +173,35 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
),
);
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_products_stats',
@ -350,6 +383,16 @@ class WC_Admin_REST_Reports_Products_Stats_Controller extends WC_REST_Reports_Co
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}

View File

@ -38,14 +38,15 @@ class WC_Admin_REST_Reports_Revenue_Stats_Controller extends WC_REST_Reports_Con
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['segmentby'] = $request['segmentby'];
return $args;
}
@ -59,7 +60,11 @@ class WC_Admin_REST_Reports_Revenue_Stats_Controller extends WC_REST_Reports_Con
public function get_items( $request ) {
$query_args = $this->prepare_reports_query( $request );
$reports_revenue = new WC_Admin_Reports_Revenue_Query( $query_args );
$report_data = $reports_revenue->get_data();
try {
$report_data = $reports_revenue->get_data();
} catch ( WC_Admin_Reports_Parameter_Exception $e ) {
return new WP_Error( $e->getErrorCode(), $e->getMessage(), array( 'status' => $e->getCode() ) );
}
$out_data = array(
'totals' => get_object_vars( $report_data->totals ),
@ -130,7 +135,7 @@ class WC_Admin_REST_Reports_Revenue_Stats_Controller extends WC_REST_Reports_Con
* @return array
*/
public function get_item_schema() {
$totals = array(
$data_values = array(
'gross_revenue' => array(
'description' => __( 'Gross revenue.', 'wc-admin' ),
'type' => 'number',
@ -197,8 +202,39 @@ class WC_Admin_REST_Reports_Revenue_Stats_Controller extends WC_REST_Reports_Con
),
);
$intervals = $totals;
unset( $intervals['products'] );
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
// Products is not shown in intervals.
unset( $data_values['products'] );
$intervals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
@ -342,6 +378,18 @@ class WC_Admin_REST_Reports_Revenue_Stats_Controller extends WC_REST_Reports_Con
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ),
'type' => 'string',
'enum' => array(
'product',
'category',
'variation',
'coupon',
'customer_type', // new vs returning.
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}

View File

@ -68,15 +68,16 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
* @return array
*/
protected function prepare_reports_query( $request ) {
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['taxes'] = (array) $request['taxes'];
$args = array();
$args['before'] = $request['before'];
$args['after'] = $request['after'];
$args['interval'] = $request['interval'];
$args['page'] = $request['page'];
$args['per_page'] = $request['per_page'];
$args['orderby'] = $request['orderby'];
$args['order'] = $request['order'];
$args['taxes'] = (array) $request['taxes'];
$args['segmentby'] = $request['segmentby'];
return $args;
}
@ -161,7 +162,7 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
* @return array
*/
public function get_item_schema() {
$totals = array(
$data_values = array(
'total_tax' => array(
'description' => __( 'Total tax.', 'wc-admin' ),
'type' => 'number',
@ -192,7 +193,7 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'tax_codes' => array(
'tax_codes' => array(
'description' => __( 'Amount of tax codes.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
@ -200,6 +201,35 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
),
);
$segments = array(
'segments' => array(
'description' => __( 'Reports data grouped by segment condition.', 'wc-admin' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'items' => array(
'type' => 'object',
'properties' => array(
'segment_id' => array(
'description' => __( 'Segment identificator.', 'wc-admin' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'subtotals' => array(
'description' => __( 'Interval subtotals.', 'wc-admin' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $data_values,
),
),
),
),
);
$totals = array_merge( $data_values, $segments );
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_taxes_stats',
@ -273,9 +303,9 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['page'] = array(
'description' => __( 'Current page of the collection.', 'wc-admin' ),
'type' => 'integer',
'default' => 1,
@ -283,7 +313,7 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'wc-admin' ),
'type' => 'integer',
'default' => 10,
@ -292,26 +322,26 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$params['after'] = array(
$params['after'] = array(
'description' => __( 'Limit response to resources published after a given ISO8601 compliant date.', 'wc-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['before'] = array(
$params['before'] = array(
'description' => __( 'Limit response to resources published before a given ISO8601 compliant date.', 'wc-admin' ),
'type' => 'string',
'format' => 'date-time',
'validate_callback' => 'rest_validate_request_arg',
);
$params['order'] = array(
$params['order'] = array(
'description' => __( 'Order sort attribute ascending or descending.', 'wc-admin' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
);
$params['orderby'] = array(
$params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'wc-admin' ),
'type' => 'string',
'default' => 'date',
@ -324,7 +354,7 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['interval'] = array(
$params['interval'] = array(
'description' => __( 'Time interval to use for buckets in the returned data.', 'wc-admin' ),
'type' => 'string',
'default' => 'week',
@ -338,7 +368,7 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['taxes'] = array(
$params['taxes'] = array(
'description' => __( 'Limit result set to all items that have the specified term assigned in the taxes taxonomy.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
@ -347,6 +377,14 @@ class WC_Admin_REST_Reports_Taxes_Stats_Controller extends WC_REST_Reports_Contr
'type' => 'integer',
),
);
$params['segmentby'] = array(
'description' => __( 'Segment the response by additional constraint.', 'wc-admin' ),
'type' => 'string',
'enum' => array(
'tax_rate_id',
),
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}

View File

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

View File

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

View File

@ -110,6 +110,19 @@ class WC_Admin_Api_Init {
// Common date time code.
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-interval.php';
// Exceptions.
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-parameter-exception.php';
// WC Class extensions.
require_once dirname( __FILE__ ) . '/class-wc-admin-order.php';
// Segmentation.
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-segmenting.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-orders-stats-segmenting.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-products-stats-segmenting.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-coupons-stats-segmenting.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-taxes-stats-segmenting.php';
// Query classes for reports.
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-revenue-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-orders-query.php';
@ -167,6 +180,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-categories-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-reviews-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-setting-options-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-system-status-tools-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-categories-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-coupons-controller.php';
@ -186,6 +200,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-taxes-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-taxes-stats-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-stock-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-taxes-controller.php';
$controllers = apply_filters(
'woocommerce_admin_rest_controllers',
@ -201,6 +216,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Product_Categories_Controller',
'WC_Admin_REST_Product_Reviews_Controller',
'WC_Admin_REST_Reports_Controller',
'WC_Admin_REST_Setting_Options_Controller',
'WC_Admin_REST_System_Status_Tools_Controller',
'WC_Admin_REST_Reports_Products_Controller',
'WC_Admin_REST_Reports_Variations_Controller',
@ -218,6 +234,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Reports_Downloads_Stats_Controller',
'WC_Admin_REST_Reports_Customers_Controller',
'WC_Admin_REST_Reports_Customers_Stats_Controller',
'WC_Admin_REST_Taxes_Controller',
)
);
@ -367,6 +384,31 @@ class WC_Admin_Api_Init {
$endpoints['/wc/v4/products/reviews'][1] = $endpoints['/wc/v4/products/reviews'][3];
}
// Override /wc/v4/taxes.
if ( isset( $endpoints['/wc/v4/taxes'] )
&& isset( $endpoints['/wc/v4/taxes'][3] )
&& isset( $endpoints['/wc/v4/taxes'][2] )
&& $endpoints['/wc/v4/taxes'][2]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
&& $endpoints['/wc/v4/taxes'][3]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
) {
$endpoints['/wc/v4/taxes'][0] = $endpoints['/wc/v4/taxes'][2];
$endpoints['/wc/v4/taxes'][1] = $endpoints['/wc/v4/taxes'][3];
}
// Override /wc/v4/settings/$group_id.
if ( isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'] )
&& isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][5] )
&& isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][4] )
&& isset( $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][3] )
&& $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][3]['callback'][0] instanceof WC_Admin_REST_Setting_Options_Controller
&& $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][4]['callback'][0] instanceof WC_Admin_REST_Setting_Options_Controller
&& $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][5]['callback'][0] instanceof WC_Admin_REST_Setting_Options_Controller
) {
$endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][0] = $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][3];
$endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][1] = $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][4];
$endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][2] = $endpoints['/wc/v4/settings/(?P<group_id>[\w-]+)'][5];
}
return $endpoints;
}
@ -405,6 +447,9 @@ class WC_Admin_Api_Init {
* Init orders data store.
*/
public static function orders_data_store_init() {
// Activate WC_Order extension.
WC_Admin_Order::add_filters();
// Initialize data stores.
WC_Admin_Reports_Orders_Stats_Data_Store::init();
WC_Admin_Reports_Products_Data_Store::init();
WC_Admin_Reports_Taxes_Data_Store::init();
@ -454,7 +499,7 @@ class WC_Admin_Api_Init {
$order_ids = $order_query->get_orders();
foreach ( $order_ids as $order_id ) {
// TODO: schedule single order update if this fails?
// @todo: schedule single order update if this fails?
WC_Admin_Reports_Orders_Stats_Data_Store::sync_order( $order_id );
WC_Admin_Reports_Products_Data_Store::sync_order_products( $order_id );
WC_Admin_Reports_Coupons_Data_Store::sync_order_coupons( $order_id );
@ -605,7 +650,7 @@ class WC_Admin_Api_Init {
$customer_ids = $customer_query->get_results();
foreach ( $customer_ids as $customer_id ) {
// TODO: schedule single customer update if this fails?
// @todo: schedule single customer update if this fails?
WC_Admin_Reports_Customers_Data_Store::update_registered_customer( $customer_id );
}
}
@ -652,7 +697,7 @@ class WC_Admin_Api_Init {
return array_merge(
$wc_tables,
array(
// TODO: will this work on multisite?
// @todo: will this work on multisite?
"{$wpdb->prefix}wc_order_stats",
"{$wpdb->prefix}wc_order_product_lookup",
"{$wpdb->prefix}wc_order_tax_lookup",
@ -691,7 +736,9 @@ class WC_Admin_Api_Init {
status varchar(200) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (order_id),
KEY date_created (date_created)
KEY date_created (date_created),
KEY customer_id (customer_id),
KEY status (status)
) $collate;
CREATE TABLE {$wpdb->prefix}wc_order_product_lookup (
order_item_id BIGINT UNSIGNED NOT NULL,
@ -702,6 +749,12 @@ class WC_Admin_Api_Init {
date_created timestamp DEFAULT '0000-00-00 00:00:00' NOT NULL,
product_qty INT UNSIGNED NOT NULL,
product_net_revenue double DEFAULT 0 NOT NULL,
product_gross_revenue double DEFAULT 0 NOT NULL,
coupon_amount double DEFAULT 0 NOT NULL,
tax_amount double DEFAULT 0 NOT NULL,
shipping_amount double DEFAULT 0 NOT NULL,
shipping_tax_amount double DEFAULT 0 NOT NULL,
refund_amount double DEFAULT 0 NOT NULL,
PRIMARY KEY (order_item_id),
KEY order_id (order_id),
KEY product_id (product_id),
@ -715,7 +768,7 @@ class WC_Admin_Api_Init {
shipping_tax double DEFAULT 0 NOT NULL,
order_tax double DEFAULT 0 NOT NULL,
total_tax double DEFAULT 0 NOT NULL,
KEY order_id (order_id),
PRIMARY KEY (order_id, tax_rate_id),
KEY tax_rate_id (tax_rate_id),
KEY date_created (date_created)
) $collate;

View File

@ -0,0 +1,189 @@
<?php
/**
* WC Admin Order
*
* WC Admin Order class that adds some functionality on top of general WooCommerce WC_Order.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Order class.
*/
class WC_Admin_Order extends WC_Order {
/**
* Holds refund amounts and quantities for the order.
*
* @var void|array
*/
protected $refunded_line_items;
/**
* Add filter(s) required to hook WC_Admin_Order class to substitute WC_Order.
*/
public static function add_filters() {
add_filter( 'woocommerce_order_class', array( __CLASS__, 'order_class_name' ), 10, 3 );
}
/**
* Filter function to swap class WC_Order for WC_Admin_Order in cases when it's suitable.
*
* @param string $classname Name of the class to be created.
* @param string $order_type Type of order object to be created.
* @param number $order_id Order id to create.
*
* @return string
*/
public static function order_class_name( $classname, $order_type, $order_id ) {
if ( 'WC_Order' === $classname ) {
return 'WC_Admin_Order';
} else {
return $classname;
}
}
/**
* Calculate shipping amount for line item/product as a total shipping amount ratio based on quantity.
*
* @param WC_Order_Item $item Line item from order.
*
* @return float|int
*/
public function get_item_shipping_amount( $item ) {
// Shipping amount loosely based on woocommerce code in includes/admin/meta-boxes/views/html-order-item(s).php
// distributed simply based on number of line items.
$quantity_refunded = $this->get_item_quantity_refunded( $item );
$product_qty = $item->get_quantity( 'edit' ) - $quantity_refunded;
$order_items = $this->get_item_count();
if ( 0 === $order_items ) {
return 0;
}
$refunded = $this->get_total_shipping_refunded();
if ( $refunded > 0 ) {
$total_shipping_amount = $this->get_shipping_total() - $refunded;
} else {
$total_shipping_amount = $this->get_shipping_total();
}
return $total_shipping_amount / $order_items * $product_qty;
}
/**
* Save refund amounts and quantities for the order in an array for later use in calculations.
*/
protected function set_order_refund_items() {
if ( ! isset( $this->refunded_line_items ) ) {
$refunds = $this->get_refunds();
$refunded_line_items = array();
foreach ( $refunds as $refund ) {
foreach ( $refund->get_items() as $refunded_item ) {
$line_item_id = wc_get_order_item_meta( $refunded_item->get_id(), '_refunded_item_id', true );
if ( ! isset( $refunded_line_items[ $line_item_id ] ) ) {
$refunded_line_items[ $line_item_id ]['quantity'] = 0;
$refunded_line_items[ $line_item_id ]['subtotal'] = 0;
}
$refunded_line_items[ $line_item_id ]['quantity'] += absint( $refunded_item['quantity'] );
$refunded_line_items[ $line_item_id ]['subtotal'] += abs( $refunded_item['subtotal'] );
}
}
$this->refunded_line_items = $refunded_line_items;
}
}
/**
* Get quantity refunded for the line item.
*
* @param WC_Order_Item $item Line item from order.
*
* @return int
*/
public function get_item_quantity_refunded( $item ) {
$this->set_order_refund_items();
$order_item_id = $item->get_id();
return isset( $this->refunded_line_items[ $order_item_id ] ) ? $this->refunded_line_items[ $order_item_id ]['quantity'] : 0;
}
/**
* Get amount refunded for the line item.
*
* @param WC_Order_Item $item Line item from order.
*
* @return int
*/
public function get_item_amount_refunded( $item ) {
$this->set_order_refund_items();
$order_item_id = $item->get_id();
return isset( $this->refunded_line_items[ $order_item_id ] ) ? $this->refunded_line_items[ $order_item_id ]['subtotal'] : 0;
}
/**
* Get item quantity minus refunded quantity for the line item.
*
* @param WC_Order_Item $item Line item from order.
*
* @return int
*/
public function get_item_quantity_minus_refunded( $item ) {
return $item->get_quantity( 'edit' ) - $this->get_item_quantity_refunded( $item );
}
/**
* Calculate shipping tax amount for line item/product as a total shipping tax amount ratio based on quantity.
*
* Loosely based on code in includes/admin/meta-boxes/views/html-order-item(s).php.
*
* @todo: if WC is currently not tax enabled, but it was before (or vice versa), would this work correctly?
*
* @param WC_Order_Item $item Line item from order.
*
* @return float|int
*/
public function get_item_shipping_tax_amount( $item ) {
$order_items = $this->get_item_count();
if ( 0 === $order_items ) {
return 0;
}
$quantity_refunded = $this->get_item_quantity_refunded( $item );
$product_qty = $item->get_quantity( 'edit' ) - $quantity_refunded;
$order_taxes = $this->get_taxes();
$line_items_shipping = $this->get_items( 'shipping' );
$total_shipping_tax_amount = 0;
foreach ( $line_items_shipping as $item_id => $shipping_item ) {
$tax_data = $shipping_item->get_taxes();
if ( $tax_data ) {
foreach ( $order_taxes as $tax_item ) {
$tax_item_id = $tax_item->get_rate_id();
$tax_item_total = isset( $tax_data['total'][ $tax_item_id ] ) ? $tax_data['total'][ $tax_item_id ] : '';
$refunded = $this->get_tax_refunded_for_item( $item_id, $tax_item_id, 'shipping' );
if ( $refunded ) {
$total_shipping_tax_amount += $tax_item_total - $refunded;
} else {
$total_shipping_tax_amount += $tax_item_total;
}
}
}
}
return $total_shipping_tax_amount / $order_items * $product_qty;
}
/**
* Calculates coupon amount for specified line item/product.
*
* Coupon calculation based on woocommerce code in includes/admin/meta-boxes/views/html-order-item.php.
*
* @param WC_Order_Item $item Line item from order.
*
* @return float
*/
public function get_item_coupon_amount( $item ) {
return floatval( $item->get_subtotal( 'edit' ) - $item->get_total( 'edit' ) );
}
}

View File

@ -0,0 +1,312 @@
<?php
/**
* Class for adding segmenting support to coupons/stats without cluttering the data store.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class WC_Admin_Reports_Coupons_Stats_Segmenting extends WC_Admin_Reports_Segmenting {
/**
* Returns SELECT clause statements to be used for product-related product-level segmenting query (e.g. coupon discount amount for product X when segmenting by product id or category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return string SELECT clause statements.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'amount' => "SUM($products_table.coupon_amount) as amount",
);
return $this->prepare_selections( $columns_mapping );
}
/**
* Returns SELECT clause statements to be used for order-related product-level segmenting query (e.g. orders_count when segmented by category).
*
* @param string $coupons_lookup_table Name of SQL table containing the order-level segmenting info.
*
* @return string SELECT clause statements.
*/
protected function get_segment_selections_order_level( $coupons_lookup_table ) {
$columns_mapping = array(
'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count",
'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count",
);
return $this->prepare_selections( $columns_mapping );
}
/**
* Returns SELECT clause statements to be used for order-level segmenting query (e.g. discount amount when segmented by coupons).
*
* @param string $coupons_lookup_table Name of SQL table containing the order-level info.
* @param array $overrides Array of overrides for default column calculations.
*
* @return string
*/
protected function segment_selections_orders( $coupons_lookup_table, $overrides = array() ) {
$columns_mapping = array(
'amount' => "SUM($coupons_lookup_table.discount_amount) as amount",
'coupons_count' => "COUNT(DISTINCT $coupons_lookup_table.coupon_id) as coupons_count",
'orders_count' => "COUNT(DISTINCT $coupons_lookup_table.order_id) as orders_count",
);
if ( $overrides ) {
$columns_mapping = array_merge( $columns_mapping, $overrides );
}
return $this->prepare_selections( $columns_mapping );
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
// Product-level numbers and order-level numbers can be fetched by the same query.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
{$segmenting_selections['order_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
// LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments.
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
// Product-level numbers and order-level numbers can be fetched by the same query.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
{$segmenting_selections['order_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
return $intervals_segments;
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
global $wpdb;
$totals_segments = $wpdb->get_results(
"SELECT
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
return $totals_segments;
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
global $wpdb;
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
$intervals_segments = $wpdb->get_results(
"SELECT
MAX($table_name.date_created) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws WC_Admin_Reports_Parameter_Exception In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = '';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'product' === $this->query_args['segmentby'] ) {
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
'order_level' => $this->get_segment_selections_order_level( $table_name ),
);
$segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_groupby = $product_segmenting_table . '.product_id';
$segmenting_dimension_name = 'product_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
throw new WC_Admin_Reports_Parameter_Exception( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'wc-admin' ) );
}
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
'order_level' => $this->get_segment_selections_order_level( $table_name ),
);
$segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
'order_level' => $this->get_segment_selections_order_level( $table_name ),
);
$segmenting_from = "
INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id
RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id
";
$segmenting_where = " AND taxonomy = 'product_cat'";
$segmenting_groupby = 'wp_term_taxonomy.term_taxonomy_id';
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
$segmenting_selections = $this->segment_selections_orders( $table_name );
$segmenting_from = '';
$segmenting_groupby = "$table_name.coupon_id";
$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
return $segments;
}
}

View File

@ -34,7 +34,7 @@ class WC_Admin_Reports_Customers_Stats_Query extends WC_Admin_Reports_Query {
'page' => 1,
'order' => 'DESC',
'orderby' => 'date_registered',
'fields' => '*', // TODO: needed?
'fields' => '*', // @todo: needed?
);
}

View File

@ -194,7 +194,7 @@ class WC_Admin_Reports_Interval {
return (int) floor( ( (int) $diff_timestamp ) / DAY_IN_SECONDS ) + 1 + $addendum;
case 'week':
// TODO: optimize? approximately day count / 7, but year end is tricky, a week can have fewer days.
// @todo: optimize? approximately day count / 7, but year end is tricky, a week can have fewer days.
$week_count = 0;
do {
$start_datetime = self::next_week_start( $start_datetime );
@ -494,13 +494,15 @@ class WC_Admin_Reports_Interval {
}
/**
* Normalize "*_between" parameters to "*_min" and "*_max".
* Normalize "*_between" parameters to "*_min" and "*_max" for numeric values
* and "*_after" and "*_before" for date values.
*
* @param array $request Query params from REST API request.
* @param string|array $param_names One or more param names to handle. Should not include "_between" suffix.
* @param bool $is_date Boolean if the param is date is related.
* @return array Normalized query values.
*/
public static function normalize_between_params( $request, $param_names ) {
public static function normalize_between_params( $request, $param_names, $is_date ) {
if ( ! is_array( $param_names ) ) {
$param_names = array( $param_names );
}
@ -518,12 +520,15 @@ class WC_Admin_Reports_Interval {
continue;
}
$min = $is_date ? '_after' : '_min';
$max = $is_date ? '_before' : '_max';
if ( $range[0] < $range[1] ) {
$normalized[ $param_name . '_min' ] = $range[0];
$normalized[ $param_name . '_max' ] = $range[1];
$normalized[ $param_name . $min ] = $range[0];
$normalized[ $param_name . $max ] = $range[1];
} else {
$normalized[ $param_name . '_min' ] = $range[1];
$normalized[ $param_name . '_max' ] = $range[0];
$normalized[ $param_name . $min ] = $range[1];
$normalized[ $param_name . $max ] = $range[0];
}
}
@ -538,7 +543,7 @@ class WC_Admin_Reports_Interval {
* @param string $param Parameter name.
* @return WP_Error|boolean
*/
public static function rest_validate_between_arg( $value, $request, $param ) {
public static function rest_validate_between_numeric_arg( $value, $request, $param ) {
if ( ! wp_is_numeric_array( $value ) ) {
return new WP_Error(
'rest_invalid_param',
@ -561,4 +566,36 @@ class WC_Admin_Reports_Interval {
return true;
}
/**
* Validate a "*_between" range argument (an array with 2 date items).
*
* @param mixed $value Parameter value.
* @param WP_REST_Request $request REST Request.
* @param string $param Parameter name.
* @return WP_Error|boolean
*/
public static function rest_validate_between_date_arg( $value, $request, $param ) {
if ( ! wp_is_numeric_array( $value ) ) {
return new WP_Error(
'rest_invalid_param',
/* translators: 1: parameter name */
sprintf( __( '%1$s is not a numerically indexed array.', 'wc-admin' ), $param )
);
}
if (
2 !== count( $value ) ||
! rest_parse_date( $value[0] ) ||
! rest_parse_date( $value[1] )
) {
return new WP_Error(
'rest_invalid_param',
/* translators: %s: parameter name */
sprintf( __( '%s must contain 2 valid dates.', 'wc-admin' ), $param )
);
}
return true;
}
}

View File

@ -0,0 +1,419 @@
<?php
/**
* Class for adding segmenting support without cluttering the data stores.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class WC_Admin_Reports_Orders_Stats_Segmenting extends WC_Admin_Reports_Segmenting {
/**
* Returns SELECT clause statements to be used for product-related product-level segmenting query (e.g. products sold, revenue from product X when segmenting by category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return string SELECT clause statements.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'num_items_sold' => "SUM($products_table.product_qty) as num_items_sold",
'gross_revenue' => "SUM($products_table.product_gross_revenue) AS gross_revenue",
'coupons' => "SUM($products_table.coupon_amount) AS coupons",
'refunds' => "SUM($products_table.refund_amount) AS refunds",
'taxes' => "SUM($products_table.tax_amount) AS taxes",
'shipping' => "SUM($products_table.shipping_amount) AS shipping",
// @todo: product_net_revenue should already have refunds subtracted, so it should not be here. Pls check.
'net_revenue' => "SUM($products_table.product_net_revenue) AS net_revenue",
);
return $this->prepare_selections( $columns_mapping );
}
/**
* Returns SELECT clause statements to be used for order-related product-level segmenting query (e.g. avg items per order when segmented by category).
*
* @param string $unique_orders_table Name of SQL table containing the order-level segmenting info.
*
* @return string SELECT clause statements.
*/
protected function get_segment_selections_order_level( $unique_orders_table ) {
$columns_mapping = array(
'orders_count' => "COUNT($unique_orders_table.order_id) AS orders_count",
'avg_items_per_order' => "AVG($unique_orders_table.num_items_sold) AS avg_items_per_order",
'avg_order_value' => "(SUM($unique_orders_table.net_total) - SUM($unique_orders_table.refund_total))/COUNT($unique_orders_table.order_id) AS avg_order_value",
'num_returning_customers' => "SUM($unique_orders_table.returning_customer) AS num_returning_customers",
'num_new_customers' => "COUNT($unique_orders_table.returning_customer) - SUM($unique_orders_table.returning_customer) AS num_new_customers",
);
return $this->prepare_selections( $columns_mapping );
}
/**
* Returns SELECT clause statements to be used for order-level segmenting query (e.g. avg items per order or net revenue when segmented by coupons).
*
* @param string $order_stats_table Name of SQL table containing the order-level info.
* @param array $overrides Array of overrides for default column calculations.
*
* @return string
*/
protected function segment_selections_orders( $order_stats_table, $overrides = array() ) {
$columns_mapping = array(
'num_items_sold' => "SUM($order_stats_table.num_items_sold) as num_items_sold",
'gross_revenue' => "SUM($order_stats_table.gross_total) AS gross_revenue",
'coupons' => "SUM($order_stats_table.coupon_total) AS coupons",
'refunds' => "SUM($order_stats_table.refund_total) AS refunds",
'taxes' => "SUM($order_stats_table.tax_total) AS taxes",
'shipping' => "SUM($order_stats_table.shipping_total) AS shipping",
'net_revenue' => "SUM($order_stats_table.net_total) - SUM($order_stats_table.refund_total) AS net_revenue",
'orders_count' => "COUNT($order_stats_table.order_id) AS orders_count",
'avg_items_per_order' => "AVG($order_stats_table.num_items_sold) AS avg_items_per_order",
'avg_order_value' => "(SUM($order_stats_table.net_total) - SUM($order_stats_table.refund_total))/COUNT($order_stats_table.order_id) AS avg_order_value",
'num_returning_customers' => "SUM($order_stats_table.returning_customer) AS num_returning_customers",
'num_new_customers' => "COUNT($order_stats_table.returning_customer) - SUM($order_stats_table.returning_customer) AS num_new_customers",
);
if ( $overrides ) {
$columns_mapping = array_merge( $columns_mapping, $overrides );
}
return $this->prepare_selections( $columns_mapping );
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Order level numbers.
// As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc.
$segments_orders = $wpdb->get_results(
"SELECT
$unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name
{$segmenting_selections['order_level']}
FROM
(
SELECT
$table_name.order_id,
$segmenting_groupby AS $segmenting_dimension_name,
MAX( num_items_sold ) AS num_items_sold,
MAX( net_total ) as net_total,
MAX( refund_total ) as refund_total,
MAX( returning_customer ) AS returning_customer
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$product_segmenting_table.order_id, $segmenting_groupby
) AS $unique_orders_table
GROUP BY
$unique_orders_table.$segmenting_dimension_name",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, $segments_orders );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments.
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Order level numbers.
// As there can be 2 same product ids (or variation ids) per one order, the orders first need to be uniqued before calculating averages, customer counts, etc.
$segments_orders = $wpdb->get_results(
"SELECT
$unique_orders_table.time_interval AS time_interval,
$unique_orders_table.$segmenting_dimension_name AS $segmenting_dimension_name
{$segmenting_selections['order_level']}
FROM
(
SELECT
MAX( $table_name.date_created ) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$table_name.order_id,
$segmenting_groupby AS $segmenting_dimension_name,
MAX( num_items_sold ) AS num_items_sold,
MAX( net_total ) as net_total,
MAX( refund_total ) as refund_total,
MAX( returning_customer ) AS returning_customer
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $product_segmenting_table.order_id, $segmenting_groupby
) AS $unique_orders_table
GROUP BY
time_interval, $unique_orders_table.$segmenting_dimension_name
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, $segments_orders );
return $intervals_segments;
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
global $wpdb;
$totals_segments = $wpdb->get_results(
"SELECT
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
return $totals_segments;
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
global $wpdb;
$segmenting_limit = '';
$limit_parts = explode( ',', $intervals_query['limit'] );
if ( 2 === count( $limit_parts ) ) {
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
}
$intervals_segments = $wpdb->get_results(
"SELECT
MAX($table_name.date_created) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws WC_Admin_Reports_Parameter_Exception In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = 'uniq_orders';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'product' === $this->query_args['segmentby'] ) {
// @todo: how to handle shipping taxes when grouped by product?
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
'order_level' => $this->get_segment_selections_order_level( $unique_orders_table ),
);
$segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_groupby = $product_segmenting_table . '.product_id';
$segmenting_dimension_name = 'product_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
throw new WC_Admin_Reports_Parameter_Exception( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'wc-admin' ) );
}
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
'order_level' => $this->get_segment_selections_order_level( $unique_orders_table ),
);
$segmenting_from = "INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)";
$segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
'order_level' => $this->get_segment_selections_order_level( $unique_orders_table ),
);
$segmenting_from = "
INNER JOIN $product_segmenting_table ON ($table_name.order_id = $product_segmenting_table.order_id)
LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id
RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id
";
$segmenting_where = " AND taxonomy = 'product_cat'";
$segmenting_groupby = 'wp_term_taxonomy.term_taxonomy_id';
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
// As there can be 2 or more coupons applied per one order, coupon amount needs to be split.
$coupon_override = array(
'coupons' => 'SUM(coupon_lookup.discount_amount) AS coupons',
);
$segmenting_selections = $this->segment_selections_orders( $table_name, $coupon_override );
$segmenting_from = "
INNER JOIN {$wpdb->prefix}wc_order_coupon_lookup AS coupon_lookup ON ($table_name.order_id = coupon_lookup.order_id)
";
$segmenting_groupby = 'coupon_lookup.coupon_id';
$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
} elseif ( 'customer_type' === $this->query_args['segmentby'] ) {
$segmenting_selections = $this->segment_selections_orders( $table_name );
$segmenting_from = '';
$segmenting_groupby = "$table_name.returning_customer";
$segments = $this->get_order_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
return $segments;
}
}

View File

@ -0,0 +1,15 @@
<?php
/**
* WooCommerce Admin Input Parameter Exception Class
*
* Exception class thrown when user provides incorrect parameters.
*
* @package WooCommerce Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Reports_Parameter_Exception class.
*/
class WC_Admin_Reports_Parameter_Exception extends WC_Data_Exception {}

View File

@ -0,0 +1,189 @@
<?php
/**
* Class for adding segmenting support without cluttering the data stores.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class WC_Admin_Reports_Products_Stats_Segmenting extends WC_Admin_Reports_Segmenting {
/**
* Returns SELECT clause statements to be used for product-related product-level segmenting query (e.g. products sold, revenue from product X when segmenting by category).
*
* @param string $products_table Name of SQL table containing the product-level segmenting info.
*
* @return string SELECT clause statements.
*/
protected function get_segment_selections_product_level( $products_table ) {
$columns_mapping = array(
'items_sold' => "SUM($products_table.product_qty) as items_sold",
'net_revenue' => "SUM($products_table.product_net_revenue ) AS net_revenue",
'orders_count' => "COUNT( DISTINCT $products_table.order_id ) AS orders_count",
'products_count' => "COUNT( DISTINCT $products_table.product_id ) AS products_count",
);
return $this->prepare_selections( $columns_mapping );
}
/**
* Calculate segments for totals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $totals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$totals_segments = $this->merge_segment_totals_results( $segmenting_dimension_name, $segments_products, array() );
return $totals_segments;
}
/**
* Calculate segments for intervals where the segmenting property is bound to product (e.g. category, product_id, variation_id).
*
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $intervals_query, $unique_orders_table ) {
global $wpdb;
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
// LIMIT offset, rowcount needs to be updated to LIMIT offset, rowcount * max number of segments.
$limit_parts = explode( ',', $intervals_query['limit'] );
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
// Can't get all the numbers from one query, so split it into one query for product-level numbers and one for order-level numbers (which first need to have orders uniqued).
// Product-level numbers.
$segments_products = $wpdb->get_results(
"SELECT
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby AS $segmenting_dimension_name
{$segmenting_selections['product_level']}
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$intervals_segments = $this->merge_segment_intervals_results( $segmenting_dimension_name, $segments_products, array() );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws WC_Admin_Reports_Parameter_Exception In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$product_segmenting_table = $wpdb->prefix . 'wc_order_product_lookup';
$unique_orders_table = 'uniq_orders';
$segmenting_where = '';
// Product, variation, and category are bound to product, so here product segmenting table is required,
// while coupon and customer are bound to order, so we don't need the extra JOIN for those.
// This also means that segment selections need to be calculated differently.
if ( 'product' === $this->query_args['segmentby'] ) {
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
);
$segmenting_from = '';
$segmenting_groupby = $product_segmenting_table . '.product_id';
$segmenting_dimension_name = 'product_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
throw new WC_Admin_Reports_Parameter_Exception( 'wc_admin_reports_invalid_segmenting_variation', __( 'product_includes parameter need to specify exactly one product when segmenting by variation.', 'wc-admin' ) );
}
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
);
$segmenting_from = '';
$segmenting_where = "AND $product_segmenting_table.product_id = {$this->query_args['product_includes'][0]}";
$segmenting_groupby = $product_segmenting_table . '.variation_id';
$segmenting_dimension_name = 'variation_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$segmenting_selections = array(
'product_level' => $this->get_segment_selections_product_level( $product_segmenting_table ),
);
$segmenting_from = "
LEFT JOIN {$wpdb->prefix}term_relationships ON {$product_segmenting_table}.product_id = {$wpdb->prefix}term_relationships.object_id
RIGHT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id
";
$segmenting_where = " AND taxonomy = 'product_cat'";
$segmenting_groupby = 'wp_term_taxonomy.term_taxonomy_id';
$segmenting_dimension_name = 'category_id';
$segments = $this->get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
}
return $segments;
}
}

View File

@ -0,0 +1,520 @@
<?php
/**
* Class for adding segmenting support without cluttering the data stores.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class WC_Admin_Reports_Segmenting {
/**
* Array of all segment ids.
*
* @var array|bool
*/
protected $all_segment_ids = false;
/**
* Query arguments supplied by the user for data store.
*
* @var array
*/
protected $query_args = '';
/**
* SQL definition for each column.
*
* @var array
*/
protected $report_columns = array();
/**
* WC_Admin_Reports_Segmenting constructor.
*
* @param array $query_args Query arguments supplied by the user for data store.
* @param array $report_columns Report columns lookup from data store.
*/
public function __construct( $query_args, $report_columns ) {
$this->query_args = $query_args;
$this->report_columns = $report_columns;
}
/**
* Filters definitions for SELECT clauses based on query_args and joins them into one string usable in SELECT clause.
*
* @param array $columns_mapping Column name -> SQL statememt mapping.
*
* @return string to be used in SELECT clause statements.
*/
protected function prepare_selections( $columns_mapping ) {
if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) {
$keep = array();
foreach ( $this->query_args['fields'] as $field ) {
if ( isset( $columns_mapping[ $field ] ) ) {
$keep[ $field ] = $columns_mapping[ $field ];
}
}
$selections = implode( ', ', $keep );
} else {
$selections = implode( ', ', $columns_mapping );
}
if ( $selections ) {
$selections = ',' . $selections;
}
return $selections;
}
/**
* Update row-level db result for segments in 'totals' section to the format used for output.
*
* @param array $segments_db_result Results from the SQL db query for segmenting.
* @param string $segment_dimension Name of column used for grouping the result.
*
* @return array Reformatted array.
*/
protected function reformat_totals_segments( $segments_db_result, $segment_dimension ) {
$segment_result = array();
if ( strpos( $segment_dimension, '.' ) ) {
$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
}
foreach ( $segments_db_result as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$segment_result[ $segment_id ] = $segment_datum;
}
return $segment_result;
}
/**
* Merges segmented results for totals response part.
*
* E.g. $r1 = array(
* 0 => array(
* 'product_id' => 3,
* 'net_amount' => 15,
* ),
* );
* $r2 = array(
* 0 => array(
* 'product_id' => 3,
* 'avg_order_value' => 25,
* ),
* );
*
* $merged = array(
* 3 => array(
* 'segment_id' => 3,
* 'subtotals' => array(
* 'net_amount' => 15,
* 'avg_order_value' => 25,
* )
* ),
* );
*
* @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets.
* @param array $result1 Array 1 of segmented figures.
* @param array $result2 Array 2 of segmented figures.
*
* @return array
*/
protected function merge_segment_totals_results( $segment_dimension, $result1, $result2 ) {
$result_segments = array();
foreach ( $result1 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$result_segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
}
foreach ( $result2 as $segment_data ) {
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
if ( ! isset( $result_segments[ $segment_id ] ) ) {
$result_segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => array(),
);
}
$result_segments[ $segment_id ]['subtotals'] = array_merge( $result_segments[ $segment_id ]['subtotals'], $segment_data );
}
return $result_segments;
}
/**
* Merges segmented results for intervals response part.
*
* E.g. $r1 = array(
* 0 => array(
* 'product_id' => 3,
* 'time_interval' => '2018-12'
* 'net_amount' => 15,
* ),
* );
* $r2 = array(
* 0 => array(
* 'product_id' => 3,
* 'time_interval' => '2018-12'
* 'avg_order_value' => 25,
* ),
* );
*
* $merged = array(
* '2018-12' => array(
* 'segments' => array(
* 3 => array(
* 'segment_id' => 3,
* 'subtotals' => array(
* 'net_amount' => 15,
* 'avg_order_value' => 25,
* ),
* ),
* ),
* ),
* );
*
* @param string $segment_dimension Name of the segment dimension=key in the result arrays used to match records from result sets.
* @param array $result1 Array 1 of segmented figures.
* @param array $result2 Array 2 of segmented figures.
*
* @return array
*/
protected function merge_segment_intervals_results( $segment_dimension, $result1, $result2 ) {
$result_segments = array();
foreach ( $result1 as $segment_data ) {
$time_interval = $segment_data['time_interval'];
if ( ! isset( $result_segments[ $time_interval ] ) ) {
$result_segments[ $time_interval ] = array();
$result_segments[ $time_interval ]['segments'] = array();
}
unset( $segment_data['time_interval'] );
unset( $segment_data['datetime_anchor'] );
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$result_segments[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
}
foreach ( $result2 as $segment_data ) {
$time_interval = $segment_data['time_interval'];
if ( ! isset( $result_segments[ $time_interval ] ) ) {
$result_segments[ $time_interval ] = array();
$result_segments[ $time_interval ]['segments'] = array();
}
unset( $segment_data['time_interval'] );
unset( $segment_data['datetime_anchor'] );
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
if ( ! isset( $result_segments[ $time_interval ]['segments'][ $segment_id ] ) ) {
$result_segments[ $time_interval ]['segments'][ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => array(),
);
}
$result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'] = array_merge( $result_segments[ $time_interval ]['segments'][ $segment_id ]['subtotals'], $segment_data );
}
return $result_segments;
}
/**
* Update row-level db result for segments in 'intervals' section to the format used for output.
*
* @param array $segments_db_result Results from the SQL db query for segmenting.
* @param string $segment_dimension Name of column used for grouping the result.
*
* @return array Reformatted array.
*/
protected function reformat_intervals_segments( $segments_db_result, $segment_dimension ) {
$aggregated_segment_result = array();
if ( strpos( $segment_dimension, '.' ) ) {
$segment_dimension = substr( strstr( $segment_dimension, '.' ), 1 );
}
foreach ( $segments_db_result as $segment_data ) {
$time_interval = $segment_data['time_interval'];
if ( ! isset( $aggregated_segment_result[ $time_interval ] ) ) {
$aggregated_segment_result[ $time_interval ] = array();
$aggregated_segment_result[ $time_interval ]['segments'] = array();
}
unset( $segment_data['time_interval'] );
unset( $segment_data['datetime_anchor'] );
$segment_id = $segment_data[ $segment_dimension ];
unset( $segment_data[ $segment_dimension ] );
$segment_datum = array(
'segment_id' => $segment_id,
'subtotals' => $segment_data,
);
$aggregated_segment_result[ $time_interval ]['segments'][ $segment_id ] = $segment_datum;
}
return $aggregated_segment_result;
}
/**
* Fetches all segment ids from db and stores it for later use.
*
* @return void
*/
protected function set_all_segments() {
global $wpdb;
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
$this->all_segment_ids = array();
return;
}
if ( 'product' === $this->query_args['segmentby'] ) {
$segments = wc_get_products(
array(
'return' => 'ids',
'limit' => -1,
)
);
} elseif ( 'variation' === $this->query_args['segmentby'] ) {
// @todo: assuming that this will only be used for one product, check assumption.
if ( ! isset( $this->query_args['product_includes'] ) || count( $this->query_args['product_includes'] ) !== 1 ) {
$this->all_segment_ids = array();
return;
}
$segments = wc_get_products(
array(
'return' => 'ids',
'limit' => - 1,
'type' => 'variation',
'parent' => $this->query_args['product_includes'][0],
)
);
} elseif ( 'category' === $this->query_args['segmentby'] ) {
$categories = get_categories(
array(
'taxonomy' => 'product_cat',
)
);
$segments = wp_list_pluck( $categories, 'cat_ID' );
} elseif ( 'coupon' === $this->query_args['segmentby'] ) {
// @todo: switch to a non-direct-SQL way to get all coupons?
// @todo: These are only currently existing coupons, but we should add also deleted ones, if they have been used at least once.
$coupon_ids = $wpdb->get_results( "SELECT ID FROM {$wpdb->prefix}posts WHERE post_type='shop_coupon' AND post_status='publish'", ARRAY_A ); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$segments = wp_list_pluck( $coupon_ids, 'ID' );
} elseif ( 'customer_type' === $this->query_args['segmentby'] ) {
// 0 -- new customer
// 1 -- returning customer
$segments = array( 0, 1 );
} elseif ( 'tax_rate_id' === $this->query_args['segmentby'] ) {
// @todo: do we need to include tax rates that existed in the past, but have never been used? I guess there are other, more pressing problems...
// Current tax rates UNION previously used tax rates.
$tax_rate_ids = $wpdb->get_results(
"SELECT tax_rate_id FROM {$wpdb->prefix}woocommerce_tax_rates
UNION
SELECT DISTINCT meta_value FROM {$wpdb->prefix}woocommerce_order_itemmeta where meta_key='rate_id'",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$segments = wp_list_pluck( $tax_rate_ids, 'tax_rate_id' );
} else {
// Catch all default.
$segments = array();
}
$this->all_segment_ids = $segments;
}
/**
* Return all segment ids for given segmentby query parameter.
*
* @return array
*/
protected function get_all_segments() {
if ( ! is_array( $this->all_segment_ids ) ) {
$this->set_all_segments();
}
return $this->all_segment_ids;
}
/**
* Compares two report data objects by pre-defined object property and ASC/DESC ordering.
*
* @param stdClass $a Object a.
* @param stdClass $b Object b.
* @return string
*/
private function segment_cmp( $a, $b ) {
if ( $a['segment_id'] === $b['segment_id'] ) {
return 0;
} elseif ( $a['segment_id'] > $b['segment_id'] ) {
return 1;
} elseif ( $a['segment_id'] < $b['segment_id'] ) {
return - 1;
}
}
/**
* Adds zeroes for segments not present in the data selection.
*
* @param array $segments Array of segments from the database for given data points.
*
* @return array
*/
protected function fill_in_missing_segments( $segments ) {
$segment_subtotals = array();
if ( isset( $this->query_args['fields'] ) && is_array( $this->query_args['fields'] ) ) {
foreach ( $this->query_args['fields'] as $field ) {
if ( isset( $this->report_columns[ $field ] ) ) {
$segment_subtotals[ $field ] = 0;
}
}
} else {
foreach ( $this->report_columns as $field => $sql_clause ) {
$segment_subtotals[ $field ] = 0;
}
}
if ( ! is_array( $segments ) ) {
$segments = array();
}
$all_segment_ids = $this->get_all_segments();
foreach ( $all_segment_ids as $segment_id ) {
if ( ! isset( $segments[ $segment_id ] ) ) {
$segments[ $segment_id ] = array(
'segment_id' => $segment_id,
'subtotals' => $segment_subtotals,
);
}
}
// Using array_values to remove custom keys, so that it gets later converted to JSON as an array.
$segments_no_keys = array_values( $segments );
usort( $segments_no_keys, array( $this, 'segment_cmp' ) );
return $segments_no_keys;
}
/**
* Adds missing segments to intervals, modifies $data.
*
* @param stdClass $data Response data.
*/
protected function fill_in_missing_interval_segments( &$data ) {
foreach ( $data->intervals as $order_id => $interval_data ) {
$data->intervals[ $order_id ]['segments'] = $this->fill_in_missing_segments( $data->intervals[ $order_id ]['segments'] );
}
}
/**
* Calculate segments for segmenting property bound to product (e.g. category, product_id, variation_id).
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $segmenting_selections SELECT part of segmenting SQL query--one for 'product_level' and one for 'order_level'.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $segmenting_dimension_name Name of the segmenting dimension.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $query_params Array of SQL clauses for intervals/totals query.
* @param string $unique_orders_table Name of temporary SQL table that holds unique orders.
*
* @return array
*/
protected function get_product_related_segments( $type, $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table ) {
if ( 'totals' === $type ) {
return $this->get_product_related_totals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
} elseif ( 'intervals' === $type ) {
return $this->get_product_related_intervals_segments( $segmenting_selections, $segmenting_from, $segmenting_where, $segmenting_groupby, $segmenting_dimension_name, $table_name, $query_params, $unique_orders_table );
}
}
/**
* Calculate segments for segmenting property bound to order (e.g. coupon or customer type).
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $query_params Array of SQL clauses for intervals/totals query.
*
* @return array
*/
protected function get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params ) {
if ( 'totals' === $type ) {
return $this->get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
} elseif ( 'intervals' === $type ) {
return $this->get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
}
/**
* Assign segments to time intervals by updating original $intervals array.
*
* @param array $intervals Result array from intervals SQL query.
* @param array $intervals_segments Result array from interval segments SQL query.
*/
protected function assign_segments_to_intervals( &$intervals, $intervals_segments ) {
$old_keys = array_keys( $intervals );
foreach ( $intervals as $interval ) {
$intervals[ $interval['time_interval'] ] = $interval;
$intervals[ $interval['time_interval'] ]['segments'] = array();
}
foreach ( $old_keys as $key ) {
unset( $intervals[ $key ] );
}
foreach ( $intervals_segments as $time_interval => $segment ) {
if ( ! isset( $intervals[ $time_interval ] ) ) {
$intervals[ $time_interval ]['segments'] = array();
}
$intervals[ $time_interval ]['segments'] = $segment['segments'];
}
// To remove time interval keys (so that REST response is formatted correctly).
$intervals = array_values( $intervals );
}
/**
* Returns an array of segments for totals part of REST response.
*
* @param array $query_params Totals SQL query parameters.
* @param string $table_name Name of the SQL table that is the main order stats table.
*
* @return array
*/
public function get_totals_segments( $query_params, $table_name ) {
$segments = $this->get_segments( 'totals', $query_params, $table_name );
return $this->fill_in_missing_segments( $segments );
}
/**
* Adds an array of segments to data->intervals object.
*
* @param stdClass $data Data object representing the REST response.
* @param array $intervals_query Intervals SQL query parameters.
* @param string $table_name Name of the SQL table that is the main order stats table.
*/
public function add_intervals_segments( &$data, $intervals_query, $table_name ) {
$intervals_segments = $this->get_segments( 'intervals', $intervals_query, $table_name );
$this->assign_segments_to_intervals( $data->intervals, $intervals_segments );
$this->fill_in_missing_interval_segments( $data );
}
}

View File

@ -0,0 +1,147 @@
<?php
/**
* Class for adding segmenting support without cluttering the data stores.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* Date & time interval and numeric range handling class for Reporting API.
*/
class WC_Admin_Reports_Taxes_Stats_Segmenting extends WC_Admin_Reports_Segmenting {
/**
* Returns SELECT clause statements to be used for order-related order-level segmenting query (e.g. tax_rate_id).
*
* @param string $lookup_table Name of SQL table containing the order-level segmenting info.
*
* @return string SELECT clause statements.
*/
protected function get_segment_selections_order_level( $lookup_table ) {
$columns_mapping = array(
'tax_codes' => "COUNT(DISTINCT $lookup_table.tax_rate_id) as tax_codes",
'total_tax' => "SUM($lookup_table.total_tax) AS total_tax",
'order_tax' => "SUM($lookup_table.order_tax) as order_tax",
'shipping_tax' => "SUM($lookup_table.shipping_tax) as shipping_tax",
'orders_count' => "COUNT(DISTINCT $lookup_table.order_id) as orders_count",
);
return $this->prepare_selections( $columns_mapping );
}
/**
* Calculate segments for totals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $totals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_totals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $totals_query ) {
global $wpdb;
$totals_segments = $wpdb->get_results(
"SELECT
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$totals_query['from_clause']}
WHERE
1=1
{$totals_query['where_time_clause']}
{$totals_query['where_clause']}
$segmenting_where
GROUP BY
$segmenting_groupby",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$totals_segments = $this->reformat_totals_segments( $totals_segments, $segmenting_groupby );
return $totals_segments;
}
/**
* Calculate segments for intervals query where the segmenting property is bound to order (e.g. coupon or customer type).
*
* @param string $segmenting_select SELECT part of segmenting SQL query.
* @param string $segmenting_from FROM part of segmenting SQL query.
* @param string $segmenting_where WHERE part of segmenting SQL query.
* @param string $segmenting_groupby GROUP BY part of segmenting SQL query.
* @param string $table_name Name of SQL table which is the stats table for orders.
* @param array $intervals_query Array of SQL clauses for intervals query.
*
* @return array
*/
protected function get_order_related_intervals_segments( $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $intervals_query ) {
global $wpdb;
$segmenting_limit = '';
$limit_parts = explode( ',', $intervals_query['limit'] );
if ( 2 === count( $limit_parts ) ) {
$orig_rowcount = intval( $limit_parts[1] );
$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
}
$intervals_segments = $wpdb->get_results(
"SELECT
MAX($table_name.date_created) AS datetime_anchor,
{$intervals_query['select_clause']} AS time_interval,
$segmenting_groupby
$segmenting_select
FROM
$table_name
$segmenting_from
{$intervals_query['from_clause']}
WHERE
1=1
{$intervals_query['where_time_clause']}
{$intervals_query['where_clause']}
$segmenting_where
GROUP BY
time_interval, $segmenting_groupby
$segmenting_limit",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
// Reformat result.
$intervals_segments = $this->reformat_intervals_segments( $intervals_segments, $segmenting_groupby );
return $intervals_segments;
}
/**
* Return array of segments formatted for REST response.
*
* @param string $type Type of segments to return--'totals' or 'intervals'.
* @param array $query_params SQL query parameter array.
* @param string $table_name Name of main SQL table for the data store (used as basis for JOINS).
*
* @return array
* @throws WC_Admin_Reports_Parameter_Exception In case of segmenting by variations, when no parent product is specified.
*/
protected function get_segments( $type, $query_params, $table_name ) {
if ( ! isset( $this->query_args['segmentby'] ) || '' === $this->query_args['segmentby'] ) {
return array();
}
$segmenting_where = '';
$segmenting_from = '';
$segments = array();
if ( 'tax_rate_id' === $this->query_args['segmentby'] ) {
$segmenting_select = $this->get_segment_selections_order_level( $table_name );
$segmenting_groupby = $table_name . '.tax_rate_id';
$segments = $this->get_order_related_segments( $type, $segmenting_select, $segmenting_from, $segmenting_where, $segmenting_groupby, $table_name, $query_params );
}
return $segments;
}
}

View File

@ -95,7 +95,7 @@ class WC_Admin_Reports_Categories_Data_Store extends WC_Admin_Reports_Data_Store
$sql_query_params['where_clause'] .= " AND {$wpdb->prefix}term_taxonomy.term_id IN ({$included_categories})";
}
// TODO: only products in the category C or orders with products from category C (and, possibly others?).
// @todo: only products in the category C or orders with products from category C (and, possibly others?).
$included_products = $this->get_included_products( $query_args );
if ( $included_products ) {
$sql_query_params['where_clause'] .= " AND {$order_product_lookup_table}.product_id IN ({$included_products})";

View File

@ -165,7 +165,9 @@ class WC_Admin_Reports_Coupons_Stats_Data_Store extends WC_Admin_Reports_Coupons
if ( null === $totals ) {
return $data;
}
$totals = (object) $this->cast_numbers( $totals[0] );
$segmenter = new WC_Admin_Reports_Coupons_Stats_Segmenting( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
$totals = (object) $this->cast_numbers( $totals[0] );
// Intervals.
$this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name );
@ -213,6 +215,7 @@ class WC_Admin_Reports_Coupons_Stats_Data_Store extends WC_Admin_Reports_Coupons
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
wp_cache_set( $cache_key, $data, $this->cache_group );

View File

@ -25,11 +25,11 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* @var array
*/
protected $column_types = array(
'customer_id' => 'intval',
'user_id' => 'intval',
'orders_count' => 'intval',
'total_spend' => 'floatval',
'avg_order_value' => 'floatval',
'customer_id' => 'intval',
'user_id' => 'intval',
'orders_count' => 'intval',
'total_spend' => 'floatval',
'avg_order_value' => 'floatval',
);
/**
@ -41,7 +41,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
'customer_id' => 'customer_id',
'user_id' => 'user_id',
'username' => 'username',
'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // TODO: what does this mean for RTL?
'name' => "CONCAT_WS( ' ', first_name, last_name ) as name", // @todo: what does this mean for RTL?
'email' => 'email',
'country' => 'country',
'city' => 'city',
@ -60,7 +60,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
global $wpdb;
// Initialize some report columns that need disambiguation.
$this->report_columns['customer_id'] = $wpdb->prefix . self::TABLE_NAME . '.customer_id';
$this->report_columns['customer_id'] = $wpdb->prefix . self::TABLE_NAME . '.customer_id';
$this->report_columns['date_last_order'] = "MAX( {$wpdb->prefix}wc_order_stats.date_created ) as date_last_order";
}
@ -274,7 +274,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
}
if ( $where_clauses ) {
$preceding_match = empty( $sql_query_params['where_time_clause'] ) ? ' AND ' : " {$match_operator} ";
$preceding_match = empty( $sql_query_params['where_time_clause'] ) ? ' AND ' : " {$match_operator} ";
$sql_query_params['where_clause'] = $preceding_match . implode( " {$match_operator} ", $where_clauses );
}
@ -284,7 +284,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
}
if ( $having_clauses ) {
$preceding_match = empty( $sql_query_params['having_clause'] ) ? ' AND ' : " {$match_operator} ";
$preceding_match = empty( $sql_query_params['having_clause'] ) ? ' AND ' : " {$match_operator} ";
$sql_query_params['having_clause'] .= $preceding_match . implode( " {$match_operator} ", $having_clauses );
}
@ -304,7 +304,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
$order_stats_table_name = $wpdb->prefix . 'wc_order_stats';
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
@ -442,7 +442,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* Retrieve a guest (no user_id) customer row by email.
*
* @param string $email Email address.
* @returns false|array Customer array if found, boolean false if not.
* @return false|array Customer array if found, boolean false if not.
*/
public function get_guest_by_email( $email ) {
global $wpdb;
@ -467,7 +467,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* Retrieve a registered customer row by user_id.
*
* @param string|int $user_id User ID.
* @returns false|array Customer array if found, boolean false if not.
* @return false|array Customer array if found, boolean false if not.
*/
public function get_customer_by_user_id( $user_id ) {
global $wpdb;
@ -492,7 +492,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
* Retrieve a registered customer row id by user_id.
*
* @param string|int $user_id User ID.
* @returns false|int Customer ID if found, boolean false if not.
* @return false|int Customer ID if found, boolean false if not.
*/
public static function get_customer_id_by_user_id( $user_id ) {
global $wpdb;
@ -554,7 +554,7 @@ class WC_Admin_Reports_Customers_Data_Store extends WC_Admin_Reports_Data_Store
if ( $customer_id ) {
// Preserve customer_id for existing user_id.
$data['customer_id'] = $customer_id;
$format[] = '%d';
$format[] = '%d';
}
return $wpdb->replace( $wpdb->prefix . self::TABLE_NAME, $data, $format );

View File

@ -49,7 +49,7 @@ class WC_Admin_Reports_Data_Store {
*/
protected $report_columns = array();
// TODO: this does not really belong here, maybe factor out the comparison as separate class?
// @todo: this does not really belong here, maybe factor out the comparison as separate class?
/**
* Order by property, used in the cmp function.
*
@ -73,7 +73,7 @@ class WC_Admin_Reports_Data_Store {
private function interval_cmp( $a, $b ) {
if ( '' === $this->order_by || '' === $this->order ) {
return 0;
// TODO: should return WP_Error here perhaps?
// @todo: should return WP_Error here perhaps?
}
if ( $a[ $this->order_by ] === $b[ $this->order_by ] ) {
return 0;
@ -94,9 +94,20 @@ class WC_Admin_Reports_Data_Store {
* @param string $direction DESC/ASC.
*/
protected function sort_intervals( &$data, $sort_by, $direction ) {
$this->sort_array( $data->intervals, $sort_by, $direction );
}
/**
* Sorts array of arrays based on subarray key $sort_by.
*
* @param array $arr Array to sort.
* @param string $sort_by Ordering property.
* @param string $direction DESC/ASC.
*/
protected function sort_array( &$arr, $sort_by, $direction ) {
$this->order_by = $this->normalize_order_by( $sort_by );
$this->order = $direction;
usort( $data->intervals, array( $this, 'interval_cmp' ) );
usort( $arr, array( $this, 'interval_cmp' ) );
}
/**
@ -110,7 +121,7 @@ class WC_Admin_Reports_Data_Store {
* @return stdClass
*/
protected function fill_in_missing_intervals( $db_intervals, $datetime_start, $datetime_end, $time_interval, &$data ) {
// TODO: this is ugly and messy.
// @todo: this is ugly and messy.
// At this point, we don't know when we can stop iterating, as the ordering can be based on any value.
$end_datetime = new DateTime( $datetime_end );
$time_ids = array_flip( wp_list_pluck( $data->intervals, 'time_interval' ) );
@ -121,7 +132,7 @@ class WC_Admin_Reports_Data_Store {
foreach ( $totals_arr as $key => $val ) {
$totals_arr[ $key ] = 0;
}
// TODO: should 'products' be in intervals?
// @todo: should 'products' be in intervals?
unset( $totals_arr['products'] );
while ( $datetime <= $end_datetime ) {
$next_start = WC_Admin_Reports_Interval::iterate( $datetime, $time_interval );
@ -325,7 +336,7 @@ class WC_Admin_Reports_Data_Store {
$start_iteration = 0;
}
if ( $start_iteration ) {
// TODO: is this correct? should it only be added if iterate runs? other two iterate instances, too?
// @todo: is this correct? should it only be added if iterate runs? other two iterate instances, too?
$new_start_date_timestamp = (int) $new_start_date->format( 'U' ) + 1;
$new_start_date->setTimestamp( $new_start_date_timestamp );
}
@ -406,7 +417,9 @@ class WC_Admin_Reports_Data_Store {
* @return array
*/
protected static function get_excluded_report_order_statuses() {
return apply_filters( 'woocommerce_reports_excluded_order_statuses', array( 'refunded', 'pending', 'failed', 'cancelled' ) );
$excluded_statuses = WC_Admin_Settings::get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) );
$excluded_statuses[] = 'refunded';
return apply_filters( 'woocommerce_reports_excluded_order_statuses', $excluded_statuses );
}
/**
@ -449,7 +462,7 @@ class WC_Admin_Reports_Data_Store {
$datetime = new DateTime( $interval['datetime_anchor'] );
$prev_start = WC_Admin_Reports_Interval::iterate( $datetime, $time_interval, true );
// TODO: not sure if the +1/-1 here are correct, especially as they are applied before the ?: below.
// @todo: not sure if the +1/-1 here are correct, especially as they are applied before the ?: below.
$prev_start_timestamp = (int) $prev_start->format( 'U' ) + 1;
$prev_start->setTimestamp( $prev_start_timestamp );
if ( $datetime_start ) {

View File

@ -45,6 +45,7 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
'num_returning_customers' => 'intval',
'num_new_customers' => 'intval',
'products' => 'intval',
'segment_id' => 'intval',
);
/**
@ -73,7 +74,7 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
*/
public static function init() {
add_action( 'save_post', array( __CLASS__, 'sync_order' ) );
// TODO: this is required as order update skips save_post.
// @todo: this is required as order update skips save_post.
add_action( 'clean_post_cache', array( __CLASS__, 'sync_order' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'sync_order' ) );
add_action( 'woocommerce_refund_deleted', array( __CLASS__, 'sync_on_refund_delete' ), 10, 2 );
@ -88,7 +89,7 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
* @param array $intervals_query Array of options for intervals db query.
*/
protected function orders_stats_sql_filter( $query_args, &$totals_query, &$intervals_query ) {
// TODO: performance of all of this?
// @todo: performance of all of this?
global $wpdb;
$from_clause = '';
@ -97,7 +98,7 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
$where_filters = array();
// TODO: maybe move the sql inside the get_included/excluded functions?
// @todo: maybe move the sql inside the get_included/excluded functions?
// Products filters.
$included_products = $this->get_included_products( $query_args );
$excluded_products = $this->get_excluded_products( $query_args );
@ -176,7 +177,6 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
/**
* Returns the report data based on parameters supplied by the user.
*
* @since 3.5.0
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
@ -197,6 +197,7 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
'after' => date( WC_Admin_Reports_Interval::$iso_datetime_format, $week_back ),
'interval' => 'week',
'fields' => '*',
'segmentby' => '',
'match' => 'all',
'status_is' => array(),
@ -248,7 +249,10 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
$unique_products = $this->get_unique_product_count( $totals_query['from_clause'], $totals_query['where_time_clause'], $totals_query['where_clause'] );
$totals[0]['products'] = $unique_products;
$totals = (object) $this->cast_numbers( $totals[0] );
$segmenting = new WC_Admin_Reports_Orders_Stats_Segmenting( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenting->get_totals_segments( $totals_query, $table_name );
$totals = (object) $this->cast_numbers( $totals[0] );
$db_intervals = $wpdb->get_col(
"SELECT
@ -317,6 +321,7 @@ class WC_Admin_Reports_Orders_Stats_Data_Store extends WC_Admin_Reports_Data_Sto
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenting->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
wp_cache_set( $cache_key, $data, $this->cache_group );

View File

@ -332,69 +332,76 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i
return;
}
$refunds = self::get_order_refund_items( $order );
foreach ( $order->get_items() as $order_item ) {
$order_item_id = $order_item->get_id();
$quantity_refunded = isset( $refunds[ $order_item_id ] ) ? $refunds[ $order_item_id ]['quantity'] : 0;
$amount_refunded = isset( $refunds[ $order_item_id ] ) ? $refunds[ $order_item_id ]['subtotal'] : 0;
$order_item_id = $order_item->get_id();
$quantity_refunded = $order->get_item_quantity_refunded( $order_item );
$amount_refunded = $order->get_item_amount_refunded( $order_item );
$product_qty = $order->get_item_quantity_minus_refunded( $order_item );
$shipping_amount = $order->get_item_shipping_amount( $order_item );
$shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_item );
$coupon_amount = $order->get_item_coupon_amount( $order_item );
// Tax amount.
// @todo: check if this calculates tax correctly with refunds.
$tax_amount = 0;
$order_taxes = $order->get_taxes();
$tax_data = $order_item->get_taxes();
foreach ( $order_taxes as $tax_item ) {
$tax_item_id = $tax_item->get_rate_id();
$tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? $tax_data['total'][ $tax_item_id ] : 0;
}
// @todo: should net revenue be affected by refunds, as refunds are tracked separately?
$net_revenue = $order_item->get_subtotal( 'edit' ) - $amount_refunded;
if ( $quantity_refunded >= $order_item->get_quantity( 'edit' ) ) {
$wpdb->delete(
$wpdb->prefix . self::TABLE_NAME,
array( 'order_item_id' => $order_item_id ),
array( '%d' )
);
); // WPCS: cache ok, DB call ok.
} else {
$wpdb->replace(
$wpdb->prefix . self::TABLE_NAME,
array(
'order_item_id' => $order_item_id,
'order_id' => $order->get_id(),
'product_id' => $order_item->get_product_id( 'edit' ),
'variation_id' => $order_item->get_variation_id( 'edit' ),
'customer_id' => ( 0 < $order->get_customer_id( 'edit' ) ) ? $order->get_customer_id( 'edit' ) : null,
'product_qty' => $order_item->get_quantity( 'edit' ) - $quantity_refunded,
'product_net_revenue' => $order_item->get_subtotal( 'edit' ) - $amount_refunded,
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
'order_item_id' => $order_item_id,
'order_id' => $order->get_id(),
'product_id' => $order_item->get_product_id( 'edit' ),
'variation_id' => $order_item->get_variation_id( 'edit' ),
'customer_id' => ( 0 < $order->get_customer_id( 'edit' ) ) ? $order->get_customer_id( 'edit' ) : null,
'product_qty' => $product_qty,
'product_net_revenue' => $net_revenue,
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
'coupon_amount' => $coupon_amount,
'tax_amount' => $tax_amount,
'shipping_amount' => $shipping_amount,
'shipping_tax_amount' => $shipping_tax_amount,
// @todo: can this be incorrect if modified by filters?
'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
'refund_amount' => $amount_refunded,
),
array(
'%d',
'%d',
'%d',
'%d',
'%d',
'%d',
'%f',
'%s',
'%d', // order_item_id.
'%d', // order_id.
'%d', // product_id.
'%d', // variation_id.
'%d', // customer_id.
'%d', // product_qty.
'%f', // product_net_revenue.
'%s', // date_created.
'%f', // coupon_amount.
'%f', // tax_amount.
'%f', // shipping_amount.
'%f', // shipping_tax_amount.
'%f', // product_gross_revenue.
'%f', // refund_amount.
)
);
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
}
}
}
/**
* Get order refund items quantity and subtotal
*
* @param object $order WC Order object.
* @return array
*/
public static function get_order_refund_items( $order ) {
$refunds = $order->get_refunds();
$refunded_line_items = array();
foreach ( $refunds as $refund ) {
foreach ( $refund->get_items() as $refunded_item ) {
$line_item_id = wc_get_order_item_meta( $refunded_item->get_id(), '_refunded_item_id', true );
if ( ! isset( $refunded_line_items[ $line_item_id ] ) ) {
$refunded_line_items[ $line_item_id ]['quantity'] = 0;
$refunded_line_items[ $line_item_id ]['subtotal'] = 0;
}
$refunded_line_items[ $line_item_id ]['quantity'] += absint( $refunded_item['quantity'] );
$refunded_line_items[ $line_item_id ]['subtotal'] += abs( $refunded_item['subtotal'] );
}
}
return $refunded_line_items;
}
/**
* Clean products data when an order is deleted.
*

View File

@ -159,6 +159,9 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$segmenter = new WC_Admin_Reports_Products_Stats_Segmenting( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
if ( null === $totals ) {
return new WP_Error( 'woocommerce_reports_products_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'wc-admin' ) );
}
@ -208,6 +211,7 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
wp_cache_set( $cache_key, $data, $this->cache_group );

View File

@ -183,6 +183,8 @@ class WC_Admin_Reports_Taxes_Stats_Data_Store extends WC_Admin_Reports_Data_Stor
if ( null === $totals ) {
return new WP_Error( 'woocommerce_reports_taxes_stats_result_failed', __( 'Sorry, fetching revenue data failed.', 'wc-admin' ) );
}
$segmenter = new WC_Admin_Reports_Taxes_Stats_Segmenting( $query_args, $this->report_columns );
$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );
$this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count, $table_name );
@ -231,6 +233,7 @@ class WC_Admin_Reports_Taxes_Stats_Data_Store extends WC_Admin_Reports_Data_Stor
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$segmenter->add_intervals_segments( $data, $intervals_query, $table_name );
$this->create_interval_subtotals( $data->intervals );
wp_cache_set( $cache_key, $data, $this->cache_group );

View File

@ -25,17 +25,18 @@ class WC_Admin_Reports_Variations_Data_Store extends WC_Admin_Reports_Data_Store
* @var array
*/
protected $column_types = array(
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'variation_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'name' => 'strval',
'price' => 'floatval',
'image' => 'strval',
'permalink' => 'strval',
'date_start' => 'strval',
'date_end' => 'strval',
'product_id' => 'intval',
'variation_id' => 'intval',
'items_sold' => 'intval',
'net_revenue' => 'floatval',
'orders_count' => 'intval',
'name' => 'strval',
'price' => 'floatval',
'image' => 'strval',
'permalink' => 'strval',
'sku' => 'strval',
);
/**
@ -64,6 +65,7 @@ class WC_Admin_Reports_Variations_Data_Store extends WC_Admin_Reports_Data_Store
'stock_status',
'stock_quantity',
'low_stock_amount',
'sku',
);
/**

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "wc-admin",
"version": "0.5.0",
"version": "0.6.0",
"main": "js/index.js",
"author": "Automattic",
"license": "GPL-2.0-or-later",
@ -49,8 +49,8 @@
"@babel/cli": "7.2.3",
"@babel/core": "7.2.2",
"@babel/plugin-transform-async-to-generator": "7.2.0",
"@babel/plugin-transform-react-jsx": "7.2.0",
"@babel/runtime-corejs2": "7.2.0",
"@babel/plugin-transform-react-jsx": "7.3.0",
"@babel/runtime-corejs2": "7.3.1",
"@wordpress/babel-plugin-import-jsx-pragma": "1.1.2",
"@wordpress/babel-plugin-makepot": "2.1.2",
"@wordpress/babel-preset-default": "3.0.1",
@ -59,7 +59,7 @@
"@wordpress/jest-preset-default": "3.0.3",
"@wordpress/postcss-themes": "1.0.4",
"ast-types": "0.11.7",
"autoprefixer": "9.4.5",
"autoprefixer": "9.4.7",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "10.0.1",
"babel-loader": "8.0.5",
@ -73,19 +73,19 @@
"deasync": "0.1.14",
"deep-freeze": "0.0.1",
"docsify-cli": "4.3.0",
"eslint": "5.12.0",
"eslint": "5.12.1",
"eslint-config-wpcalypso": "4.0.1",
"eslint-loader": "2.1.1",
"eslint-plugin-jest": "22.1.3",
"eslint-plugin-jsx-a11y": "6.1.2",
"eslint-plugin-react": "7.12.3",
"eslint-plugin-jest": "22.2.1",
"eslint-plugin-jsx-a11y": "6.2.0",
"eslint-plugin-react": "7.12.4",
"eslint-plugin-wpcalypso": "4.0.2",
"grunt": "1.0.3",
"grunt-checktextdomain": "1.0.1",
"grunt-wp-i18n": "1.0.3",
"husky": "1.3.1",
"lerna": "3.10.5",
"locutus": "^2.0.10",
"lerna": "3.10.7",
"locutus": "2.0.10",
"mini-css-extract-plugin": "0.5.0",
"node-sass": "4.11.0",
"node-watch": "0.6.0",
@ -122,7 +122,7 @@
"@wordpress/viewport": "^2.0.7",
"browser-filesaver": "^1.1.1",
"classnames": "^2.2.5",
"core-js": "2.6.2",
"core-js": "2.6.3",
"d3-array": "^2.0.0",
"d3-axis": "^1.0.12",
"d3-format": "^1.3.2",

View File

@ -1,7 +1,11 @@
# 1.4.1 (unreleased)
# 1.4.2
- Add emoji-flags dependency
# 1.4.1
- Chart component: format numbers and prices using store currency settings.
- Make `href`/linking optional in SummaryNumber.
- Fix SummaryNumber example code.
- New `<Search>` prop named `showClearButton`, that will display a 'Clear' button when the search box contains one or more tags.
# 1.4.0
- Add download log ip address autocompleter to search component

View File

@ -1,6 +1,6 @@
{
"name": "@woocommerce/components",
"version": "1.4.0",
"version": "1.4.2",
"description": "UI components for WooCommerce.",
"author": "Automattic",
"license": "GPL-2.0-or-later",
@ -21,11 +21,11 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.2.0",
"@woocommerce/csv-export": "^1.0.2",
"@woocommerce/currency": "^1.0.0",
"@woocommerce/date": "^1.0.5",
"@woocommerce/navigation": "^1.1.0",
"@babel/runtime-corejs2": "7.3.1",
"@woocommerce/csv-export": "^1.0.3",
"@woocommerce/currency": "^1.1.0",
"@woocommerce/date": "^1.0.6",
"@woocommerce/navigation": "^1.1.1",
"@wordpress/components": "7.0.5",
"@wordpress/compose": "3.0.0",
"@wordpress/date": "3.0.1",
@ -35,7 +35,7 @@
"@wordpress/keycodes": "2.0.5",
"@wordpress/viewport": "^2.0.7",
"classnames": "^2.2.5",
"core-js": "2.6.2",
"core-js": "2.6.3",
"d3-array": "^2.0.0",
"d3-axis": "^1.0.12",
"d3-format": "^1.3.2",
@ -44,6 +44,7 @@
"d3-selection": "^1.3.2",
"d3-shape": "^1.2.2",
"d3-time-format": "^2.1.3",
"emoji-flags": "^1.2.0",
"gridicons": "3.1.1",
"interpolate-components": "1.1.1",
"lodash": "^4.17.11",
@ -52,8 +53,7 @@
"qs": "^6.5.2",
"react-dates": "^18.0.4",
"react-router-dom": "4.3.1",
"react-transition-group": "^2.4.0",
"react-world-flags": "1.2.4"
"react-transition-group": "^2.4.0"
},
"publishConfig": {
"access": "public"

View File

@ -132,6 +132,16 @@ export class Autocomplete extends Component {
loadOptions( completer, query ) {
const { options } = completer;
if ( ! query ) {
this.setState( {
options: [],
filteredOptions: [],
selectedIndex: 0,
} );
return;
}
/*
* We support both synchronous and asynchronous retrieval of completer options
* but internally treat all as async so we maintain a single, consistent code path.
@ -171,9 +181,7 @@ export class Autocomplete extends Component {
filteredOptions,
selectedIndex,
} );
if ( query ) {
this.announce( filteredOptions );
}
this.announce( filteredOptions );
} ) );
}
@ -183,7 +191,7 @@ export class Autocomplete extends Component {
const container = event.target;
// look for the trigger prefix and search query just before the cursor location
const query = container.value;
const query = container.value.trim();
// asynchronously load the options for the open completer
if ( completer && query !== wasQuery ) {
if ( completer.isDebounced ) {
@ -196,7 +204,8 @@ export class Autocomplete extends Component {
const expression = 'undefined' !== typeof completer.getSearchExpression
? completer.getSearchExpression( escapeRegExp( query ) )
: escapeRegExp( query );
const search = new RegExp( expression, 'i' );
// if there is no expression, match empty string
const search = expression ? new RegExp( expression, 'i' ) : /^$/;
// filter the options we already have
const filteredOptions = filterOptions( search, this.state.options, selected );
// update the state
@ -207,26 +216,36 @@ export class Autocomplete extends Component {
}
}
getOptions() {
const { allowFreeText, completer } = this.props;
const { getFreeTextOptions } = completer;
const { filteredOptions, query } = this.state;
const additionalOptions = allowFreeText && getFreeTextOptions ? getFreeTextOptions( query ) : [];
return additionalOptions.concat( filteredOptions );
}
handleKeyDown( event ) {
const { selectedIndex, filteredOptions } = this.state;
if ( filteredOptions.length === 0 ) {
const options = this.getOptions();
const { selectedIndex } = this.state;
if ( options.length === 0 ) {
return;
}
let nextSelectedIndex;
switch ( event.keyCode ) {
case UP:
nextSelectedIndex = ( selectedIndex === 0 ? filteredOptions.length : selectedIndex ) - 1;
nextSelectedIndex = ( selectedIndex === 0 ? options.length : selectedIndex ) - 1;
this.setState( { selectedIndex: nextSelectedIndex } );
break;
case TAB:
case DOWN:
nextSelectedIndex = ( selectedIndex + 1 ) % filteredOptions.length;
nextSelectedIndex = ( selectedIndex + 1 ) % options.length;
this.setState( { selectedIndex: nextSelectedIndex } );
break;
case ENTER:
this.select( filteredOptions[ selectedIndex ] );
this.select( options[ selectedIndex ] );
break;
case LEFT:
@ -255,9 +274,13 @@ export class Autocomplete extends Component {
this.node[ handler ]( 'keydown', this.handleKeyDown, true );
}
isExpanded( props, state ) {
return state.filteredOptions.length > 0 || ( props.completer.getFreeTextOptions && state.query );
}
componentDidUpdate( prevProps, prevState ) {
const isExpanded = this.state.filteredOptions.length > 0;
const wasExpanded = prevState.filteredOptions.length > 0;
const isExpanded = this.isExpanded( this.props, this.state );
const wasExpanded = this.isExpanded( prevProps, prevState );
if ( isExpanded && ! wasExpanded ) {
this.toggleKeyEvents( true );
} else if ( ! isExpanded && wasExpanded ) {
@ -272,9 +295,10 @@ export class Autocomplete extends Component {
render() {
const { children, instanceId, completer: { className = '' }, staticResults } = this.props;
const { selectedIndex, filteredOptions, query } = this.state;
const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {};
const isExpanded = filteredOptions.length > 0 && !! query;
const { selectedIndex } = this.state;
const isExpanded = this.isExpanded( this.props, this.state );
const options = isExpanded ? this.getOptions() : [];
const { key: selectedKey = '' } = options[ selectedIndex ] || {};
const listBoxId = isExpanded ? `woocommerce-search__autocomplete-${ instanceId }` : null;
const activeId = isExpanded
? `woocommerce-search__autocomplete-${ instanceId }-${ selectedKey }`
@ -282,12 +306,13 @@ export class Autocomplete extends Component {
const resultsClasses = classnames( 'woocommerce-search__autocomplete-results', {
'is-static-results': staticResults,
} );
return (
<div ref={ this.bindNode } className="woocommerce-search__autocomplete">
{ children( { isExpanded, listBoxId, activeId, onChange: this.search } ) }
{ isExpanded && (
<div id={ listBoxId } role="listbox" className={ resultsClasses }>
{ filteredOptions.map( ( option, index ) => (
{ options.map( ( option, index ) => (
<Button
key={ option.key }
id={ `woocommerce-search__autocomplete-${ instanceId }-${ option.key }` }

View File

@ -2,7 +2,9 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from 'interpolate-components';
/**
* WooCommerce dependencies
@ -33,12 +35,31 @@ export default {
};
payload = stringifyQuery( query );
}
return apiFetch( { path: `/wc/v3/products/categories${ payload }` } );
return apiFetch( { path: `/wc/v4/products/categories${ payload }` } );
},
isDebounced: true,
getOptionKeywords( cat ) {
return [ cat.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __( 'All categories with titles that include {{query /}}', 'wc-admin' ),
components: {
query: <strong className="components-form-token-field__suggestion-match">{ query }</strong>,
},
} ) }
</span>
);
const titleOption = {
key: 'title',
label: label,
value: { id: query, name: query },
};
return [ titleOption ];
},
getOptionLabel( cat, query ) {
const match = computeSuggestionMatch( cat.name, query ) || {};
// @todo bring back ProductImage, but allow for product category image

View File

@ -2,7 +2,9 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from 'interpolate-components';
/**
* WooCommerce dependencies
@ -38,6 +40,25 @@ export default {
getOptionKeywords( coupon ) {
return [ coupon.code ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __( 'All coupons with codes that include {{query /}}', 'wc-admin' ),
components: {
query: <strong className="components-form-token-field__suggestion-match">{ query }</strong>,
},
} ) }
</span>
);
const codeOption = {
key: 'code',
label: label,
value: { id: query, code: query },
};
return [ codeOption ];
},
getOptionLabel( coupon, query ) {
const match = computeSuggestionMatch( coupon.code, query ) || {};
return [

View File

@ -2,7 +2,9 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from 'interpolate-components';
/**
* WooCommerce dependencies
@ -29,7 +31,7 @@ export default {
let payload = '';
if ( name ) {
const query = {
name,
search: name,
per_page: 10,
};
payload = stringifyQuery( query );
@ -40,6 +42,25 @@ export default {
getOptionKeywords( customer ) {
return [ getName( customer ) ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __( 'All customers with names that include {{query /}}', 'wc-admin' ),
components: {
query: <strong className="components-form-token-field__suggestion-match">{ query }</strong>,
},
} ) }
</span>
);
const nameOption = {
key: 'name',
label: label,
value: { id: query, first_name: query },
};
return [ nameOption ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( getName( customer ), query ) || {};
return [

View File

@ -2,7 +2,9 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from 'interpolate-components';
/**
* WooCommerce dependencies
@ -40,6 +42,25 @@ export default {
getOptionKeywords( product ) {
return [ product.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __( 'All products with titles that include {{query /}}', 'wc-admin' ),
components: {
query: <strong className="components-form-token-field__suggestion-match">{ query }</strong>,
},
} ) }
</span>
);
const titleOption = {
key: 'title',
label: label,
value: { id: query, name: query },
};
return [ titleOption ];
},
getOptionLabel( product, query ) {
const match = computeSuggestionMatch( product.name, query ) || {};
return [

View File

@ -2,7 +2,9 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from 'interpolate-components';
/**
* WooCommerce dependencies
@ -38,6 +40,25 @@ export default {
getOptionKeywords( tax ) {
return [ tax.id, getTaxCode( tax ) ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __( 'All taxes with codes that include {{query /}}', 'wc-admin' ),
components: {
query: <strong className="components-form-token-field__suggestion-match">{ query }</strong>,
},
} ) }
</span>
);
const codeOption = {
key: 'code',
label: label,
value: { id: query, name: query },
};
return [ codeOption ];
},
getOptionLabel( tax, query ) {
const match = computeSuggestionMatch( getTaxCode( tax ), query ) || {};
return [

View File

@ -27,12 +27,12 @@ export function computeSuggestionMatch( suggestion, query ) {
export function getTaxCode( tax ) {
return [ tax.country, tax.state, tax.name || __( 'TAX', 'wc-admin' ), tax.priority ]
.filter( Boolean )
.map( item =>
item
.toString()
.toUpperCase()
.trim()
)
.filter( Boolean )
.join( '-' );
}

View File

@ -4,6 +4,7 @@
*/
import { __, sprintf } from '@wordpress/i18n';
import { Component, createRef, Fragment } from '@wordpress/element';
import { Button, Icon } from '@wordpress/components';
import { withInstanceId } from '@wordpress/compose';
import { findIndex, noop } from 'lodash';
import Gridicon from 'gridicons';
@ -44,6 +45,7 @@ class Search extends Component {
this.input = createRef();
this.selectResult = this.selectResult.bind( this );
this.removeAll = this.removeAll.bind( this );
this.removeResult = this.removeResult.bind( this );
this.updateSearch = this.updateSearch.bind( this );
this.onFocus = this.onFocus.bind( this );
@ -60,6 +62,11 @@ class Search extends Component {
}
}
removeAll() {
const { onChange } = this.props;
onChange( [] );
}
removeResult( id ) {
return () => {
const { selected, onChange } = this.props;
@ -149,7 +156,16 @@ class Search extends Component {
render() {
const autocompleter = this.getAutocompleter();
const { placeholder, inlineTags, selected, instanceId, className, staticResults } = this.props;
const {
allowFreeTextSearch,
className,
inlineTags,
instanceId,
placeholder,
selected,
showClearButton,
staticResults,
} = this.props;
const { value = '', isActive } = this.state;
const aria = {
'aria-labelledby': this.props[ 'aria-labelledby' ],
@ -164,6 +180,7 @@ class Search extends Component {
'has-inline-tags': inlineTags,
} ) }>
<Autocomplete
allowFreeText={ allowFreeTextSearch }
completer={ autocompleter }
onSelect={ this.selectResult }
selected={ selected.map( s => s.id ) }
@ -209,7 +226,7 @@ class Search extends Component {
{ ...aria }
/>
<span id={ `search-inline-input-${ instanceId }` } className="screen-reader-text">
{ __( 'Move backward for selected items' ) }
{ __( 'Move backward for selected items', 'wc-admin' ) }
</span>
</div>
</div>
@ -231,6 +248,16 @@ class Search extends Component {
}
</Autocomplete>
{ ! inlineTags && this.renderTags() }
{ showClearButton && shouldRenderTags ? (
<Button
className="woocommerce-search__clear"
isLink
onClick={ this.removeAll }
>
<Icon icon="dismiss" />
<span className="screen-reader-text">{ __( 'Clear all', 'wc-admin' ) }</span>
</Button>
) : null }
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
@ -238,6 +265,10 @@ class Search extends Component {
}
Search.propTypes = {
/**
* Render additional options in the autocompleter to allow free text entering depending on the type.
*/
allowFreeTextSearch: PropTypes.bool,
/**
* Class name applied to parent div.
*/
@ -284,6 +315,10 @@ Search.propTypes = {
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton: PropTypes.bool,
/**
* Render results list positioned statically instead of absolutely.
*/
@ -291,9 +326,11 @@ Search.propTypes = {
};
Search.defaultProps = {
allowFreeTextSearch: false,
onChange: noop,
selected: [],
inlineTags: false,
showClearButton: false,
staticResults: false,
};

View File

@ -127,3 +127,13 @@
.woocommerce-search__result-name {
text-decoration: underline; // @todo Not actually a link, should underline?
}
.woocommerce-search__clear {
position: absolute;
right: 10px;
top: calc( 50% - 10px );
& > .dashicon {
color: #c9c9c9;
}
}

View File

@ -3,6 +3,7 @@
* External dependencies
*/
import { mount } from 'enzyme';
import { Button } from '@wordpress/components';
/**
* Internal dependencies
@ -10,19 +11,43 @@ import { mount } from 'enzyme';
import { Autocomplete } from '../autocomplete';
describe( 'Autocomplete', () => {
const suggestionClassname = 'autocomplete-result';
const search = 'lorem';
const options = [
{ key: '1', label: 'lorem 1', value: { id: '1' } },
{ key: '2', label: 'lorem 2', value: { id: '2' } },
{ key: '3', label: 'bar', value: { id: '3' } },
];
it( 'returns matching elements', () => {
const autocomplete = mount(
<Autocomplete
children={ () => null }
completer={ {
className: suggestionClassname,
} }
/>
);
autocomplete.setState( {
options,
query: {},
search,
} );
autocomplete.instance().search( { target: { value: search } } );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + suggestionClassname ).length ).toBe( 2 );
} );
it( 'doesn\'t return matching excluded elements', () => {
const suggestionClassname = 'autocomplete-result';
const search = 'lorem';
const options = [
{ key: '1', label: 'lorem 1', value: { id: '1' } },
{ key: '2', label: 'lorem 2', value: { id: '2' } },
];
const exclude = [ '2' ];
const autocomplete = mount(
<Autocomplete
children={ () => null }
className={ suggestionClassname }
completer={ {} }
completer={ {
className: suggestionClassname,
} }
selected={ exclude }
/>
);
@ -35,6 +60,27 @@ describe( 'Autocomplete', () => {
autocomplete.instance().search( { target: { value: search } } );
autocomplete.update();
expect( autocomplete.find( '.' + suggestionClassname ).length ).toBe( 1 );
expect( autocomplete.find( Button ).filter( '.' + suggestionClassname ).length ).toBe( 1 );
} );
it( 'trims spaces from input', () => {
const autocomplete = mount(
<Autocomplete
children={ () => null }
completer={ {
className: suggestionClassname,
} }
/>
);
autocomplete.setState( {
options,
query: {},
search,
} );
autocomplete.instance().search( { target: { value: ' ' + search + ' ' } } );
autocomplete.update();
expect( autocomplete.find( Button ).filter( '.' + suggestionClassname ).length ).toBe( 2 );
} );
} );

View File

@ -48,7 +48,7 @@ class TableCard extends Component {
const { compareBy, query } = props;
const showCols = props.headers.map( ( { key, hiddenByDefault } ) => ! hiddenByDefault && key ).filter( Boolean );
const selectedRows = getIdsFromQuery( query[ compareBy ] );
const selectedRows = query.filter ? getIdsFromQuery( query[ compareBy ] ) : [];
this.state = { showCols, selectedRows };
this.onColumnToggle = this.onColumnToggle.bind( this );
@ -61,14 +61,17 @@ class TableCard extends Component {
componentDidUpdate( { query: prevQuery, headers: prevHeaders } ) {
const { compareBy, headers, query } = this.props;
const prevIds = getIdsFromQuery( prevQuery[ compareBy ] );
const currentIds = getIdsFromQuery( query[ compareBy ] );
if ( ! isEqual( prevIds.sort(), currentIds.sort() ) ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
selectedRows: currentIds,
} );
/* eslint-enable react/no-did-update-set-state */
if ( query.filter || prevQuery.filter ) {
const prevIds = prevQuery.filter ? getIdsFromQuery( prevQuery[ compareBy ] ) : [];
const currentIds = query.filter ? getIdsFromQuery( query[ compareBy ] ) : [];
if ( ! isEqual( prevIds.sort(), currentIds.sort() ) ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
selectedRows: currentIds,
} );
/* eslint-enable react/no-did-update-set-state */
}
}
if ( ! isEqual( headers, prevHeaders ) ) {
/* eslint-disable react/no-did-update-set-state */
@ -148,19 +151,17 @@ class TableCard extends Component {
}
onSearch( values ) {
const { compareBy, compareParam, onQueryChange, searchBy, searchParam } = this.props;
const ids = values.map( v => v.id );
if ( compareBy ) {
const { selectedRows } = this.state;
onQueryChange( 'compare' )(
compareBy,
compareParam,
[ ...selectedRows, ...ids ].join( ',' )
);
} else if ( searchBy ) {
const { compareParam } = this.props;
const labels = values.map( v => v.label );
if ( labels.length ) {
updateQueryString( {
filter: 'advanced',
[ searchParam ]: ids.join( ',' ),
filter: undefined,
[ compareParam ]: undefined,
search: uniq( labels ).join( ',' ),
} );
} else {
updateQueryString( {
search: undefined,
} );
}
}
@ -236,13 +237,14 @@ class TableCard extends Component {
rowHeader,
rowsPerPage,
searchBy,
searchParam,
showMenu,
summary,
title,
totalRows,
} = this.props;
const { selectedRows, showCols } = this.state;
const searchedValues = query.search ? query.search.split( ',' ) : [];
const searchedLabels = searchedValues.map( v => ( { id: v, label: v } ) );
const allHeaders = this.props.headers;
let headers = this.getVisibleHeaders();
let rows = this.getVisibleRows();
@ -277,13 +279,16 @@ class TableCard extends Component {
{ labels.compareButton || __( 'Compare', 'wc-admin' ) }
</CompareButton>
),
( compareBy || searchBy ) && (
searchBy && (
<Search
allowFreeTextSearch={ true }
inlineTags
key="search"
placeholder={ labels.placeholder || __( 'Search by item name', 'wc-admin' ) }
type={ compareBy || searchBy }
onChange={ this.onSearch }
selected={ searchParam && getIdsFromQuery( query[ searchParam ] ).map( id => ( { id } ) ) }
placeholder={ labels.placeholder || __( 'Search by item name', 'wc-admin' ) }
selected={ searchedLabels }
showClearButton={ true }
type={ searchBy }
/>
),
( downloadable || onClickDownload ) && (
@ -438,10 +443,6 @@ TableCard.propTypes = {
* The string to use as a query parameter when searching row items.
*/
searchBy: PropTypes.string,
/**
* Url query parameter search function operates on
*/
searchParam: PropTypes.string,
/**
* Boolean to determine whether or not ellipsis menu is shown.
*/

View File

@ -54,7 +54,7 @@
}
}
&.has-search {
&.has-search:not(.has-compare) {
.woocommerce-card__action {
grid-template-columns: 1fr auto;

View File

@ -1,6 +1,6 @@
{
"name": "@woocommerce/csv-export",
"version": "1.0.2",
"version": "1.0.3",
"description": "WooCommerce utility library to convert data to CSV files.",
"author": "Automattic",
"license": "GPL-2.0-or-later",
@ -21,7 +21,7 @@
"module": "build-module/index.js",
"react-native": "src/index",
"dependencies": {
"@babel/runtime-corejs2": "7.2.0",
"@babel/runtime-corejs2": "7.3.1",
"browser-filesaver": "^1.1.1",
"moment": "^2.22.2"
},

View File

@ -1,4 +1,4 @@
# 1.1.0 (unreleased)
# 1.1.0
- Format using store currency settings (instead of locale)
- Add optional currency symbol parameter

View File

@ -1,6 +1,6 @@
{
"name": "@woocommerce/currency",
"version": "1.0.0",
"version": "1.1.0",
"description": "WooCommerce currency utilities.",
"author": "Automattic",
"license": "GPL-2.0-or-later",
@ -22,8 +22,8 @@
"react-native": "src/index",
"dependencies": {
"@babel/runtime": "^7.0.0",
"lodash": "^4.17.11",
"@woocommerce/number": "1.0.0"
"@woocommerce/number": "^1.0.1",
"lodash": "^4.17.11"
},
"publishConfig": {
"access": "public"

Some files were not shown because too many files have changed in this diff Show More