Merge branch 'master' into fix/1035

# Conflicts:
#	includes/wc-admin-order-functions.php
This commit is contained in:
Peter Fabian 2019-01-02 14:08:23 +01:00
commit 1e39bc0756
147 changed files with 5779 additions and 1859 deletions

View File

@ -12,45 +12,82 @@ const { parse, resolver } = require( 'react-docgen' );
*/ */
const { getDescription, getProps, getTitle } = require( './lib/formatting' ); const { getDescription, getProps, getTitle } = require( './lib/formatting' );
const { const {
ANALYTICS_FOLDER,
PACKAGES_FOLDER, PACKAGES_FOLDER,
DOCS_FOLDER,
deleteExistingDocs, deleteExistingDocs,
getExportedFileList, getExportedFileList,
getMdFileName, getMdFileName,
getRealFilePaths, getRealFilePaths,
writeTableOfContents, getTocSection,
} = require( './lib/file-system' ); } = require( './lib/file-system' );
// Start by wiping the existing docs. **Change this if we end up manually editing docs** const fileCollections = [
deleteExistingDocs(); {
folder: ANALYTICS_FOLDER,
// Read components file to get a list of exported files, convert that to a list of absolute paths to public components. route: 'analytics',
const files = [ title: 'Analytics components',
...getRealFilePaths( getExportedFileList( path.resolve( PACKAGES_FOLDER, 'index.js' ) ), PACKAGES_FOLDER ), },
{
folder: PACKAGES_FOLDER,
route: 'packages',
title: 'Package components',
},
]; ];
const tocSections = [];
fileCollections.forEach( fileCollection => {
// Start by wiping the existing docs. **Change this if we end up manually editing docs**
deleteExistingDocs( fileCollection.route );
// Read components file to get a list of exported files, convert that to a list of absolute paths to public components.
const files = getRealFilePaths( getExportedFileList( path.resolve( fileCollection.folder, 'index.js' ) ), fileCollection.folder );
// Build documentation
buildComponentDocs( files, fileCollection.route );
// Concatenate TOC contents
tocSections.push( ...getTocSection( files, fileCollection.route, fileCollection.title ) );
} );
// Write TOC file
const tocFile = path.resolve( DOCS_FOLDER, '_sidebar.md' );
const tocHeader = '* [Home](/)\n\n* [Components](components/)\n\n';
fs.writeFileSync( tocFile, tocHeader + tocSections.join( '\n' ) );
// Sum the number of TOC lines and substract the titles
const numberOfFiles = tocSections.length - fileCollections.length;
console.log( `Wrote docs for ${ numberOfFiles } files.` );
/**
* Parse each file's content & build up a markdown file.
*
* @param { Array } files The absolute path of this file.
* @param { string } route Folder where the docs must be stored.
*/
function buildComponentDocs( files, route ) {
// Build the documentation by reading each file. // Build the documentation by reading each file.
files.forEach( file => { files.forEach( file => {
try { try {
const content = fs.readFileSync( file ); const content = fs.readFileSync( file );
buildDocs( file, content ); buildDocs( file, route, content );
} catch ( readErr ) { } catch ( readErr ) {
console.warn( file, readErr ); console.warn( file, readErr );
} }
} ); } );
}
writeTableOfContents( files );
console.log( `Wrote docs for ${ files.length } files.` );
/** /**
* Parse each file's content & build up a markdown file. * Parse each file's content & build up a markdown file.
* *
* @param { string } fileName The absolute path of this file. * @param { string } fileName The absolute path of this file.
* @param { string } route Folder where the docs must be stored.
* @param { string } content Content of this file. * @param { string } content Content of this file.
* @param { boolean } multiple If there are multiple exports in this file, we need to use a different resolver. * @param { boolean } multiple If there are multiple exports in this file, we need to use a different resolver.
*/ */
function buildDocs( fileName, content, multiple = false ) { function buildDocs( fileName, route, content, multiple = false ) {
const mdFileName = getMdFileName( fileName ); const mdFileName = getMdFileName( fileName, route );
let markdown; let markdown;
try { try {
@ -65,7 +102,7 @@ function buildDocs( fileName, content, multiple = false ) {
} catch ( parseErr ) { } catch ( parseErr ) {
if ( ! multiple ) { if ( ! multiple ) {
// The most likely error is that there are multiple exported components // The most likely error is that there are multiple exported components
buildDocs( fileName, content, true ); buildDocs( fileName, route, content, true );
return; return;
} }
console.warn( fileName, parseErr ); console.warn( fileName, parseErr );

View File

@ -14,24 +14,28 @@ const { namedTypes } = types;
*/ */
const { camelCaseDash } = require( './formatting' ); const { camelCaseDash } = require( './formatting' );
const ANALYTICS_FOLDER = path.resolve( __dirname, '../../../client/analytics/components/' );
const PACKAGES_FOLDER = path.resolve( __dirname, '../../../packages/components/src/' ); const PACKAGES_FOLDER = path.resolve( __dirname, '../../../packages/components/src/' );
const DOCS_FOLDER = path.resolve( __dirname, '../../../docs/components/' ); const DOCS_FOLDER = path.resolve( __dirname, '../../../docs/components/' );
/** /**
* Remove all files in existing docs folder. * Remove all files in existing docs folder.
* @param { String } route Route of the folder to clean.
*/ */
function deleteExistingDocs() { function deleteExistingDocs( route ) {
if ( ! isDirectory( DOCS_FOLDER ) ) { const folderRoute = path.resolve( DOCS_FOLDER, route );
fs.mkdirSync( DOCS_FOLDER );
if ( ! isDirectory( folderRoute ) ) {
fs.mkdirSync( folderRoute );
return; return;
} }
const files = fs.readdirSync( DOCS_FOLDER ); const files = fs.readdirSync( folderRoute );
files.map( file => { files.map( file => {
if ( 'README.md' === file ) { if ( 'README.md' === file ) {
return; return;
} }
fs.unlinkSync( path.resolve( DOCS_FOLDER, file ) ); fs.unlinkSync( path.resolve( folderRoute, file ) );
} ); } );
} }
@ -70,10 +74,11 @@ function getExportedFileList( filePath ) {
* Return the markdown file name for a given component file. * Return the markdown file name for a given component file.
* *
* @param { string } filepath File path for this component. * @param { string } filepath File path for this component.
* @param { string } route Folder where the docs must be stored.
* @param { boolean } absolute Whether to return full path (true) or just filename (false). * @param { boolean } absolute Whether to return full path (true) or just filename (false).
* @return { string } Markdown file name. * @return { string } Markdown file name.
*/ */
function getMdFileName( filepath, absolute = true ) { function getMdFileName( filepath, route, absolute = true ) {
const fileParts = filepath.split( '/components/' ); const fileParts = filepath.split( '/components/' );
if ( ! fileParts || ! fileParts[ 1 ] ) { if ( ! fileParts || ! fileParts[ 1 ] ) {
return; return;
@ -82,7 +87,7 @@ function getMdFileName( filepath, absolute = true ) {
if ( ! absolute ) { if ( ! absolute ) {
return name + '.md'; return name + '.md';
} }
return path.resolve( DOCS_FOLDER, name + '.md' ); return path.resolve( DOCS_FOLDER + '/' + route + '/', name + '.md' );
} }
/** /**
@ -145,25 +150,32 @@ function isFile( file ) {
/** /**
* Create a table of contents given a list of markdown files. * Create a table of contents given a list of markdown files.
* *
* @param { array } files A list of readme files. * @param { array } files A list of files, presumably in the components directory.
* @param { string } route Folder where the docs are stored.
* @param { string } title Title of the TOC section
* @return { string } TOC contents.
*/ */
function writeTableOfContents( files ) { function getTocSection( files, route, title ) {
const mdFiles = files.map( f => getMdFileName( f, false ) ).sort(); const mdFiles = files.map( f => getMdFileName( f, route, false ) ).sort();
const TOC = uniq( mdFiles ).map( doc => {
const name = camelCaseDash( doc.replace( '.md', '' ) );
return ` * [${ name }](components/${ doc })`;
} ).join( '\n' );
const TocFile = path.resolve( DOCS_FOLDER, '_sidebar.md' ); const toc = uniq( mdFiles ).map( doc => {
fs.writeFileSync( TocFile, '* [Home](/)\n\n* [Components](components/)\n\n' + TOC + '\n' ); const name = camelCaseDash( doc.replace( '.md', '' ) );
return ` * [${ name }](components/${ route }/${ doc })`;
} );
return [
' * [' + title + '](components/' + route + '/)',
...toc,
];
} }
module.exports = { module.exports = {
DOCS_FOLDER, DOCS_FOLDER,
ANALYTICS_FOLDER,
PACKAGES_FOLDER, PACKAGES_FOLDER,
deleteExistingDocs, deleteExistingDocs,
getExportedFileList, getExportedFileList,
getMdFileName, getMdFileName,
getRealFilePaths, getRealFilePaths,
writeTableOfContents, getTocSection,
}; };

View File

@ -17,6 +17,6 @@ module.exports = [
}, },
}, },
} ), } ),
require( 'autoprefixer' ), require( 'autoprefixer' )( { grid: true } ),
require( 'postcss-color-function' ), require( 'postcss-color-function' ),
]; ];

View File

@ -0,0 +1,6 @@
/** @format */
export { default as ReportChart } from './report-chart';
export { default as ReportError } from './report-error';
export { default as ReportSummary } from './report-summary';
export { default as ReportTable } from './report-table';

View File

