* 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:
Albert Juhé Lluveras 2018-10-11 10:30:51 +02:00 committed by GitHub
parent 3d1fc63373
commit 9623898acd
13 changed files with 328 additions and 83 deletions

View File

@ -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 );

View File

@ -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();
}
}

View File

@ -24,6 +24,7 @@ export default class extends Component {
label: __( 'Product Title', 'wc-admin' ),
key: 'name',
required: true,
isLeftAligned: true,
isSortable: true,
},
{

View File

@ -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' ),

View File

@ -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;

View File

@ -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.
*/

View File

@ -25,6 +25,7 @@ export class TopSellingProducts extends Component {
label: __( 'Product', 'wc-admin' ),
key: 'product',
required: true,
isLeftAligned: true,
isSortable: false,
},
{

View File

@ -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`;

View File

@ -9,6 +9,10 @@ export default [
label: 'Orders',
key: 'orders_count',
},
{
label: 'Description',
key: 'description',
},
{
label: 'Gross Revenue',
key: 'gross_revenue',

View File

@ -10,6 +10,10 @@ export default [
display: 'Product 30',
value: '30',
},
{
display: 'Lorem, ipsum',
value: 'lorem, ipsum',
},
{
display: '€200.00',
value: 200,

View File

@ -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' )
: [];
}

View File

@ -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 };

View File

@ -4,7 +4,4 @@ export default {
getOrders( state ) {
return state.orders.orders;
},
getOrderIds( state ) {
return state.orders.ids;
},
};