woocommerce/plugins/woocommerce-admin/client/components/chart/index.js

419 lines
11 KiB
JavaScript
Raw Normal View History

/** @format */
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
2018-08-08 11:00:45 +00:00
import { Component, createRef } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
2018-09-12 11:16:36 +00:00
import { formatDefaultLocale as d3FormatDefaultLocale } from 'd3-format';
import { get, isEqual, partial } from 'lodash';
2018-09-11 11:04:26 +00:00
import Gridicon from 'gridicons';
import { IconButton, NavigableMenu, SelectControl } from '@wordpress/components';
import { interpolateViridis as d3InterpolateViridis } from 'd3-scale-chromatic';
import PropTypes from 'prop-types';
import { withViewportMatch } from '@wordpress/viewport';
/**
* WooCommerce dependencies
*/
import { updateQueryString } from '@woocommerce/navigation';
import { H, Section } from '@woocommerce/components';
/**
* Internal dependencies
*/
import './style.scss';
import D3Chart from 'components/d3chart';
import Legend from 'components/d3chart/legend';
2018-09-12 11:16:36 +00:00
d3FormatDefaultLocale( {
decimal: '.',
thousands: ',',
grouping: [ 3 ],
2018-09-14 09:53:54 +00:00
currency: [ decodeEntities( get( wcSettings, 'currency.symbol', '' ) ), '' ],
2018-09-12 11:16:36 +00:00
} );
function getOrderedKeys( props ) {
const updatedKeys = [
2018-08-07 11:57:45 +00:00
...new Set(
props.data.reduce( ( accum, curr ) => {
2018-08-07 11:57:45 +00:00
Object.keys( curr ).forEach( key => key !== 'date' && accum.push( key ) );
return accum;
}, [] )
),
].map( key => ( {
key,
total: props.data.reduce( ( a, c ) => a + c[ key ].value, 0 ),
visible: true,
focus: true,
} ) );
if ( props.mode === 'item-comparison' ) {
updatedKeys.sort( ( a, b ) => b.total - a.total );
}
return updatedKeys;
2018-08-07 11:57:45 +00:00
}
/**
* A chart container using d3, to display timeseries data with an interactive legend.
*/
class Chart extends Component {
constructor( props ) {
super( props );
this.chartBodyRef = createRef();
this.state = {
2018-08-08 11:00:45 +00:00
data: props.data,
orderedKeys: getOrderedKeys( props ),
2018-08-12 17:01:10 +00:00
visibleData: [ ...props.data ],
width: 0,
};
2018-09-11 11:04:26 +00:00
this.handleTypeToggle = this.handleTypeToggle.bind( this );
this.handleLegendToggle = this.handleLegendToggle.bind( this );
this.handleLegendHover = this.handleLegendHover.bind( this );
this.updateDimensions = this.updateDimensions.bind( this );
2018-08-12 17:01:10 +00:00
this.getVisibleData = this.getVisibleData.bind( this );
this.setInterval = this.setInterval.bind( this );
}
2018-08-08 11:00:45 +00:00
componentDidUpdate( prevProps ) {
const { data } = this.props;
const orderedKeys = getOrderedKeys( this.props );
2018-08-08 11:00:45 +00:00
if ( ! isEqual( [ ...data ].sort(), [ ...prevProps.data ].sort() ) ) {
/* eslint-disable react/no-did-update-set-state */
this.setState( {
2018-08-12 17:01:10 +00:00
orderedKeys: orderedKeys,
visibleData: this.getVisibleData( data, orderedKeys ),
2018-08-08 11:00:45 +00:00
} );
/* eslint-enable react/no-did-update-set-state */
2018-08-07 11:57:45 +00:00
}
}
componentDidMount() {
this.updateDimensions();
window.addEventListener( 'resize', this.updateDimensions );
}
componentWillUnmount() {
window.removeEventListener( 'resize', this.updateDimensions );
}
2018-09-11 11:04:26 +00:00
handleTypeToggle( type ) {
2018-11-14 01:45:05 +00:00
if ( this.props.type !== type ) {
const { path, query } = this.props;
updateQueryString( { type }, path, query );
2018-09-11 11:04:26 +00:00
}
}
handleLegendToggle( event ) {
2018-08-12 17:01:10 +00:00
const { data } = this.props;
const orderedKeys = this.state.orderedKeys.map( d => ( {
...d,
visible: d.key === event.target.id ? ! d.visible : d.visible,
} ) );
2018-09-07 09:21:52 +00:00
const copyEvent = { ...event }; // can't pass a synthetic event into the hover handler
this.setState(
{
orderedKeys,
visibleData: this.getVisibleData( data, orderedKeys ),
},
() => {
this.handleLegendHover( copyEvent );
}
);
}
handleLegendHover( event ) {
2018-09-07 09:21:52 +00:00
const hoverTarget = this.state.orderedKeys.filter( d => d.key === event.target.id )[ 0 ];
this.setState( {
orderedKeys: this.state.orderedKeys.map( d => {
2018-09-07 09:21:52 +00:00
let enterFocus = d.key === event.target.id ? true : false;
enterFocus = ! hoverTarget.visible ? true : enterFocus;
return {
...d,
focus: event.type === 'mouseleave' || event.type === 'blur' ? true : enterFocus,
};
} ),
} );
}
updateDimensions() {
this.setState( {
width: this.chartBodyRef.current.offsetWidth,
} );
}
2018-08-12 17:01:10 +00:00
getVisibleData( data, orderedKeys ) {
const visibleKeys = orderedKeys.filter( d => d.visible );
return data.map( d => {
const newRow = { date: d.date };
visibleKeys.forEach( row => {
newRow[ row.key ] = d[ row.key ];
} );
return newRow;
} );
}
setInterval( interval ) {
const { path, query } = this.props;
updateQueryString( { interval }, path, query );
}
renderIntervalSelector() {
const { interval, allowedIntervals } = this.props;
if ( ! allowedIntervals || allowedIntervals.length < 1 ) {
return null;
}
const intervalLabels = {
hour: __( 'By hour', 'wc-admin' ),
day: __( 'By day', 'wc-admin' ),
week: __( 'By week', 'wc-admin' ),
month: __( 'By month', 'wc-admin' ),
quarter: __( 'By quarter', 'wc-admin' ),
year: __( 'By year', 'wc-admin' ),
};
return (
<SelectControl
className="woocommerce-chart__interval-select"
value={ interval }
options={ allowedIntervals.map( allowedInterval => ( {
value: allowedInterval,
label: intervalLabels[ allowedInterval ],
} ) ) }
onChange={ this.setInterval }
/>
);
}
getChartHeight() {
const { isViewportLarge, isViewportMobile } = this.props;
if ( isViewportMobile ) {
return 180;
}
if ( isViewportLarge ) {
return 300;
}
return 220;
}
render() {
2018-11-14 01:45:05 +00:00
const { orderedKeys, visibleData, width } = this.state;
const {
dateParser,
itemsLabel,
mode,
isViewportLarge,
isViewportWide,
title,
tooltipLabelFormat,
tooltipValueFormat,
tooltipTitle,
xFormat,
x2Format,
interval,
valueType,
2018-11-14 01:45:05 +00:00
type,
} = this.props;
let { yFormat } = this.props;
const legendDirection = mode === 'time-comparison' && isViewportWide ? 'row' : 'column';
const chartDirection = mode === 'item-comparison' && isViewportWide ? 'row' : 'column';
const chartHeight = this.getChartHeight();
const legend = (
<Legend
colorScheme={ d3InterpolateViridis }
data={ orderedKeys }
handleLegendHover={ this.handleLegendHover }
handleLegendToggle={ this.handleLegendToggle }
legendDirection={ legendDirection }
itemsLabel={ itemsLabel }
valueType={ valueType }
/>
);
const margin = {
bottom: 50,
2018-09-04 11:31:18 +00:00
left: 80,
2018-09-04 13:03:46 +00:00
right: 30,
top: 0,
};
switch ( valueType ) {
case 'average':
yFormat = '.0f';
break;
case 'currency':
yFormat = '$.3s';
break;
case 'number':
yFormat = '.0f';
break;
}
return (
<div className="woocommerce-chart">
2018-08-08 11:00:45 +00:00
<div className="woocommerce-chart__header">
<H className="woocommerce-chart__title">{ title }</H>
{ isViewportWide && legendDirection === 'row' && legend }
{ this.renderIntervalSelector() }
<NavigableMenu
className="woocommerce-chart__types"
orientation="horizontal"
role="menubar"
>
2018-09-11 11:04:26 +00:00
<IconButton
className={ classNames( 'woocommerce-chart__type-button', {
'woocommerce-chart__type-button-selected': type === 'line',
} ) }
icon={ <Gridicon icon="line-graph" /> }
title={ __( 'Line chart', 'wc-admin' ) }
aria-checked={ type === 'line' }
role="menuitemradio"
tabIndex={ type === 'line' ? 0 : -1 }
2018-09-11 11:04:26 +00:00
onClick={ partial( this.handleTypeToggle, 'line' ) }
/>
<IconButton
className={ classNames( 'woocommerce-chart__type-button', {
'woocommerce-chart__type-button-selected': type === 'bar',
} ) }
icon={ <Gridicon icon="stats-alt" /> }
title={ __( 'Bar chart', 'wc-admin' ) }
aria-checked={ type === 'bar' }
role="menuitemradio"
tabIndex={ type === 'bar' ? 0 : -1 }
2018-09-11 11:04:26 +00:00
onClick={ partial( this.handleTypeToggle, 'bar' ) }
/>
</NavigableMenu>
</div>
<Section component={ false }>
<div
className={ classNames(
'woocommerce-chart__body',
`woocommerce-chart__body-${ chartDirection }`
) }
ref={ this.chartBodyRef }
>
{ isViewportWide && legendDirection === 'column' && legend }
{ width > 0 && (
<D3Chart
colorScheme={ d3InterpolateViridis }
data={ visibleData }
dateParser={ dateParser }
height={ chartHeight }
interval={ interval }
margin={ margin }
mode={ mode }
orderedKeys={ orderedKeys }
tooltipLabelFormat={ tooltipLabelFormat }
tooltipValueFormat={ tooltipValueFormat }
tooltipPosition={ isViewportLarge ? 'over' : 'below' }
tooltipTitle={ tooltipTitle }
type={ type }
width={ chartDirection === 'row' ? width - 320 : width }
xFormat={ xFormat }
x2Format={ x2Format }
yFormat={ yFormat }
valueType={ valueType }
/>
) }
</div>
{ ! isViewportWide && <div className="woocommerce-chart__footer">{ legend }</div> }
</Section>
</div>
);
}
}
Chart.propTypes = {
/**
* An array of data.
*/
data: PropTypes.array.isRequired,
/**
* Format to parse dates into d3 time format
*/
dateParser: PropTypes.string.isRequired,
/**
* Current path
*/
path: PropTypes.string,
/**
* The query string represented in object form
*/
query: PropTypes.object,
2018-09-05 20:52:35 +00:00
/**
* A datetime formatting string or overriding function to format the tooltip label.
2018-09-05 20:52:35 +00:00
*/
tooltipLabelFormat: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ),
/**
* A number formatting string or function to format the value displayed in the tooltips.
*/
tooltipValueFormat: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ),
/**
* A string to use as a title for the tooltip. Takes preference over `tooltipLabelFormat`.
*/
tooltipTitle: PropTypes.string,
2018-09-05 20:52:35 +00:00
/**
* A datetime formatting string, passed to d3TimeFormat.
*/
xFormat: PropTypes.string,
2018-09-07 10:28:02 +00:00
/**
* A datetime formatting string, passed to d3TimeFormat.
*/
x2Format: PropTypes.string,
2018-09-05 20:52:35 +00:00
/**
* A number formatting string, passed to d3Format.
*/
yFormat: PropTypes.string,
/**
* `item-comparison` (default) or `time-comparison`, this is used to generate correct
* ARIA properties.
*/
mode: PropTypes.oneOf( [ 'item-comparison', 'time-comparison' ] ),
2018-09-10 11:23:50 +00:00
/**
* A title describing this chart.
*/
title: PropTypes.string,
2018-09-11 11:04:26 +00:00
/**
* Chart type of either `line` or `bar`.
*/
type: PropTypes.oneOf( [ 'bar', 'line' ] ),
/**
* Information about the currently selected interval, and set of allowed intervals for the chart. See `getIntervalsForQuery`.
*/
intervalData: PropTypes.object,
/**
* Interval specification (hourly, daily, weekly etc).
*/
interval: PropTypes.oneOf( [ 'hour', 'day', 'week', 'month', 'quarter', 'year' ] ),
/**
* Allowed intervals to show in a dropdown.
*/
allowedIntervals: PropTypes.array,
/**
* What type of data is to be displayed? Number, Average, String?
*/
valueType: PropTypes.string,
};
2018-08-08 11:00:45 +00:00
Chart.defaultProps = {
data: [],
dateParser: '%Y-%m-%dT%H:%M:%S',
tooltipLabelFormat: '%B %d, %Y',
tooltipValueFormat: ',',
2018-09-07 10:28:02 +00:00
xFormat: '%d',
x2Format: '%b %Y',
2018-10-12 20:39:38 +00:00
yFormat: '$.3s',
mode: 'item-comparison',
2018-09-11 11:04:26 +00:00
type: 'line',
interval: 'day',
2018-08-08 11:00:45 +00:00
};
export default withViewportMatch( {
isViewportMobile: '< medium',
isViewportLarge: '>= large',
isViewportWide: '>= wide',
} )( Chart );