Add loading indicators, error state, and EmptyContent to the revenue report. (#347, woocommerce/woocommerce-admin#348)

* Add loading indiciators for the revenue report.

* Improve accessibility, and fix up some documentation comments.

* Fix top border on mobile

* Add EmptyContent Component and revenue error/empty states. (https://github.com/woocommerce/woocommerce-admin/pull/348)

* Add EmptyContent Component and revenue error/empty states.

* Move relative image handling to ImageAsset, combine secondary and primary action rendering, add some missing isRequired proptypes, add empty error handling.

* Handle PR Feedback: Clean up button css, set a default for illustration, fix deprecation typo, some code cleanup.
This commit is contained in:
Justin Shreve 2018-09-05 12:45:49 -04:00 committed by GitHub
parent 9183756a70
commit f5479e1c18
18 changed files with 474 additions and 20 deletions

View File

@ -11,6 +11,7 @@ import { find } from 'lodash';
/**
* Internal dependencies
*/
import './style.scss';
import ExampleReport from './example';
import Header from 'layout/header';
import OrdersReport from './orders';

View File

@ -16,10 +16,14 @@ import { withSelect } from '@wordpress/data';
import {
Card,
Chart,
ChartPlaceholder,
EmptyContent,
ReportFilters,
SummaryList,
SummaryListPlaceholder,
SummaryNumber,
TableCard,
TablePlaceholder,
} from '@woocommerce/components';
import { downloadCSVFile, generateCSVDataFromTable, generateCSVFileName } from 'lib/csv';
import { formatCurrency, getCurrencyFormatDecimal } from 'lib/currency';
@ -328,7 +332,11 @@ export class RevenueReport extends Component {
const headers = this.getHeadersContent();
const tableQuery = { ...query, orderby: query.orderby || 'date_start', order: query.order || 'asc' };
const tableQuery = {
...query,
orderby: query.orderby || 'date_start',
order: query.order || 'asc',
};
return (
<TableCard
title={ __( 'Revenue', 'wc-admin' ) }
@ -344,30 +352,63 @@ export class RevenueReport extends Component {
);
}
renderPlaceholder() {
const { path, query } = this.props;
const headers = this.getHeadersContent();
const charts = this.getCharts();
return (
<Fragment>
<ReportFilters query={ query } path={ path } />
<span className="screen-reader-text">
{ __( 'Your requested data is loading', 'wc-admin' ) }
</span>
<SummaryListPlaceholder numberOfItems={ charts.length } />
<ChartPlaceholder />
<Card
title={ __( 'Revenue', 'wc-admin' ) }
className="woocommerce-analytics__table-placeholder"
>
<TablePlaceholder caption={ __( 'Revenue', 'wc-admin' ) } headers={ headers } />
</Card>
</Fragment>
);
}
render() {
const { path, query, primaryData, secondaryData } = this.props;
// TODO The loading, error, and empty messages below are all temporary.
// So we need to use an actual EmptyState components and add in a loading indicator.
const tempMessage = message => {
return (
<div>
<ReportFilters query={ query } path={ path } />
<p>{ message }</p>
</div>
);
};
if ( isReportDataEmpty( primaryData ) ) {
return tempMessage( 'Empty Data' );
}
if ( primaryData.isRequesting || secondaryData.isRequesting ) {
return tempMessage( 'Loading...' );
return this.renderPlaceholder();
}
if ( isReportDataEmpty( primaryData ) || primaryData.isError || secondaryData.isError ) {
let title, actionLabel, actionURL, actionCallback;
if ( primaryData.isError || secondaryData.isError ) {
return tempMessage( 'Error' );
title = __( 'There was an error getting your stats. Please try again.', 'wc-admin' );
actionLabel = __( 'Reload', 'wc-admin' );
actionCallback = () => {
// TODO Add tracking for how often an error is displayed, and the reload action is clicked.
window.location.reload();
};
} else {
title = __( 'No results could be found for this date range.', 'wc-admin' );
actionLabel = __( 'View Orders', 'wc-admin' );
actionURL = getAdminLink( 'edit.php?post_type=shop_order' );
}
return (
<Fragment>
<ReportFilters query={ query } path={ path } />
<EmptyContent
title={ title }
actionLabel={ actionLabel }
actionURL={ actionURL }
actionCallback={ actionCallback }
/>
</Fragment>
);
}
return (

View File

@ -0,0 +1,14 @@
/** @format */
.woocommerce-analytics__table-placeholder {
.woocommerce-card__body {
padding: 0;
}
.woocommerce-table__table {
margin-bottom: 0;
tr:last-child {
border-bottom-style: none;
}
}
}

View File

@ -0,0 +1,16 @@
/** @format */
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
/**
* `ChartPlaceholder` displays a large loading indiciator for use in place of a `Chart` while data is loading.
*/
class ChartPlaceholder extends Component {
render() {
return <div className="woocommerce-chart is-placeholder" aria-hidden="true" />;
}
}
export default ChartPlaceholder;

View File

@ -123,6 +123,17 @@
stroke-width: 1px;
}
}
&.is-placeholder {
@include placeholder();
display: inline-block;
height: 200px;
width: 100%;
margin: 0;
padding: 0;
margin-bottom: $gap;
border: 1px solid $core-grey-light-700;
}
}
.woocommerce-chart__container {

View File

@ -0,0 +1,161 @@
/** @format */
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { Component } from '@wordpress/element';
import classnames from 'classnames';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
import { H } from 'layout/section';
import ImageAsset from 'components/image-asset';
/**
* A component to be used when there is no data to show.
* It can be used as an opportunity to provide explanation or guidance to help a user progress.
*/
class EmptyContent extends Component {
renderIllustration() {
const { illustrationWidth, illustrationHeight, illustration } = this.props;
return (
<ImageAsset
alt=""
src={ illustration }
width={ illustrationWidth }
height={ illustrationHeight }
className="woocommerce-empty-content__illustration"
/>
);
}
renderActionButtons( type ) {
const actionLabel =
'secondary' === type ? this.props.secondaryActionLabel : this.props.actionLabel;
const actionURL = 'secondary' === type ? this.props.secondaryActionURL : this.props.actionURL;
const actionCallback =
'secondary' === type ? this.props.secondaryActionCallback : this.props.actionCallback;
const isPrimary = 'secondary' === type ? false : true;
if ( actionURL && actionCallback ) {
return (
<Button
className="woocommerce-empty-content__action"
isPrimary={ isPrimary }
onClick={ actionCallback }
href={ actionURL }
>
{ actionLabel }
</Button>
);
} else if ( actionURL ) {
return (
<Button
className="woocommerce-empty-content__action"
isPrimary={ isPrimary }
href={ actionURL }
>
{ actionLabel }
</Button>
);
} else if ( actionCallback ) {
return (
<Button
className="woocommerce-empty-content__action"
isPrimary={ isPrimary }
onClick={ actionCallback }
>
{ actionLabel }
</Button>
);
}
return null;
}
renderActions() {
const { actionLabel, secondaryActionLabel } = this.props;
return (
<div className="woocommerce-empty-content__actions">
{ actionLabel && this.renderActionButtons( 'primary' ) }
{ secondaryActionLabel && this.renderActionButtons( 'secondary' ) }
</div>
);
}
render() {
const { title, message, illustration } = this.props;
return (
<div className={ classnames( 'woocommerce-empty-content', this.props.className ) }>
{ illustration && this.renderIllustration() }
{ title ? <H className="woocommerce-empty-content__title">{ title }</H> : null }
{ message ? <p className="woocommerce-empty-content__message">{ message }</p> : null }
{ this.renderActions() }
</div>
);
}
}
EmptyContent.propTypes = {
/**
* The title to be displayed.
*/
title: PropTypes.string.isRequired,
/**
* An additional message to be displayed.
*/
message: PropTypes.string,
/**
* The url string of an image path. Prefix with `/` to load an image relative to the plugin directory.
*/
illustration: PropTypes.string,
/**
* Height to use for the illustration.
*/
illustrationHeight: PropTypes.number,
/**
* Width to use for the illustration.
*/
illustrationWidth: PropTypes.number,
/**
* Label to be used for the primary action button.
*/
actionLabel: PropTypes.string.isRequired,
/**
* URL to be used for the primary action button.
*/
actionURL: PropTypes.string,
/**
* Callback to be used for the primary action button.
*/
actionCallback: PropTypes.func,
/**
* Label to be used for the secondary action button.
*/
secondaryActionLabel: PropTypes.string,
/**
* URL to be used for the secondary action button.
*/
secondaryActionURL: PropTypes.string,
/**
* Callback to be used for the secondary action button.
*/
secondaryActionCallback: PropTypes.func,
/**
* Additional CSS classes.
*/
className: PropTypes.string,
};
EmptyContent.defaultProps = {
illustration: '/empty-content.svg',
illustrationHeight: 400,
illustrationWidth: 400,
};
export default EmptyContent;

View File

@ -0,0 +1,11 @@
/** @format */
.woocommerce-empty-content {
text-align: center;
.woocommerce-empty-content__actions {
.components-button + .components-button {
margin-left: $gap;
}
}
}

View File

@ -0,0 +1,37 @@
/** @format */
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import PropTypes from 'prop-types';
/**
* A component that loads an image, allowing images to be loaded relative to the main asset/plugin folder.
* Props are passed through to `<img />`
*/
class ImageAsset extends Component {
render() {
const { src, alt, ...restOfProps } = this.props;
let illustrationSrc = src;
if ( illustrationSrc.indexOf( '/' ) === 0 ) {
illustrationSrc = illustrationSrc.substring( 1 );
illustrationSrc = wcSettings.wcAdminAssetUrl + illustrationSrc;
}
return <img src={ illustrationSrc } alt={ alt || '' } { ...restOfProps } />;
}
}
ImageAsset.propTypes = {
/**
* Image location to pass through to `<img />`.
*/
src: PropTypes.string.isRequired,
/**
* Alt text to pass through to `<img />`.
*/
alt: PropTypes.string.isRequired,
};
export default ImageAsset;

View File

@ -11,15 +11,18 @@ export { default as AnimationSlider } from './animation-slider';
export { default as Card } from './card';
export { default as Chart } from './chart';
export { default as ChartLegend } from './chart/legend';
export { default as ChartPlaceholder } from './chart/placeholder';
export { default as Count } from './count';
export { default as D3Chart } from './chart/charts';
export { default as DatePicker } from './filters/date';
export { default as DateRange } from './calendar';
export { default as DropdownButton } from './dropdown-button';
export { default as EllipsisMenu } from './ellipsis-menu';
export { default as EmptyContent } from './empty-content';
export { default as Flag } from './flag';
export { default as FilterPicker } from './filters/filter';
export { default as Gravatar } from './gravatar';
export { default as ImageAsset } from './image-asset';
export { default as Link } from './link';
export { default as MenuItem } from './ellipsis-menu/menu-item';
export { default as MenuTitle } from './ellipsis-menu/menu-title';
@ -34,6 +37,7 @@ export { default as Search } from './search';
export { default as SegmentedSelection } from './segmented-selection';
export { default as SplitButton } from './split-button';
export { default as SummaryList } from './summary';
export { default as SummaryListPlaceholder } from './summary/placeholder';
export { default as SummaryNumber } from './summary/item';
export { default as Table } from './table/table';
export { default as TableCard } from './table';

View File

@ -10,7 +10,7 @@ import { isUndefined } from 'lodash';
import PropTypes from 'prop-types';
/**
* External dependencies
* Internal dependencies
*/
import Link from 'components/link';

View File

@ -0,0 +1,67 @@
/** @format */
/**
* External dependencies
*/
import { Component } from '@wordpress/element';
import classnames from 'classnames';
import { range } from 'lodash';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { isMobileViewport, isTabletViewport } from 'lib/ui';
/**
* `SummaryListPlaceholder` behaves like `SummaryList` but displays placeholder summary items instead of data.
* This can be used while loading data.
*/
class SummaryListPlaceholder extends Component {
render() {
const isDropdownBreakpoint = isTabletViewport() || isMobileViewport();
const numberOfItems = isDropdownBreakpoint ? 1 : this.props.numberOfItems;
const hasItemsClass = numberOfItems < 10 ? `has-${ numberOfItems }-items` : 'has-10-items';
const classes = classnames( 'woocommerce-summary', {
[ hasItemsClass ]: ! isDropdownBreakpoint,
'is-placeholder': true,
} );
const rows = range( numberOfItems ).map( i => {
return (
<li className="woocommerce-summary__item-container is-placeholder" key={ i }>
<span className="woocommerce-summary__item">
<span className="woocommerce-summary__item-label" />
<span className="woocommerce-summary__item-data">
<span className="woocommerce-summary__item-value" />
<div className="woocommerce-summary__item-delta">
<span className="woocommerce-summary__item-delta-value" />
</div>
</span>
<span className="woocommerce-summary__item-prev-label" />
<span className="woocommerce-summary__item-prev-value" />
</span>
</li>
);
} );
return (
<ul className={ classes } aria-hidden="true">
{ rows }
</ul>
);
}
}
SummaryListPlaceholder.propTypes = {
/**
* An integer with the number of summary items to display.
*/
numberOfItems: PropTypes.number.isRequired,
};
SummaryListPlaceholder.defaultProps = {
numberOfRows: 5,
};
export default SummaryListPlaceholder;

View File

@ -46,6 +46,16 @@ $inner-border: $core-grey-light-500;
background-color: $core-grey-light-300;
box-shadow: inset -1px -1px 0 $outer-border;
@include breakpoint( '<782px' ) {
&.is-placeholder {
border-top: 0;
}
.woocommerce-summary__item-container.is-placeholder {
border-top: 1px solid $outer-border;
}
}
// Specificity
.components-popover.components-popover {
// !important to override element-level styles
@ -214,6 +224,36 @@ $inner-border: $core-grey-light-500;
border-right: none;
}
}
&.is-placeholder {
.woocommerce-summary__item-label {
@include placeholder();
display: inline-block;
height: 16px;
max-width: 110px;
width: 70%;
}
.woocommerce-summary__item-data {
justify-content: space-between;
}
.woocommerce-summary__item-value,
.woocommerce-summary__item-prev-value {
@include placeholder();
display: inline-block;
height: 16px;
max-width: 140px;
width: 80%;
}
.woocommerce-summary__item-delta-value {
@include placeholder();
display: inline-block;
height: 16px;
width: 20px;
}
}
}
.woocommerce-summary__item {

View File

@ -11,6 +11,9 @@ import PropTypes from 'prop-types';
*/
import Table from './table';
/**
* `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading.
*/
class TablePlaceholder extends Component {
render() {
const { caption, headers, numberOfRows } = this.props;
@ -32,7 +35,13 @@ class TablePlaceholder extends Component {
}
TablePlaceholder.propTypes = {
/**
* A label for the content in this table.
*/
caption: PropTypes.string.isRequired,
/**
* An array of column headers (see `Table` props).
*/
headers: PropTypes.arrayOf(
PropTypes.shape( {
defaultSort: PropTypes.bool,
@ -42,6 +51,9 @@ TablePlaceholder.propTypes = {
required: PropTypes.bool,
} )
),
/**
* An integer with the number of rows to display.
*/
numberOfRows: PropTypes.number,
};

View File

@ -38,6 +38,7 @@ describe( 'isReportDataEmpty()', () => {
describe( 'getAllReportData()', () => {
const select = jest.fn().mockReturnValue( {} );
const response = {
isEmpty: false,
isError: false,
isRequesting: false,
data: {
@ -208,4 +209,22 @@ describe( 'getAllReportData()', () => {
const result = getAllReportData( 'revenue', {}, select );
expect( result ).toEqual( { ...response, isError: true } );
} );
it( 'returns empty state if a query returns no data', () => {
setIsReportStatsRequesting( () => {
return false;
} );
setIsReportStatsError( () => {
return false;
} );
setGetReportStats( () => {
return {
totalResults: undefined,
data: {},
};
} );
const result = getAllReportData( 'revenue', {}, select );
expect( result ).toEqual( { ...response, isEmpty: true } );
} );
} );

View File

@ -45,6 +45,7 @@ export function getAllReportData( endpoint, query, select ) {
const { getReportStats, isReportStatsRequesting, isReportStatsError } = select( 'wc-admin' );
const response = {
isEmpty: false,
isError: false,
isRequesting: false,
data: {
@ -62,6 +63,8 @@ export function getAllReportData( endpoint, query, select ) {
return { ...response, isRequesting: true };
} else if ( isReportStatsError( endpoint, query ) ) {
return { ...response, isError: true };
} else if ( isReportDataEmpty( stats ) ) {
return { ...response, isEmpty: true };
}
const totals = ( stats && stats.data && stats.data.totals ) || null;

View File

@ -49,4 +49,14 @@
a:focus {
box-shadow: 0 0 0 1px $woocommerce-300, 0 0 2px 1px rgba($woocommerce-300, 0.8);
}
.components-button.is-button.is-primary {
color: $white;
}
.components-button.is-button.is-primary:hover,
.components-button.is-button.is-primary:active,
.components-button.is-button.is-primary:focus {
color: $white;
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -57,10 +57,16 @@ function wc_admin_register_script() {
wp_enqueue_script( 'wp-api' );
/**
* TODO: On merge, once plugin images are added to core WooCommerce, `wcAdminAssetUrl` can be retired, and
* `wcAssetUrl` can be used in its place throughout the codebase.
*/
// Settings and variables can be passed here for access in the app.
$settings = array(
'adminUrl' => admin_url(),
'wcAssetUrl' => plugins_url( 'assets/', WC_PLUGIN_FILE ),
'wcAdminAssetUrl' => plugins_url( 'images/', wc_admin_dir_path( 'wc-admin.php' ) ), // Temporary for plugin. See above.
'embedBreadcrumbs' => wc_admin_get_embed_breadcrumbs(),
'siteLocale' => esc_attr( get_bloginfo( 'language' ) ),
'currency' => wc_admin_currency_settings(),