diff --git a/plugins/woocommerce-admin/client/analytics/report/revenue/index.js b/plugins/woocommerce-admin/client/analytics/report/revenue/index.js index 96b918442af..aadf29ef6ce 100644 --- a/plugins/woocommerce-admin/client/analytics/report/revenue/index.js +++ b/plugins/woocommerce-admin/client/analytics/report/revenue/index.js @@ -338,18 +338,16 @@ export class RevenueReport extends Component { } ); return ( - - - + ); } diff --git a/plugins/woocommerce-admin/client/components/calendar/style.scss b/plugins/woocommerce-admin/client/components/calendar/style.scss index 7ebf90a3cef..08f746a81fa 100644 --- a/plugins/woocommerce-admin/client/components/calendar/style.scss +++ b/plugins/woocommerce-admin/client/components/calendar/style.scss @@ -79,6 +79,7 @@ display: flex; align-items: center; justify-content: center; + grid-column-start: 2; } .woocommerce-calendar__input { @@ -95,6 +96,14 @@ } } + &:first-child { + grid-column-start: 1; + } + + &:last-child { + grid-column-start: 3; + } + &.is-empty { .dashicons-calendar path { fill: $core-grey-dark-300; diff --git a/plugins/woocommerce-admin/client/components/chart/charts.js b/plugins/woocommerce-admin/client/components/chart/charts.js index 92222f4653c..67d40f643a4 100644 --- a/plugins/woocommerce-admin/client/components/chart/charts.js +++ b/plugins/woocommerce-admin/client/components/chart/charts.js @@ -23,6 +23,7 @@ import { getOrderedKeys, getLine, getLineData, + getXTicks, getUniqueKeys, getUniqueDates, getXScale, @@ -97,6 +98,7 @@ class D3Chart extends Component { data, dateParser, height, + layout, margin, orderedKeys, tooltipFormat, @@ -120,6 +122,7 @@ class D3Chart extends Component { const uniqueDates = getUniqueDates( lineData, parseDate ); const xLineScale = getXLineScale( uniqueDates, adjWidth ); const xScale = getXScale( uniqueDates, adjWidth ); + const xTicks = getXTicks( uniqueDates, adjWidth, layout ); return { colorScheme, dateSpaces: getDateSpaces( uniqueDates, adjWidth, xLineScale ), @@ -139,6 +142,7 @@ class D3Chart extends Component { x2Format: d3TimeFormat( x2Format ), xGroupScale: getXGroupScale( orderedKeys, xScale ), xLineScale, + xTicks, xScale, yMax, yScale, @@ -195,6 +199,11 @@ D3Chart.propTypes = { * Interval specification (hourly, daily, weekly etc.) */ interval: PropTypes.oneOf( [ 'hour', 'day', 'week', 'month', 'quarter', 'year' ] ), + /** + * `standard` (default) legend layout in the header or `comparison` moves legend layout + * to the left or 'compact' has the legend below + */ + layout: PropTypes.oneOf( [ 'standard', 'comparison', 'compact' ] ), /** * Margins for axis and chart padding. */ @@ -244,6 +253,7 @@ D3Chart.defaultProps = { right: 0, top: 20, }, + layout: 'standard', tooltipFormat: '%Y-%m-%d', type: 'line', width: 600, diff --git a/plugins/woocommerce-admin/client/components/chart/index.js b/plugins/woocommerce-admin/client/components/chart/index.js index 9bf0a0be2f9..df6fc9bbe57 100644 --- a/plugins/woocommerce-admin/client/components/chart/index.js +++ b/plugins/woocommerce-admin/client/components/chart/index.js @@ -291,9 +291,10 @@ Chart.propTypes = { */ yFormat: PropTypes.string, /** - * `standard` (default) legend layout in the header or `comparison` moves legend layout to the left + * `standard` (default) legend layout in the header or `comparison` moves legend layout + * to the left or 'compact' has the legend below */ - layout: PropTypes.oneOf( [ 'standard', 'comparison' ] ), + layout: PropTypes.oneOf( [ 'standard', 'comparison', 'compact' ] ), /** * A title describing this chart. */ diff --git a/plugins/woocommerce-admin/client/components/chart/style.scss b/plugins/woocommerce-admin/client/components/chart/style.scss index 5db8d7e2723..604765c2731 100644 --- a/plugins/woocommerce-admin/client/components/chart/style.scss +++ b/plugins/woocommerce-admin/client/components/chart/style.scss @@ -1,15 +1,22 @@ /** @format */ .woocommerce-chart { - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: flex-start; - margin: -$gap; - border-top: 1px solid $core-grey-light-700; + margin-top: -$gap; + margin-bottom: $gap-large; + background: white; + border: 1px solid $core-grey-light-700; + border-top: 0; + + @include breakpoint( '<782px' ) { + margin-left: -16px; + margin-right: -16px; + margin-bottom: $gap-small; + border-left: none; + border-right: none; + width: auto; + } .woocommerce-chart__header { min-height: 50px; - border-top: 1px solid $core-grey-light-700; border-bottom: 1px solid $core-grey-light-700; display: flex; flex-wrap: nowrap; @@ -181,13 +188,9 @@ &.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; } } diff --git a/plugins/woocommerce-admin/client/components/chart/utils.js b/plugins/woocommerce-admin/client/components/chart/utils.js index bde074cca9c..bce3971f0e5 100644 --- a/plugins/woocommerce-admin/client/components/chart/utils.js +++ b/plugins/woocommerce-admin/client/components/chart/utils.js @@ -13,13 +13,30 @@ import { scaleLinear as d3ScaleLinear, scaleTime as d3ScaleTime, } from 'd3-scale'; -import { mouse as d3Mouse, select as d3Select } from 'd3-selection'; +import { event as d3Event, mouse as d3Mouse, select as d3Select } from 'd3-selection'; import { line as d3Line } from 'd3-shape'; /** * Internal dependencies */ import { formatCurrency } from 'lib/currency'; +/** + * Describes `smallestFactor` + * @param {number} inputNum - any double or integer + * @returns {integer} smallest factor of num + */ +export const getFactors = inputNum => { + const num_factors = []; + for ( let i = 1; i <= Math.floor( Math.sqrt( inputNum ) ); i += 1 ) { + if ( inputNum % i === 0 ) { + num_factors.push( i ); + inputNum / i !== i && num_factors.push( inputNum / i ); + } + } + num_factors.sort( ( x, y ) => x - y ); // numeric sort + return num_factors; +}; + /** * Describes `getUniqueKeys` * @param {array} data - The chart component's `data` prop. @@ -187,6 +204,74 @@ export const getLine = ( xLineScale, yScale ) => .x( d => xLineScale( new Date( d.date ) ) ) .y( d => yScale( d.value ) ); +/** + * Describes getXTicks + * @param {array} uniqueDates - all the unique dates from the input data for the chart + * @param {integer} width - calculated page width + * @param {string} layout - standard, comparison or compact chart types + * @returns {integer} number of x-axis ticks based on width and chart layout + */ +export const getXTicks = ( uniqueDates, width, layout ) => { + // caluclate the maximum number of ticks allowed in the x-axis based on the width + // and layout of the chart + let ticks = 16; + if ( width < 783 ) { + ticks = 7; + } else if ( width >= 783 && width < 1129 ) { + ticks = 12; + } else if ( width >= 1130 && width < 1365 ) { + if ( layout === 'standard' ) { + ticks = 16; + } else if ( layout === 'comparison' ) { + ticks = 12; + } else if ( layout === 'compact' ) { + ticks = 7; + } + } else if ( width >= 1365 ) { + if ( layout === 'standard' ) { + ticks = 31; + } else if ( layout === 'comparison' ) { + ticks = 16; + } else if ( layout === 'compact' ) { + ticks = 12; + } + } + if ( uniqueDates.length <= ticks ) { + return uniqueDates; + } + let factors = []; + let i = 0; + // first we get all the factors of the length of the uniqieDates array + // if the number is a prime number or near prime (with 3 factors) then we + // step down by 1 integer and try again + while ( factors.length <= 3 ) { + factors = getFactors( uniqueDates.length - ( 1 + i ) ); + i += 1; + } + let newTicks = []; + let factorIndex = 0; + // newTicks is the first tick plus the smallest factor (initiallY) etc. + // however, if we still end up with too many ticks we look at the next factor + // and try again unttil we have fewer ticks than the max + while ( newTicks.length > ticks || newTicks.length === 0 ) { + if ( newTicks.length > ticks ) { + factorIndex += 1; + newTicks = []; + } + for ( let idx = 0; idx < uniqueDates.length; idx = idx + factors[ factorIndex ] ) { + newTicks.push( uniqueDates[ idx ] ); + } + } + // if, for some reason, the first or last date is missing from the newTicks array, add it back in + if ( newTicks[ 0 ] !== uniqueDates[ 0 ] ) { + newTicks.unshift( uniqueDates[ 0 ] ); + } + if ( newTicks[ newTicks.length - 1 ] !== uniqueDates[ uniqueDates.length - 1 ] ) { + newTicks.push( uniqueDates[ uniqueDates.length - 1 ] ); + } + return newTicks; +}; + /** * Describes getDateSpaces * @param {array} uniqueDates - from `getUniqueDates` @@ -223,13 +308,15 @@ export const drawAxis = ( node, params ) => { yGrids.push( i / 3 * params.yMax ); } + const ticks = params.xTicks.map( d => ( params.type === 'line' ? new Date( d ) : d ) ); + node .append( 'g' ) .attr( 'class', 'axis' ) .attr( 'transform', `translate(0,${ params.height })` ) .call( d3AxisBottom( xScale ) - .tickValues( params.uniqueDates.map( d => ( params.type === 'line' ? new Date( d ) : d ) ) ) + .tickValues( ticks ) .tickFormat( d => params.xFormat( d instanceof Date ? d : new Date( d ) ) ) ); @@ -239,7 +326,7 @@ export const drawAxis = ( node, params ) => { .attr( 'transform', `translate(3, ${ params.height + 20 })` ) .call( d3AxisBottom( xScale ) - .tickValues( params.uniqueDates.map( d => ( params.type === 'line' ? new Date( d ) : d ) ) ) + .tickValues( ticks ) .tickFormat( ( d, i ) => { const monthDate = d instanceof Date ? d : new Date( d ); let prevMonth = i !== 0 ? params.uniqueDates[ i - 1 ] : params.uniqueDates[ i ]; @@ -263,7 +350,7 @@ export const drawAxis = ( node, params ) => { .attr( 'transform', `translate(0, ${ params.height })` ) .call( d3AxisBottom( xScale ) - .tickValues( params.uniqueDates.map( d => ( params.type === 'line' ? new Date( d ) : d ) ) ) + .tickValues( ticks ) .tickSize( 5 ) .tickFormat( '' ) ); @@ -303,9 +390,9 @@ export const drawAxis = ( node, params ) => { .remove(); }; -const showTooltip = ( node, params, d ) => { +const showTooltip = ( node, params, d, position ) => { const chartCoords = node.node().getBoundingClientRect(); - let [ xPosition, yPosition ] = d3Mouse( node.node() ); + let [ xPosition, yPosition ] = position ? position : d3Mouse( node.node() ); xPosition = xPosition > chartCoords.width - 340 ? xPosition - 340 : xPosition + 100; yPosition = yPosition > chartCoords.height - 150 ? yPosition - 200 : yPosition + 20; const keys = params.orderedKeys.filter( row => row.visible ).map( @@ -333,25 +420,25 @@ const showTooltip = ( node, params, d ) => { ` ); }; -const handleMouseOverBarChart = ( d, i, nodes, node, data, params ) => { +const handleMouseOverBarChart = ( d, i, nodes, node, data, params, position ) => { d3Select( nodes[ i ].parentNode ) .select( '.barfocus' ) .attr( 'opacity', '0.1' ); - showTooltip( node, params, d ); + showTooltip( node, params, d, position ); }; const handleMouseOutBarChart = ( d, i, nodes, params ) => { d3Select( nodes[ i ].parentNode ) .select( '.barfocus' ) .attr( 'opacity', '0' ); - params.tooltip.style( 'display', 'flex' ); + params.tooltip.style( 'display', 'none' ); }; -const handleMouseOverLineChart = ( d, i, nodes, node, data, params ) => { +const handleMouseOverLineChart = ( d, i, nodes, node, data, params, position ) => { d3Select( nodes[ i ].parentNode ) .select( '.focus-grid' ) .attr( 'opacity', '1' ); - showTooltip( node, params, data.find( e => e.date === d.date ) ); + showTooltip( node, params, data.find( e => e.date === d.date ), position ); }; const handleMouseOutLineChart = ( d, i, nodes, params ) => { @@ -361,6 +448,12 @@ const handleMouseOutLineChart = ( d, i, nodes, params ) => { params.tooltip.style( 'display', 'none' ); }; +const calculatePositionInChart = ( element, chart ) => { + const elementCoords = element.getBoundingClientRect(); + const chartCoords = chart.getBoundingClientRect(); + return [ elementCoords.x - chartCoords.x, elementCoords.y - chartCoords.y ]; +}; + export const drawLines = ( node, data, params ) => { const series = node .append( 'g' ) @@ -384,25 +477,26 @@ export const drawLines = ( node, data, params ) => { } ) .attr( 'd', d => params.line( d.values ) ); - series - .selectAll( 'circle' ) - .data( ( d, i ) => d.values.map( row => ( { ...row, i, visible: d.visible, key: d.key } ) ) ) - .enter() - .append( 'circle' ) - .attr( 'r', 6 ) - .attr( 'fill', d => getColor( d.key, params ) ) - .attr( 'stroke', '#fff' ) - .attr( 'stroke-width', 3 ) - .style( 'opacity', d => { - const opacity = d.focus ? 1 : 0.1; - return d.visible ? opacity : 0; - } ) - .attr( 'cx', d => params.xLineScale( new Date( d.date ) ) ) - .attr( 'cy', d => params.yScale( d.value ) ) - .on( 'mouseover', ( d, i, nodes ) => - handleMouseOverLineChart( d, i, nodes, node, data, params ) - ) - .on( 'mouseout', ( d, i, nodes ) => handleMouseOutLineChart( d, i, nodes, params ) ); + params.uniqueDates.length < 50 && + series + .selectAll( 'circle' ) + .data( ( d, i ) => d.values.map( row => ( { ...row, i, visible: d.visible, key: d.key } ) ) ) + .enter() + .append( 'circle' ) + .attr( 'r', 6 ) + .attr( 'fill', d => getColor( d.key, params ) ) + .attr( 'stroke', '#fff' ) + .attr( 'stroke-width', 3 ) + .style( 'opacity', d => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .attr( 'cx', d => params.xLineScale( new Date( d.date ) ) ) + .attr( 'cy', d => params.yScale( d.value ) ) + .on( 'mouseover', ( d, i, nodes ) => + handleMouseOverLineChart( d, i, nodes, node, data, params ) + ) + .on( 'mouseout', ( d, i, nodes ) => handleMouseOutLineChart( d, i, nodes, params ) ); const focus = node .append( 'g' ) @@ -430,10 +524,15 @@ export const drawLines = ( node, data, params ) => { .attr( 'width', d => d.width ) .attr( 'height', params.height ) .attr( 'opacity', 0 ) + .attr( 'tabindex', '0' ) .on( 'mouseover', ( d, i, nodes ) => handleMouseOverLineChart( d, i, nodes, node, data, params ) ) - .on( 'mouseout', ( d, i, nodes ) => handleMouseOutLineChart( d, i, nodes, params ) ); + .on( 'focus', ( d, i, nodes ) => { + const position = calculatePositionInChart( d3Event.target, node.node() ); + handleMouseOverLineChart( d, i, nodes, node, data, params, position ); + } ) + .on( 'mouseout blur', ( d, i, nodes ) => handleMouseOutLineChart( d, i, nodes, params ) ); }; export const drawBars = ( node, data, params ) => { @@ -491,8 +590,13 @@ export const drawBars = ( node, data, params ) => { .attr( 'width', params.xGroupScale.range()[ 1 ] ) .attr( 'height', params.height ) .attr( 'opacity', '0' ) + .attr( 'tabindex', '0' ) .on( 'mouseover', ( d, i, nodes ) => handleMouseOverBarChart( d, i, nodes, node, data, params ) ) - .on( 'mouseout', ( d, i, nodes ) => handleMouseOutBarChart( d, i, nodes, params ) ); + .on( 'focus', ( d, i, nodes ) => { + const position = calculatePositionInChart( d3Event.target, node.node() ); + handleMouseOverBarChart( d, i, nodes, node, data, params, position ); + } ) + .on( 'mouseout blur', ( d, i, nodes ) => handleMouseOutBarChart( d, i, nodes, params ) ); }; diff --git a/plugins/woocommerce-admin/client/dashboard/top-selling-products/__mocks__/mock-data.js b/plugins/woocommerce-admin/client/dashboard/top-selling-products/__mocks__/mock-data.js index 98948fd3123..9f12de891dc 100644 --- a/plugins/woocommerce-admin/client/dashboard/top-selling-products/__mocks__/mock-data.js +++ b/plugins/woocommerce-admin/client/dashboard/top-selling-products/__mocks__/mock-data.js @@ -9,7 +9,8 @@ And as such will require data layer logic for products to fully build the table "product_id": 20, "items_sold": 100, "gross_revenue": 999.99, - "orders_count": 54, + "orders_count": 54, + "name": 'Product name', "_links": { "product": [ { @@ -27,6 +28,7 @@ export default [ items_sold: 1000, gross_revenue: 999.99, orders_count: 54, + name: 'awesome shirt', _links: { product: [ { @@ -40,6 +42,7 @@ export default [ items_sold: 90, gross_revenue: 875, orders_count: 41, + name: 'awesome pants', _links: { product: [ { @@ -53,6 +56,7 @@ export default [ items_sold: 55, gross_revenue: 75.75, orders_count: 28, + name: 'awesome hat', _links: { product: [ { @@ -66,6 +70,7 @@ export default [ items_sold: 10, gross_revenue: 24.5, orders_count: 14, + name: 'awesome sticker', _links: { product: [ { @@ -79,6 +84,7 @@ export default [ items_sold: 1, gross_revenue: 0.99, orders_count: 1, + name: 'awesome button', _links: { product: [ { diff --git a/plugins/woocommerce-admin/client/dashboard/top-selling-products/index.js b/plugins/woocommerce-admin/client/dashboard/top-selling-products/index.js index fe94943bae6..4232abe6719 100644 --- a/plugins/woocommerce-admin/client/dashboard/top-selling-products/index.js +++ b/plugins/woocommerce-admin/client/dashboard/top-selling-products/index.js @@ -53,17 +53,15 @@ export class TopSellingProducts extends Component { getRowsContent( data ) { return map( data, row => { - const { product_id, items_sold, gross_revenue, orders_count } = row; + const { product_id, items_sold, gross_revenue, orders_count, name } = row; - // @TODO We also will need to have product data to properly display the product name here - const productName = `Product ${ product_id }`; const productLink = ( - { productName } + { name } ); return [ { display: productLink, - value: productName, + value: name, }, { display: numberFormat( items_sold ), @@ -123,7 +121,7 @@ export default compose( 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 }; + const query = { orderby: 'items_sold', per_page: 5, extended_product_info: 1 }; const stats = getReportStats( endpoint, query ); const isRequesting = isReportStatsRequesting( endpoint, query ); diff --git a/plugins/woocommerce-admin/client/dashboard/top-selling-products/test/index.js b/plugins/woocommerce-admin/client/dashboard/top-selling-products/test/index.js index 7d5dcb62ba2..7d3e8261ff3 100644 --- a/plugins/woocommerce-admin/client/dashboard/top-selling-products/test/index.js +++ b/plugins/woocommerce-admin/client/dashboard/top-selling-products/test/index.js @@ -43,7 +43,7 @@ describe( 'TopSellingProducts', () => { const table = topSellingProducts.find( 'Table' ); const firstRow = table.props().rows[ 0 ]; - expect( firstRow[ 0 ].value ).toBe( `Product ${ mockData[ 0 ].product_id }` ); + expect( firstRow[ 0 ].value ).toBe( mockData[ 0 ].name ); expect( firstRow[ 1 ].display ).toBe( numberFormat( mockData[ 0 ].items_sold ) ); expect( firstRow[ 1 ].value ).toBe( mockData[ 0 ].items_sold ); expect( firstRow[ 2 ].display ).toBe( numberFormat( mockData[ 0 ].orders_count ) ); @@ -73,7 +73,7 @@ describe( 'TopSellingProducts', () => { const topSellingProducts = topSellingProductsWrapper.root.findByType( TopSellingProducts ); const endpoint = '/wc/v3/reports/products'; - const query = { orderby: 'items_sold', per_page: 5 }; + const query = { orderby: 'items_sold', per_page: 5, extended_product_info: 1 }; expect( getReportStatsMock.mock.calls[ 0 ][ 1 ] ).toBe( endpoint ); expect( getReportStatsMock.mock.calls[ 0 ][ 2 ] ).toEqual( query );