@ -31,6 +31,9 @@ import withSelect from 'wc-api/with-select';
export const DEFAULT_FILTER = 'all'; export const DEFAULT_FILTER = 'all';
/**
* Component that renders the chart in reports.
*/
export class ReportChart extends Component { export class ReportChart extends Component {
getSelectedFilter( filters, query ) { getSelectedFilter( filters, query ) {
if ( filters.length === 0 ) { if ( filters.length === 0 ) {
@ -65,7 +68,7 @@ export class ReportChart extends Component {
} }
render() { render() {
const { query, itemsLabel, path, primaryData, secondaryData, selectedChart } = this.props; const { query, itemsLabel, mode, path, primaryData, secondaryData, selectedChart } = this.props;
if ( primaryData.isError || secondaryData.isError ) { if ( primaryData.isError || secondaryData.isError ) {
return <ReportError isError />; return <ReportError isError />;
@ -80,7 +83,7 @@ export class ReportChart extends Component {
const chartData = primaryData.data.intervals.map( function( interval, index ) { const chartData = primaryData.data.intervals.map( function( interval, index ) {
const secondaryDate = getPreviousDate( const secondaryDate = getPreviousDate(
formatDate( 'Y-m-d', interval.date_start ), interval.date_start,
primary.after, primary.after,
secondary.after, secondary.after,
query.compare, query.compare,
@ -111,7 +114,7 @@ export class ReportChart extends Component {
type={ getChartTypeForQuery( query ) } type={ getChartTypeForQuery( query ) }
allowedIntervals={ allowedIntervals } allowedIntervals={ allowedIntervals }
itemsLabel={ itemsLabel } itemsLabel={ itemsLabel }
mode={ this.getChartMode() } mode={ mode || this.getChartMode() }
tooltipLabelFormat={ formats.tooltipLabelFormat } tooltipLabelFormat={ formats.tooltipLabelFormat }
tooltipValueFormat={ getTooltipValueFormat( selectedChart.type ) } tooltipValueFormat={ getTooltipValueFormat( selectedChart.type ) }
tooltipTitle={ selectedChart.label } tooltipTitle={ selectedChart.label }
@ -126,12 +129,38 @@ export class ReportChart extends Component {
} }
ReportChart.propTypes = { ReportChart.propTypes = {
/**
* Filters available for that report.
*/
filters: PropTypes.array, filters: PropTypes.array,
/**
* Label describing the legend items.
*/
itemsLabel: PropTypes.string, itemsLabel: PropTypes.string,
/**
* `items-comparison` (default) or `time-comparison`, this is used to generate correct
* ARIA properties.
*/
mode: PropTypes.string,
/**
* Current path
*/
path: PropTypes.string.isRequired, path: PropTypes.string.isRequired,
/**
* Primary data to display in the chart.
*/
primaryData: PropTypes.object.isRequired, primaryData: PropTypes.object.isRequired,
/**
* The query string represented in object form.
*/
query: PropTypes.object.isRequired, query: PropTypes.object.isRequired,
/**
* Secondary data to display in the chart.
*/
secondaryData: PropTypes.object.isRequired, secondaryData: PropTypes.object.isRequired,
/**
* Properties of the selected chart.
*/
selectedChart: PropTypes.object.isRequired, selectedChart: PropTypes.object.isRequired,
}; };

View File

@ -12,6 +12,10 @@ import PropTypes from 'prop-types';
import { EmptyContent } from '@woocommerce/components'; import { EmptyContent } from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/navigation'; import { getAdminLink } from '@woocommerce/navigation';
/**
* Component to render when there is an error in a report component due to data
* not being loaded or being invalid.
*/
class ReportError extends Component { class ReportError extends Component {
render() { render() {
const { className, isError, isEmpty } = this.props; const { className, isError, isEmpty } = this.props;
@ -42,8 +46,17 @@ class ReportError extends Component {
} }
ReportError.propTypes = { ReportError.propTypes = {
/**
* Additional class name to style the component.
*/
className: PropTypes.string, className: PropTypes.string,
/**
* Boolean representing whether there was an error.
*/
isError: PropTypes.bool, isError: PropTypes.bool,
/**
* Boolean representing whether the issue is that there is no data.
*/
isEmpty: PropTypes.bool, isEmpty: PropTypes.bool,
}; };

View File

@ -22,6 +22,9 @@ import ReportError from 'analytics/components/report-error';
import { calculateDelta, formatValue } from './utils'; import { calculateDelta, formatValue } from './utils';
import withSelect from 'wc-api/with-select'; import withSelect from 'wc-api/with-select';
/**
* Component to render summary numbers in reports.
*/
export class ReportSummary extends Component { export class ReportSummary extends Component {
render() { render() {
const { charts, query, selectedChart, summaryData } = this.props; const { charts, query, selectedChart, summaryData } = this.props;
@ -70,10 +73,30 @@ export class ReportSummary extends Component {
} }
ReportSummary.propTypes = { ReportSummary.propTypes = {
/**
* Properties of all the charts available for that report.
*/
charts: PropTypes.array.isRequired, charts: PropTypes.array.isRequired,
/**
* The endpoint to use in API calls to populate the Summary Numbers.
* For example, if `taxes` is provided, data will be fetched from the report
* `taxes` endpoint (ie: `/wc/v3/reports/taxes/stats`). If the provided endpoint
* doesn't exist, an error will be shown to the user with `ReportError`.
*/
endpoint: PropTypes.string.isRequired, endpoint: PropTypes.string.isRequired,
/**
* The query string represented in object form.
*/
query: PropTypes.object.isRequired, query: PropTypes.object.isRequired,
selectedChart: PropTypes.object.isRequired, /**
* Properties of the selected chart.
*/
selectedChart: PropTypes.shape( {
/**
* Key of the selected chart.
*/
key: PropTypes.string.isRequired,
} ).isRequired,
}; };
export default compose( export default compose(

View File

@ -25,28 +25,39 @@ import { extendTableData } from './utils';
const TABLE_FILTER = 'woocommerce_admin_report_table'; const TABLE_FILTER = 'woocommerce_admin_report_table';
/**
* Component that extends `TableCard` to facilitate its usage in reports.
*/
class ReportTable extends Component { class ReportTable extends Component {
onColumnsChange = columns => { constructor( props ) {
const { columnPrefsKey } = this.props; super( props );
this.onColumnsChange = this.onColumnsChange.bind( this );
}
onColumnsChange( shownColumns ) {
const { columnPrefsKey, getHeadersContent } = this.props;
const columns = getHeadersContent().map( header => header.key );
const hiddenColumns = columns.filter( column => ! shownColumns.includes( column ) );
if ( columnPrefsKey ) { if ( columnPrefsKey ) {
const userDataFields = { const userDataFields = {
[ columnPrefsKey ]: columns, [ columnPrefsKey ]: hiddenColumns,
}; };
this.props.updateCurrentUserData( userDataFields ); this.props.updateCurrentUserData( userDataFields );
} }
}; }
filterShownHeaders = ( headers, shownKeys ) => { filterShownHeaders( headers, hiddenKeys ) {
if ( ! shownKeys ) { if ( ! hiddenKeys ) {
return headers; return headers;
} }
return headers.map( header => { return headers.map( header => {
const hidden = ! shownKeys.includes( header.key ); const hidden = hiddenKeys.includes( header.key ) && ! header.required;
return { ...header, hiddenByDefault: hidden }; return { ...header, hiddenByDefault: hidden };
} ); } );
}; }
render() { render() {
const { const {
@ -56,7 +67,7 @@ class ReportTable extends Component {
itemIdField, itemIdField,
primaryData, primaryData,
tableData, tableData,
// These two props are not used in the render function, but are destructured // These props are not used in the render function, but are destructured
// so they are not included in the `tableProps` variable. // so they are not included in the `tableProps` variable.
endpoint, endpoint,
tableQuery, tableQuery,
@ -113,7 +124,11 @@ ReportTable.propTypes = {
*/ */
columnPrefsKey: PropTypes.string, columnPrefsKey: PropTypes.string,
/** /**
* The endpoint to use in API calls. * The endpoint to use in API calls to populate the table rows and summary.
* For example, if `taxes` is provided, data will be fetched from the report
* `taxes` endpoint (ie: `/wc/v3/reports/taxes` and `/wc/v3/reports/taxes/stats`).
* If the provided endpoint doesn't exist, an error will be shown to the user
* with `ReportError`.
*/ */
endpoint: PropTypes.string, endpoint: PropTypes.string,
/** /**
@ -143,11 +158,13 @@ ReportTable.propTypes = {
*/ */
itemIdField: PropTypes.string, itemIdField: PropTypes.string,
/** /**
* Primary data of that report. * Primary data of that report. If it's not provided, it will be automatically
* loaded via the provided `endpoint`.
*/ */
primaryData: PropTypes.object.isRequired, primaryData: PropTypes.object.isRequired,
/** /**
* Table data of that report. * Table data of that report. If it's not provided, it will be automatically
* loaded via the provided `endpoint`.
*/ */
tableData: PropTypes.object.isRequired, tableData: PropTypes.object.isRequired,
/** /**

View File

@ -0,0 +1,67 @@
/** @format */
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import { first, last } from 'lodash';
import { Spinner } from '@wordpress/components';
/**
* WooCommerce dependencies
*/
import { Link } from '@woocommerce/components';
export default class CategoryBreadcrumbs extends Component {
getCategoryAncestorIds( category, categories ) {
const ancestors = [];
let parent = category.parent;
while ( parent ) {
ancestors.unshift( parent );
parent = categories[ parent ].parent;
}
return ancestors;
}
getCategoryAncestors( category, categories ) {
const ancestorIds = this.getCategoryAncestorIds( category, categories );
if ( ! ancestorIds.length ) {
return;
}
if ( ancestorIds.length === 1 ) {
return categories[ first( ancestorIds ) ].name + ' ';
}
if ( ancestorIds.length === 2 ) {
return (
categories[ first( ancestorIds ) ].name +
' ' +
categories[ last( ancestorIds ) ].name +
' '
);
}
return (
categories[ first( ancestorIds ) ].name +
' … ' +
categories[ last( ancestorIds ) ].name +
' '
);
}
render() {
const { categories, category } = this.props;
return category ? (
<div className="woocommerce-table__breadcrumbs">
{ this.getCategoryAncestors( category, categories ) }
<Link
href={ 'term.php?taxonomy=product_cat&post_type=product&tag_ID=' + category.id }
type="wp-admin"
>
{ category.name }
</Link>
</div>
) : (
<Spinner />
);
}
}

View File

@ -4,6 +4,12 @@
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { getRequestByIdString } from 'lib/async-requests';
import { NAMESPACE } from 'store/constants';
export const charts = [ export const charts = [
{ {
key: 'items_sold', key: 'items_sold',
@ -30,7 +36,37 @@ export const filters = [
showFilters: () => true, showFilters: () => true,
filters: [ filters: [
{ label: __( 'All Categories', 'wc-admin' ), value: 'all' }, { label: __( 'All Categories', 'wc-admin' ), value: 'all' },
{ label: __( 'Advanced Filters', 'wc-admin' ), value: 'advanced' }, {
label: __( 'Comparison', 'wc-admin' ),
value: 'compare-categories',
chartMode: 'item-comparison',
settings: {
type: 'categories',
param: 'categories',
getLabels: getRequestByIdString( NAMESPACE + 'products/categories', cat => ( {
id: cat.id,
label: cat.name,
} ) ),
labels: {
helpText: __( 'Select at least two categories to compare', 'wc-admin' ),
placeholder: __( 'Search for categories to compare', 'wc-admin' ),
title: __( 'Compare Categories', 'wc-admin' ),
update: __( 'Compare', 'wc-admin' ),
},
},
},
{
label: __( 'Top Categories by Items Sold', 'wc-admin' ),
value: 'top_items',
chartMode: 'item-comparison',
query: { orderby: 'items_sold', order: 'desc' },
},
{
label: __( 'Top Categories by Net Revenue', 'wc-admin' ),
value: 'top_revenue',
chartMode: 'item-comparison',
query: { orderby: 'net_revenue', order: 'desc' },
},
], ],
}, },
]; ];

View File

@ -4,21 +4,23 @@
*/ */
import { __, _n } from '@wordpress/i18n'; import { __, _n } from '@wordpress/i18n';
import { Component } from '@wordpress/element'; import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { map } from 'lodash'; import { map } from 'lodash';
/** /**
* WooCommerce dependencies * WooCommerce dependencies
*/ */
import { Link } from '@woocommerce/components';
import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency'; import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import ReportTable from 'analytics/components/report-table'; import CategoryBreacrumbs from './breadcrumbs';
import { numberFormat } from 'lib/number'; import { numberFormat } from 'lib/number';
import ReportTable from 'analytics/components/report-table';
import withSelect from 'wc-api/with-select';
export default class CategoriesReportTable extends Component { class CategoriesReportTable extends Component {
constructor( props ) { constructor( props ) {
super( props ); super( props );
@ -32,7 +34,6 @@ export default class CategoriesReportTable extends Component {
key: 'category', key: 'category',
required: true, required: true,
isLeftAligned: true, isLeftAligned: true,
isSortable: true,
}, },
{ {
label: __( 'Items sold', 'wc-admin' ), label: __( 'Items sold', 'wc-admin' ),
@ -43,8 +44,7 @@ export default class CategoriesReportTable extends Component {
isNumeric: true, isNumeric: true,
}, },
{ {
label: __( 'G. Revenue', 'wc-admin' ), label: __( 'Net Revenue', 'wc-admin' ),
screenReaderLabel: __( 'Gross Revenue', 'wc-admin' ),
key: 'net_revenue', key: 'net_revenue',
isSortable: true, isSortable: true,
isNumeric: true, isNumeric: true,
@ -64,29 +64,16 @@ export default class CategoriesReportTable extends Component {
]; ];
} }
getRowsContent( categories ) { getRowsContent( categoryStats ) {
return map( categories, category => { return map( categoryStats, categoryStat => {
const { const { category_id, items_sold, net_revenue, products_count, orders_count } = categoryStat;
category_id, const categories = this.props.categories;
items_sold, const category = categories[ category_id ];
net_revenue,
products_count,
orders_count,
extended_info,
} = category;
const { name } = extended_info;
return [ return [
{ {
display: ( display: <CategoryBreacrumbs category={ category } categories={ categories } />,
<Link value: category && category.name,
href={ 'term.php?taxonomy=product_cat&post_type=product&tag_ID=' + category_id }
type="wp-admin"
>
{ name }
</Link>
),
value: name,
}, },
{ {
display: numberFormat( items_sold ), display: numberFormat( items_sold ),
@ -136,15 +123,21 @@ export default class CategoriesReportTable extends Component {
render() { render() {
const { query } = this.props; const { query } = this.props;
const labels = {
helpText: __( 'Select at least two categories to compare', 'wc-admin' ),
placeholder: __( 'Search by category name', 'wc-admin' ),
};
return ( return (
<ReportTable <ReportTable
compareBy="product_cats" compareBy="categories"
endpoint="categories" endpoint="categories"
getHeadersContent={ this.getHeadersContent } getHeadersContent={ this.getHeadersContent }
getRowsContent={ this.getRowsContent } getRowsContent={ this.getRowsContent }
getSummary={ this.getSummary } getSummary={ this.getSummary }
itemIdField="category_id" itemIdField="category_id"
query={ query } query={ query }
labels={ labels }
tableQuery={ { tableQuery={ {
orderby: query.orderby || 'items_sold', orderby: query.orderby || 'items_sold',
order: query.order || 'desc', order: query.order || 'desc',
@ -156,3 +149,18 @@ export default class CategoriesReportTable extends Component {
); );
} }
} }
export default compose(
withSelect( select => {
const { getCategories, getCategoriesError, isGetCategoriesRequesting } = select( 'wc-api' );
const tableQuery = {
per_page: -1,
};
const categories = getCategories( tableQuery );
const isError = Boolean( getCategoriesError( tableQuery ) );
const isRequesting = isGetCategoriesRequesting( tableQuery );
return { categories, isError, isRequesting };
} )
)( CategoriesReportTable );

View File

@ -12,13 +12,13 @@ import { NAMESPACE } from 'store/constants';
export const charts = [ export const charts = [
{ {
key: 'discounted_orders', key: 'orders_count',
label: __( 'Discounted Orders', 'wc-admin' ), label: __( 'Discounted Orders', 'wc-admin' ),
type: 'number', type: 'number',
}, },
{ {
key: 'coupons', key: 'amount',
label: __( 'Gross Discounted', 'wc-admin' ), label: __( 'Amount', 'wc-admin' ),
type: 'currency', type: 'currency',
}, },
]; ];
@ -48,7 +48,7 @@ export const filters = [
}, },
}, },
{ label: __( 'Top Coupons by Discounted Orders', 'wc-admin' ), value: 'top_orders' }, { label: __( 'Top Coupons by Discounted Orders', 'wc-admin' ), value: 'top_orders' },
{ label: __( 'Top Coupons by Gross Discounted', 'wc-admin' ), value: 'top_discount' }, { label: __( 'Top Coupons by Amount Discounted', 'wc-admin' ), value: 'top_discount' },
], ],
}, },
]; ];

View File

@ -28,13 +28,13 @@ export default class CouponsReport extends Component {
<ReportFilters query={ query } path={ path } filters={ filters } /> <ReportFilters query={ query } path={ path } filters={ filters } />
<ReportSummary <ReportSummary
charts={ charts } charts={ charts }
endpoint="orders" endpoint="coupons"
query={ query } query={ query }
selectedChart={ getSelectedChart( query.chart, charts ) } selectedChart={ getSelectedChart( query.chart, charts ) }
/> />
<ReportChart <ReportChart
charts={ charts } charts={ charts }
endpoint="orders" endpoint="coupons"
path={ path } path={ path }
query={ query } query={ query }
selectedChart={ getSelectedChart( query.chart, charts ) } selectedChart={ getSelectedChart( query.chart, charts ) }

View File

@ -33,11 +33,9 @@ export default class CouponsReportTable extends Component {
return [ return [
{ {
label: __( 'Coupon Code', 'wc-admin' ), label: __( 'Coupon Code', 'wc-admin' ),
// @TODO it should be the coupon code, not the coupon ID
key: 'coupon_id', key: 'coupon_id',
required: true, required: true,
isLeftAligned: true, isLeftAligned: true,
isSortable: true,
}, },
{ {
label: __( 'Orders', 'wc-admin' ), label: __( 'Orders', 'wc-admin' ),
@ -48,26 +46,22 @@ export default class CouponsReportTable extends Component {
isNumeric: true, isNumeric: true,
}, },
{ {
label: __( 'G. Discounted', 'wc-admin' ), label: __( 'Amount Discounted', 'wc-admin' ),
screenReaderLabel: __( 'Gross Discounted', 'wc-admin' ), key: 'amount',
key: 'gross_discount',
isSortable: true, isSortable: true,
isNumeric: true, isNumeric: true,
}, },
{ {
label: __( 'Created', 'wc-admin' ), label: __( 'Created', 'wc-admin' ),
key: 'created', key: 'created',
isSortable: true,
}, },
{ {
label: __( 'Expires', 'wc-admin' ), label: __( 'Expires', 'wc-admin' ),
key: 'expires', key: 'expires',
isSortable: true,
}, },
{ {
label: __( 'Type', 'wc-admin' ), label: __( 'Type', 'wc-admin' ),
key: 'type', key: 'type',
isSortable: false,
}, },
]; ];
} }
@ -78,12 +72,13 @@ export default class CouponsReportTable extends Component {
const { tableFormat } = getDateFormatsForInterval( currentInterval ); const { tableFormat } = getDateFormatsForInterval( currentInterval );
return map( coupons, coupon => { return map( coupons, coupon => {
const { coupon_id, gross_discount, orders_count } = coupon; const { amount, coupon_id, extended_info, orders_count } = coupon;
const { code, date_created, date_expires, discount_type } = extended_info;
// @TODO must link to the coupon detail report // @TODO must link to the coupon detail report
const couponLink = ( const couponLink = (
<Link href="" type="wc-admin"> <Link href="" type="wc-admin">
{ coupon_id } { code }
</Link> </Link>
); );
@ -97,33 +92,29 @@ export default class CouponsReportTable extends Component {
); );
return [ return [
// @TODO it should be the coupon code, not the coupon ID
{ {
display: couponLink, display: couponLink,
value: coupon_id, value: code,
}, },
{ {
display: ordersLink, display: ordersLink,
value: orders_count, value: orders_count,
}, },
{ {
display: formatCurrency( gross_discount ), display: formatCurrency( amount ),
value: getCurrencyFormatDecimal( gross_discount ), value: getCurrencyFormatDecimal( amount ),
}, },
{ {
// @TODO display: formatDate( tableFormat, date_created ),
display: formatDate( tableFormat, '' ), value: date_created,
value: '',
}, },
{ {
// @TODO display: date_expires ? formatDate( tableFormat, date_expires ) : __( 'N/A', 'wc-admin' ),
display: formatDate( tableFormat, '' ), value: date_expires,
value: '',
}, },
{ {
// @TODO display: this.getCouponType( discount_type ),
display: '', value: discount_type,
value: '',
}, },
]; ];
} ); } );
@ -143,12 +134,21 @@ export default class CouponsReportTable extends Component {
value: numberFormat( totals.orders_count ), value: numberFormat( totals.orders_count ),
}, },
{ {
label: __( 'gross discounted', 'wc-admin' ), label: __( 'amount discounted', 'wc-admin' ),
value: formatCurrency( totals.gross_discount ), value: formatCurrency( totals.amount ),
}, },
]; ];
} }
getCouponType( discount_type ) {
const couponTypes = {
percent: __( 'Percentage', 'wc-admin' ),
fixed_cart: __( 'Fixed cart', 'wc-admin' ),
fixed_product: __( 'Fixed product', 'wc-admin' ),
};
return couponTypes[ discount_type ];
}
render() { render() {
const { query } = this.props; const { query } = this.props;
@ -161,6 +161,11 @@ export default class CouponsReportTable extends Component {
getSummary={ this.getSummary } getSummary={ this.getSummary }
itemIdField="coupon_id" itemIdField="coupon_id"
query={ query } query={ query }
tableQuery={ {
orderby: query.orderby || 'coupon_id',
order: query.order || 'asc',
extended_info: true,
} }
title={ __( 'Coupons', 'wc-admin' ) } title={ __( 'Coupons', 'wc-admin' ) }
columnPrefsKey="coupons_report_columns" columnPrefsKey="coupons_report_columns"
/> />

View File

@ -166,6 +166,91 @@ export const advancedFilters = {
} ) ), } ) ),
}, },
}, },
order_count: {
labels: {
add: __( 'No. of Orders', 'wc-admin' ),
remove: __( 'Remove order filter', 'wc-admin' ),
rule: __( 'Select an order count filter match', 'wc-admin' ),
title: __( 'No. of Orders {{rule /}} {{filter /}}', 'wc-admin' ),
},
rules: [
{
value: 'max',
/* translators: Sentence fragment, logical, "Less Than" refers to number of orders a customer has placed, less than a given amount. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'Less Than', 'number of orders', 'wc-admin' ),
},
{
value: 'min',
/* translators: Sentence fragment, logical, "More Than" refers to number of orders a customer has placed, more than a given amount. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'More Than', 'number of orders', 'wc-admin' ),
},
{
value: 'between',
/* translators: Sentence fragment, logical, "Between" refers to number of orders a customer has placed, between two given integers. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'Between', 'number of orders', 'wc-admin' ),
},
],
input: {
component: 'Number',
},
},
total_spend: {
labels: {
add: __( 'Total Spend', 'wc-admin' ),
remove: __( 'Remove total spend filter', 'wc-admin' ),
rule: __( 'Select a total spend filter match', 'wc-admin' ),
title: __( 'Total Spend {{rule /}} {{filter /}}', 'wc-admin' ),
},
rules: [
{
value: 'max',
/* translators: Sentence fragment, logical, "Less Than" refers to total spending by a customer, less than a given amount. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'Less Than', 'total spend by customer', 'wc-admin' ),
},
{
value: 'min',
/* translators: Sentence fragment, logical, "Less Than" refers to total spending by a customer, more than a given amount. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'More Than', 'total spend by customer', 'wc-admin' ),
},
{
value: 'between',
/* translators: Sentence fragment, logical, "Between" refers to total spending by a customer, between two given amounts. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'Between', 'total spend by customer', 'wc-admin' ),
},
],
input: {
component: 'Currency',
},
},
avg_order_value: {
labels: {
add: __( 'AOV', 'wc-admin' ),
remove: __( 'Remove average older value filter', 'wc-admin' ),
rule: __( 'Select an average order value filter match', 'wc-admin' ),
title: __( 'AOV {{rule /}} {{filter /}}', 'wc-admin' ),
},
rules: [
{
value: 'max',
/* translators: Sentence fragment, logical, "Less Than" refers to average order value of a customer, more than a given amount. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'Less Than', 'average order value of customer', 'wc-admin' ),
},
{
value: 'min',
/* translators: Sentence fragment, logical, "Less Than" refers to average order value of a customer, less than a given amount. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'More Than', 'average order value of customer', 'wc-admin' ),
},
{
value: 'between',
/* translators: Sentence fragment, logical, "Between" refers to average order value of a customer, between two given amounts. Screenshot for context: https://cloudup.com/cCsm3GeXJbE */
label: _x( 'Between', 'average order value of customer', 'wc-admin' ),
},
],
input: {
component: 'Currency',
},
},
}, },
}; };
/*eslint-enable max-len*/ /*eslint-enable max-len*/

View File

@ -70,6 +70,104 @@ export const advancedFilters = {
} ) ), } ) ),
}, },
}, },
username: {
labels: {
add: __( 'Username', 'wc-admin' ),
placeholder: __( 'Search customer username', 'wc-admin' ),
remove: __( 'Remove customer username filter', 'wc-admin' ),
rule: __( 'Select a customer username filter match', 'wc-admin' ),
/* translators: A sentence describing a customer username filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'Username {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select customer username', 'wc-admin' ),
},
rules: [
{
value: 'includes',
/* translators: Sentence fragment, logical, "Includes" refers to customer usernames including a given username(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
label: _x( 'Includes', 'customer usernames', 'wc-admin' ),
},
{
value: 'excludes',
/* translators: Sentence fragment, logical, "Excludes" refers to customer usernames excluding a given username(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
label: _x( 'Excludes', 'customer usernames', 'wc-admin' ),
},
],
input: {
component: 'Search',
type: 'usernames',
getLabels: getRequestByIdString( NAMESPACE + 'customers', customer => ( {
id: customer.id,
label: customer.username,
} ) ),
},
},
order: {
labels: {
add: __( 'Order number', 'wc-admin' ),
placeholder: __( 'Search order number', 'wc-admin' ),
remove: __( 'Remove order number filter', 'wc-admin' ),
rule: __( 'Select a order number filter match', 'wc-admin' ),
/* translators: A sentence describing a order number filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'Order number {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select order number', 'wc-admin' ),
},
rules: [
{
value: 'includes',
/* translators: Sentence fragment, logical, "Includes" refers to order numbers including a given order(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
label: _x( 'Includes', 'order numbers', 'wc-admin' ),
},
{
value: 'excludes',
/* translators: Sentence fragment, logical, "Excludes" refers to order numbers excluding a given order(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
label: _x( 'Excludes', 'order numbers', 'wc-admin' ),
},
],
input: {
component: 'Search',
type: 'orders',
getLabels: getRequestByIdString( NAMESPACE + 'orders', order => ( {
id: order.id,
label: '#' + order.id,
} ) ),
},
},
downloadIp: {
labels: {
add: __( 'IP Address', 'wc-admin' ),
placeholder: __( 'Search IP address', 'wc-admin' ),
remove: __( 'Remove IP address filter', 'wc-admin' ),
rule: __( 'Select an IP address filter match', 'wc-admin' ),
/* translators: A sentence describing a order number filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
title: __( 'IP Address {{rule /}} {{filter /}}', 'wc-admin' ),
filter: __( 'Select IP address', 'wc-admin' ),
},
rules: [
{
value: 'includes',
/* translators: Sentence fragment, logical, "Includes" refers to IP addresses including a given address(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
label: _x( 'Includes', 'IP addresses', 'wc-admin' ),
},
{
value: 'excludes',
/* translators: Sentence fragment, logical, "Excludes" refers to IP addresses excluding a given address(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
label: _x( 'Excludes', 'IP addresses', 'wc-admin' ),
},
],
input: {
component: 'Search',
type: 'downloadIps',
getLabels: async value => {
const ips = value.split( ',' );
return await ips.map( ip => {
return {
id: ip,
label: ip,
};
} );
},
},
},
}, },
}; };
/*eslint-enable max-len*/ /*eslint-enable max-len*/

View File

@ -81,10 +81,10 @@ const filterConfig = {
}, },
{ {
label: __( 'Product Category Comparison', 'wc-admin' ), label: __( 'Product Category Comparison', 'wc-admin' ),
value: 'compare-product_cats', value: 'compare-categories',
chartMode: 'item-comparison', chartMode: 'item-comparison',
settings: { settings: {
type: 'product_cats', type: 'categories',
param: 'categories', param: 'categories',
getLabels: getRequestByIdString( NAMESPACE + 'products/categories', category => ( { getLabels: getRequestByIdString( NAMESPACE + 'products/categories', category => ( {
id: category.id, id: category.id,

View File

@ -0,0 +1,16 @@
/** @format */
.woocommerce-table__product-categories {
> .woocommerce-table__breadcrumbs {
display: inline-block;
margin-right: $gap-small;
}
.components-popover__content {
padding: 0 $gap;
text-align: left;
}
.components-popover__content .woocommerce-table__breadcrumbs {
margin-top: $gap-small;
margin-bottom: $gap-small;
}
}

View File

@ -2,25 +2,29 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __, _n, _x } from '@wordpress/i18n'; import { __, _n, _x, sprintf } from '@wordpress/i18n';
import { Component } from '@wordpress/element'; import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { map } from 'lodash'; import { map } from 'lodash';
/** /**
* WooCommerce dependencies * WooCommerce dependencies
*/ */
import { Link } from '@woocommerce/components';
import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency'; import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency';
import { getNewPath, getPersistedQuery } from '@woocommerce/navigation'; import { getNewPath, getPersistedQuery } from '@woocommerce/navigation';
import { Link, Tag } from '@woocommerce/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import ReportTable from 'analytics/components/report-table'; import CategoryBreacrumbs from '../categories/breadcrumbs';
import { numberFormat } from 'lib/number';
import { isLowStock } from './utils'; import { isLowStock } from './utils';
import { numberFormat } from 'lib/number';
import ReportTable from 'analytics/components/report-table';
import withSelect from 'wc-api/with-select';
import './style.scss';
export default class ProductsReportTable extends Component { class ProductsReportTable extends Component {
constructor() { constructor() {
super(); super();
@ -91,15 +95,20 @@ export default class ProductsReportTable extends Component {
return map( data, row => { return map( data, row => {
const { const {
product_id, product_id,
sku = '', // @TODO
extended_info, extended_info,
items_sold, items_sold,
net_revenue, net_revenue,
orders_count, orders_count,
categories = [], // @TODO
variations = [], // @TODO variations = [], // @TODO
} = row; } = row;
const { name, stock_status, stock_quantity, low_stock_amount } = extended_info; const {
category_ids,
low_stock_amount,
name,
sku,
stock_status,
stock_quantity,
} = extended_info;
const ordersLink = getNewPath( persistedQuery, 'orders', { const ordersLink = getNewPath( persistedQuery, 'orders', {
filter: 'advanced', filter: 'advanced',
product_includes: product_id, product_includes: product_id,
@ -108,6 +117,10 @@ export default class ProductsReportTable extends Component {
filter: 'single_product', filter: 'single_product',
products: product_id, products: product_id,
} ); } );
const categories = this.props.categories;
const productCategories = category_ids
.map( category_id => categories[ category_id ] )
.filter( Boolean );
return [ return [
{ {
@ -139,10 +152,29 @@ export default class ProductsReportTable extends Component {
value: orders_count, value: orders_count,
}, },
{ {
display: Array.isArray( categories ) display: (
? categories.map( cat => cat.name ).join( ', ' ) <div className="woocommerce-table__product-categories">
: '', { productCategories[ 0 ] && (
value: Array.isArray( categories ) ? categories.map( cat => cat.name ).join( ', ' ) : '', <CategoryBreacrumbs category={ productCategories[ 0 ] } categories={ categories } />
) }
{ productCategories.length > 1 && (
<Tag
label={ sprintf(
_x( '+%d more', 'categories', 'wc-admin' ),
productCategories.length - 1
) }
popoverContents={ productCategories.map( category => (
<CategoryBreacrumbs
category={ category }
categories={ categories }
key={ category.id }
/>
) ) }
/>
) }
</div>
),
value: productCategories.map( category => category.name ).join( ', ' ),
}, },
{ {
display: numberFormat( variations.length ), display: numberFormat( variations.length ),
@ -219,3 +251,18 @@ export default class ProductsReportTable extends Component {
); );
} }
} }
export default compose(
withSelect( select => {
const { getCategories, getCategoriesError, isGetCategoriesRequesting } = select( 'wc-api' );
const tableQuery = {
per_page: -1,
};
const categories = getCategories( tableQuery );
const isError = Boolean( getCategoriesError( tableQuery ) );
const isRequesting = isGetCategoriesRequesting( tableQuery );
return { categories, isError, isRequesting };
} )
)( ProductsReportTable );

View File

@ -0,0 +1,51 @@
/** @format */
/**
* External dependencies
*/
import { Component, Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
/**
* WooCommerce dependencies
*/
import { Card } from '@woocommerce/components';
/**
* Internal dependencies
*/
import ReportChart from 'analytics/components/report-chart';
import './block.scss';
class ChartBlock extends Component {
render() {
const { charts, endpoint, path, query } = this.props;
if ( ! charts || ! charts.length ) {
return null;
}
return (
<Fragment>
<Card className="woocommerce-dashboard__chart-block" title={ charts[ 0 ].label }>
<ReportChart
charts={ charts }
endpoint={ endpoint }
mode="block"
path={ path }
query={ query }
selectedChart={ charts[ 0 ] }
/>
</Card>
</Fragment>
);
}
}
ChartBlock.propTypes = {
charts: PropTypes.array,
endpoint: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
query: PropTypes.object.isRequired,
};
export default ChartBlock;

View File

@ -0,0 +1,37 @@
/** @format */
.woocommerce-dashboard__chart-block {
.woocommerce-card__body {
padding: 0;
position: relative;
.woocommerce-chart {
border: none;
margin: 0;
.woocommerce-legend__item > button {
cursor: default;
&:hover {
background: $core-grey-light-100;
}
.woocommerce-legend__item-container {
cursor: default;
.woocommerce-legend__item-checkmark.woocommerce-legend__item-checkmark-checked::after {
display: none;
}
}
}
&:hover {
background: $core-grey-light-100;
.woocommerce-legend__item > button {
background: $core-grey-light-100;
}
}
}
}
&:hover {
background: $core-grey-light-100;
}
}

View File

@ -0,0 +1,39 @@
/** @format */
/**
* Internal dependencies
*/
import { charts as ordersCharts } from 'analytics/report/orders/config';
import { charts as productsCharts } from 'analytics/report/products/config';
import { charts as revenueCharts } from 'analytics/report/revenue/config';
import { charts as categoriesCharts } from 'analytics/report/categories/config';
import { charts as couponsCharts } from 'analytics/report/coupons/config';
import { charts as taxesCharts } from 'analytics/report/taxes/config';
const allCharts = ordersCharts
.map( d => ( { ...d, endpoint: 'orders' } ) )
.concat(
productsCharts.map( d => ( { ...d, endpoint: 'products' } ) ),
revenueCharts.map( d => ( { ...d, endpoint: 'revenue' } ) ),
categoriesCharts.map( d => ( { ...d, endpoint: 'categories' } ) ),
couponsCharts.map( d => ( { ...d, endpoint: 'orders' } ) ),
taxesCharts.map( d => ( { ...d, endpoint: 'taxes' } ) )
);
// Need to remove duplicate charts, by key, from the configs
const uniqCharts = allCharts.reduce( ( a, b ) => {
if ( a.findIndex( d => d.key === b.key ) < 0 ) {
a.push( b );
}
return a;
}, [] );
// Default charts.
// TODO: Implement user-based toggling/persistence.
const defaultCharts = [ 'items_sold', 'gross_revenue' ];
export const showCharts = uniqCharts.map( d => ( {
...d,
show: defaultCharts.indexOf( d.key ) >= 0,
} ) );
export const getChartFromKey = key => allCharts.filter( d => d.key === key );

View File

@ -0,0 +1,130 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import Gridicon from 'gridicons';
import { ToggleControl, IconButton, NavigableMenu } from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
/**
* WooCommerce dependencies
*/
import { EllipsisMenu, MenuItem, SectionHeader } from '@woocommerce/components';
/**
* Internal dependencies
*/
import ChartBlock from './block';
import { getChartFromKey, showCharts } from './config';
import './style.scss';
class DashboardCharts extends Component {
constructor( props ) {
super( ...arguments );
this.state = {
showCharts,
query: props.query,
};
this.toggle = this.toggle.bind( this );
}
toggle( key ) {
return () => {
this.setState( state => {
const foundIndex = state.showCharts.findIndex( x => x.key === key );
state.showCharts[ foundIndex ].show = ! state.showCharts[ foundIndex ].show;
return state;
} );
};
}
handleTypeToggle( type ) {
return () => {
this.setState( state => ( { query: { ...state.query, type } } ) );
};
}
renderMenu() {
return (
<EllipsisMenu label={ __( 'Choose which charts to display', 'wc-admin' ) }>
{ this.state.showCharts.map( chart => {
return (
<MenuItem onInvoke={ this.toggle( chart.key ) } key={ chart.key }>
<ToggleControl
label={ __( `${ chart.label }`, 'wc-admin' ) }
checked={ chart.show }
onChange={ this.toggle( chart.key ) }
/>
</MenuItem>
);
} ) }
</EllipsisMenu>
);
}
render() {
const { path } = this.props;
const { query } = this.state;
return (
<Fragment>
<div className="woocommerce-dashboard__dashboard-charts">
<SectionHeader title={ __( 'Charts', 'wc-admin' ) } menu={ this.renderMenu() }>
<NavigableMenu
className="woocommerce-chart__types"
orientation="horizontal"
role="menubar"
>
<IconButton
className={ classNames( 'woocommerce-chart__type-button', {
'woocommerce-chart__type-button-selected': ! query.type || query.type === 'line',
} ) }
icon={ <Gridicon icon="line-graph" /> }
title={ __( 'Line chart', 'wc-admin' ) }
aria-checked={ query.type === 'line' }
role="menuitemradio"
tabIndex={ query.type === 'line' ? 0 : -1 }
onClick={ this.handleTypeToggle( 'line' ) }
/>
<IconButton
className={ classNames( 'woocommerce-chart__type-button', {
'woocommerce-chart__type-button-selected': query.type === 'bar',
} ) }
icon={ <Gridicon icon="stats-alt" /> }
title={ __( 'Bar chart', 'wc-admin' ) }
aria-checked={ query.type === 'bar' }
role="menuitemradio"
tabIndex={ query.type === 'bar' ? 0 : -1 }
onClick={ this.handleTypeToggle( 'bar' ) }
/>
</NavigableMenu>
</SectionHeader>
<div className="woocommerce-dashboard__columns">
{ this.state.showCharts.map( chart => {
return ! chart.show ? null : (
<div key={ chart.key }>
<ChartBlock
charts={ getChartFromKey( chart.key ) }
endpoint={ chart.endpoint }
path={ path }
query={ query }
/>
</div>
);
} ) }
</div>
</div>
</Fragment>
);
}
}
DashboardCharts.propTypes = {
path: PropTypes.string.isRequired,
query: PropTypes.object.isRequired,
};
export default DashboardCharts;

View File

@ -0,0 +1,18 @@
/** @format */
.woocommerce-dashboard__dashboard-charts {
border-bottom: 0;
border-right: 0;
.woocommerce-section-header__actions {
flex-grow: 0;
}
.woocommerce-card__body {
padding: 0;
}
.woocommerce-summary {
margin: 0;
}
}

View File

@ -12,6 +12,7 @@ import './style.scss';
import Header from 'header'; import Header from 'header';
import StorePerformance from './store-performance'; import StorePerformance from './store-performance';
import TopSellingProducts from './top-selling-products'; import TopSellingProducts from './top-selling-products';
import DashboardCharts from './dashboard-charts';
import { ReportFilters } from '@woocommerce/components'; import { ReportFilters } from '@woocommerce/components';
export default class Dashboard extends Component { export default class Dashboard extends Component {
@ -24,9 +25,10 @@ export default class Dashboard extends Component {
<StorePerformance /> <StorePerformance />
<div className="woocommerce-dashboard__columns"> <div className="woocommerce-dashboard__columns">
<div> <div>
<TopSellingProducts /> <TopSellingProducts query={ query } />
</div> </div>
</div> </div>
<DashboardCharts query={ query } path={ path } />
</Fragment> </Fragment>
); );
} }

View File

@ -0,0 +1,96 @@
/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { get } from 'lodash';
import PropTypes from 'prop-types';
/**
* WooCommerce dependencies
*/
import { Card, EmptyTable, TableCard } from '@woocommerce/components';
/**
* Internal dependencies
*/
import ReportError from 'analytics/components/report-error';
import { getReportTableData } from 'store/reports/utils';
import withSelect from 'wc-api/with-select';
import './style.scss';
export class Leaderboard extends Component {
render() {
const { getHeadersContent, getRowsContent, isRequesting, isError, items, title } = this.props;
const data = get( items, [ 'data' ], [] );
const rows = getRowsContent( data );
if ( isError ) {
return <ReportError className="woocommerce-leaderboard" isError />;
}
if ( ! isRequesting && rows.length === 0 ) {
return (
<Card title={ title } className="woocommerce-leaderboard">
<EmptyTable>
{ __( 'No data recorded for the selected time period.', 'wc-admin' ) }
</EmptyTable>
</Card>
);
}
return (
<TableCard
className="woocommerce-leaderboard"
headers={ getHeadersContent() }
isLoading={ isRequesting }
rows={ rows }
rowsPerPage={ 5 }
title={ title }
totalRows={ 5 }
/>
);
}
}
Leaderboard.propTypes = {
/**
* The endpoint to use in API calls to populate the table rows and summary.
* For example, if `taxes` is provided, data will be fetched from the report
* `taxes` endpoint (ie: `/wc/v3/reports/taxes` and `/wc/v3/reports/taxes/stats`).
* If the provided endpoint doesn't exist, an error will be shown to the user
* with `ReportError`.
*/
endpoint: PropTypes.string,
/**
* A function that returns the headers object to build the table.
*/
getHeadersContent: PropTypes.func.isRequired,
/**
* A function that returns the rows array to build the table.
*/
getRowsContent: PropTypes.func.isRequired,
/**
* Query args added to the report table endpoint request.
*/
query: PropTypes.object,
/**
* Properties to be added to the query sent to the report table endpoint.
*/
tableQuery: PropTypes.object,
/**
* String to display as the title of the table.
*/
title: PropTypes.string.isRequired,
};
export default compose(
withSelect( ( select, props ) => {
const { endpoint, tableQuery, query } = props;
const tableData = getReportTableData( endpoint, query, select, tableQuery );
return { ...tableData };
} )
)( Leaderboard );

View File

@ -1,5 +1,5 @@
/** @format */ /** @format */
.woocommerce-top-selling-products { .woocommerce-leaderboard {
&.woocommerce-empty-content { &.woocommerce-empty-content {
margin-bottom: $gap-large; margin-bottom: $gap-large;
} }

View File

@ -4,6 +4,7 @@
* @format * @format
*/ */
import TestRenderer from 'react-test-renderer'; import TestRenderer from 'react-test-renderer';
import { map, noop } from 'lodash';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { createRegistry, RegistryProvider } from '@wordpress/data'; import { createRegistry, RegistryProvider } from '@wordpress/data';
@ -15,7 +16,7 @@ import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency'
/** /**
* Internal dependencies * Internal dependencies
*/ */
import TopSellingProductsWithSelect, { TopSellingProducts } from '../'; import LeaderboardWithSelect, { Leaderboard } from '../';
import { numberFormat } from 'lib/number'; import { numberFormat } from 'lib/number';
import mockData from '../__mocks__/top-selling-products-mock-data'; import mockData from '../__mocks__/top-selling-products-mock-data';
@ -26,16 +27,49 @@ jest.mock( '@woocommerce/components', () => ( {
TableCard: () => null, TableCard: () => null,
} ) ); } ) );
describe( 'TopSellingProducts', () => { const getRowsContent = data => {
test( 'should render empty message when there are no rows', () => { return map( data, row => {
const topSellingProducts = shallow( <TopSellingProducts data={ {} } /> ); const { name, items_sold, net_revenue, orders_count } = row;
return [
{
display: name,
value: name,
},
{
display: numberFormat( items_sold ),
value: items_sold,
},
{
display: numberFormat( orders_count ),
value: orders_count,
},
{
display: formatCurrency( net_revenue ),
value: getCurrencyFormatDecimal( net_revenue ),
},
];
} );
};
expect( topSellingProducts.find( 'EmptyTable' ).length ).toBe( 1 ); describe( 'Leaderboard', () => {
test( 'should render empty message when there are no rows', () => {
const leaderboard = shallow(
<Leaderboard title={ '' } getHeadersContent={ noop } getRowsContent={ getRowsContent } />
);
expect( leaderboard.find( 'EmptyTable' ).length ).toBe( 1 );
} ); } );
test( 'should render correct data in the table', () => { test( 'should render correct data in the table', () => {
const topSellingProducts = shallow( <TopSellingProducts data={ mockData } /> ); const leaderboard = shallow(
const table = topSellingProducts.find( 'TableCard' ); <Leaderboard
title={ '' }
getHeadersContent={ noop }
getRowsContent={ getRowsContent }
items={ { data: { ...mockData } } }
/>
);
const table = leaderboard.find( 'TableCard' );
const firstRow = table.props().rows[ 0 ]; const firstRow = table.props().rows[ 0 ];
expect( firstRow[ 0 ].value ).toBe( mockData[ 0 ].name ); expect( firstRow[ 0 ].value ).toBe( mockData[ 0 ].name );
@ -47,12 +81,13 @@ describe( 'TopSellingProducts', () => {
expect( firstRow[ 3 ].value ).toBe( getCurrencyFormatDecimal( mockData[ 0 ].net_revenue ) ); expect( firstRow[ 3 ].value ).toBe( getCurrencyFormatDecimal( mockData[ 0 ].net_revenue ) );
} ); } );
test( 'should load report stats from API', () => { // TODO: Since this now uses fresh-data / wc-api, the API testing needs to be revisted.
xtest( 'should load report stats from API', () => {
const getReportStatsMock = jest.fn().mockReturnValue( { data: mockData } ); const getReportStatsMock = jest.fn().mockReturnValue( { data: mockData } );
const isReportStatsRequestingMock = jest.fn().mockReturnValue( false ); const isReportStatsRequestingMock = jest.fn().mockReturnValue( false );
const isReportStatsErrorMock = jest.fn().mockReturnValue( false ); const isReportStatsErrorMock = jest.fn().mockReturnValue( false );
const registry = createRegistry(); const registry = createRegistry();
registry.registerStore( 'wc-admin', { registry.registerStore( 'wc-api', {
reducer: () => {}, reducer: () => {},
selectors: { selectors: {
getReportStats: getReportStatsMock, getReportStats: getReportStatsMock,
@ -60,12 +95,12 @@ describe( 'TopSellingProducts', () => {
isReportStatsError: isReportStatsErrorMock, isReportStatsError: isReportStatsErrorMock,
}, },
} ); } );
const topSellingProductsWrapper = TestRenderer.create( const leaderboardWrapper = TestRenderer.create(
<RegistryProvider value={ registry }> <RegistryProvider value={ registry }>
<TopSellingProductsWithSelect /> <LeaderboardWithSelect />
</RegistryProvider> </RegistryProvider>
); );
const topSellingProducts = topSellingProductsWrapper.root.findByType( TopSellingProducts ); const leaderboard = leaderboardWrapper.root.findByType( Leaderboard );
const endpoint = '/wc/v3/reports/products'; const endpoint = '/wc/v3/reports/products';
const query = { orderby: 'items_sold', per_page: 5, extended_info: 1 }; const query = { orderby: 'items_sold', per_page: 5, extended_info: 1 };
@ -76,6 +111,6 @@ describe( 'TopSellingProducts', () => {
expect( isReportStatsRequestingMock.mock.calls[ 0 ][ 2 ] ).toEqual( query ); expect( isReportStatsRequestingMock.mock.calls[ 0 ][ 2 ] ).toEqual( query );
expect( isReportStatsErrorMock.mock.calls[ 0 ][ 1 ] ).toBe( endpoint ); expect( isReportStatsErrorMock.mock.calls[ 0 ][ 1 ] ).toBe( endpoint );
expect( isReportStatsErrorMock.mock.calls[ 0 ][ 2 ] ).toEqual( query ); expect( isReportStatsErrorMock.mock.calls[ 0 ][ 2 ] ).toEqual( query );
expect( topSellingProducts.props.data ).toBe( mockData ); expect( leaderboard.props.data ).toBe( mockData );
} ); } );
} ); } );

View File

@ -5,13 +5,10 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element'; import { Component } from '@wordpress/element';
import { get, map } from 'lodash'; import { get, map } from 'lodash';
import { compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
/** /**
* WooCommerce dependencies * WooCommerce dependencies
*/ */
import { Card, EmptyTable, TableCard } from '@woocommerce/components';
import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency'; import { formatCurrency, getCurrencyFormatDecimal } from '@woocommerce/currency';
import { getAdminLink } from '@woocommerce/navigation'; import { getAdminLink } from '@woocommerce/navigation';
@ -19,11 +16,16 @@ import { getAdminLink } from '@woocommerce/navigation';
* Internal dependencies * Internal dependencies
*/ */
import { numberFormat } from 'lib/number'; import { numberFormat } from 'lib/number';
import ReportError from 'analytics/components/report-error'; import Leaderboard from 'dashboard/leaderboard';
import { NAMESPACE } from 'store/constants';
import './style.scss';
export class TopSellingProducts extends Component { export class TopSellingProducts extends Component {
constructor( props ) {
super( props );
this.getRowsContent = this.getRowsContent.bind( this );
this.getHeadersContent = this.getHeadersContent.bind( this );
}
getHeadersContent() { getHeadersContent() {
return [ return [
{ {
@ -59,8 +61,8 @@ export class TopSellingProducts extends Component {
getRowsContent( data ) { getRowsContent( data ) {
return map( data, row => { return map( data, row => {
const { product_id, items_sold, net_revenue, orders_count, name } = row; const { product_id, items_sold, net_revenue, orders_count, extended_info } = row;
const name = get( extended_info, [ 'name' ] );
const productLink = ( const productLink = (
<a href={ getAdminLink( `/post.php?post=${ product_id }&action=edit` ) }>{ name }</a> <a href={ getAdminLink( `/post.php?post=${ product_id }&action=edit` ) }>{ name }</a>
); );
@ -86,53 +88,24 @@ export class TopSellingProducts extends Component {
} }
render() { render() {
const { data, isRequesting, isError } = this.props; const tableQuery = {
orderby: 'items_sold',
const rows = isRequesting || isError ? [] : this.getRowsContent( data ); order: 'desc',
const title = __( 'Top Selling Products', 'wc-admin' ); per_page: 5, //TODO replace with user configured leaderboard per page value.
extended_info: true,
if ( isError ) { };
return <ReportError className="woocommerce-top-selling-products" isError />;
}
if ( ! isRequesting && rows.length === 0 ) {
return (
<Card title={ title } className="woocommerce-top-selling-products">
<EmptyTable>
{ __( 'When new orders arrive, popular products will be listed here.', 'wc-admin' ) }
</EmptyTable>
</Card>
);
}
const headers = this.getHeadersContent();
return ( return (
<TableCard <Leaderboard
className="woocommerce-top-selling-products" endpoint="products"
headers={ headers } getHeadersContent={ this.getHeadersContent }
isLoading={ isRequesting } getRowsContent={ this.getRowsContent }
rows={ rows } query={ this.props.query }
rowsPerPage={ 5 } tableQuery={ tableQuery }
title={ title } title={ __( 'Top Selling Products', 'wc-admin' ) }
totalRows={ 5 }
/> />
); );
} }
} }
export default compose( export default TopSellingProducts;
withSelect( select => {
const { getReportStats, isReportStatsRequesting, isReportStatsError } = select( 'wc-admin' );
const endpoint = NAMESPACE + 'reports/products';
// @TODO We will need to add the date parameters from the Date Picker
// { after: '2018-04-22', before: '2018-05-06' }
const query = { orderby: 'items_sold', per_page: 5, extended_info: 1 };
const stats = getReportStats( endpoint, query );
const isRequesting = isReportStatsRequesting( endpoint, query );
const isError = isReportStatsError( endpoint, query );
return { data: get( stats, 'data', [] ), isRequesting, isError };
} )
)( TopSellingProducts );

View File

@ -11,7 +11,7 @@
height: 80px; height: 80px;
position: fixed; position: fixed;
width: 100%; width: 100%;
top: 32px; top: $adminbar-height;
z-index: 99991; /* higher than component-popover (99990), lower than notification dropdown at 99999 */ z-index: 99991; /* higher than component-popover (99990), lower than notification dropdown at 99999 */
&.is-scrolled { &.is-scrolled {
@ -19,7 +19,7 @@
} }
@include breakpoint( '<782px' ) { @include breakpoint( '<782px' ) {
top: 46px; top: $adminbar-height-mobile;
height: 50px; height: 50px;
flex-flow: row wrap; flex-flow: row wrap;
} }

View File

@ -14,28 +14,12 @@ import { stringifyQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { NAMESPACE, SWAGGERNAMESPACE } from 'store/constants'; import { NAMESPACE } from 'store/constants';
export default { export default {
async getReportItems( ...args ) { async getReportItems( ...args ) {
const [ endpoint, query ] = args.slice( -2 ); const [ endpoint, query ] = args.slice( -2 );
const swaggerEndpoints = [ 'coupons' ];
if ( swaggerEndpoints.indexOf( endpoint ) >= 0 ) {
try {
const response = await fetch(
SWAGGERNAMESPACE + 'reports/' + endpoint + stringifyQuery( query )
);
const itemsData = await response.json();
dispatch( 'wc-admin' ).setReportItems( endpoint, query, itemsData );
} catch ( error ) {
dispatch( 'wc-admin' ).setReportItemsError( endpoint, query );
}
return;
}
try { try {
const response = await apiFetch( { const response = await apiFetch( {
parse: false, parse: false,

View File

@ -28,7 +28,7 @@ export default {
let apiPath = endpoint + stringifyQuery( query ); let apiPath = endpoint + stringifyQuery( query );
// TODO: Remove once swagger endpoints are phased out. // TODO: Remove once swagger endpoints are phased out.
const swaggerEndpoints = [ 'categories', 'coupons' ]; const swaggerEndpoints = [ 'categories' ];
if ( swaggerEndpoints.indexOf( endpoint ) >= 0 ) { if ( swaggerEndpoints.indexOf( endpoint ) >= 0 ) {
apiPath = SWAGGERNAMESPACE + 'reports/' + endpoint + '/stats' + stringifyQuery( query ); apiPath = SWAGGERNAMESPACE + 'reports/' + endpoint + '/stats' + stringifyQuery( query );
try { try {

View File

@ -327,7 +327,7 @@ describe( 'getSummaryNumbers()', () => {
} ); } );
setGetReportStats( ( endpoint, _query ) => { setGetReportStats( ( endpoint, _query ) => {
if ( '2018-10-10T00:00:00+00:00' === _query.after ) { if ( '2018-10-10T00:00:00' === _query.after ) {
return { return {
data: { data: {
totals: totals.primary, totals: totals.primary,

View File

@ -119,15 +119,15 @@ export function isReportDataEmpty( report ) {
* @returns {Object} data request query parameters. * @returns {Object} data request query parameters.
*/ */
function getRequestQuery( endpoint, dataType, query ) { function getRequestQuery( endpoint, dataType, query ) {
const datesFromQuery = getCurrentDates( query ); const datesFromQuery = getCurrentDates( query, 'YYYY-MM-DDTHH:00:00' );
const interval = getIntervalForQuery( query ); const interval = getIntervalForQuery( query );
const filterQuery = getFilterQuery( endpoint, query ); const filterQuery = getFilterQuery( endpoint, query );
return { return {
order: 'asc', order: 'asc',
interval, interval,
per_page: MAX_PER_PAGE, per_page: MAX_PER_PAGE,
after: appendTimestamp( datesFromQuery[ dataType ].after, 'start' ), after: datesFromQuery[ dataType ].after,
before: appendTimestamp( datesFromQuery[ dataType ].before, 'end' ), before: datesFromQuery[ dataType ].before,
...filterQuery, ...filterQuery,
}; };
} }

View File

@ -23,3 +23,7 @@ $light-gray-500: $core-grey-light-500;
$dark-gray-300: $core-grey-dark-300; $dark-gray-300: $core-grey-dark-300;
$dark-gray-900: $core-grey-dark-900; $dark-gray-900: $core-grey-dark-900;
$alert-red: $error-red; $alert-red: $error-red;
// WordPress defaults
$adminbar-height: 32px;
$adminbar-height-mobile: 46px;

View File

@ -9,6 +9,7 @@
#wpbody-content { #wpbody-content {
padding: 0; padding: 0;
overflow-x: hidden !important; overflow-x: hidden !important;
min-height: calc(100vh - #{$adminbar-height});
} }
@include breakpoint( '<782px' ) { @include breakpoint( '<782px' ) {
@ -16,6 +17,10 @@
.wp-responsive-open #wpbody { .wp-responsive-open #wpbody {
right: -14.5em; right: -14.5em;
} }
#wpcontent,
#wpbody-content {
min-height: calc(100vh - #{$adminbar-height-mobile});
}
} }
@include breakpoint( '>960px' ) { @include breakpoint( '>960px' ) {

View File

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

View File

@ -0,0 +1,51 @@
/** @format */
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* WooCommerce dependencies
*/
import { stringifyQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { isResourcePrefix, getResourceIdentifier, getResourceName } from '../utils';
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 = `/wc/v3/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

@ -0,0 +1,56 @@
/** @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

@ -17,7 +17,7 @@ import { NAMESPACE } from '../../constants';
import { SWAGGERNAMESPACE } from 'store/constants'; import { SWAGGERNAMESPACE } from 'store/constants';
// TODO: Remove once swagger endpoints are phased out. // TODO: Remove once swagger endpoints are phased out.
const swaggerEndpoints = [ 'coupons', 'customers', 'downloads' ]; const swaggerEndpoints = [ 'customers', 'downloads' ];
const typeEndpointMap = { const typeEndpointMap = {
'report-items-query-orders': 'orders', 'report-items-query-orders': 'orders',

View File

@ -16,9 +16,9 @@ import { getResourceIdentifier, getResourcePrefix } from '../../utils';
import { NAMESPACE } from '../../constants'; import { NAMESPACE } from '../../constants';
import { SWAGGERNAMESPACE } from 'store/constants'; import { SWAGGERNAMESPACE } from 'store/constants';
const statEndpoints = [ 'orders', 'revenue', 'products', 'taxes' ]; const statEndpoints = [ 'orders', 'revenue', 'products', 'taxes', 'coupons' ];
// TODO: Remove once swagger endpoints are phased out. // TODO: Remove once swagger endpoints are phased out.
const swaggerEndpoints = [ 'categories', 'coupons', 'downloads' ]; const swaggerEndpoints = [ 'categories', 'downloads' ];
const typeEndpointMap = { const typeEndpointMap = {
'report-stats-query-orders': 'orders', 'report-stats-query-orders': 'orders',

View File

@ -3,6 +3,7 @@
/** /**
* Internal dependencies * Internal dependencies
*/ */
import categories from './categories';
import customers from './customers'; import customers from './customers';
import notes from './notes'; import notes from './notes';
import orders from './orders'; import orders from './orders';
@ -17,6 +18,7 @@ function createWcApiSpec() {
...user.mutations, ...user.mutations,
}, },
selectors: { selectors: {
...categories.selectors,
...customers.selectors, ...customers.selectors,
...notes.selectors, ...notes.selectors,
...orders.selectors, ...orders.selectors,
@ -28,6 +30,7 @@ function createWcApiSpec() {
operations: { operations: {
read( resourceNames ) { read( resourceNames ) {
return [ return [
...categories.operations.read( resourceNames ),
...customers.operations.read( resourceNames ), ...customers.operations.read( resourceNames ),
...notes.operations.read( resourceNames ), ...notes.operations.read( resourceNames ),
...orders.operations.read( resourceNames ), ...orders.operations.read( resourceNames ),

View File

@ -1,38 +1,4 @@
Components Components
========== ==========
This folder contains the WooCommerce-created components. These are exported onto a global, `wc.components`, for general use. This folder contains components used in `wc-admin`. They are divided in [analytics components](components/analytics), only available internally, and [package components](components/packages), which are available for plugins and third-party developers.
## How to use:
For any files not imported into `components/index.js` (`analytics/*`, `layout/*`, `dashboard/*`, etc), we can use `import { Card, etc … } from @woocommerce/components`.
For any `component/*` files, we should import from component-specific paths, not from `component/index.js`, to prevent circular dependencies. See `components/card/index.js` for an example.
```jsx
import { Card, Link } from '@woocommerce/components';
render: function() {
return (
<Card title="Card demo">
Card content with an <Link href="/">example link.</Link>
</Card>
);
}
```
## For external development
External developers will need to enqueue the components library, `wc-components`, and then can access them from the global.
```jsx
const { Card, Link } = wc.components;
render: function() {
return (
<Card title="Card demo">
Card content with an <Link href="/">example link.</Link>
</Card>
);
}
```

View File

@ -2,30 +2,36 @@
* [Components](components/) * [Components](components/)
* [AnimationSlider](components/animation-slider.md) * [Analytics components](components/analytics/)
* [Calendar](components/calendar.md) * [ReportChart](components/analytics/report-chart.md)
* [Card](components/card.md) * [ReportError](components/analytics/report-error.md)
* [Chart](components/chart.md) * [ReportSummary](components/analytics/report-summary.md)
* [Count](components/count.md) * [ReportTable](components/analytics/report-table.md)
* [DropdownButton](components/dropdown-button.md) * [Package components](components/packages/)
* [EllipsisMenu](components/ellipsis-menu.md) * [AnimationSlider](components/packages/animation-slider.md)
* [EmptyContent](components/empty-content.md) * [Calendar](components/packages/calendar.md)
* [Filters](components/filters.md) * [Card](components/packages/card.md)
* [Flag](components/flag.md) * [Chart](components/packages/chart.md)
* [Gravatar](components/gravatar.md) * [Count](components/packages/count.md)
* [ImageAsset](components/image-asset.md) * [DropdownButton](components/packages/dropdown-button.md)
* [Link](components/link.md) * [EllipsisMenu](components/packages/ellipsis-menu.md)
* [OrderStatus](components/order-status.md) * [EmptyContent](components/packages/empty-content.md)
* [Pagination](components/pagination.md) * [Filters](components/packages/filters.md)
* [ProductImage](components/product-image.md) * [Flag](components/packages/flag.md)
* [Rating](components/rating.md) * [Gravatar](components/packages/gravatar.md)
* [Search](components/search.md) * [ImageAsset](components/packages/image-asset.md)
* [SectionHeader](components/section-header.md) * [Link](components/packages/link.md)
* [Section](components/section.md) * [OrderStatus](components/packages/order-status.md)
* [SegmentedSelection](components/segmented-selection.md) * [Pagination](components/packages/pagination.md)
* [SplitButton](components/split-button.md) * [ProductImage](components/packages/product-image.md)
* [Summary](components/summary.md) * [Rating](components/packages/rating.md)
* [Table](components/table.md) * [Search](components/packages/search.md)
* [Tag](components/tag.md) * [SectionHeader](components/packages/section-header.md)
* [TextControlWithAffixes](components/text-control-with-affixes.md) * [Section](components/packages/section.md)
* [ViewMoreList](components/view-more-list.md) * [SegmentedSelection](components/packages/segmented-selection.md)
* [SplitButton](components/packages/split-button.md)
* [Summary](components/packages/summary.md)
* [Table](components/packages/table.md)
* [Tag](components/packages/tag.md)
* [TextControlWithAffixes](components/packages/text-control-with-affixes.md)
* [ViewMoreList](components/packages/view-more-list.md)

View File

@ -0,0 +1,24 @@
Analytics Components
====================
This folder contains components internally used by `wc-admin`.
## How to use:
Analytics components can be imported by their relative or absolute path.
```jsx
import ReportChart from 'analytics/components/report-chart';
render: function() {
return (
<ReportChart
charts={ charts }
endpoint={ endpoint }
path={ path }
query={ query }
selectedChart={ selectedChart }
/>
);
}
```

View File

@ -0,0 +1,62 @@
`ReportChart` (component)
=========================
Component that renders the chart in reports.
Props
-----
### `filters`
- Type: Array
- Default: null
Filters available for that report.
### `itemsLabel`
- Type: String
- Default: null
Label describing the legend items.
### `path`
- **Required**
- Type: String
- Default: null
Current path
### `primaryData`
- **Required**
- Type: Object
- Default: null
Primary data to display in the chart.
### `query`
- **Required**
- Type: Object
- Default: null
The query string represented in object form.
### `secondaryData`
- **Required**
- Type: Object
- Default: null
Secondary data to display in the chart.
### `selectedChart`
- **Required**
- Type: Object
- Default: null
Properties of the selected chart.

View File

@ -0,0 +1,30 @@
`ReportError` (component)
=========================
Component to render when there is an error in a report component due to data
not being loaded or being invalid.
Props
-----
### `className`
- Type: String
- Default: `''`
Additional class name to style the component.
### `isError`
- Type: Boolean
- Default: null
Boolean representing whether there was an error.
### `isEmpty`
- Type: Boolean
- Default: null
Boolean representing whether the issue is that there is no data.

View File

@ -0,0 +1,41 @@
`ReportSummary` (component)
===========================
Component to render summary numbers in reports.
Props
-----
### `charts`
- **Required**
- Type: Array
- Default: null
Properties of all the charts available for that report.
### `endpoint`
- **Required**
- Type: String
- Default: null
The endpoint to use in API calls.
### `query`
- **Required**
- Type: Object
- Default: null
The query string represented in object form.
### `selectedChart`
- **Required**
- Type: Object
- key: String - Key of the selected chart.
- Default: null
Properties of the selected chart.

View File

@ -0,0 +1,96 @@
`ReportTable` (component)
=========================
Component that extends `TableCard` to facilitate its usage in reports.
Props
-----
### `columnPrefsKey`
- Type: String
- Default: null
The key for user preferences settings for column visibility.
### `endpoint`
- Type: String
- Default: null
The endpoint to use in API calls.
### `extendItemsMethodNames`
- Type: Object
- getError: String
- isRequesting: String
- load: String
- Default: null
Name of the methods available via `select( 'wc-api' )` that will be used to
load more data for table items. If omitted, no call will be made and only
the data returned by the reports endpoint will be used.
### `getHeadersContent`
- **Required**
- Type: Function
- Default: null
A function that returns the headers object to build the table.
### `getRowsContent`
- **Required**
- Type: Function
- Default: null
A function that returns the rows array to build the table.
### `getSummary`
- Type: Function
- Default: null
A function that returns the summary object to build the table.
### `itemIdField`
- Type: String
- Default: null
The name of the property in the item object which contains the id.
### `primaryData`
- **Required**
- Type: Object
- Default: null
Primary data of that report. If it's not provided, it will be automatically
loaded via the provided `endpoint`.
### `tableData`
- Type: Object
- Default: `{}`
Table data of that report. If it's not provided, it will be automatically
loaded via the provided `endpoint`.
### `tableQuery`
- Type: Object
- Default: `{}`
Properties to be added to the query sent to the report table endpoint.
### `title`
- **Required**
- Type: String
- Default: null
String to display as the title of the table.

View File

@ -0,0 +1,38 @@
Package Components
==================
This folder contains the WooCommerce-created components. These are exported onto a global, `wc.components`, for general use.
## How to use:
For any files not imported into `components/index.js` (`analytics/*`, `layout/*`, `dashboard/*`, etc), we can use `import { Card, etc … } from @woocommerce/components`.
For any `component/*` files, we should import from component-specific paths, not from `component/index.js`, to prevent circular dependencies. See `components/card/index.js` for an example.
```jsx
import { Card, Link } from '@woocommerce/components';
render: function() {
return (
<Card title="Card demo">
Card content with an <Link href="/">example link.</Link>
</Card>
);
}
```
## For external development
External developers will need to enqueue the components library, `wc-components`, and then can access them from the global.
```jsx
const { Card, Link } = wc.components;
render: function() {
return (
<Card title="Card demo">
Card content with an <Link href="/">example link.</Link>
</Card>
);
}
```

View File

@ -1,3 +1,56 @@
`DatePicker` (component)
========================
Props
-----
### `date`
- Type: Object
- Default: null
A moment date object representing the selected date. `null` for no selection.
### `text`
- Type: String
- Default: null
The date in human-readable format. Displayed in the text input.
### `error`
- Type: String
- Default: null
A string error message, shown to the user.
### `invalidDays`
- Type: One of type: enum, func
- Default: null
(Coming Soon) Optionally invalidate certain days. `past`, `future`, `none`, or function are accepted.
A function will be passed to react-dates' `isOutsideRange` prop
### `onUpdate`
- **Required**
- Type: Function
- Default: null
A function called upon selection of a date or input change.
### `dateFormat`
- **Required**
- Type: String
- Default: null
The date format in moment.js-style tokens.
`DateRange` (component) `DateRange` (component)
======================= =======================

View File

@ -20,6 +20,13 @@ An array of data.
Format to parse dates into d3 time format Format to parse dates into d3 time format
### `itemsLabel`
- Type: String
- Default: null
Label describing the legend items.
### `path` ### `path`
- Type: String - Type: String

View File

@ -144,8 +144,8 @@ The query string represented in object form
Which type of autocompleter should be used in the Search Which type of autocompleter should be used in the Search
`DatePicker` (component) `DateRangeFilterPicker` (component)
======================== ===================================
Select a range of dates or single dates. Select a range of dates or single dates.

View File

@ -24,7 +24,7 @@ Function called when selected results change, passed result list.
### `type` ### `type`
- **Required** - **Required**
- Type: One of: 'products', 'product_cats', 'orders', 'customers', 'coupons', 'taxes', 'variations' - Type: One of: 'countries', 'coupons', 'customers', 'emails', 'orders', 'products', 'product_cats', 'taxes', 'usernames', 'variations'
- Default: null - Default: null
The object type to be used in searching. The object type to be used in searching.
@ -39,7 +39,7 @@ A placeholder for the search input.
### `selected` ### `selected`
- Type: Array - Type: Array
- id: Number - id: One of type: number, string
- label: String - label: String
- Default: `[]` - Default: `[]`

View File

@ -11,7 +11,7 @@ Props
### `id` ### `id`
- Type: Number - Type: One of type: number, string
- Default: null - Default: null
The ID for this item, used in the remove function. The ID for this item, used in the remove function.

View File

@ -76,6 +76,15 @@ class WC_Admin_REST_Admin_Notes_Controller extends WC_REST_CRUD_Controller {
*/ */
public function get_item( $request ) { public function get_item( $request ) {
$note = WC_Admin_Notes::get_note( $request->get_param( 'id' ) ); $note = WC_Admin_Notes::get_note( $request->get_param( 'id' ) );
if ( ! $note ) {
return new WP_Error(
'woocommerce_admin_notes_invalid_id',
__( 'Sorry, there is no resouce with that ID.', 'wc-admin' ),
array( 'status' => 404 )
);
}
if ( is_wp_error( $note ) ) { if ( is_wp_error( $note ) ) {
return $note; return $note;
} }

View File

@ -0,0 +1,40 @@
<?php
/**
* REST API Data Controller
*
* Handles requests to /data
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* Data controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_Data_Controller
*/
class WC_Admin_REST_Data_Controller extends WC_REST_Data_Controller {
/**
* Return the list of data resources.
*
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
$response = parent::get_items( $request );
$response->data[] = $this->prepare_response_for_collection(
$this->prepare_item_for_response(
(object) array(
'slug' => 'download-ips',
'description' => __( 'An endpoint used for searching download logs for a specific IP address.', 'wc-admin' ),
),
$request
)
);
return $response;
}
}

View File

@ -0,0 +1,167 @@
<?php
/**
* REST API Data Download IP Controller
*
* Handles requests to /data/download-ips
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* Data Download IP controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_Data_Controller
*/
class WC_Admin_REST_Data_Download_Ips_Controller extends WC_REST_Data_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wc/v3';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'data/download-ips';
/**
* Register routes.
*
* @since 3.5.0
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Return the download IPs matching the passed parameters.
*
* @since 3.5.0
* @param WP_REST_Request $request Request data.
* @return WP_Error|WP_REST_Response
*/
public function get_items( $request ) {
global $wpdb;
if ( isset( $request['match'] ) ) {
$downloads = $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT( user_ip_address ) FROM {$wpdb->prefix}wc_download_log
WHERE user_ip_address LIKE %s
LIMIT 10",
$request['match'] . '%'
)
);
} else {
return new WP_Error( 'woocommerce_rest_data_download_ips_invalid_request', __( 'Invalid request. Please pass the match parameter.', 'wc-admin' ), array( 'status' => 400 ) );
}
$data = array();
if ( ! empty( $downloads ) ) {
foreach ( $downloads as $download ) {
$response = $this->prepare_item_for_response( $download, $request );
$data[] = $this->prepare_response_for_collection( $response );
}
}
return rest_ensure_response( $data );
}
/**
* Prepare the data object for response.
*
* @since 3.5.0
* @param object $item Data object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, $request ) {
$data = $this->add_additional_fields_to_object( $item, $request );
$data = $this->filter_response_by_context( $data, 'view' );
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $item ) );
/**
* Filter the ist returned from the API.
*
* @param WP_REST_Response $response The response object.
* @param array $item The original item.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_data_download_ip', $response, $item, $request );
}
/**
* Prepare links for the request.
*
* @param object $item Data object.
* @return array Links for the given object.
*/
protected function prepare_links( $item ) {
$links = array(
'collection' => array(
'href' => rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ),
),
);
return $links;
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = array();
$params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
$params['match'] = array(
'description' => __( 'A partial IP address can be passed and matching results will be returned.', 'wc-admin' ),
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Get the schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'data_download_ips',
'type' => 'object',
'properties' => array(
'user_ip_address' => array(
'type' => 'string',
'description' => __( 'IP address.', 'wc-admin' ),
'context' => array( 'view' ),
'readonly' => true,
),
),
);
return $this->add_additional_fields_schema( $schema );
}
}

View File

@ -0,0 +1,61 @@
<?php
/**
* REST API Orders Controller
*
* Handles requests to /orders/*
*
* @package WooCommerce Admin/API
*/
defined( 'ABSPATH' ) || exit;
/**
* Orders controller.
*
* @package WooCommerce Admin/API
* @extends WC_REST_Orders_Controller
*/
class WC_Admin_REST_Orders_Controller extends WC_REST_Orders_Controller {
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['number'] = array(
'description' => __( 'Limit result set to orders matching part of an order number.', 'wc-admin' ),
'type' => 'integer',
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
return $params;
}
/**
* Prepare objects query.
*
* @param WP_REST_Request $request Full details about the request.
* @return array
*/
protected function prepare_objects_query( $request ) {
global $wpdb;
$args = parent::prepare_objects_query( $request );
// Search by partial order number.
if ( ! empty( $request['number'] ) ) {
$order_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM {$wpdb->prefix}posts WHERE post_type = 'shop_order' AND ID LIKE %s",
intval( $request['number'] ) . '%'
)
);
// Force WP_Query return empty if don't found any order.
$order_ids = ! empty( $order_ids ) ? $order_ids : array( 0 );
$args['post__in'] = $order_ids;
}
return $args;
}
}

View File

@ -252,9 +252,9 @@ class WC_Admin_REST_Reports_Categories_Controller extends WC_REST_Reports_Contro
$params['orderby'] = array( $params['orderby'] = array(
'description' => __( 'Sort collection by object attribute.', 'wc-admin' ), 'description' => __( 'Sort collection by object attribute.', 'wc-admin' ),
'type' => 'string', 'type' => 'string',
'default' => 'date', 'default' => 'category_id',
'enum' => array( 'enum' => array(
'date', 'category_id',
'items_sold', 'items_sold',
'net_revenue', 'net_revenue',
'orders_count', 'orders_count',

View File

@ -51,10 +51,13 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
$args['last_active_after'] = $request['last_active_after']; $args['last_active_after'] = $request['last_active_after'];
$args['order_count_min'] = $request['order_count_min']; $args['order_count_min'] = $request['order_count_min'];
$args['order_count_max'] = $request['order_count_max']; $args['order_count_max'] = $request['order_count_max'];
$args['order_count_between'] = $request['order_count_between'];
$args['total_spend_min'] = $request['total_spend_min']; $args['total_spend_min'] = $request['total_spend_min'];
$args['total_spend_max'] = $request['total_spend_max']; $args['total_spend_max'] = $request['total_spend_max'];
$args['total_spend_between'] = $request['total_spend_between'];
$args['avg_order_value_min'] = $request['avg_order_value_min']; $args['avg_order_value_min'] = $request['avg_order_value_min'];
$args['avg_order_value_max'] = $request['avg_order_value_max']; $args['avg_order_value_max'] = $request['avg_order_value_max'];
$args['avg_order_value_between'] = $request['avg_order_value_between'];
return $args; return $args;
} }
@ -279,6 +282,11 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg', 'validate_callback' => 'rest_validate_request_arg',
); );
$params['order_count_between'] = array(
'description' => __( 'Limit response to objects with an order count between two given integers.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
);
$params['total_spend_min'] = array( $params['total_spend_min'] = array(
'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'wc-admin' ), 'description' => __( 'Limit response to objects with a total order spend greater than or equal to given number.', 'wc-admin' ),
'type' => 'number', 'type' => 'number',
@ -289,6 +297,11 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'type' => 'number', 'type' => 'number',
'validate_callback' => 'rest_validate_request_arg', 'validate_callback' => 'rest_validate_request_arg',
); );
$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' => 'rest_validate_request_arg',
);
$params['avg_order_value_min'] = array( $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' ), 'description' => __( 'Limit response to objects with an average order spend greater than or equal to given number.', 'wc-admin' ),
'type' => 'number', 'type' => 'number',
@ -299,6 +312,11 @@ class WC_Admin_REST_Reports_Customers_Controller extends WC_REST_Reports_Control
'type' => 'number', 'type' => 'number',
'validate_callback' => 'rest_validate_request_arg', 'validate_callback' => 'rest_validate_request_arg',
); );
$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' => 'rest_validate_request_arg',
);
return $params; return $params;
} }
} }

View File

@ -30,4 +30,334 @@ class WC_Admin_REST_Reports_Downloads_Controller extends WC_REST_Reports_Control
* @var string * @var string
*/ */
protected $rest_base = 'reports/downloads'; protected $rest_base = 'reports/downloads';
/**
* Get items.
*
* @param WP_REST_Request $request Request data.
*
* @return array|WP_Error
*/
public function get_items( $request ) {
$args = array();
$registered = array_keys( $this->get_collection_params() );
foreach ( $registered as $param_name ) {
if ( isset( $request[ $param_name ] ) ) {
$args[ $param_name ] = $request[ $param_name ];
}
}
$reports = new WC_Admin_Reports_Downloads_Query( $args );
$downloads_data = $reports->get_data();
$data = array();
foreach ( $downloads_data->data as $download_data ) {
$item = $this->prepare_item_for_response( $download_data, $request );
$data[] = $this->prepare_response_for_collection( $item );
}
$response = rest_ensure_response( $data );
$response->header( 'X-WP-Total', (int) $downloads_data->total );
$response->header( 'X-WP-TotalPages', (int) $downloads_data->pages );
$page = $downloads_data->page_no;
$max_pages = $downloads_data->pages;
$base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );
if ( $page > 1 ) {
$prev_page = $page - 1;
if ( $prev_page > $max_pages ) {
$prev_page = $max_pages;
}
$prev_link = add_query_arg( 'page', $prev_page, $base );
$response->link_header( 'prev', $prev_link );
}
if ( $max_pages > $page ) {
$next_page = $page + 1;
$next_link = add_query_arg( 'page', $next_page, $base );
$response->link_header( 'next', $next_link );
}
return $response;
}
/**
* Prepare a report object for serialization.
*
* @param Array $report Report data.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function prepare_item_for_response( $report, $request ) {
$data = $report;
$context = ! empty( $request['context'] ) ? $request['context'] : 'view';
$data = $this->add_additional_fields_to_object( $data, $request );
$data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
$response->add_links( $this->prepare_links( $report ) );
$response->data['date'] = get_date_from_gmt( $data['date_gmt'], 'Y-m-d H:i:s' );
// Figure out file name.
// Matches https://github.com/woocommerce/woocommerce/blob/4be0018c092e617c5d2b8c46b800eb71ece9ddef/includes/class-wc-download-handler.php#L197.
$product_id = intval( $data['product_id'] );
$_product = wc_get_product( $product_id );
$file_path = $_product->get_file_download_path( $data['download_id'] );
$filename = basename( $file_path );
$response->data['file_name'] = apply_filters( 'woocommerce_file_download_filename', $filename, $product_id );
/**
* Filter a report returned from the API.
*
* Allows modification of the report data right before it is returned.
*
* @param WP_REST_Response $response The response object.
* @param object $report The original report object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'woocommerce_rest_prepare_report_downloads', $response, $report, $request );
}
/**
* Prepare links for the request.
*
* @param Array $object Object data.
* @return array Links for the given post.
*/
protected function prepare_links( $object ) {
$links = array(
'product' => array(
'href' => rest_url( sprintf( '/%s/%s/%d', $this->namespace, 'products', $object['product_id'] ) ),
'embeddable' => true,
),
'user' => array(
'href' => rest_url( 'wp/v2/users/' . $object['user_id'] ),
'embeddable' => true,
),
);
return $links;
}
/**
* Get the Report's schema, conforming to JSON Schema.
*
* @return array
*/
public function get_item_schema() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'report_downloads',
'type' => 'object',
'properties' => array(
'id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'ID.', 'wc-admin' ),
),
'product_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product ID.', 'wc-admin' ),
),
'date' => array(
'description' => __( "The date of the download, in the site's timezone.", 'wc-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'date_gmt' => array(
'description' => __( 'The date of the download, as GMT.', 'wc-admin' ),
'type' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'download_id' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Download ID.', 'wc-admin' ),
),
'file_name' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'File name.', 'wc-admin' ),
),
'product_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product ID.', 'wc-admin' ),
),
'order_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Order ID.', 'wc-admin' ),
),
'user_id' => array(
'type' => 'integer',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'User ID for the downloader.', 'wc-admin' ),
),
'ip_address' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'IP address for the downloader.', 'wc-admin' ),
),
),
);
return $this->add_additional_fields_schema( $schema );
}
/**
* Get the query params for collections.
*
* @return array
*/
public function get_collection_params() {
$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,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
'minimum' => 1,
);
$params['per_page'] = array(
'description' => __( 'Maximum number of items to be returned in result set.', 'wc-admin' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
);
$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(
'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(
'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(
'description' => __( 'Sort collection by object attribute.', 'wc-admin' ),
'type' => 'string',
'default' => 'date',
'enum' => array(
'date',
),
'validate_callback' => 'rest_validate_request_arg',
);
$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: products, orders, username, ip_address.', 'wc-admin' ),
'type' => 'string',
'default' => 'all',
'enum' => array(
'all',
'any',
),
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_includes'] = array(
'description' => __( 'Limit result set to items that have the specified product(s) assigned.', 'wc-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
);
$params['product_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'wc-admin' ),
'type' => 'array',
'items' => array(
'type' => 'integer',
),
'default' => array(),
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
);
$params['order_includes'] = array(
'description' => __( 'Limit result set to items that have the specified order ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['order_excludes'] = array(
'description' => __( 'Limit result set to items that don\'t have the specified order ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['user_includes'] = array(
'description' => __( 'Limit response to objects that have the specified user ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['user_excludes'] = array(
'description' => __( 'Limit response to objects that don\'t have the specified user ids.', 'wc-admin' ),
'type' => 'array',
'sanitize_callback' => 'wp_parse_id_list',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'integer',
),
);
$params['ip_address_includes'] = array(
'description' => __( 'Limit response to objects that have a specified ip address.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
$params['ip_address_excludes'] = array(
'description' => __( 'Limit response to objects that don\'t have a specified ip address.', 'wc-admin' ),
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
),
);
return $params;
}
} }

View File

@ -224,6 +224,12 @@ class WC_Admin_REST_Reports_Products_Controller extends WC_REST_Reports_Controll
'context' => array( 'view', 'edit' ), 'context' => array( 'view', 'edit' ),
'description' => __( 'Product inventory threshold for low stock.', 'wc-admin' ), 'description' => __( 'Product inventory threshold for low stock.', 'wc-admin' ),
), ),
'sku' => array(
'type' => 'string',
'readonly' => true,
'context' => array( 'view', 'edit' ),
'description' => __( 'Product SKU.', 'wc-admin' ),
),
), ),
), ),
); );

View File

@ -53,6 +53,7 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-taxes-stats-query.php'; require_once dirname( __FILE__ ) . '/class-wc-admin-reports-taxes-stats-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-coupons-query.php'; require_once dirname( __FILE__ ) . '/class-wc-admin-reports-coupons-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-coupons-stats-query.php'; require_once dirname( __FILE__ ) . '/class-wc-admin-reports-coupons-stats-query.php';
require_once dirname( __FILE__ ) . '/class-wc-admin-reports-downloads-query.php';
// Data stores. // Data stores.
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-data-store.php'; require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-data-store.php';
@ -65,9 +66,9 @@ class WC_Admin_Api_Init {
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-taxes-stats-data-store.php'; require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-taxes-stats-data-store.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-coupons-data-store.php'; require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-coupons-data-store.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-coupons-stats-data-store.php'; require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-coupons-stats-data-store.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-reports-downloads-data-store.php';
// Data triggers. // Data triggers.
require_once dirname( __FILE__ ) . '/wc-admin-order-functions.php';
require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-notes-data-store.php'; require_once dirname( __FILE__ ) . '/data-stores/class-wc-admin-notes-data-store.php';
// CRUD classes. // CRUD classes.
@ -81,6 +82,9 @@ class WC_Admin_Api_Init {
public function rest_api_init() { public function rest_api_init() {
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-admin-notes-controller.php'; require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-admin-notes-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-customers-controller.php'; require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-customers-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-data-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-data-download-ips-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-orders-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-products-controller.php'; require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-products-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-product-reviews-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-reports-controller.php';
@ -100,10 +104,14 @@ 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-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-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-reports-stock-controller.php';
require_once dirname( __FILE__ ) . '/api/class-wc-admin-rest-reports-downloads-controller.php';
$controllers = array( $controllers = array(
'WC_Admin_REST_Admin_Notes_Controller', 'WC_Admin_REST_Admin_Notes_Controller',
'WC_Admin_REST_Customers_Controller', 'WC_Admin_REST_Customers_Controller',
'WC_Admin_REST_Data_Controller',
'WC_Admin_REST_Data_Download_Ips_Controller',
'WC_Admin_REST_Orders_Controller',
'WC_Admin_REST_Products_Controller', 'WC_Admin_REST_Products_Controller',
'WC_Admin_REST_Product_Reviews_Controller', 'WC_Admin_REST_Product_Reviews_Controller',
'WC_Admin_REST_Reports_Controller', 'WC_Admin_REST_Reports_Controller',
@ -119,6 +127,7 @@ class WC_Admin_Api_Init {
'WC_Admin_REST_Reports_Coupons_Controller', 'WC_Admin_REST_Reports_Coupons_Controller',
'WC_Admin_REST_Reports_Coupons_Stats_Controller', 'WC_Admin_REST_Reports_Coupons_Stats_Controller',
'WC_Admin_REST_Reports_Stock_Controller', 'WC_Admin_REST_Reports_Stock_Controller',
'WC_Admin_REST_Reports_Downloads_Controller',
); );
foreach ( $controllers as $controller ) { foreach ( $controllers as $controller ) {
@ -162,17 +171,6 @@ class WC_Admin_Api_Init {
$endpoints['/wc/v3/reports'][0] = $endpoints['/wc/v3/reports'][1]; $endpoints['/wc/v3/reports'][0] = $endpoints['/wc/v3/reports'][1];
} }
// Override /wc/v3/products.
if ( isset( $endpoints['/wc/v3/products'] )
&& isset( $endpoints['/wc/v3/products'][3] )
&& isset( $endpoints['/wc/v3/products'][2] )
&& $endpoints['/wc/v3/products'][2]['callback'][0] instanceof WC_Admin_REST_Products_Controller
&& $endpoints['/wc/v3/products'][3]['callback'][0] instanceof WC_Admin_REST_Products_Controller
) {
$endpoints['/wc/v3/products'][0] = $endpoints['/wc/v3/products'][2];
$endpoints['/wc/v3/products'][1] = $endpoints['/wc/v3/products'][3];
}
// Override /wc/v3/customers. // Override /wc/v3/customers.
if ( isset( $endpoints['/wc/v3/customers'] ) if ( isset( $endpoints['/wc/v3/customers'] )
&& isset( $endpoints['/wc/v3/customers'][3] ) && isset( $endpoints['/wc/v3/customers'][3] )
@ -184,6 +182,50 @@ class WC_Admin_Api_Init {
$endpoints['/wc/v3/customers'][1] = $endpoints['/wc/v3/customers'][3]; $endpoints['/wc/v3/customers'][1] = $endpoints['/wc/v3/customers'][3];
} }
// Override /wc/v3/orders/$id.
if ( isset( $endpoints['/wc/v3/orders/(?P<id>[\d]+)'] )
&& isset( $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][5] )
&& isset( $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][4] )
&& isset( $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][3] )
&& $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][3]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
&& $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][4]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
&& $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][5]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
) {
$endpoints['/wc/v3/orders/(?P<id>[\d]+)'][0] = $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][3];
$endpoints['/wc/v3/orders/(?P<id>[\d]+)'][1] = $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][4];
$endpoints['/wc/v3/orders/(?P<id>[\d]+)'][2] = $endpoints['/wc/v3/orders/(?P<id>[\d]+)'][5];
}
// Override /wc/v3orders.
if ( isset( $endpoints['/wc/v3/orders'] )
&& isset( $endpoints['/wc/v3/orders'][3] )
&& isset( $endpoints['/wc/v3/orders'][2] )
&& $endpoints['/wc/v3/orders'][2]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
&& $endpoints['/wc/v3/orders'][3]['callback'][0] instanceof WC_Admin_REST_Orders_Controller
) {
$endpoints['/wc/v3/orders'][0] = $endpoints['/wc/v3/orders'][2];
$endpoints['/wc/v3/orders'][1] = $endpoints['/wc/v3/orders'][3];
}
// Override /wc/v3/data.
if ( isset( $endpoints['/wc/v3/data'] )
&& isset( $endpoints['/wc/v3/data'][1] )
&& $endpoints['/wc/v3/data'][1]['callback'][0] instanceof WC_Admin_REST_Data_Controller
) {
$endpoints['/wc/v3/data'][0] = $endpoints['/wc/v3/data'][1];
}
// Override /wc/v3/products.
if ( isset( $endpoints['/wc/v3/products'] )
&& isset( $endpoints['/wc/v3/products'][3] )
&& isset( $endpoints['/wc/v3/products'][2] )
&& $endpoints['/wc/v3/products'][2]['callback'][0] instanceof WC_Admin_REST_Products_Controller
&& $endpoints['/wc/v3/products'][3]['callback'][0] instanceof WC_Admin_REST_Products_Controller
) {
$endpoints['/wc/v3/products'][0] = $endpoints['/wc/v3/products'][2];
$endpoints['/wc/v3/products'][1] = $endpoints['/wc/v3/products'][3];
}
// Override /wc/v3/products/$id. // Override /wc/v3/products/$id.
if ( isset( $endpoints['/wc/v3/products/(?P<id>[\d]+)'] ) if ( isset( $endpoints['/wc/v3/products/(?P<id>[\d]+)'] )
&& isset( $endpoints['/wc/v3/products/(?P<id>[\d]+)'][5] ) && isset( $endpoints['/wc/v3/products/(?P<id>[\d]+)'][5] )
@ -215,7 +257,7 @@ class WC_Admin_Api_Init {
/** /**
* Regenerate data for reports. * Regenerate data for reports.
*/ */
public static function regenrate_report_data() { public static function regenerate_report_data() {
WC_Admin_Reports_Orders_Data_Store::queue_order_stats_repopulate_database(); WC_Admin_Reports_Orders_Data_Store::queue_order_stats_repopulate_database();
self::order_product_lookup_store_init(); self::order_product_lookup_store_init();
} }
@ -234,7 +276,7 @@ class WC_Admin_Api_Init {
'name' => __( 'Rebuild reports data', 'wc-admin' ), 'name' => __( 'Rebuild reports data', 'wc-admin' ),
'button' => __( 'Rebuild reports', 'wc-admin' ), 'button' => __( 'Rebuild reports', 'wc-admin' ),
'desc' => __( 'This tool will rebuild all of the information used by the reports.', 'wc-admin' ), 'desc' => __( 'This tool will rebuild all of the information used by the reports.', 'wc-admin' ),
'callback' => array( 'WC_Admin_Api_Init', 'regenrate_report_data' ), 'callback' => array( 'WC_Admin_Api_Init', 'regenerate_report_data' ),
), ),
) )
); );
@ -245,6 +287,9 @@ class WC_Admin_Api_Init {
*/ */
public static function orders_data_store_init() { public static function orders_data_store_init() {
WC_Admin_Reports_Orders_Data_Store::init(); WC_Admin_Reports_Orders_Data_Store::init();
WC_Admin_Reports_Products_Data_Store::init();
WC_Admin_Reports_Taxes_Data_Store::init();
WC_Admin_Reports_Coupons_Data_Store::init();
} }
/** /**
@ -270,35 +315,7 @@ class WC_Admin_Api_Init {
// Process orders until close to running out of memory timeouts on large sites then requeue. // Process orders until close to running out of memory timeouts on large sites then requeue.
foreach ( $orders as $order_id ) { foreach ( $orders as $order_id ) {
$order = wc_get_order( $order_id ); WC_Admin_Reports_Products_Data_Store::sync_order_products( $order_id );
if ( ! $order ) {
continue;
}
foreach ( $order->get_items() as $order_item ) {
$wpdb->replace(
$wpdb->prefix . 'wc_order_product_lookup',
array(
'order_item_id' => $order_item->get_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' ),
'product_net_revenue' => $order_item->get_subtotal( 'edit' ),
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
),
array(
'%d',
'%d',
'%d',
'%d',
'%d',
'%d',
'%f',
'%s',
)
);
}
// Pop the order ID from the array for updating the transient later should we near memory exhaustion. // Pop the order ID from the array for updating the transient later should we near memory exhaustion.
unset( $orders[ $order_id ] ); unset( $orders[ $order_id ] );
if ( $updater instanceof WC_Background_Updater && $updater->is_memory_exceeded() ) { if ( $updater instanceof WC_Background_Updater && $updater->is_memory_exceeded() ) {
@ -331,6 +348,7 @@ class WC_Admin_Api_Init {
'report-taxes-stats' => 'WC_Admin_Reports_Taxes_Stats_Data_Store', 'report-taxes-stats' => 'WC_Admin_Reports_Taxes_Stats_Data_Store',
'report-coupons' => 'WC_Admin_Reports_Coupons_Data_Store', 'report-coupons' => 'WC_Admin_Reports_Coupons_Data_Store',
'report-coupons-stats' => 'WC_Admin_Reports_Coupons_Stats_Data_Store', 'report-coupons-stats' => 'WC_Admin_Reports_Coupons_Stats_Data_Store',
'report-downloads' => 'WC_Admin_Reports_Downloads_Data_Store',
'admin-note' => 'WC_Admin_Notes_Data_Store', 'admin-note' => 'WC_Admin_Notes_Data_Store',
) )
); );
@ -477,6 +495,17 @@ class WC_Admin_Api_Init {
add_action( 'woocommerce_after_register_post_type', array( 'WC_Admin_Api_Init', 'order_product_lookup_store_init' ), 20 ); add_action( 'woocommerce_after_register_post_type', array( 'WC_Admin_Api_Init', 'order_product_lookup_store_init' ), 20 );
} }
/**
* Enables the WP REST API for product categories
*
* @param array $args Default arguments for product_cat taxonomy.
* @return array
*/
public static function show_product_categories_in_rest( $args ) {
$args['show_in_rest'] = true;
return $args;
}
} }
new WC_Admin_Api_Init(); new WC_Admin_Api_Init();

View File

@ -0,0 +1,47 @@
<?php
/**
* Class for parameter-based downloads report querying.
*
* Example usage:
* $args = array(
* 'before' => '2018-07-19 00:00:00',
* 'after' => '2018-07-05 00:00:00',
* 'page' => 2,
* 'products' => array(1,2,3)
* );
* $report = new WC_Admin_Reports_Downloads_Query( $args );
* $mydata = $report->get_data();
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Reports_Downloads_Query
*/
class WC_Admin_Reports_Downloads_Query extends WC_Admin_Reports_Query {
/**
* Valid fields for downloads report.
*
* @return array
*/
protected function get_default_query_vars() {
return array();
}
/**
* Get downloads data based on the current query vars.
*
* @return array
*/
public function get_data() {
$args = apply_filters( 'woocommerce_reports_downloads_query_args', $this->get_query_vars() );
$data_store = WC_Data_Store::load( 'report-downloads' );
$results = $data_store->get_data( $args );
return apply_filters( 'woocommerce_reports_downloads_select_query', $results, $args );
}
}

View File

@ -56,7 +56,7 @@ class WC_Admin_Reports_Categories_Data_Store extends WC_Admin_Reports_Data_Store
'items_sold' => 'SUM(product_qty) as items_sold', 'items_sold' => 'SUM(product_qty) as items_sold',
'net_revenue' => 'SUM(product_net_revenue) AS net_revenue', 'net_revenue' => 'SUM(product_net_revenue) AS net_revenue',
'orders_count' => 'COUNT(DISTINCT order_id) as orders_count', 'orders_count' => 'COUNT(DISTINCT order_id) as orders_count',
// 'products_count' is not a SQL column at the moment, see below. 'products_count' => 'COUNT(DISTINCT product_id) as products_count',
); );
/** /**
@ -73,6 +73,16 @@ class WC_Admin_Reports_Categories_Data_Store extends WC_Admin_Reports_Data_Store
// Limit is left out here so that the grouping in code by PHP can be applied correctly. // Limit is left out here so that the grouping in code by PHP can be applied correctly.
$sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) ); $sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) );
// join wp_order_product_lookup_table with relationships and taxonomies
// @TODO: How to handle custom product tables?
$sql_query_params['from_clause'] .= " LEFT JOIN {$wpdb->prefix}term_relationships ON {$order_product_lookup_table}.product_id = {$wpdb->prefix}term_relationships.object_id";
$sql_query_params['from_clause'] .= " LEFT JOIN {$wpdb->prefix}term_taxonomy ON {$wpdb->prefix}term_relationships.term_taxonomy_id = {$wpdb->prefix}term_taxonomy.term_taxonomy_id";
$included_categories = $this->get_included_categories( $query_args );
if ( $included_categories ) {
$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 ); $included_products = $this->get_included_products( $query_args );
if ( $included_products ) { if ( $included_products ) {
@ -85,21 +95,24 @@ class WC_Admin_Reports_Categories_Data_Store extends WC_Admin_Reports_Data_Store
$sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )"; $sql_query_params['where_clause'] .= " AND ( {$order_status_filter} )";
} }
$sql_query_params['where_clause'] .= " AND taxonomy = 'product_cat' ";
return $sql_query_params; return $sql_query_params;
} }
/** /**
* Maps ordering specified by the user to columns in the database/fields in the data. * Returns comma separated ids of included categories, based on query arguments from the user.
* *
* @param string $order_by Sorting criterion. * @param array $query_args Parameters supplied by the user.
* @return string * @return string
*/ */
protected function normalize_order_by( $order_by ) { protected function get_included_categories( $query_args ) {
if ( 'date' === $order_by ) { $included_categories_str = '';
return 'date_created';
}
return $order_by; if ( isset( $query_args['categories'] ) && is_array( $query_args['categories'] ) && count( $query_args['categories'] ) > 0 ) {
$included_categories_str = implode( ',', $query_args['categories'] );
}
return $included_categories_str;
} }
/** /**
@ -208,10 +221,9 @@ class WC_Admin_Reports_Categories_Data_Store extends WC_Admin_Reports_Data_Store
$selections = $this->selected_columns( $query_args ); $selections = $this->selected_columns( $query_args );
$sql_query_params = $this->get_sql_query_params( $query_args ); $sql_query_params = $this->get_sql_query_params( $query_args );
$products_data = $wpdb->get_results( $categories_data = $wpdb->get_results(
"SELECT "SELECT
product_id, term_id as category_id,
date_created,
{$selections} {$selections}
FROM FROM
{$table_name} {$table_name}
@ -221,39 +233,15 @@ class WC_Admin_Reports_Categories_Data_Store extends WC_Admin_Reports_Data_Store
{$sql_query_params['where_time_clause']} {$sql_query_params['where_time_clause']}
{$sql_query_params['where_clause']} {$sql_query_params['where_clause']}
GROUP BY GROUP BY
product_id category_id
", ",
ARRAY_A ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok. ); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $products_data ) { if ( null === $categories_data ) {
return new WP_Error( 'woocommerce_reports_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'wc-admin' ), array( 'status' => 500 ) ); return new WP_Error( 'woocommerce_reports_categories_result_failed', __( 'Sorry, fetching revenue data failed.', 'wc-admin' ), array( 'status' => 500 ) );
} }
// Group by category without a helper table, worst case we add it and change the SQL afterwards.
// Other option would be a join with wp_post and taxonomies, but a) performance might be bad, b) how to handle custom product tables?
$categories_data = array();
foreach ( $products_data as $product_data ) {
$categories = get_the_terms( $product_data['product_id'], 'product_cat' );
foreach ( $categories as $category ) {
$cat_id = $category->term_id;
if ( ! key_exists( $cat_id, $categories_data ) ) {
$categories_data[ $cat_id ] = array(
'category_id' => 0,
'items_sold' => 0,
'net_revenue' => 0.0,
'orders_count' => 0,
'products_count' => 0,
);
}
$categories_data[ $cat_id ]['category_id'] = $cat_id;
$categories_data[ $cat_id ]['items_sold'] += $product_data['items_sold'];
$categories_data[ $cat_id ]['net_revenue'] += $product_data['net_revenue'];
$categories_data[ $cat_id ]['orders_count'] += $product_data['orders_count'];
$categories_data[ $cat_id ]['products_count'] ++;
}
}
$record_count = count( $categories_data ); $record_count = count( $categories_data );
$total_pages = (int) ceil( $record_count / $query_args['per_page'] ); $total_pages = (int) ceil( $record_count / $query_args['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {

View File

@ -41,6 +41,15 @@ class WC_Admin_Reports_Coupons_Data_Store extends WC_Admin_Reports_Data_Store im
'orders_count' => 'COUNT(DISTINCT order_id) as orders_count', 'orders_count' => 'COUNT(DISTINCT order_id) as orders_count',
); );
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'save_post', array( __CLASS__, 'sync_order_coupons' ) );
add_action( 'clean_post_cache', array( __CLASS__, 'sync_order_coupons' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'sync_order_coupons' ) );
}
/** /**
* Returns comma separated ids of included coupons, based on query arguments from the user. * Returns comma separated ids of included coupons, based on query arguments from the user.
* *
@ -249,4 +258,48 @@ class WC_Admin_Reports_Coupons_Data_Store extends WC_Admin_Reports_Data_Store im
return 'woocommerce_' . self::TABLE_NAME . '_' . md5( wp_json_encode( $params ) ); return 'woocommerce_' . self::TABLE_NAME . '_' . md5( wp_json_encode( $params ) );
} }
/**
* Create or update an an entry in the wc_order_coupon_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return void
*/
public static function sync_order_coupons( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
if ( ! in_array( $order->get_status(), parent::get_report_order_statuses(), true ) ) {
$wpdb->delete(
$wpdb->prefix . self::TABLE_NAME,
array( 'order_id' => $order->get_id() ),
array( '%d' )
);
return;
}
$coupon_items = $order->get_items( 'coupon' );
foreach ( $coupon_items as $coupon_item ) {
$wpdb->replace(
$wpdb->prefix . self::TABLE_NAME,
array(
'order_id' => $order_id,
'coupon_id' => wc_get_coupon_id_by_code( $coupon_item->get_code() ),
'discount_amount' => $coupon_item->get_discount(),
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
),
array(
'%d',
'%d',
'%f',
'%s',
)
);
}
}
} }

View File

@ -635,6 +635,65 @@ class WC_Admin_Reports_Data_Store {
return $excluded_coupons_str; return $excluded_coupons_str;
} }
/**
* Returns comma separated ids of included orders, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_orders( $query_args ) {
$included_orders_str = '';
if ( isset( $query_args['order_includes'] ) && is_array( $query_args['order_includes'] ) && count( $query_args['order_includes'] ) > 0 ) {
$included_orders_str = implode( ',', $query_args['order_includes'] );
}
return $included_orders_str;
}
/**
* Returns comma separated ids of excluded orders, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_orders( $query_args ) {
$excluded_orders_str = '';
if ( isset( $query_args['order_excludes'] ) && is_array( $query_args['order_excludes'] ) && count( $query_args['order_excludes'] ) > 0 ) {
$excluded_orders_str = implode( ',', $query_args['order_excludes'] );
}
return $excluded_orders_str;
}
/**
* Returns comma separated ids of included users, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_users( $query_args ) {
$included_users_str = '';
if ( isset( $query_args['user_includes'] ) && is_array( $query_args['user_includes'] ) && count( $query_args['user_includes'] ) > 0 ) {
$included_users_str = implode( ',', $query_args['user_includes'] );
}
return $included_users_str;
}
/**
* Returns comma separated ids of excluded users, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_users( $query_args ) {
$excluded_users_str = '';
if ( isset( $query_args['user_excludes'] ) && is_array( $query_args['user_excludes'] ) && count( $query_args['user_excludes'] ) > 0 ) {
$excluded_users_str = implode( ',', $query_args['user_excludes'] );
}
return $excluded_users_str;
}
/** /**
* Returns order status subquery to be used in WHERE SQL query, based on query arguments from the user. * Returns order status subquery to be used in WHERE SQL query, based on query arguments from the user.

View File

@ -0,0 +1,383 @@
<?php
/**
* WC_Admin_Reports_Downloads_Data_Store class file.
*
* @package WooCommerce Admin/Classes
*/
defined( 'ABSPATH' ) || exit;
/**
* WC_Admin_Reports_Downloads_Data_Store.
*/
class WC_Admin_Reports_Downloads_Data_Store extends WC_Admin_Reports_Data_Store implements WC_Admin_Reports_Data_Store_Interface {
/**
* Table used to get the data.
*
* @var string
*/
const TABLE_NAME = 'wc_download_log';
/**
* Mapping columns to data type to return correct response types.
*
* @var array
*/
protected $column_types = array(
'id' => 'intval',
'date' => 'strval',
'date_gmt' => 'strval',
'download_id' => 'strval', // String because this can sometimes be a hash.
'file_name' => 'strval',
'product_id' => 'intval',
'order_id' => 'intval',
'user_id' => 'intval',
'ip_address' => 'strval',
);
/**
* SQL columns to select in the db query and their mapping to SQL code.
*
* @var array
*/
protected $report_columns = array(
'id' => 'download_log_id as id',
'date' => 'timestamp as date_gmt',
'download_id' => 'product_permissions.download_id',
'product_id' => 'product_permissions.product_id',
'order_id' => 'product_permissions.order_id',
'user_id' => 'product_permissions.user_id',
'ip_address' => 'user_ip_address as ip_address',
);
/**
* Constructor
*/
public function __construct() {
global $wpdb;
}
/**
* Updates the database query with parameters used for downloads report.
*
* @param array $query_args Query arguments supplied by the user.
* @return array Array of parameters used for SQL query.
*/
protected function get_sql_query_params( $query_args ) {
global $wpdb;
$lookup_table = $wpdb->prefix . self::TABLE_NAME;
$operator = $this->get_match_operator( $query_args );
$where_filters = array();
$sql_query_params = $this->get_time_period_sql_params( $query_args, $lookup_table );
$sql_query_params = array_merge( $sql_query_params, $this->get_limit_sql_params( $query_args ) );
$sql_query_params = array_merge( $sql_query_params, $this->get_order_by_sql_params( $query_args ) );
$included_products = $this->get_included_products( $query_args );
$excluded_products = $this->get_excluded_products( $query_args );
if ( $included_products ) {
$where_filters[] = " {$lookup_table}.permission_id IN (
SELECT
DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id
FROM
{$wpdb->prefix}woocommerce_downloadable_product_permissions
WHERE
{$wpdb->prefix}woocommerce_downloadable_product_permissions.product_id IN ({$included_products})
)";
}
if ( $excluded_products ) {
$where_filters[] = " {$lookup_table}.permission_id NOT IN (
SELECT
DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id
FROM
{$wpdb->prefix}woocommerce_downloadable_product_permissions
WHERE
{$wpdb->prefix}woocommerce_downloadable_product_permissions.product_id IN ({$excluded_products})
)";
}
$included_orders = $this->get_included_orders( $query_args );
$excluded_orders = $this->get_excluded_orders( $query_args );
if ( $included_orders ) {
$where_filters[] = " {$lookup_table}.permission_id IN (
SELECT
DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id
FROM
{$wpdb->prefix}woocommerce_downloadable_product_permissions
WHERE
{$wpdb->prefix}woocommerce_downloadable_product_permissions.order_id IN ({$included_orders})
)";
}
if ( $excluded_orders ) {
$where_filters[] = " {$lookup_table}.permission_id NOT IN (
SELECT
DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id
FROM
{$wpdb->prefix}woocommerce_downloadable_product_permissions
WHERE
{$wpdb->prefix}woocommerce_downloadable_product_permissions.order_id IN ({$excluded_orders})
)";
}
$included_users = $this->get_included_users( $query_args );
$excluded_users = $this->get_excluded_users( $query_args );
if ( $included_users ) {
$where_filters[] = " {$lookup_table}.permission_id IN (
SELECT
DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id
FROM
{$wpdb->prefix}woocommerce_downloadable_product_permissions
WHERE
{$wpdb->prefix}woocommerce_downloadable_product_permissions.user_id IN ({$included_users})
)";
}
if ( $excluded_users ) {
$where_filters[] = " {$lookup_table}.permission_id NOT IN (
SELECT
DISTINCT {$wpdb->prefix}woocommerce_downloadable_product_permissions.permission_id
FROM
{$wpdb->prefix}woocommerce_downloadable_product_permissions
WHERE
{$wpdb->prefix}woocommerce_downloadable_product_permissions.user_id IN ({$excluded_users})
)";
}
$included_ip_addresses = $this->get_included_ip_addresses( $query_args );
$excluded_ip_addresses = $this->get_excluded_ip_addresses( $query_args );
if ( $included_ip_addresses ) {
$where_filters[] = " {$lookup_table}.user_ip_address IN ('{$included_ip_addresses}')";
}
if ( $excluded_ip_addresses ) {
$where_filters[] = " {$lookup_table}.user_ip_address NOT IN ('{$excluded_ip_addresses}')";
}
$where_subclause = implode( " $operator ", $where_filters );
if ( $where_subclause ) {
$sql_query_params['where_clause'] .= " AND ( $where_subclause )";
}
$sql_query_params['from_clause'] .= " JOIN {$wpdb->prefix}woocommerce_downloadable_product_permissions as product_permissions ON {$lookup_table}.permission_id = product_permissions.permission_id";
return $sql_query_params;
}
/**
* Returns comma separated ids of included ip address, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_included_ip_addresses( $query_args ) {
$included_ips_str = '';
if ( isset( $query_args['ip_address_includes'] ) && is_array( $query_args['ip_address_includes'] ) && count( $query_args['ip_address_includes'] ) > 0 ) {
$ip_includes = array();
foreach ( $query_args['ip_address_includes'] as $ip ) {
$ip_includes[] = esc_sql( $ip );
}
$included_ips_str = implode( "','", $ip_includes );
}
return $included_ips_str;
}
/**
* Returns comma separated ids of excluded ip address, based on query arguments from the user.
*
* @param array $query_args Parameters supplied by the user.
* @return string
*/
protected function get_excluded_ip_addresses( $query_args ) {
$excluded_ips_str = '';
if ( isset( $query_args['ip_address_excludes'] ) && is_array( $query_args['ip_address_excludes'] ) && count( $query_args['ip_address_excludes'] ) > 0 ) {
$ip_excludes = array();
foreach ( $query_args['ip_address_excludes'] as $ip ) {
$ip_excludes[] = esc_sql( $ip );
}
$excluded_ips_str = implode( ',', $ip_excludes );
}
return $excluded_ips_str;
}
/**
* Fills WHERE clause of SQL request with date-related constraints.
*
* @param array $query_args Parameters supplied by the user.
* @param string $table_name Name of the db table relevant for the date constraint.
* @return array
*/
protected function get_time_period_sql_params( $query_args, $table_name ) {
$sql_query = array(
'from_clause' => '',
'where_time_clause' => '',
'where_clause' => '',
);
if ( isset( $query_args['before'] ) && '' !== $query_args['before'] ) {
$datetime = new DateTime( $query_args['before'] );
$datetime_str = $datetime->format( WC_Admin_Reports_Interval::$sql_datetime_format );
$sql_query['where_time_clause'] .= " AND {$table_name}.timestamp <= '$datetime_str'";
}
if ( isset( $query_args['after'] ) && '' !== $query_args['after'] ) {
$datetime = new DateTime( $query_args['after'] );
$datetime_str = $datetime->format( WC_Admin_Reports_Interval::$sql_datetime_format );
$sql_query['where_time_clause'] .= " AND {$table_name}.timestamp >= '$datetime_str'";
}
return $sql_query;
}
/**
* Fills ORDER BY clause of SQL request based on user supplied parameters.
*
* @param array $query_args Parameters supplied by the user.
* @return array
*/
protected function get_order_by_sql_params( $query_args ) {
$sql_query['order_by_clause'] = '';
if ( isset( $query_args['orderby'] ) ) {
$sql_query['order_by_clause'] = $this->normalize_order_by( $query_args['orderby'] );
}
if ( isset( $query_args['order'] ) ) {
$sql_query['order_by_clause'] .= ' ' . $query_args['order'];
} else {
$sql_query['order_by_clause'] .= ' DESC';
}
return $sql_query;
}
/**
* Returns the report data based on parameters supplied by the user.
*
* @param array $query_args Query parameters.
* @return stdClass|WP_Error Data.
*/
public function get_data( $query_args ) {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_NAME;
$now = time();
$week_back = $now - WEEK_IN_SECONDS;
// These defaults are only partially applied when used via REST API, as that has its own defaults.
$defaults = array(
'per_page' => get_option( 'posts_per_page' ),
'page' => 1,
'order' => 'DESC',
'orderby' => 'timestamp',
'before' => date( WC_Admin_Reports_Interval::$iso_datetime_format, $now ),
'after' => date( WC_Admin_Reports_Interval::$iso_datetime_format, $week_back ),
'fields' => '*',
);
$query_args = wp_parse_args( $query_args, $defaults );
$cache_key = $this->get_cache_key( $query_args );
$data = wp_cache_get( $cache_key, $this->cache_group );
if ( false === $data ) {
$data = (object) array(
'data' => array(),
'total' => 0,
'pages' => 0,
'page_no' => 0,
);
$selections = $this->selected_columns( $query_args );
$sql_query_params = $this->get_sql_query_params( $query_args );
$db_records_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM (
SELECT
{$table_name}.download_log_id
FROM
{$table_name}
{$sql_query_params['from_clause']}
WHERE
1=1
{$sql_query_params['where_time_clause']}
{$sql_query_params['where_clause']}
GROUP BY
{$table_name}.download_log_id
) AS tt"
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$total_pages = (int) ceil( $db_records_count / $sql_query_params['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return $data;
}
$download_data = $wpdb->get_results(
"SELECT
{$selections}
FROM
{$table_name}
{$sql_query_params['from_clause']}
WHERE
1=1
{$sql_query_params['where_time_clause']}
{$sql_query_params['where_clause']}
GROUP BY
{$table_name}.download_log_id
ORDER BY
{$sql_query_params['order_by_clause']}
{$sql_query_params['limit']}
",
ARRAY_A
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
if ( null === $download_data ) {
return $data;
}
$download_data = array_map( array( $this, 'cast_numbers' ), $download_data );
$data = (object) array(
'data' => $download_data,
'total' => $db_records_count,
'pages' => $total_pages,
'page_no' => (int) $query_args['page'],
);
wp_cache_set( $cache_key, $data, $this->cache_group );
}
return $data;
}
/**
* Returns string to be used as cache key for the data.
*
* @param array $params Query parameters.
* @return string
*/
protected function get_cache_key( $params ) {
return 'woocommerce_' . self::TABLE_NAME . '_' . md5( wp_json_encode( $params ) );
}
/**
* Maps ordering specified by the user to columns in the database/fields in the data.
*
* @param string $order_by Sorting criterion.
* @return string
*/
protected function normalize_order_by( $order_by ) {
global $wpdb;
if ( 'date' === $order_by ) {
return $wpdb->prefix . 'wc_download_log.timestamp';
}
return $order_by;
}
}

View File

@ -66,7 +66,7 @@ class WC_Admin_Reports_Orders_Data_Store extends WC_Admin_Reports_Data_Store imp
'shipping' => 'SUM(shipping_total) AS shipping', 'shipping' => 'SUM(shipping_total) AS shipping',
'net_revenue' => '( SUM(net_total) - SUM(refund_total) ) AS net_revenue', 'net_revenue' => '( SUM(net_total) - SUM(refund_total) ) AS net_revenue',
'avg_items_per_order' => 'AVG(num_items_sold) AS avg_items_per_order', 'avg_items_per_order' => 'AVG(num_items_sold) AS avg_items_per_order',
'avg_order_value' => 'AVG(gross_total) AS avg_order_value', 'avg_order_value' => '( SUM(net_total) - SUM(refund_total) ) / COUNT(*) AS avg_order_value',
'num_returning_customers' => 'SUM(returning_customer = 1) AS num_returning_customers', 'num_returning_customers' => 'SUM(returning_customer = 1) AS num_returning_customers',
'num_new_customers' => 'SUM(returning_customer = 0) AS num_new_customers', 'num_new_customers' => 'SUM(returning_customer = 0) AS num_new_customers',
); );
@ -95,6 +95,7 @@ class WC_Admin_Reports_Orders_Data_Store extends WC_Admin_Reports_Data_Store imp
// 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( 'clean_post_cache', array( __CLASS__, 'sync_order' ) );
add_action( 'woocommerce_order_refunded', 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 );
if ( ! self::$background_process ) { if ( ! self::$background_process ) {
self::$background_process = new WC_Admin_Order_Stats_Background_Process(); self::$background_process = new WC_Admin_Order_Stats_Background_Process();
@ -711,6 +712,16 @@ class WC_Admin_Reports_Orders_Data_Store extends WC_Admin_Reports_Data_Store imp
self::update( $order ); self::update( $order );
} }
/**
* Syncs order information when a refund is deleted.
*
* @param int $refund_id Refund ID.
* @param int $order_id Order ID.
*/
public static function sync_on_refund_delete( $refund_id, $order_id ) {
self::sync_order( $order_id );
}
/** /**
* Update the database with stats data. * Update the database with stats data.
* *

View File

@ -36,6 +36,11 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i
'price' => 'floatval', 'price' => 'floatval',
'image' => 'strval', 'image' => 'strval',
'permalink' => 'strval', 'permalink' => 'strval',
'stock_status' => 'strval',
'stock_quantity' => 'intval',
'low_stock_amount' => 'intval',
'category_ids' => 'array_values',
'sku' => 'strval',
); );
/** /**
@ -63,8 +68,19 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i
'stock_status', 'stock_status',
'stock_quantity', 'stock_quantity',
'low_stock_amount', 'low_stock_amount',
'category_ids',
'sku',
); );
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'save_post', array( __CLASS__, 'sync_order_products' ) );
add_action( 'clean_post_cache', array( __CLASS__, 'sync_order_products' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'sync_order_products' ) );
}
/** /**
* Fills ORDER BY clause of SQL request based on user supplied parameters. * Fills ORDER BY clause of SQL request based on user supplied parameters.
* *
@ -282,4 +298,93 @@ class WC_Admin_Reports_Products_Data_Store extends WC_Admin_Reports_Data_Store i
return 'woocommerce_' . self::TABLE_NAME . '_' . md5( wp_json_encode( $params ) ); return 'woocommerce_' . self::TABLE_NAME . '_' . md5( wp_json_encode( $params ) );
} }
/**
* Create or update an entry in the wc_admin_order_product_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return void
*/
public static function sync_order_products( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
// This hook gets called on refunds as well, so return early to avoid errors.
if ( ! $order || 'shop_order_refund' === $order->get_type() ) {
return;
}
if ( ! in_array( $order->get_status(), parent::get_report_order_statuses(), true ) ) {
$wpdb->delete(
$wpdb->prefix . self::TABLE_NAME,
array( 'order_id' => $order->get_id() ),
array( '%d' )
);
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;
if ( $quantity_refunded >= $order_item->get_quantity( 'edit' ) ) {
$wpdb->delete(
$wpdb->prefix . self::TABLE_NAME,
array( 'order_item_id' => $order_item_id ),
array( '%d' )
);
} 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() ),
),
array(
'%d',
'%d',
'%d',
'%d',
'%d',
'%d',
'%f',
'%s',
)
);
}
}
}
/**
* 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;
}
} }

View File

@ -115,9 +115,8 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
$intervals_query = array(); $intervals_query = array();
$this->update_sql_query_params( $query_args, $totals_query, $intervals_query ); $this->update_sql_query_params( $query_args, $totals_query, $intervals_query );
$db_records_count = (int) $wpdb->get_var( $db_intervals = $wpdb->get_col(
"SELECT COUNT(*) FROM ( "SELECT
SELECT
{$intervals_query['select_clause']} AS time_interval {$intervals_query['select_clause']} AS time_interval
FROM FROM
{$table_name} {$table_name}
@ -127,15 +126,18 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
{$intervals_query['where_time_clause']} {$intervals_query['where_time_clause']}
{$intervals_query['where_clause']} {$intervals_query['where_clause']}
GROUP BY GROUP BY
time_interval time_interval"
) AS t" ); // WPCS: cache ok, DB call ok, , unprepared SQL ok.
); // WPCS: cache ok, DB call ok, unprepared SQL ok.
$total_pages = (int) ceil( $db_records_count / $intervals_query['per_page'] ); $db_interval_count = count( $db_intervals );
$expected_interval_count = WC_Admin_Reports_Interval::intervals_between( $query_args['after'], $query_args['before'], $query_args['interval'] );
$total_pages = (int) ceil( $expected_interval_count / $intervals_query['per_page'] );
if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) { if ( $query_args['page'] < 1 || $query_args['page'] > $total_pages ) {
return array(); return array();
} }
$this->update_intervals_sql_params( $intervals_query, $query_args, $db_interval_count, $expected_interval_count );
$totals = $wpdb->get_results( $totals = $wpdb->get_results(
"SELECT "SELECT
{$selections} {$selections}
@ -183,21 +185,41 @@ class WC_Admin_Reports_Products_Stats_Data_Store extends WC_Admin_Reports_Produc
$totals = (object) $this->cast_numbers( $totals[0] ); $totals = (object) $this->cast_numbers( $totals[0] );
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $intervals );
$this->create_interval_subtotals( $intervals );
$data = (object) array( $data = (object) array(
'totals' => $totals, 'totals' => $totals,
'intervals' => $intervals, 'intervals' => $intervals,
'total' => $db_records_count, 'total' => $expected_interval_count,
'pages' => $total_pages, 'pages' => $total_pages,
'page_no' => (int) $query_args['page'], 'page_no' => (int) $query_args['page'],
); );
if ( WC_Admin_Reports_Interval::intervals_missing( $expected_interval_count, $db_interval_count, $intervals_query['per_page'], $query_args['page'], $query_args['order'], $query_args['orderby'], count( $intervals ) ) ) {
$this->fill_in_missing_intervals( $db_intervals, $query_args['adj_after'], $query_args['adj_before'], $query_args['interval'], $data );
$this->sort_intervals( $data, $query_args['orderby'], $query_args['order'] );
$this->remove_extra_records( $data, $query_args['page'], $intervals_query['per_page'], $db_interval_count, $expected_interval_count, $query_args['orderby'] );
} else {
$this->update_interval_boundary_dates( $query_args['after'], $query_args['before'], $query_args['interval'], $data->intervals );
}
$this->create_interval_subtotals( $data->intervals );
wp_cache_set( $cache_key, $data, $this->cache_group ); wp_cache_set( $cache_key, $data, $this->cache_group );
} }
return $data; return $data;
} }
/**
* Normalizes order_by clause to match to SQL query.
*
* @param string $order_by Order by option requeste by user.
* @return string
*/
protected function normalize_order_by( $order_by ) {
if ( 'date' === $order_by ) {
return 'time_interval';
}
return $order_by;
}
} }

View File

@ -65,6 +65,15 @@ class WC_Admin_Reports_Taxes_Data_Store extends WC_Admin_Reports_Data_Store impl
$this->report_columns['tax_rate_id'] = $table_name . '.' . $this->report_columns['tax_rate_id']; $this->report_columns['tax_rate_id'] = $table_name . '.' . $this->report_columns['tax_rate_id'];
} }
/**
* Set up all the hooks for maintaining and populating table data.
*/
public static function init() {
add_action( 'save_post', array( __CLASS__, 'sync_order_taxes' ) );
add_action( 'clean_post_cache', array( __CLASS__, 'sync_order_taxes' ) );
add_action( 'woocommerce_order_refunded', array( __CLASS__, 'sync_order_taxes' ) );
}
/** /**
* Updates the database query with parameters used for Taxes report: categories and order status. * Updates the database query with parameters used for Taxes report: categories and order status.
* *
@ -235,4 +244,49 @@ class WC_Admin_Reports_Taxes_Data_Store extends WC_Admin_Reports_Data_Store impl
return $order_by; return $order_by;
} }
/**
* Create or update an entry in the wc_order_tax_lookup table for an order.
*
* @param int $order_id Order ID.
* @return void
*/
public static function sync_order_taxes( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
if ( ! in_array( $order->get_status(), parent::get_report_order_statuses(), true ) ) {
$wpdb->delete(
$wpdb->prefix . self::TABLE_NAME,
array( 'order_id' => $order->get_id() ),
array( '%d' )
);
return;
}
foreach ( $order->get_items( 'tax' ) as $tax_item ) {
$wpdb->replace(
$wpdb->prefix . self::TABLE_NAME,
array(
'order_id' => $order->get_id(),
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
'tax_rate_id' => $tax_item->get_rate_id(),
'shipping_tax' => $tax_item->get_shipping_tax_total(),
'order_tax' => $tax_item->get_tax_total(),
'total_tax' => $tax_item->get_tax_total() + $tax_item->get_shipping_tax_total(),
),
array(
'%d',
'%s',
'%d',
'%f',
'%f',
'%f',
)
);
}
}
} }

View File

@ -1,192 +0,0 @@
<?php
/**
* Order Functions
*
* @package WooCommerce Admin
*/
/**
* Make an entry in the wc_admin_order_product_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return void
*/
function wc_admin_order_product_lookup_entry( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
foreach ( $order->get_items() as $order_item ) {
// Shipping amount based on woocommerce code in includes/admin/meta-boxes/views/html-order-item(s).php
// distributed simply based on number of line items.
$order_items = $order->get_item_count();
$refunded = $order->get_total_shipping_refunded();
if ( $refunded > 0 ) {
$total_shipping_amount = $order->get_shipping_total() - $refunded;
} else {
$total_shipping_amount = $order->get_shipping_total();
}
$shipping_amount = $total_shipping_amount / $order_items;
// Shipping amount tax based on woocommerce code in includes/admin/meta-boxes/views/html-order-item(s).php
// distribute simply based on number of line items.
$shipping_tax_amount = 0;
// TODO: if WC is currently not tax enabled, but it was before (or vice versa), would this work correctly?
if ( wc_tax_enabled() ) {
$order_taxes = $order->get_taxes();
$line_items_shipping = $order->get_items( 'shipping' );
$total_shipping_tax_amount = 0;
foreach ( $line_items_shipping as $item_id => $item ) {
$tax_data = $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 = $order->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;
}
}
}
}
$shipping_tax_amount = $total_shipping_tax_amount / $order_items;
}
// Tax amount.
// TODO: check if this calculates tax correctly with refunds.
$tax_amount = 0;
if ( wc_tax_enabled() ) {
$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;
}
}
$net_revenue = $order_item->get_subtotal( 'edit' );
// Coupon calculation based on woocommerce code in includes/admin/meta-boxes/views/html-order-item.php.
$coupon_amount = $order_item->get_subtotal( 'edit' ) - $order_item->get_total( 'edit' );
$wpdb->replace(
$wpdb->prefix . 'wc_order_product_lookup',
array(
'order_item_id' => $order_item->get_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' ),
'product_net_revenue' => $net_revenue,
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
'price' => $order_item->get_subtotal( 'edit' ) / $order_item->get_quantity( 'edit' ),
'coupon_amount' => $coupon_amount,
'tax_amount' => $tax_amount,
'shipping_amount' => $shipping_amount,
'shipping_tax_amount' => $shipping_tax_amount,
'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
),
array(
'%d',
'%d',
'%d',
'%d',
'%d',
'%d',
'%f',
'%s',
'%f',
'%f',
'%f',
'%f',
'%f',
'%f',
)
);
}
}
// TODO: maybe replace these with woocommerce_create_order, woocommerce_update_order, woocommerce_trash_order, woocommerce_delete_order, as clean_post_cache might be called in other circumstances and trigger too many updates?
add_action( 'save_post', 'wc_admin_order_product_lookup_entry', 10, 1 );
add_action( 'clean_post_cache', 'wc_admin_order_product_lookup_entry', 10, 1 );
/**
* Make an entry in the wc_order_tax_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return void
*/
function wc_order_tax_lookup_entry( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
foreach ( $order->get_items( 'tax' ) as $tax_item ) {
$wpdb->replace(
$wpdb->prefix . 'wc_order_tax_lookup',
array(
'order_id' => $order->get_id(),
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
'tax_rate_id' => $tax_item->get_rate_id(),
'shipping_tax' => $tax_item->get_shipping_tax_total(),
'order_tax' => $tax_item->get_tax_total(),
'total_tax' => $tax_item->get_tax_total() + $tax_item->get_shipping_tax_total(),
),
array(
'%d',
'%s',
'%d',
'%f',
'%f',
'%f',
)
);
}
}
add_action( 'save_post', 'wc_order_tax_lookup_entry', 10, 1 );
add_action( 'clean_post_cache', 'wc_order_tax_lookup_entry', 10, 1 );
/**
* Make an entry in the wc_order_coupon_lookup table for an order.
*
* @since 3.5.0
* @param int $order_id Order ID.
* @return void
*/
function wc_order_coupon_lookup_entry( $order_id ) {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
return;
}
$coupon_items = $order->get_items( 'coupon' );
foreach ( $coupon_items as $coupon_item ) {
$wpdb->replace(
$wpdb->prefix . 'wc_order_coupon_lookup',
array(
'order_id' => $order_id,
'coupon_id' => wc_get_coupon_id_by_code( $coupon_item->get_code() ),
'discount_amount' => $coupon_item->get_discount(),
'date_created' => date( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
),
array(
'%d',
'%d',
'%f',
'%s',
)
);
}
}
add_action( 'save_post', 'wc_order_coupon_lookup_entry', 10, 1 );
add_action( 'clean_post_cache', 'wc_order_coupon_lookup_entry', 10, 1 );

View File

@ -214,6 +214,16 @@ function wc_admin_enqueue_script() {
wp_enqueue_script( WC_ADMIN_APP ); wp_enqueue_script( WC_ADMIN_APP );
wp_enqueue_style( WC_ADMIN_APP ); wp_enqueue_style( WC_ADMIN_APP );
// Use server-side detection to prevent unneccessary stylesheet loading in other browsers.
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : ''; // WPCS: sanitization ok.
preg_match( '/MSIE (.*?);/', $user_agent, $matches );
if ( count( $matches ) < 2 ) {
preg_match( '/Trident\/\d{1,2}.\d{1,2}; rv:([0-9]*)/', $user_agent, $matches );
}
if ( count( $matches ) > 1 ) {
wp_enqueue_style( 'wc-components-ie' );
}
} }
add_action( 'admin_enqueue_scripts', 'wc_admin_enqueue_script' ); add_action( 'admin_enqueue_scripts', 'wc_admin_enqueue_script' );

View File

@ -102,6 +102,13 @@ function wc_admin_register_script() {
filemtime( wc_admin_dir_path( 'dist/components/style.css' ) ) filemtime( wc_admin_dir_path( 'dist/components/style.css' ) )
); );
wp_register_style(
'wc-components-ie',
wc_admin_url( 'dist/components/ie.css' ),
array( 'wp-edit-blocks' ),
filemtime( wc_admin_dir_path( 'dist/components/ie.css' ) )
);
wp_register_style( wp_register_style(
WC_ADMIN_APP, WC_ADMIN_APP,
wc_admin_url( "dist/{$entry}/style.css" ), wc_admin_url( "dist/{$entry}/style.css" ),

File diff suppressed because it is too large Load Diff

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