Add Orders table (https://github.com/woocommerce/woocommerce-admin/pull/493)
* Create Orders table * Remove getOrderIds selector * Create an OrdersReportTable component * Alphabetically order extracted props * Fix JS error for missing coupon properties * Add TODO comment for missing customer type * Add calculation for net revenue * Align all table cells but identifier to the right * Remove temporary text * Improve cell link inline CSS * Count several purchases of the same product as items sold * Add TODO message to coupon link * Add Download to Orders table * Don't use camelCase for table column keys * Cleanup * Make products and coupons columns non-sortable * Create renderList method to simplify products/coupons list creation * Display correct currency for each order * RTL table fixes * Fix: products and coupons columns showing 'false' in CSV download * Minor fixes * Rename 'isIdentifier' with 'isLeftAligned' in table columns * Remove toggleStatus method
This commit is contained in:
parent
3d1fc63373
commit
9623898acd
|
@ -4,34 +4,23 @@
|
|||
*/
|
||||
import { Component, Fragment } from '@wordpress/element';
|
||||
import { compose } from '@wordpress/compose';
|
||||
import { Button } from '@wordpress/components';
|
||||
import { withSelect, withDispatch } from '@wordpress/data';
|
||||
import moment from 'moment';
|
||||
import { partial } from 'lodash';
|
||||
import { withSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Card, ReportFilters } from '@woocommerce/components';
|
||||
import { ReportFilters } from '@woocommerce/components';
|
||||
import { filters, advancedFilterConfig } from './config';
|
||||
import OrdersReportTable from './table';
|
||||
|
||||
class OrdersReport extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind( this );
|
||||
}
|
||||
|
||||
toggleStatus( order ) {
|
||||
const { requestUpdateOrder } = this.props;
|
||||
const updatedOrder = { ...order };
|
||||
const status = updatedOrder.status === 'completed' ? 'processing' : 'completed';
|
||||
updatedOrder.status = status;
|
||||
requestUpdateOrder( updatedOrder );
|
||||
}
|
||||
|
||||
render() {
|
||||
const { orders, orderIds, query, path } = this.props;
|
||||
const { isRequesting, orders, path, query } = this.props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ReportFilters
|
||||
|
@ -40,39 +29,7 @@ class OrdersReport extends Component {
|
|||
filters={ filters }
|
||||
advancedConfig={ advancedFilterConfig }
|
||||
/>
|
||||
<p>Below is a temporary example</p>
|
||||
<Card title="Orders">
|
||||
<table style={ { width: '100%' } }>
|
||||
<thead>
|
||||
<tr style={ { textAlign: 'left' } }>
|
||||
<th>Id</th>
|
||||
<th>Date</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ orderIds &&
|
||||
orderIds.map( id => {
|
||||
const order = orders[ id ];
|
||||
return (
|
||||
<tr key={ id }>
|
||||
<td>{ id }</td>
|
||||
<td>{ moment( order.date_created ).format( 'LL' ) }</td>
|
||||
<td>{ order.total }</td>
|
||||
<td>{ order.status }</td>
|
||||
<td>
|
||||
<Button isPrimary onClick={ partial( this.toggleStatus, order ) }>
|
||||
Toggle Status
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
} ) }
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<OrdersReportTable isRequesting={ isRequesting } orders={ orders } query={ query } />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
@ -80,17 +37,9 @@ class OrdersReport extends Component {
|
|||
|
||||
export default compose(
|
||||
withSelect( select => {
|
||||
const { getOrders, getOrderIds } = select( 'wc-admin' );
|
||||
return {
|
||||
orders: getOrders(),
|
||||
orderIds: getOrderIds(),
|
||||
};
|
||||
} ),
|
||||
withDispatch( dispatch => {
|
||||
return {
|
||||
requestUpdateOrder: function( updatedOrder ) {
|
||||
dispatch( 'wc-admin' ).requestUpdateOrder( updatedOrder );
|
||||
},
|
||||
};
|
||||
const { getOrders } = select( 'wc-admin' );
|
||||
const orders = getOrders();
|
||||
const isRequesting = select( 'core/data' ).isResolving( 'wc-admin', 'getOrders' );
|
||||
return { isRequesting, orders };
|
||||
} )
|
||||
)( OrdersReport );
|
||||
|
|
|
@ -0,0 +1,265 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Component, Fragment } from '@wordpress/element';
|
||||
import { format as formatDate } from '@wordpress/date';
|
||||
import { map, orderBy } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Card, OrderStatus, TableCard, TablePlaceholder } from '@woocommerce/components';
|
||||
import { downloadCSVFile, generateCSVDataFromTable, generateCSVFileName } from 'lib/csv';
|
||||
import { formatCurrency, getCurrencyFormatDecimal } from 'lib/currency';
|
||||
import { getIntervalForQuery, getDateFormatsForInterval } from 'lib/date';
|
||||
import { getAdminLink, onQueryChange } from 'lib/nav-utils';
|
||||
|
||||
export default class OrdersReportTable extends Component {
|
||||
constructor( props ) {
|
||||
super( props );
|
||||
}
|
||||
|
||||
onDownload( headers, rows, query ) {
|
||||
// @TODO The current implementation only downloads the contents displayed in the table.
|
||||
// Another solution is required when the data set is larger (see #311).
|
||||
return () => {
|
||||
downloadCSVFile(
|
||||
generateCSVFileName( 'orders', query ),
|
||||
generateCSVDataFromTable( headers, rows )
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
getHeadersContent() {
|
||||
return [
|
||||
{
|
||||
label: __( 'Date', 'wc-admin' ),
|
||||
key: 'date_created',
|
||||
required: true,
|
||||
defaultSort: true,
|
||||
isLeftAligned: true,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
label: __( 'Order #', 'wc-admin' ),
|
||||
key: 'id',
|
||||
required: true,
|
||||
isLeftAligned: true,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
label: __( 'Status', 'wc-admin' ),
|
||||
key: 'status',
|
||||
required: false,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
label: __( 'Customer', 'wc-admin' ),
|
||||
key: 'customer_id',
|
||||
required: false,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
label: __( 'Product(s)', 'wc-admin' ),
|
||||
key: 'products',
|
||||
required: false,
|
||||
isSortable: false,
|
||||
},
|
||||
{
|
||||
label: __( 'Items Sold', 'wc-admin' ),
|
||||
key: 'items_sold',
|
||||
required: false,
|
||||
isSortable: true,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
label: __( 'Coupon(s)', 'wc-admin' ),
|
||||
key: 'coupons',
|
||||
required: false,
|
||||
isSortable: false,
|
||||
},
|
||||
{
|
||||
label: __( 'N. Revenue', 'wc-admin' ),
|
||||
key: 'net_revenue',
|
||||
required: true,
|
||||
isSortable: true,
|
||||
isNumeric: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
formatTableData( data ) {
|
||||
return map( data, row => {
|
||||
const {
|
||||
date_created,
|
||||
id,
|
||||
status,
|
||||
customer_id,
|
||||
line_items,
|
||||
coupon_lines,
|
||||
currency,
|
||||
total,
|
||||
total_tax,
|
||||
shipping_total,
|
||||
discount_total,
|
||||
} = row;
|
||||
|
||||
return {
|
||||
date_created,
|
||||
id,
|
||||
status,
|
||||
customer_id,
|
||||
line_items,
|
||||
items_sold: line_items.reduce( ( acc, item ) => item.quantity + acc, 0 ),
|
||||
coupon_lines,
|
||||
currency,
|
||||
net_revenue: getCurrencyFormatDecimal(
|
||||
total - total_tax - shipping_total - discount_total
|
||||
),
|
||||
};
|
||||
} );
|
||||
}
|
||||
|
||||
getRowsContent( tableData ) {
|
||||
const { query } = this.props;
|
||||
const currentInterval = getIntervalForQuery( query );
|
||||
const { tableFormat } = getDateFormatsForInterval( currentInterval );
|
||||
|
||||
return map( tableData, row => {
|
||||
const {
|
||||
date_created,
|
||||
id,
|
||||
status,
|
||||
customer_id,
|
||||
line_items,
|
||||
items_sold,
|
||||
coupon_lines,
|
||||
currency,
|
||||
net_revenue,
|
||||
} = row;
|
||||
|
||||
return [
|
||||
{
|
||||
display: formatDate( tableFormat, date_created ),
|
||||
value: date_created,
|
||||
},
|
||||
{
|
||||
display: <a href={ getAdminLink( 'post.php?post=' + id + '&action=edit' ) }>{ id }</a>,
|
||||
value: id,
|
||||
},
|
||||
{
|
||||
display: <OrderStatus order={ { status } } />,
|
||||
value: status,
|
||||
},
|
||||
{
|
||||
// @TODO This should display customer type (new/returning) once it's
|
||||
// implemented in the API.
|
||||
display: customer_id,
|
||||
value: customer_id,
|
||||
},
|
||||
{
|
||||
display: this.renderList(
|
||||
line_items.map( item => ( {
|
||||
href: getAdminLink( 'post.php?post=' + item.product_id + '&action=edit' ),
|
||||
label: item.name,
|
||||
} ) )
|
||||
),
|
||||
value: line_items
|
||||
.map( item => item.name )
|
||||
.join()
|
||||
.toLowerCase(),
|
||||
},
|
||||
{
|
||||
display: items_sold,
|
||||
value: items_sold,
|
||||
},
|
||||
{
|
||||
display: this.renderList(
|
||||
coupon_lines.map( coupon => ( {
|
||||
// @TODO It should link to the coupons report.
|
||||
href: getAdminLink( 'edit.php?s=' + coupon.code + '&post_type=shop_coupon' ),
|
||||
label: coupon.code,
|
||||
} ) )
|
||||
),
|
||||
value: coupon_lines
|
||||
.map( item => item.code )
|
||||
.join()
|
||||
.toLowerCase(),
|
||||
},
|
||||
{
|
||||
display: formatCurrency( net_revenue, currency ),
|
||||
value: net_revenue,
|
||||
},
|
||||
];
|
||||
} );
|
||||
}
|
||||
|
||||
renderList( items ) {
|
||||
// @TODO Use ViewMore component if there are many items.
|
||||
return items.map( ( item, i ) => (
|
||||
<Fragment key={ i }>
|
||||
{ i > 0 ? ', ' : null }
|
||||
<a className={ items.length > 1 ? 'is-inline' : null } href={ item.href }>
|
||||
{ item.label }
|
||||
</a>
|
||||
</Fragment>
|
||||
) );
|
||||
}
|
||||
|
||||
renderPlaceholderTable() {
|
||||
const headers = this.getHeadersContent();
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={ __( 'Orders', 'wc-admin' ) }
|
||||
className="woocommerce-analytics__table-placeholder"
|
||||
>
|
||||
<TablePlaceholder caption={ __( 'Orders last week', 'wc-admin' ) } headers={ headers } />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
renderTable() {
|
||||
const { orders, query } = this.props;
|
||||
|
||||
const page = parseInt( query.page ) || 1;
|
||||
const rowsPerPage = parseInt( query.per_page ) || 25;
|
||||
const rows = this.getRowsContent(
|
||||
orderBy(
|
||||
this.formatTableData( orders ),
|
||||
query.orderby || 'date_created',
|
||||
query.order || 'asc'
|
||||
).slice( ( page - 1 ) * rowsPerPage, page * rowsPerPage )
|
||||
);
|
||||
|
||||
const headers = this.getHeadersContent();
|
||||
|
||||
const tableQuery = {
|
||||
...query,
|
||||
orderby: query.orderby || 'date_created',
|
||||
order: query.order || 'asc',
|
||||
};
|
||||
|
||||
return (
|
||||
<TableCard
|
||||
title={ __( 'Orders last week', 'wc-admin' ) }
|
||||
rows={ rows }
|
||||
totalRows={ Object.keys( orders ).length }
|
||||
rowsPerPage={ rowsPerPage }
|
||||
headers={ headers }
|
||||
onClickDownload={ this.onDownload( headers, rows, tableQuery ) }
|
||||
onQueryChange={ onQueryChange }
|
||||
query={ tableQuery }
|
||||
summary={ null }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isRequesting } = this.props;
|
||||
|
||||
return isRequesting ? this.renderPlaceholderTable() : this.renderTable();
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ export default class extends Component {
|
|||
label: __( 'Product Title', 'wc-admin' ),
|
||||
key: 'name',
|
||||
required: true,
|
||||
isLeftAligned: true,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -93,6 +93,7 @@ export class RevenueReport extends Component {
|
|||
key: 'date',
|
||||
required: true,
|
||||
defaultSort: true,
|
||||
isLeftAligned: true,
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
|
@ -100,6 +101,7 @@ export class RevenueReport extends Component {
|
|||
key: 'orders_count',
|
||||
required: false,
|
||||
isSortable: true,
|
||||
isNumeric: true,
|
||||
},
|
||||
{
|
||||
label: __( 'Gross Revenue', 'wc-admin' ),
|
||||
|
|
|
@ -58,9 +58,11 @@
|
|||
text-align: left;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
margin: ($gap*-1) ($gap-large*-1);
|
||||
padding: $gap $gap-large;
|
||||
&:not(.is-inline) {
|
||||
display: block;
|
||||
margin: ($gap*-1) ($gap-large*-1);
|
||||
padding: $gap $gap-large;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
|
@ -80,21 +82,28 @@
|
|||
width: 80%;
|
||||
}
|
||||
|
||||
&.is-numeric {
|
||||
&:not(.is-left-aligned) {
|
||||
text-align: right;
|
||||
|
||||
.rtl & {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.is-placeholder {
|
||||
max-width: 40px;
|
||||
}
|
||||
&.is-numeric .is-placeholder {
|
||||
max-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.woocommerce-table__header,
|
||||
th.woocommerce-table__item {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.woocommerce-table__header {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -106,6 +115,11 @@ th.woocommerce-table__item {
|
|||
|
||||
& + .woocommerce-table__header {
|
||||
border-left: 1px solid $core-grey-light-700;
|
||||
|
||||
.rtl & {
|
||||
border-left: 0;
|
||||
border-right: 1px solid $core-grey-light-700;
|
||||
}
|
||||
}
|
||||
|
||||
.components-button.is-button {
|
||||
|
@ -118,6 +132,10 @@ th.woocommerce-table__item {
|
|||
background: transparent;
|
||||
box-shadow: none !important;
|
||||
|
||||
.rtl & {
|
||||
padding: $gap-smaller 0 $gap-smaller $gap-large;
|
||||
}
|
||||
|
||||
// @todo Add interactive styles
|
||||
&:hover {
|
||||
box-shadow: none !important;
|
||||
|
|
|
@ -120,10 +120,11 @@ class Table extends Component {
|
|||
<tbody>
|
||||
<tr>
|
||||
{ headers.map( ( header, i ) => {
|
||||
const { isSortable, isNumeric, key, label } = header;
|
||||
const { isLeftAligned, isSortable, isNumeric, key, label } = header;
|
||||
const labelId = `header-${ instanceId } -${ i }`;
|
||||
const thProps = {
|
||||
className: classnames( 'woocommerce-table__header', {
|
||||
'is-left-aligned': isLeftAligned,
|
||||
'is-sortable': isSortable,
|
||||
'is-sorted': sortedBy === key,
|
||||
'is-numeric': isNumeric,
|
||||
|
@ -173,10 +174,11 @@ class Table extends Component {
|
|||
{ rows.map( ( row, i ) => (
|
||||
<tr key={ i }>
|
||||
{ row.map( ( cell, j ) => {
|
||||
const { isNumeric } = headers[ j ];
|
||||
const { isLeftAligned, isNumeric } = headers[ j ];
|
||||
const isHeader = rowHeader === j;
|
||||
const Cell = isHeader ? 'th' : 'td';
|
||||
const cellClasses = classnames( 'woocommerce-table__item', {
|
||||
'is-left-aligned': isLeftAligned,
|
||||
'is-numeric': isNumeric,
|
||||
} );
|
||||
return (
|
||||
|
@ -217,6 +219,10 @@ Table.propTypes = {
|
|||
* Boolean, true if this column is the default for sorting. Only one column should have this set.
|
||||
*/
|
||||
defaultSort: PropTypes.bool,
|
||||
/**
|
||||
* Boolean, true if this column should be aligned to the left.
|
||||
*/
|
||||
isLeftAligned: PropTypes.bool,
|
||||
/**
|
||||
* Boolean, true if this column is a number value.
|
||||
*/
|
||||
|
|
|
@ -25,6 +25,7 @@ export class TopSellingProducts extends Component {
|
|||
label: __( 'Product', 'wc-admin' ),
|
||||
key: 'product',
|
||||
required: true,
|
||||
isLeftAligned: true,
|
||||
isSortable: false,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
/** @format */
|
||||
|
||||
export default `Date,Orders,Gross Revenue,Refunds,Coupons,Taxes,Shipping,Net Revenue
|
||||
2018-04-29T00:00:00,30,200,19,19,100,19,200`;
|
||||
export default `Date,Orders,Description,Gross Revenue,Refunds,Coupons,Taxes,Shipping,Net Revenue
|
||||
2018-04-29T00:00:00,30,lorem ipsum,200,19,19,100,19,200`;
|
||||
|
|
|
@ -9,6 +9,10 @@ export default [
|
|||
label: 'Orders',
|
||||
key: 'orders_count',
|
||||
},
|
||||
{
|
||||
label: 'Description',
|
||||
key: 'description',
|
||||
},
|
||||
{
|
||||
label: 'Gross Revenue',
|
||||
key: 'gross_revenue',
|
||||
|
|
|
@ -10,6 +10,10 @@ export default [
|
|||
display: 'Product 30',
|
||||
value: '30',
|
||||
},
|
||||
{
|
||||
display: 'Lorem, ipsum',
|
||||
value: 'lorem, ipsum',
|
||||
},
|
||||
{
|
||||
display: '€200.00',
|
||||
value: 200,
|
||||
|
|
|
@ -11,7 +11,11 @@ function getCSVHeaders( headers ) {
|
|||
|
||||
function getCSVRows( rows ) {
|
||||
return Array.isArray( rows )
|
||||
? rows.map( row => row.map( rowItem => rowItem.value ).join( ',' ) ).join( '\n' )
|
||||
? rows
|
||||
.map( row =>
|
||||
row.map( rowItem => rowItem.value.toString().replace( /,/g, '' ) ).join( ',' )
|
||||
)
|
||||
.join( '\n' )
|
||||
: [];
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
/** @format */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { union } from 'lodash';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
orders: {},
|
||||
|
@ -13,7 +9,6 @@ export default function ordersReducer( state = DEFAULT_STATE, action ) {
|
|||
switch ( action.type ) {
|
||||
case 'SET_ORDERS':
|
||||
const { orders } = action;
|
||||
const ids = orders.map( order => order.id );
|
||||
const ordersMap = orders.reduce( ( map, order ) => {
|
||||
map[ order.id ] = order;
|
||||
return map;
|
||||
|
@ -21,7 +16,6 @@ export default function ordersReducer( state = DEFAULT_STATE, action ) {
|
|||
return {
|
||||
...state,
|
||||
orders: Object.assign( {}, state.orders, ordersMap ),
|
||||
ids: union( state.ids, ids ),
|
||||
};
|
||||
case 'UPDATE_ORDER':
|
||||
const updatedOrders = { ...state.orders };
|
||||
|
|
|
@ -4,7 +4,4 @@ export default {
|
|||
getOrders( state ) {
|
||||
return state.orders.orders;
|
||||
},
|
||||
getOrderIds( state ) {
|
||||
return state.orders.ids;
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue