UI/1.11 client count component tests (#15748)

* add line chart test

* add empty state option to line chart

* add empty state test

* add tooltip coverage

* add test files

* add monthly usage tests

* finish tests

* tidying

* address comments, add average test

* finish tests broken from calendar
This commit is contained in:
claire bontempo
2022-06-03 15:47:19 -07:00
committed by GitHub
parent df48bb8055
commit 47a43ab8ac
24 changed files with 3518 additions and 103 deletions

View File

@@ -8,6 +8,7 @@ import { axisLeft } from 'd3-axis';
import { max, maxIndex } from 'd3-array'; import { max, maxIndex } from 'd3-array';
import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE, formatTooltipNumber } from 'vault/utils/chart-helpers'; import { BAR_COLOR_HOVER, GREY, LIGHT_AND_DARK_BLUE, formatTooltipNumber } from 'vault/utils/chart-helpers';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { formatNumber } from 'core/helpers/format-number';
/** /**
* @module HorizontalBarChart * @module HorizontalBarChart
@@ -22,6 +23,7 @@ import { tracked } from '@glimmer/tracking';
* @param {string} labelKey - string of key name for label value in chart data * @param {string} labelKey - string of key name for label value in chart data
* @param {string} xKey - string of key name for x value in chart data * @param {string} xKey - string of key name for x value in chart data
* @param {object} totalCounts - object to calculate percentage for tooltip * @param {object} totalCounts - object to calculate percentage for tooltip
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/ */
// SIZING CONSTANTS // SIZING CONSTANTS
@@ -247,7 +249,7 @@ export default class HorizontalBarChart extends Component {
.data(dataset) .data(dataset)
.enter() .enter()
.append('text') .append('text')
.text((d) => d[xKey]) .text((d) => formatNumber([d[xKey]]))
.attr('fill', '#000') .attr('fill', '#000')
.attr('class', 'total-value') .attr('class', 'total-value')
.style('font-size', '.8rem') .style('font-size', '.8rem')

View File

@@ -14,6 +14,7 @@ import {
formatNumbers, formatNumbers,
} from 'vault/utils/chart-helpers'; } from 'vault/utils/chart-helpers';
import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters'; import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
import { formatNumber } from 'core/helpers/format-number';
/** /**
* @module LineChart * @module LineChart
@@ -21,10 +22,12 @@ import { parseAPITimestamp, formatChartDate } from 'core/utils/date-formatters';
* *
* @example * @example
* ```js * ```js
* <LineChart @dataset={dataset} /> * <LineChart @dataset={{dataset}} @upgradeData={{this.versionHistory}}/>
* ``` * ```
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset * @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset * @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
* @param {array} upgradeData - array of objects containing version history from the /version-history endpoint
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/ */
export default class LineChart extends Component { export default class LineChart extends Component {
@@ -42,6 +45,27 @@ export default class LineChart extends Component {
return this.args.xKey || 'month'; return this.args.xKey || 'month';
} }
get upgradeData() {
const upgradeData = this.args.upgradeData;
if (!upgradeData) return null;
if (!Array.isArray(upgradeData)) {
console.debug('upgradeData must be an array of objects containing upgrade history');
return null;
} else if (!Object.keys(upgradeData[0]).includes('timestampInstalled')) {
console.debug(
`upgrade must be an object with the following key names: ['id', 'previousVersion', 'timestampInstalled']`
);
return null;
} else {
return upgradeData?.map((versionData) => {
return {
[this.xKey]: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'),
...versionData,
};
});
}
}
@action removeTooltip() { @action removeTooltip() {
this.tooltipTarget = null; this.tooltipTarget = null;
} }
@@ -49,17 +73,10 @@ export default class LineChart extends Component {
@action @action
renderChart(element, [chartData]) { renderChart(element, [chartData]) {
const dataset = chartData; const dataset = chartData;
const upgradeData = [];
if (this.args.upgradeData) {
this.args.upgradeData.forEach((versionData) =>
upgradeData.push({ month: parseAPITimestamp(versionData.timestampInstalled, 'M/yy'), ...versionData })
);
}
const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp) const filteredData = dataset.filter((e) => Object.keys(e).includes(this.yKey)); // months with data will contain a 'clients' key (otherwise only a timestamp)
const domainMax = max(filteredData.map((d) => d[this.yKey])); const domainMax = max(filteredData.map((d) => d[this.yKey]));
const chartSvg = select(element); const chartSvg = select(element);
chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions chartSvg.attr('viewBox', `-50 20 600 ${SVG_DIMENSIONS.height}`); // set svg dimensions
// clear out DOM before appending anything // clear out DOM before appending anything
chartSvg.selectAll('g').remove().exit().data(filteredData).enter(); chartSvg.selectAll('g').remove().exit().data(filteredData).enter();
@@ -93,7 +110,9 @@ export default class LineChart extends Component {
chartSvg.selectAll('.domain').remove(); chartSvg.selectAll('.domain').remove();
const findUpgradeData = (datum) => { const findUpgradeData = (datum) => {
return upgradeData.find((upgrade) => upgrade[this.xKey] === datum[this.xKey]); return this.upgradeData
? this.upgradeData.find((upgrade) => upgrade[this.xKey] === datum[this.xKey])
: null;
}; };
// VERSION UPGRADE INDICATOR // VERSION UPGRADE INDICATOR
@@ -104,6 +123,7 @@ export default class LineChart extends Component {
.enter() .enter()
.append('circle') .append('circle')
.attr('class', 'upgrade-circle') .attr('class', 'upgrade-circle')
.attr('data-test-line-chart', (d) => `upgrade-${d[this.xKey]}`)
.attr('fill', UPGRADE_WARNING) .attr('fill', UPGRADE_WARNING)
.style('opacity', (d) => (findUpgradeData(d) ? '1' : '0')) .style('opacity', (d) => (findUpgradeData(d) ? '1' : '0'))
.attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`) .attr('cy', (d) => `${100 - yScale(d[this.yKey])}%`)
@@ -158,8 +178,8 @@ export default class LineChart extends Component {
hoverCircles.on('mouseover', (data) => { hoverCircles.on('mouseover', (data) => {
// TODO: how to generalize this? // TODO: how to generalize this?
this.tooltipMonth = formatChartDate(data[this.xKey]); this.tooltipMonth = formatChartDate(data[this.xKey]);
this.tooltipTotal = data[this.yKey] + ' total clients'; this.tooltipTotal = formatNumber([data[this.yKey]]) + ' total clients';
this.tooltipNew = (data?.new_clients[this.yKey] || '0') + ' new clients'; this.tooltipNew = (formatNumber([data?.new_clients[this.yKey]]) || '0') + ' new clients';
this.tooltipUpgradeText = ''; this.tooltipUpgradeText = '';
let upgradeInfo = findUpgradeData(data); let upgradeInfo = findUpgradeData(data);
if (upgradeInfo) { if (upgradeInfo) {

View File

@@ -1,5 +1,5 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { calculateAverageClients } from 'vault/utils/chart-helpers'; import { calculateAverage } from 'vault/utils/chart-helpers';
/** /**
* @module MonthlyUsage * @module MonthlyUsage
@@ -34,13 +34,13 @@ import { calculateAverageClients } from 'vault/utils/chart-helpers';
*/ */
export default class MonthlyUsage extends Component { export default class MonthlyUsage extends Component {
get averageTotalClients() { get averageTotalClients() {
return calculateAverageClients(this.args.verticalBarChartData, 'clients') || '0'; return calculateAverage(this.args.verticalBarChartData, 'clients') || '0';
} }
get averageNewClients() { get averageNewClients() {
return ( return (
calculateAverageClients( calculateAverage(
this.args.verticalBarChartData.map((d) => d.new_clients), this.args.verticalBarChartData?.map((d) => d.new_clients),
'clients' 'clients'
) || '0' ) || '0'
); );

View File

@@ -1,5 +1,5 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { calculateAverageClients } from 'vault/utils/chart-helpers'; import { calculateAverage } from 'vault/utils/chart-helpers';
/** /**
* @module RunningTotal * @module RunningTotal
@@ -45,14 +45,14 @@ export default class RunningTotal extends Component {
get entityClientData() { get entityClientData() {
return { return {
runningTotal: this.args.runningTotals.entity_clients, runningTotal: this.args.runningTotals.entity_clients,
averageNewClients: calculateAverageClients(this.args.barChartData, 'entity_clients') || '0', averageNewClients: calculateAverage(this.args.barChartData, 'entity_clients') || '0',
}; };
} }
get nonEntityClientData() { get nonEntityClientData() {
return { return {
runningTotal: this.args.runningTotals.non_entity_clients, runningTotal: this.args.runningTotals.non_entity_clients,
averageNewClients: calculateAverageClients(this.args.barChartData, 'non_entity_clients') || '0', averageNewClients: calculateAverage(this.args.barChartData, 'non_entity_clients') || '0',
}; };
} }
@@ -71,8 +71,8 @@ export default class RunningTotal extends Component {
} }
get showSingleMonth() { get showSingleMonth() {
if (this.args.barChartData.length === 1) { if (this.args.lineChartData?.length === 1) {
const monthData = this.args.lineChartData[0]; const monthData = this.args?.lineChartData[0];
return { return {
total: { total: {
total: monthData.clients, total: monthData.clients,

View File

@@ -14,6 +14,7 @@ import {
TRANSLATE, TRANSLATE,
formatNumbers, formatNumbers,
} from 'vault/utils/chart-helpers'; } from 'vault/utils/chart-helpers';
import { formatNumber } from 'core/helpers/format-number';
/** /**
* @module VerticalBarChart * @module VerticalBarChart
@@ -27,6 +28,7 @@ import {
* @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked * @param {array} chartLegend - array of objects with key names 'key' and 'label' so data can be stacked
* @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset * @param {string} xKey - string denoting key for x-axis data (data[xKey]) of dataset
* @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset * @param {string} yKey - string denoting key for y-axis data (data[yKey]) of dataset
* @param {string} [noDataMessage] - custom empty state message that displays when no dataset is passed to the chart
*/ */
export default class VerticalBarChart extends Component { export default class VerticalBarChart extends Component {
@@ -83,6 +85,7 @@ export default class VerticalBarChart extends Component {
.append('rect') .append('rect')
.attr('width', '7px') .attr('width', '7px')
.attr('class', 'data-bar') .attr('class', 'data-bar')
.attr('data-test-vertical-chart', 'data-bar')
.attr('height', (stackedData) => `${yScale(stackedData[1] - stackedData[0])}%`) .attr('height', (stackedData) => `${yScale(stackedData[1] - stackedData[0])}%`)
.attr('x', ({ data }) => xScale(data[this.xKey])) // uses destructuring because was data.data.month .attr('x', ({ data }) => xScale(data[this.xKey])) // uses destructuring because was data.data.month
.attr('y', (data) => `${100 - yScale(data[1])}%`); // subtract higher than 100% to give space for x axis ticks .attr('y', (data) => `${100 - yScale(data[1])}%`); // subtract higher than 100% to give space for x axis ticks
@@ -101,8 +104,13 @@ export default class VerticalBarChart extends Component {
const xAxis = axisBottom(xScale).tickSize(0); const xAxis = axisBottom(xScale).tickSize(0);
yAxis(chartSvg.append('g')); yAxis(chartSvg.append('g').attr('data-test-vertical-chart', 'y-axis-labels'));
xAxis(chartSvg.append('g').attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)); xAxis(
chartSvg
.append('g')
.attr('transform', `translate(0, ${SVG_DIMENSIONS.height + 10})`)
.attr('data-test-vertical-chart', 'x-axis-labels')
);
chartSvg.selectAll('.domain').remove(); // remove domain lines chartSvg.selectAll('.domain').remove(); // remove domain lines
@@ -129,9 +137,9 @@ export default class VerticalBarChart extends Component {
// MOUSE EVENT FOR TOOLTIP // MOUSE EVENT FOR TOOLTIP
tooltipRect.on('mouseover', (data) => { tooltipRect.on('mouseover', (data) => {
let hoveredMonth = data[this.xKey]; let hoveredMonth = data[this.xKey];
this.tooltipTotal = `${data[this.yKey]} ${data.new_clients ? 'total' : 'new'} clients`; this.tooltipTotal = `${formatNumber([data[this.yKey]])} ${data.new_clients ? 'total' : 'new'} clients`;
this.entityClients = `${data.entity_clients} entity clients`; this.entityClients = `${formatNumber([data.entity_clients])} entity clients`;
this.nonEntityClients = `${data.non_entity_clients} non-entity clients`; this.nonEntityClients = `${formatNumber([data.non_entity_clients])} non-entity clients`;
let node = chartSvg let node = chartSvg
.selectAll('rect.data-bar') .selectAll('rect.data-bar')
// filter for the top data bar (so y-coord !== 0) with matching month // filter for the top data bar (so y-coord !== 0) with matching month

View File

@@ -130,10 +130,17 @@
max-width: none; max-width: none;
padding-right: 20px; padding-right: 20px;
padding-left: 20px; padding-left: 20px;
display: flex;
> div { > div {
box-shadow: none !important; box-shadow: none !important;
} }
> div.empty-state {
white-space: nowrap;
align-self: stretch;
width: 100%;
}
} }
.chart-subTitle { .chart-subTitle {

View File

@@ -24,7 +24,10 @@
{{#if this.barChartTotalClients}} {{#if this.barChartTotalClients}}
{{#if (or @isDateRange @isCurrentMonth)}} {{#if (or @isDateRange @isCurrentMonth)}}
<div class="chart-container-wide" data-test-chart-container="total-clients"> <div
class={{concat (unless this.barChartTotalClients "chart-empty-state ") "chart-container-wide"}}
data-test-chart-container="total-clients"
>
<Clients::HorizontalBarChart <Clients::HorizontalBarChart
@dataset={{this.barChartTotalClients}} @dataset={{this.barChartTotalClients}}
@chartLegend={{@chartLegend}} @chartLegend={{@chartLegend}}

View File

@@ -204,6 +204,7 @@
<button <button
type="button" type="button"
class="button link" class="button link"
data-test-date-modal-month={{month}}
disabled={{if (lt index this.allowedMonthMax) false true}} disabled={{if (lt index this.allowedMonthMax) false true}}
{{on "click" (fn this.selectStartMonth month D.actions)}} {{on "click" (fn this.selectStartMonth month D.actions)}}
> >
@@ -230,6 +231,7 @@
<button <button
type="button" type="button"
class="button link" class="button link"
data-test-date-modal-year={{year}}
disabled={{if (eq year this.disabledYear) true false}} disabled={{if (eq year this.disabledYear) true false}}
{{on "click" (fn this.selectStartYear year D.actions)}} {{on "click" (fn this.selectStartYear year D.actions)}}
> >

View File

@@ -8,9 +8,7 @@
> >
</svg> </svg>
{{else}} {{else}}
<div class="chart-empty-state"> <EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
</div>
{{/if}} {{/if}}
{{#if this.tooltipTarget}} {{#if this.tooltipTarget}}
{{! Required to set tag name = div https://github.com/yapplabs/ember-modal-dialog/issues/290 }} {{! Required to set tag name = div https://github.com/yapplabs/ember-modal-dialog/issues/290 }}

View File

@@ -1,11 +1,15 @@
<svg {{#if @dataset}}
data-test-line-chart <svg
class="chart has-grid" data-test-line-chart
{{on "mouseleave" this.removeTooltip}} class="chart has-grid"
{{did-insert this.renderChart @dataset}} {{on "mouseleave" this.removeTooltip}}
{{did-update this.renderChart @dataset}} {{did-insert this.renderChart @dataset}}
> {{did-update this.renderChart @dataset}}
</svg> >
</svg>
{{else}}
<EmptyState @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
{{/if}}
{{! TOOLTIP }} {{! TOOLTIP }}

Before

Width:  |  Height:  |  Size: 988 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -6,7 +6,7 @@
</p> </p>
</div> </div>
<div class="chart-container-wide"> <div class={{concat (unless @verticalBarChartData "chart-empty-state ") "chart-container-wide"}}>
<Clients::VerticalBarChart @dataset={{@verticalBarChartData}} @chartLegend={{@chartLegend}} /> <Clients::VerticalBarChart @dataset={{@verticalBarChartData}} @chartLegend={{@chartLegend}} />
</div> </div>
@@ -17,30 +17,31 @@
</p> </p>
</div> </div>
<div class="data-details-top"> <div class="data-details-top" data-test-monthly-usage-average-total>
<h3 class="data-details">Average total clients per month</h3> <h3 class="data-details">Average total clients per month</h3>
<p class="data-details"> <p class="data-details">
{{format-number this.averageTotalClients}} {{format-number this.averageTotalClients}}
</p> </p>
</div> </div>
<div class="data-details-bottom"> <div class="data-details-bottom" data-test-monthly-usage-average-new>
<h3 class="data-details">Average new clients per month</h3> <h3 class="data-details">Average new clients per month</h3>
<p class="data-details"> <p class="data-details">
{{format-number this.averageNewClients}} {{format-number this.averageNewClients}}
</p> </p>
</div> </div>
<div class="timestamp"> <div data-test-monthly-usage-timestamp class="timestamp">
{{#if @timestamp}} {{#if @timestamp}}
Updated Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}} {{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}}
{{/if}} {{/if}}
</div> </div>
<div class="legend-right"> {{#if @verticalBarChartData}}
<span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span> <div data-test-monthly-usage-legend class="legend-right">
<span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span> <span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span>
</div> <span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span>
</div>
{{/if}}
</div> </div>

View File

@@ -7,7 +7,7 @@
The total client count number is an important consideration for Vault billing. The total client count number is an important consideration for Vault billing.
</p> </p>
</div> </div>
<div class="single-month-stats"> <div class="single-month-stats" data-test-new>
<div class="single-month-section-title"> <div class="single-month-section-title">
<StatText <StatText
@label="New clients" @label="New clients"
@@ -23,7 +23,7 @@
<StatText @label="Non-entity clients" @value={{this.showSingleMonth.new.nonEntityClients}} @size="m" /> <StatText @label="Non-entity clients" @value={{this.showSingleMonth.new.nonEntityClients}} @size="m" />
</div> </div>
</div> </div>
<div class="single-month-stats"> <div class="single-month-stats" data-test-total>
<div class="single-month-section-title"> <div class="single-month-section-title">
<StatText <StatText
@label="Total monthly clients" @label="Total monthly clients"
@@ -51,7 +51,7 @@
</p> </p>
</div> </div>
<div class="chart-container-wide"> <div class={{concat (unless @lineChartData "chart-empty-state ") "chart-container-wide"}}>
<Clients::LineChart @dataset={{@lineChartData}} @upgradeData={{@upgradeData}} /> <Clients::LineChart @dataset={{@lineChartData}} @upgradeData={{@upgradeData}} />
</div> </div>
@@ -109,17 +109,19 @@
</p> </p>
</div> </div>
<div class="timestamp"> <div class="timestamp" data-test-running-total-timestamp>
{{#if @timestamp}} {{#if @timestamp}}
Updated Updated
{{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}} {{date-format @timestamp "MMM d yyyy, h:mm:ss aaa"}}
{{/if}} {{/if}}
</div> </div>
<div class="legend-right"> {{#if this.hasAverageNewClients}}
<span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span> <div class="legend-right" data-test-running-total-legend>
<span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span> <span class="light-dot"></span><span class="legend-label">{{capitalize @chartLegend.0.label}}</span>
</div> <span class="dark-dot"></span><span class="legend-label">{{capitalize @chartLegend.1.label}}</span>
</div>
{{/if}}
</div> </div>
</div> </div>
{{/if}} {{/if}}

View File

@@ -8,9 +8,7 @@
> >
</svg> </svg>
{{else}} {{else}}
<div class="chart-empty-state"> <EmptyState @title={{@noDataTitle}} @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
<EmptyState @title={{@noDataTitle}} @subTitle={{or @noDataMessage "No data to display"}} @bottomBorder={{true}} />
</div>
{{/if}} {{/if}}
{{! TOOLTIP }} {{! TOOLTIP }}

View File

@@ -27,9 +27,11 @@ export function formatTooltipNumber(value) {
return new Intl.NumberFormat().format(value); return new Intl.NumberFormat().format(value);
} }
export function calculateAverageClients(dataset, objectKey) { export function calculateAverage(dataset, objectKey) {
// dataset is an array of objects (consumed by the chart components) if (!Array.isArray(dataset) || dataset?.length === 0) return null;
// objectKey is the key of the integer we want to calculate, ex: 'entity_clients', 'non_entity_clients', 'clients' // if an array of objects, objectKey of the integer we want to calculate, ex: 'entity_clients'
let getIntegers = dataset.map((d) => (d[objectKey] ? d[objectKey] : 0)); // if undefined no data, so return 0 // if d[objectKey] is undefined there is no value, so return 0
return getIntegers.length !== 0 ? Math.round(mean(getIntegers)) : null; const getIntegers = objectKey ? dataset?.map((d) => (d[objectKey] ? d[objectKey] : 0)) : dataset;
let checkIntegers = getIntegers.every((n) => Number.isInteger(n)); // decimals will be false
return checkIntegers ? Math.round(mean(getIntegers)) : null;
} }

View File

@@ -1,6 +1,6 @@
<div <div
class={{concat "stat-text-container " @size (unless @subText "-no-subText")}} class={{concat "stat-text-container " @size (unless @subText "-no-subText")}}
data-test-stat-text-container data-test-stat-text-container={{(or @label "true")}}
...attributes ...attributes
> >
<div class="stat-label has-bottom-margin-xs">{{@label}}</div> <div class="stat-label has-bottom-margin-xs">{{@label}}</div>

View File

@@ -34,7 +34,7 @@ export const parseRFC3339 = (timestamp) => {
return date ? [`${date.getFullYear()}`, date.getMonth()] : null; return date ? [`${date.getFullYear()}`, date.getMonth()] : null;
}; };
// convert MM/yy (format of dates in charts) to 'Month yyyy' (format in tooltip) // convert M/yy (format of dates in charts) to 'Month yyyy' (format in tooltip)
export function formatChartDate(date) { export function formatChartDate(date) {
let array = date.split('/'); let array = date.split('/');
array.splice(1, 0, '01'); array.splice(1, 0, '01');

View File

@@ -1684,6 +1684,7 @@ const MOCK_MONTHLY_DATA = [
}, },
}, },
{ {
timestamp: formatISO(addMonths(UPGRADE_DATE, 3)),
counts: { counts: {
distinct_entities: 0, distinct_entities: 0,
entity_clients: 10873, entity_clients: 10873,
@@ -2236,7 +2237,7 @@ const MOCK_MONTHLY_DATA = [
}, },
}, },
{ {
timestamp: formatISO(addMonths(UPGRADE_DATE, 3)), timestamp: formatISO(addMonths(UPGRADE_DATE, 4)),
counts: { counts: {
distinct_entities: 0, distinct_entities: 0,
entity_clients: 10342, entity_clients: 10342,

View File

@@ -9,6 +9,8 @@ import { SELECTORS, overrideResponse } from '../helpers/clients';
import { create } from 'ember-cli-page-object'; import { create } from 'ember-cli-page-object';
import ss from 'vault/tests/pages/components/search-select'; import ss from 'vault/tests/pages/components/search-select';
import { clickTrigger } from 'ember-power-select/test-support/helpers'; import { clickTrigger } from 'ember-power-select/test-support/helpers';
import { ARRAY_OF_MONTHS } from 'core/utils/date-formatters';
const searchSelect = create(ss); const searchSelect = create(ss);
const NEW_DATE = new Date(); const NEW_DATE = new Date();
@@ -133,18 +135,47 @@ module('Acceptance | clients history tab', function (hooks) {
}); });
test('updates correctly when querying date ranges', async function (assert) { test('updates correctly when querying date ranges', async function (assert) {
assert.expect(17); assert.expect(26);
// TODO CMB: wire up dynamically generated activity to mirage clients handler // TODO CMB: wire up dynamically generated activity to mirage clients handler
// const activity = generateActivityResponse(5, LICENSE_START, LAST_MONTH); // const activity = generateActivityResponse(5, LICENSE_START, LAST_MONTH);
await visit('/vault/clients/history'); await visit('/vault/clients/history');
assert.equal(currentURL(), '/vault/clients/history'); assert.equal(currentURL(), '/vault/clients/history');
// change billing start month // query for single, historical month with no new counts
await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]');
if (parseInt(find('[data-test-display-year]').innerText) > LICENSE_START.getFullYear()) {
await click('[data-test-previous-year]');
}
await click(find(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`));
assert.dom('[data-test-usage-stats]').exists('total usage stats show');
assert
.dom(SELECTORS.runningTotalMonthStats)
.doesNotExist('running total single month stat boxes do not show');
assert
.dom(SELECTORS.runningTotalMonthlyCharts)
.doesNotExist('running total month over month charts do not show');
assert.dom(SELECTORS.monthlyUsageBlock).doesNotExist('does not show monthly usage block');
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
assert
.dom('[data-test-chart-container="new-clients"] [data-test-component="empty-state"]')
.exists('new client attribution has empty state');
assert
.dom('[data-test-empty-state-subtext]')
.hasText('There are no new clients for this namespace during this time period. ');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
// reset to billing period
await click('[data-test-popup-menu-trigger]');
await click('[data-test-current-billing-period]');
// change billing start to month/year of first upgrade
await click('[data-test-start-date-editor] button'); await click('[data-test-start-date-editor] button');
await click(SELECTORS.monthDropdown); await click(SELECTORS.monthDropdown);
await click(find('.menu-list button:not([disabled])')); await click(find(`[data-test-date-modal-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`));
await click(SELECTORS.yearDropdown); await click(SELECTORS.yearDropdown);
await click(find('.menu-list button:not([disabled])')); await click(find(`[data-test-date-modal-year="${UPGRADE_DATE.getFullYear()}`));
await click('[data-test-modal-save]'); await click('[data-test-modal-save]');
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area'); assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
@@ -154,7 +185,7 @@ module('Acceptance | clients history tab', function (hooks) {
.exists('Shows running totals with monthly breakdown charts'); .exists('Shows running totals with monthly breakdown charts');
assert assert
.dom(find('[data-test-line-chart="x-axis-labels"] g.tick text')) .dom(find('[data-test-line-chart="x-axis-labels"] g.tick text'))
.hasText('1/22', 'x-axis labels start with updated billing start month'); .hasText(`${format(UPGRADE_DATE, 'M/yy')}`, 'x-axis labels start with updated billing start month');
assert.equal( assert.equal(
findAll('[data-test-line-chart="plot-point"]').length, findAll('[data-test-line-chart="plot-point"]').length,
5, 5,
@@ -164,9 +195,10 @@ module('Acceptance | clients history tab', function (hooks) {
// query custom end month // query custom end month
await click(SELECTORS.rangeDropdown); await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]'); await click('[data-test-show-calendar]');
let readOnlyMonths = findAll('[data-test-calendar-month].is-readOnly'); if (parseInt(find('[data-test-display-year]').innerText) < NEW_DATE.getFullYear()) {
let clickableMonths = findAll('[data-test-calendar-month]').filter((m) => !readOnlyMonths.includes(m)); await click('[data-test-future-year]');
await click(clickableMonths[1]); }
await click(find(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LAST_MONTH.getMonth() - 2]}]`));
assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area'); assert.dom(SELECTORS.attributionBlock).exists('Shows attribution area');
assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block'); assert.dom(SELECTORS.monthlyUsageBlock).exists('Shows monthly usage block');
@@ -175,23 +207,27 @@ module('Acceptance | clients history tab', function (hooks) {
.exists('Shows running totals with monthly breakdown charts'); .exists('Shows running totals with monthly breakdown charts');
assert.equal( assert.equal(
findAll('[data-test-line-chart="plot-point"]').length, findAll('[data-test-line-chart="plot-point"]').length,
2, 3,
`line chart plots 2 points to match query` `line chart plots 3 points to match query`
); );
let xAxisLabels = findAll('[data-test-line-chart="x-axis-labels"] g.tick text');
assert assert
.dom(findAll('[data-test-line-chart="x-axis-labels"] g.tick text')[1]) .dom(xAxisLabels[xAxisLabels.length - 1])
.hasText('2/22', 'x-axis labels start with updated billing start month'); .hasText(`${format(subMonths(LAST_MONTH, 2), 'M/yy')}`, 'x-axis labels end with queried end month');
// query for single, historical month // query for single, historical month
await click(SELECTORS.rangeDropdown); await click(SELECTORS.rangeDropdown);
await click('[data-test-show-calendar]'); await click('[data-test-show-calendar]');
readOnlyMonths = findAll('[data-test-calendar-month].is-readOnly'); if (parseInt(find('[data-test-display-year]').innerText) < NEW_DATE.getFullYear()) {
clickableMonths = findAll('[data-test-calendar-month]').filter((m) => !readOnlyMonths.includes(m)); await click('[data-test-future-year]');
await click(clickableMonths[0]); }
await click(find(`[data-test-calendar-month=${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}]`));
assert.dom(SELECTORS.runningTotalMonthStats).exists('running total single month stat boxes show'); assert.dom(SELECTORS.runningTotalMonthStats).exists('running total single month stat boxes show');
assert assert
.dom(SELECTORS.runningTotalMonthlyCharts) .dom(SELECTORS.runningTotalMonthlyCharts)
.doesNotExist('running total month over month charts do not show'); .doesNotExist('running total month over month charts do not show');
assert.dom(SELECTORS.monthlyUsageBlock).doesNotExist('Does not show monthly usage block');
assert.dom(SELECTORS.attributionBlock).exists('attribution area shows'); assert.dom(SELECTORS.attributionBlock).exists('attribution area shows');
assert.dom('[data-test-chart-container="new-clients"]').exists('new client attribution chart shows'); assert.dom('[data-test-chart-container="new-clients"]').exists('new client attribution chart shows');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows'); assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
@@ -203,8 +239,7 @@ module('Acceptance | clients history tab', function (hooks) {
// query month older than count start date // query month older than count start date
await click('[data-test-start-date-editor] button'); await click('[data-test-start-date-editor] button');
await click(SELECTORS.yearDropdown); await click(SELECTORS.yearDropdown);
let years = findAll('.menu-list button:not([disabled])'); await click(find(`[data-test-date-modal-year="${LICENSE_START.getFullYear() - 3}`));
await click(years[years.length - 1]);
await click('[data-test-modal-save]'); await click('[data-test-modal-save]');
assert assert

View File

@@ -8,20 +8,14 @@ import { Response } from 'miragejs';
Filtering (data with mounts) Filtering (data with mounts)
Filtering (data without mounts) Filtering (data without mounts)
Filtering (data without mounts) Filtering (data without mounts)
* -- HISTORY ONLY --
* -- HISTORY ONLY -- Filtering different date ranges (hist only)
Upgrade warning
No permissions for license No permissions for license
Version Version
queries available queries available
queries unavailable queries unavailable
License start date this month License start date this month
*/
// TODO
/*
Filtering different date ranges (hist only)
Upgrade warning
*/ */
export const SELECTORS = { export const SELECTORS = {
currentMonthActiveTab: '.active[data-test-current-month]', currentMonthActiveTab: '.active[data-test-current-month]',

View File

@@ -1,12 +1,15 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit'; import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers'; import { find, render, findAll, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars'; import { hbs } from 'ember-cli-htmlbars';
import { format, formatRFC3339, subMonths } from 'date-fns';
import { formatChartDate } from 'core/utils/date-formatters';
module('Integration | Component | clients/line-chart', function (hooks) { module('Integration | Component | clients/line-chart', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
const CURRENT_DATE = new Date();
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.set('xKey', 'foo');
this.set('yKey', 'bar');
this.set('dataset', [ this.set('dataset', [
{ {
foo: 1, foo: 1,
@@ -30,11 +33,179 @@ module('Integration | Component | clients/line-chart', function (hooks) {
test('it renders', async function (assert) { test('it renders', async function (assert) {
await render(hbs` await render(hbs`
<div class="chart-container-wide"> <div class="chart-container-wide">
<Clients::LineChart @dataset={{dataset}} @xKey="foo" @yKey="bar" /> <Clients::LineChart @dataset={{dataset}} @xKey={{xKey}} @yKey={{yKey}} />
</div> </div>
`); `);
assert.dom('[data-test-line-chart]').exists('Chart is rendered'); assert.dom('[data-test-line-chart]').exists('Chart is rendered');
assert.dom('.hover-circle').exists({ count: 4 }, 'Renders dot for each data point'); assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
findAll('[data-test-line-chart="x-axis-labels"] text').forEach((e, i) => {
assert
.dom(e)
.hasText(`${this.dataset[i][this.xKey]}`, `renders x-axis label: ${this.dataset[i][this.xKey]}`);
});
assert.dom(find('[data-test-line-chart="y-axis-labels"] text')).hasText('0', `y-axis starts at 0`);
});
test('it renders upgrade data', async function (assert) {
this.set('dataset', [
{
foo: format(subMonths(CURRENT_DATE, 4), 'M/yy'),
bar: 4,
},
{
foo: format(subMonths(CURRENT_DATE, 3), 'M/yy'),
bar: 8,
},
{
foo: format(subMonths(CURRENT_DATE, 2), 'M/yy'),
bar: 14,
},
{
foo: format(subMonths(CURRENT_DATE, 1), 'M/yy'),
bar: 10,
},
]);
this.set('upgradeData', [
{
id: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: formatRFC3339(subMonths(CURRENT_DATE, 2)),
},
]);
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
@dataset={{dataset}}
@upgradeData={{upgradeData}}
@xKey={{xKey}}
@yKey={{yKey}}
/>
</div>
`);
assert.dom('[data-test-line-chart]').exists('Chart is rendered');
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, `renders ${this.dataset.length} plot points`);
assert
.dom(find(`[data-test-line-chart="upgrade-${this.dataset[2][this.xKey]}"]`))
.hasStyle({ opacity: '1' }, `upgrade data point ${this.dataset[2][this.xKey]} has yellow highlight`);
});
test('it renders tooltip', async function (assert) {
const tooltipData = [
{
month: format(subMonths(CURRENT_DATE, 4), 'M/yy'),
clients: 4,
new_clients: {
clients: 0,
},
},
{
month: format(subMonths(CURRENT_DATE, 3), 'M/yy'),
clients: 8,
new_clients: {
clients: 4,
},
},
{
month: format(subMonths(CURRENT_DATE, 2), 'M/yy'),
clients: 14,
new_clients: {
clients: 6,
},
},
{
month: format(subMonths(CURRENT_DATE, 1), 'M/yy'),
clients: 20,
new_clients: {
clients: 4,
},
},
];
this.set('dataset', tooltipData);
this.set('upgradeData', [
{
id: '1.10.1',
previousVersion: '1.9.2',
timestampInstalled: formatRFC3339(subMonths(CURRENT_DATE, 2)),
},
]);
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
@dataset={{dataset}}
@upgradeData={{upgradeData}}
/>
</div>
`);
const tooltipHoverCircles = findAll('[data-test-line-chart] circle.hover-circle');
for (let [i, bar] of tooltipHoverCircles.entries()) {
await triggerEvent(bar, 'mouseover');
let tooltip = document.querySelector('.ember-modal-dialog');
let { month, clients, new_clients } = tooltipData[i];
assert
.dom(tooltip)
.includesText(
`${formatChartDate(month)} ${clients} total clients ${new_clients.clients} new clients`,
`tooltip text is correct for ${month}`
);
}
});
test('it fails gracefully when upgradeData is an object', async function (assert) {
this.set('upgradeData', { some: 'object' });
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
@dataset={{dataset}}
@upgradeData={{upgradeData}}
@xKey={{xKey}}
@yKey={{yKey}}
/>
</div>
`);
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, 'chart still renders when upgradeData is not an array');
});
test('it fails gracefully when upgradeData has incorrect key names', async function (assert) {
this.set('upgradeData', [{ incorrect: 'key names' }]);
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart
@dataset={{dataset}}
@upgradeData={{upgradeData}}
@xKey={{xKey}}
@yKey={{yKey}}
/>
</div>
`);
assert
.dom('[data-test-line-chart="plot-point"]')
.exists({ count: this.dataset.length }, 'chart still renders when upgradeData has incorrect keys');
});
test('it renders empty state when no dataset', async function (assert) {
await render(hbs`
<div class="chart-container-wide">
<Clients::LineChart @noDataMessage="this is a custom message to explain why you're not seeing a line chart"/>
</div>
`);
assert.dom('[data-test-component="empty-state"]').exists('renders empty state when no data');
assert
.dom('[data-test-empty-state-subtext]')
.hasText(
`this is a custom message to explain why you're not seeing a line chart`,
'custom message renders'
);
}); });
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit'; import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers'; import { render, findAll, find, triggerEvent } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars'; import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | clients/vertical-bar-chart', function (hooks) { module('Integration | Component | clients/vertical-bar-chart', function (hooks) {
@@ -12,19 +12,96 @@ module('Integration | Component | clients/vertical-bar-chart', function (hooks)
]); ]);
}); });
test('it renders', async function (assert) { test('it renders chart and tooltip for total clients', async function (assert) {
const barChartData = [ const barChartData = [
{ month: 'january', clients: 200, entity_clients: 91, non_entity_clients: 50, new_clients: 5 }, { month: 'january', clients: 141, entity_clients: 91, non_entity_clients: 50, new_clients: 5 },
{ month: 'february', clients: 300, entity_clients: 101, non_entity_clients: 150, new_clients: 5 }, { month: 'february', clients: 251, entity_clients: 101, non_entity_clients: 150, new_clients: 5 },
]; ];
this.set('barChartData', barChartData); this.set('barChartData', barChartData);
await render(hbs` await render(hbs`
<div class="chart-container-wide">
<Clients::VerticalBarChart <Clients::VerticalBarChart
@dataset={{barChartData}} @dataset={{barChartData}}
@chartLegend={{chartLegend}} @chartLegend={{chartLegend}}
/> />
</div>
`); `);
assert.dom('[data-test-vertical-bar-chart]').exists(); const tooltipHoverBars = findAll('[data-test-vertical-bar-chart] rect.tooltip-rect');
assert.dom('[data-test-vertical-bar-chart]').exists('renders chart');
assert
.dom('[data-test-vertical-chart="data-bar"]')
.exists({ count: barChartData.length * 2 }, 'renders correct number of bars'); // multiply length by 2 because bars are stacked
assert.dom(find('[data-test-vertical-chart="y-axis-labels"] text')).hasText('0', `y-axis starts at 0`);
findAll('[data-test-vertical-chart="x-axis-labels"] text').forEach((e, i) => {
assert.dom(e).hasText(`${barChartData[i].month}`, `renders x-axis label: ${barChartData[i].month}`);
});
for (let [i, bar] of tooltipHoverBars.entries()) {
await triggerEvent(bar, 'mouseover');
let tooltip = document.querySelector('.ember-modal-dialog');
assert
.dom(tooltip)
.includesText(
`${barChartData[i].clients} total clients ${barChartData[i].entity_clients} entity clients ${barChartData[i].non_entity_clients} non-entity clients`,
'tooltip text is correct'
);
}
});
test('it renders chart and tooltip for new clients', async function (assert) {
const barChartData = [
{ month: 'january', entity_clients: 91, non_entity_clients: 50, clients: 0 },
{ month: 'february', entity_clients: 101, non_entity_clients: 150, clients: 110 },
];
this.set('barChartData', barChartData);
await render(hbs`
<div class="chart-container-wide">
<Clients::VerticalBarChart
@dataset={{barChartData}}
@chartLegend={{chartLegend}}
/>
</div>
`);
const tooltipHoverBars = findAll('[data-test-vertical-bar-chart] rect.tooltip-rect');
assert.dom('[data-test-vertical-bar-chart]').exists('renders chart');
assert
.dom('[data-test-vertical-chart="data-bar"]')
.exists({ count: barChartData.length * 2 }, 'renders correct number of bars'); // multiply length by 2 because bars are stacked
assert.dom(find('[data-test-vertical-chart="y-axis-labels"] text')).hasText('0', `y-axis starts at 0`);
findAll('[data-test-vertical-chart="x-axis-labels"] text').forEach((e, i) => {
assert.dom(e).hasText(`${barChartData[i].month}`, `renders x-axis label: ${barChartData[i].month}`);
});
for (let [i, bar] of tooltipHoverBars.entries()) {
await triggerEvent(bar, 'mouseover');
let tooltip = document.querySelector('.ember-modal-dialog');
assert
.dom(tooltip)
.includesText(
`${barChartData[i].clients} new clients ${barChartData[i].entity_clients} entity clients ${barChartData[i].non_entity_clients} non-entity clients`,
'tooltip text is correct'
);
}
});
test('it renders empty state when no dataset', async function (assert) {
await render(hbs`
<div class="chart-container-wide">
<Clients::VerticalBarChart @noDataMessage="this is a custom message to explain why you're not seeing a vertical bar chart"/>
</div>
`);
assert.dom('[data-test-component="empty-state"]').exists('renders empty state when no data');
assert
.dom('[data-test-empty-state-subtext]')
.hasText(
`this is a custom message to explain why you're not seeing a vertical bar chart`,
'custom message renders'
);
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { formatNumbers, formatTooltipNumber } from 'vault/utils/chart-helpers'; import { formatNumbers, formatTooltipNumber, calculateAverage } from 'vault/utils/chart-helpers';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
const SMALL_NUMBERS = [0, 7, 27, 103, 999]; const SMALL_NUMBERS = [0, 7, 27, 103, 999];
@@ -28,4 +28,35 @@ module('Unit | Utility | chart-helpers', function () {
const formatted = formatTooltipNumber(120300200100); const formatted = formatTooltipNumber(120300200100);
assert.equal(formatted.length, 15, 'adds punctuation at proper place for large numbers'); assert.equal(formatted.length, 15, 'adds punctuation at proper place for large numbers');
}); });
test('calculateAverage is accurate', function (assert) {
const testArray1 = [
{ label: 'foo', value: 10 },
{ label: 'bar', value: 22 },
];
const testArray2 = [
{ label: 'foo', value: undefined },
{ label: 'bar', value: 22 },
];
const getAverage = (array) => array.reduce((a, b) => a + b, 0) / array.length;
assert.equal(calculateAverage(null), null, 'returns null if dataset it null');
assert.equal(calculateAverage([]), null, 'returns null if dataset it empty array');
assert.equal(calculateAverage([0]), getAverage([0]), `returns ${getAverage([0])} if array is just 0 0`);
assert.equal(calculateAverage([1]), getAverage([1]), `returns ${getAverage([1])} if array is just 1`);
assert.equal(
calculateAverage([5, 1, 41, 5]),
getAverage([5, 1, 41, 5]),
`returns correct average for array of integers`
);
assert.equal(
calculateAverage(testArray1, 'value'),
getAverage([10, 22]),
`returns correct average for array of objects`
);
assert.equal(
calculateAverage(testArray2, 'value'),
getAverage([0, 22]),
`returns correct average for array of objects containing undefined values`
);
});
}); });