UI/Client counts changelog 1.10 (#14166)
* adds changelog for client counts work * capitalizes feature * delete old client count files * remove import from core.scss
This commit is contained in:
parent
345857fa1b
commit
7c11323d71
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
**UI Client Count Improvements**: Restructures client count dashboard, making use of billing start date to improve accuracy. Adds mount-level distribution and filtering.
|
||||
```
|
|
@ -1,124 +0,0 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default class HistoryComponent extends Component {
|
||||
max_namespaces = 10;
|
||||
|
||||
@tracked selectedNamespace = null;
|
||||
|
||||
@tracked barChartSelection = false;
|
||||
|
||||
// Determine if we have client count data based on the current tab
|
||||
get hasClientData() {
|
||||
if (this.args.tab === 'current') {
|
||||
// Show the current numbers as long as config is on
|
||||
return this.args.model.config?.enabled !== 'Off';
|
||||
}
|
||||
return this.args.model.activity && this.args.model.activity.total;
|
||||
}
|
||||
|
||||
// Show namespace graph only if we have more than 1
|
||||
get showGraphs() {
|
||||
return (
|
||||
this.args.model.activity &&
|
||||
this.args.model.activity.byNamespace &&
|
||||
this.args.model.activity.byNamespace.length > 1
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the namespace model for the search select component
|
||||
get searchDataset() {
|
||||
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
|
||||
return null;
|
||||
}
|
||||
let dataList = this.args.model.activity.byNamespace;
|
||||
return dataList.map((d) => {
|
||||
return {
|
||||
name: d['namespace_id'],
|
||||
id: d['namespace_path'] === '' ? 'root' : d['namespace_path'],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Construct the namespace model for the bar chart component
|
||||
get barChartDataset() {
|
||||
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
|
||||
return null;
|
||||
}
|
||||
let dataset = this.args.model.activity.byNamespace.slice(0, this.max_namespaces);
|
||||
return dataset.map((d) => {
|
||||
return {
|
||||
label: d['namespace_path'] === '' ? 'root' : d['namespace_path'],
|
||||
non_entity_tokens: d['counts']['non_entity_tokens'],
|
||||
distinct_entities: d['counts']['distinct_entities'],
|
||||
total: d['counts']['clients'],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Create namespaces data for csv format
|
||||
get getCsvData() {
|
||||
if (!this.args.model.activity || !this.args.model.activity.byNamespace) {
|
||||
return null;
|
||||
}
|
||||
let results = '',
|
||||
namespaces = this.args.model.activity.byNamespace,
|
||||
fields = ['Namespace path', 'Active clients', 'Unique entities', 'Non-entity tokens'];
|
||||
|
||||
results = fields.join(',') + '\n';
|
||||
|
||||
namespaces.forEach(function (item) {
|
||||
let path = item.namespace_path !== '' ? item.namespace_path : 'root',
|
||||
total = item.counts.clients,
|
||||
unique = item.counts.distinct_entities,
|
||||
non_entity = item.counts.non_entity_tokens;
|
||||
|
||||
results += path + ',' + total + ',' + unique + ',' + non_entity + '\n';
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
// Return csv filename with start and end dates
|
||||
get getCsvFileName() {
|
||||
let defaultFileName = `clients-by-namespace`,
|
||||
startDate =
|
||||
this.args.model.queryStart || `${format(new Date(this.args.model.activity.startTime), 'MM-yyyy')}`,
|
||||
endDate =
|
||||
this.args.model.queryEnd || `${format(new Date(this.args.model.activity.endTime), 'MM-yyyy')}`;
|
||||
if (startDate && endDate) {
|
||||
defaultFileName += `-${startDate}-${endDate}`;
|
||||
}
|
||||
return defaultFileName;
|
||||
}
|
||||
|
||||
// Get the namespace by matching the path from the namespace list
|
||||
getNamespace(path) {
|
||||
return this.args.model.activity.byNamespace.find((ns) => {
|
||||
if (path === 'root') {
|
||||
return ns.namespace_path === '';
|
||||
}
|
||||
return ns.namespace_path === path;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
selectNamespace(value) {
|
||||
// In case of search select component, value returned is an array
|
||||
if (Array.isArray(value)) {
|
||||
this.selectedNamespace = this.getNamespace(value[0]);
|
||||
this.barChartSelection = false;
|
||||
} else if (typeof value === 'object') {
|
||||
// While D3 bar selection returns an object
|
||||
this.selectedNamespace = this.getNamespace(value.label);
|
||||
this.barChartSelection = true;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
resetData() {
|
||||
this.barChartSelection = false;
|
||||
this.selectedNamespace = null;
|
||||
}
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
/**
|
||||
* @module PricingMetricsDates
|
||||
* PricingMetricsDates components are used on the Pricing Metrics page to handle queries related to pricing metrics.
|
||||
* This component assumes that query parameters (as in, from route params) are being passed in with the format MM-yyyy,
|
||||
* while the inputs expect a format of MM/yyyy.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <PricingMetricsDates @resultStart="2020-03-01T00:00:00Z" @resultEnd="2020-08-31T23:59:59Z" @queryStart="03-2020" @queryEnd="08-2020" />
|
||||
* ```
|
||||
* @param {object} resultStart - resultStart is the start date of the metrics returned. Should be a valid date string that the built-in Date() fn can parse
|
||||
* @param {object} resultEnd - resultEnd is the end date of the metrics returned. Should be a valid date string that the built-in Date() fn can parse
|
||||
* @param {string} [queryStart] - queryStart is the route param (formatted MM-yyyy) that the result will be measured against for showing discrepancy warning
|
||||
* @param {string} [queryEnd] - queryEnd is the route param (formatted MM-yyyy) that the result will be measured against for showing discrepancy warning
|
||||
* @param {number} [defaultSpan=12] - setting for default time between start and end input dates
|
||||
* @param {number} [retentionMonths=24] - setting for the retention months, which informs valid dates to query by
|
||||
*/
|
||||
import { set, computed } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import Component from '@ember/component';
|
||||
import { subMonths, startOfToday, format, endOfMonth, startOfMonth, isBefore } from 'date-fns';
|
||||
import layout from '../templates/components/pricing-metrics-dates';
|
||||
import { parseDateString } from 'vault/helpers/parse-date-string';
|
||||
|
||||
export default Component.extend({
|
||||
layout,
|
||||
router: service(),
|
||||
|
||||
queryStart: null,
|
||||
queryEnd: null,
|
||||
resultStart: null,
|
||||
resultEnd: null,
|
||||
|
||||
start: null,
|
||||
end: null,
|
||||
|
||||
defaultSpan: 12,
|
||||
retentionMonths: 24,
|
||||
|
||||
startDate: computed('start', function () {
|
||||
if (!this.start) return null;
|
||||
let date;
|
||||
try {
|
||||
date = parseDateString(this.start, '/');
|
||||
if (date) return date;
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
endDate: computed('end', function () {
|
||||
if (!this.end) return null;
|
||||
let date;
|
||||
try {
|
||||
date = parseDateString(this.end, '/');
|
||||
if (date) return endOfMonth(date);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
|
||||
error: computed('end', 'endDate', 'retentionMonths', 'start', 'startDate', function () {
|
||||
if (!this.startDate) {
|
||||
return 'Start date is invalid. Please use format MM/yyyy';
|
||||
}
|
||||
if (!this.endDate) {
|
||||
return 'End date is invalid. Please use format MM/yyyy';
|
||||
}
|
||||
if (isBefore(this.endDate, this.startDate)) {
|
||||
return 'Start date is after end date';
|
||||
}
|
||||
const lastMonthAvailable = endOfMonth(subMonths(startOfToday(), 1));
|
||||
if (isBefore(lastMonthAvailable, this.endDate)) {
|
||||
return `Data is not available until the end of the month`;
|
||||
}
|
||||
const earliestRetained = startOfMonth(subMonths(lastMonthAvailable, this.retentionMonths));
|
||||
if (isBefore(this.startDate, earliestRetained)) {
|
||||
return `No data retained before ${format(earliestRetained, 'MM/yyyy')} due to your settings`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
let initialEnd;
|
||||
let initialStart;
|
||||
|
||||
initialEnd = subMonths(startOfToday(), 1);
|
||||
if (this.queryEnd) {
|
||||
initialEnd = parseDateString(this.queryEnd, '-');
|
||||
} else {
|
||||
// if query isn't passed in, set it so that showResultsWarning works
|
||||
this.queryEnd = format(initialEnd, 'MM-yyyy');
|
||||
}
|
||||
initialStart = subMonths(initialEnd, this.defaultSpan);
|
||||
if (this.queryStart) {
|
||||
initialStart = parseDateString(this.queryStart, '-');
|
||||
} else {
|
||||
// if query isn't passed in, set it so that showResultsWarning works
|
||||
this.queryStart = format(initialStart, 'MM-yyyy');
|
||||
}
|
||||
|
||||
set(this, 'start', format(initialStart, 'MM/yyyy'));
|
||||
set(this, 'end', format(initialEnd, 'MM/yyyy'));
|
||||
},
|
||||
|
||||
actions: {
|
||||
handleQuery() {
|
||||
const start = format(this.startDate, 'MM-yyyy');
|
||||
const end = format(this.endDate, 'MM-yyyy');
|
||||
this.router.transitionTo('vault.cluster.clients', {
|
||||
queryParams: {
|
||||
start,
|
||||
end,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,4 +0,0 @@
|
|||
.pricing-metrics-date-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
|
@ -82,7 +82,6 @@
|
|||
@import './components/navigate-input';
|
||||
@import './components/page-header';
|
||||
@import './components/popup-menu';
|
||||
@import './components/pricing-metrics-dates';
|
||||
@import './components/radio-card';
|
||||
@import './components/radial-progress';
|
||||
@import './components/raft-join';
|
||||
|
|
|
@ -1,213 +0,0 @@
|
|||
{{#if (and (eq @tab "history") (eq @model.config.queriesAvailable false))}}
|
||||
{{#if (eq @model.config.enabled "On")}}
|
||||
<EmptyState
|
||||
@title="No monthly history"
|
||||
@message="There is no data in the monthly history yet. We collect it at the end of each month, so your data will be available on the first of next month."
|
||||
/>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="Data tracking is disabled"
|
||||
@message="Tracking is disabled, and no data is being collected. To turn it on, edit the configuration."
|
||||
>
|
||||
{{#if @model.config.configPath.canUpdate}}
|
||||
<p>
|
||||
<LinkTo @route="vault.cluster.clients.config">
|
||||
Go to configuration
|
||||
</LinkTo>
|
||||
</p>
|
||||
{{/if}}
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="box is-sideless is-fullwidth is-marginless is-bottomless">
|
||||
{{#if (eq @tab "current")}}
|
||||
<h1 data-test-client-count-title class="title is-4 has-bottom-margin-s">
|
||||
Current month
|
||||
</h1>
|
||||
<p class="has-bottom-margin-s">
|
||||
The below data is for the current month starting from the first day. For historical data, see the monthly history
|
||||
tab.
|
||||
</p>
|
||||
{{#if (eq @model.config.enabled "Off")}}
|
||||
<EmptyState
|
||||
@title="Tracking is disabled"
|
||||
@message="Tracking is disabled and data is not being collected. To turn it on edit the configuration."
|
||||
>
|
||||
{{#if @model.config.configPath.canUpdate}}
|
||||
<LinkTo @route="vault.cluster.clients.config">
|
||||
Go to configuration
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</EmptyState>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if (eq @model.config.enabled "Off")}}
|
||||
<AlertBanner data-test-tracking-disabled @type="warning" @title="Tracking is disabled">
|
||||
Tracking is currently disabled and data is not being collected. Historical data can be searched, but you will need
|
||||
to
|
||||
<LinkTo @route="vault.cluster.clients.edit">
|
||||
edit the configuration
|
||||
</LinkTo>
|
||||
to enable tracking again.
|
||||
</AlertBanner>
|
||||
{{/if}}
|
||||
<h1 data-test-client-count-title class="title is-4 has-bottom-margin-s">
|
||||
Monthly history
|
||||
</h1>
|
||||
<p class="has-bottom-margin-s">
|
||||
This data is presented by full month. If there is data missing, it's possible that tracking was turned off at the
|
||||
time. Vault will only show data for contiguous blocks of time during which tracking was on.
|
||||
</p>
|
||||
<PricingMetricsDates
|
||||
@queryStart={{@model.queryStart}}
|
||||
@queryEnd={{@model.queryEnd}}
|
||||
@resultStart={{@model.activity.startTime}}
|
||||
@resultEnd={{@model.activity.endTime}}
|
||||
@defaultSpan={{or @model.config.defaultReportMonths 12}}
|
||||
@retentionMonths={{@model.config.retentionMonths}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if @isLoading}}
|
||||
<LayoutLoading />
|
||||
{{else if this.hasClientData}}
|
||||
<div class="card has-bottom-margin-m">
|
||||
<div class="card-content">
|
||||
<div class="is-flex is-flex-center">
|
||||
<div class="is-flex-1">
|
||||
<h2 class="title is-5 is-marginless">
|
||||
Total usage
|
||||
</h2>
|
||||
<p class="sub-text">
|
||||
These totals are within this namespace and all its children.
|
||||
</p>
|
||||
</div>
|
||||
<LearnLink @path="/tutorials/vault/usage-metrics">
|
||||
Learn more
|
||||
</LearnLink>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="columns">
|
||||
<div class="column" data-test-client-count-stats>
|
||||
<StatText
|
||||
@label="Total active clients"
|
||||
@value={{or @model.activity.clients @model.activity.total.clients "0"}}
|
||||
@size="l"
|
||||
@subText="The sum of unique entities and non-entity tokens; Vault's primary billing metric."
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<StatText
|
||||
class="column"
|
||||
@label="Unique entities"
|
||||
@value={{or @model.activity.distinct_entities @model.activity.total.distinct_entities "0"}}
|
||||
@size="l"
|
||||
@subText="Representation of a particular user, client or application that created a token via login."
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<StatText
|
||||
class="column"
|
||||
@label="Non-entity tokens"
|
||||
@value={{or @model.activity.non_entity_tokens @model.activity.total.non_entity_tokens "0"}}
|
||||
@size="l"
|
||||
@subText="Tokens created via a method that is not associated with an entity."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{#if this.showGraphs}}
|
||||
<div class="columns has-bottom-margin-m" {{did-update this.resetData}} {{did-insert this.resetData}}>
|
||||
<div class="column is-two-thirds" data-test-client-count-graph>
|
||||
<BarChart
|
||||
@title="Top 10 Namespaces"
|
||||
@description="Each namespace's client count includes clients in child namespaces."
|
||||
@dataset={{this.barChartDataset}}
|
||||
@tooltipData={{or @model.activity.clients @model.activity.total.clients}}
|
||||
@onClick={{action this.selectNamespace}}
|
||||
@mapLegend={{array
|
||||
(hash key="non_entity_tokens" label="Non-entity tokens")
|
||||
(hash key="distinct_entities" label="Unique entities")
|
||||
}}
|
||||
>
|
||||
<DownloadCsv
|
||||
@label={{"Export all namespace data"}}
|
||||
@csvData={{this.getCsvData}}
|
||||
@fileName={{this.getCsvFileName}}
|
||||
/>
|
||||
</BarChart>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
{{#if (and this.barChartSelection this.selectedNamespace)}}
|
||||
<label class="title is-5 has-bottom-margin-m">Single namespace</label>
|
||||
<ul class="has-bottom-margin-l search-select-list">
|
||||
<li class="search-select-list-item">
|
||||
<div>
|
||||
{{or this.selectedNamespace.namespace_path "root"}}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-ghost" {{action "resetData"}}>
|
||||
<Icon @name="trash" class="has-text-grey" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{{else}}
|
||||
<SearchSelect
|
||||
@id="namespaces"
|
||||
@labelClass="title is-5"
|
||||
@disallowNewItems={{true}}
|
||||
@onChange={{action this.selectNamespace}}
|
||||
@label="Single namespace"
|
||||
@options={{or this.searchDataset (array)}}
|
||||
@searchField="namespace_path"
|
||||
@selectLimit={{1}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{#if this.selectedNamespace}}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<StatText @label="Active clients" @value={{this.selectedNamespace.counts.clients}} @size="l" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<StatText
|
||||
@label="Unique entities"
|
||||
@value={{this.selectedNamespace.counts.distinct_entities}}
|
||||
@size="m"
|
||||
/>
|
||||
</div>
|
||||
<div class="column">
|
||||
<StatText
|
||||
@label="Non-entity tokens"
|
||||
@value={{this.selectedNamespace.counts.non_entity_tokens}}
|
||||
@size="m"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<EmptyState
|
||||
@title="No namespace selected"
|
||||
@message="Click on a namespace in the Top 10 chart or type its name in the box to view it's individual client counts."
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else if (eq @tab "current")}}
|
||||
{{#if (eq @model.config.enabled "On")}}
|
||||
<EmptyState
|
||||
@title="No data received"
|
||||
@message="Tracking is turned on and Vault is gathering data. It should appear here within 30 minutes."
|
||||
/>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<EmptyState @title="No data received" @message="No data exists for that query period. Try searching again." />
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
|
@ -1,46 +0,0 @@
|
|||
<form onsubmit={{action "handleQuery"}}>
|
||||
<div class="field-body pricing-metrics-date-form" data-test-pricing-metrics-form>
|
||||
<div class="field is-narrow">
|
||||
<label for="start" class="is-label">From</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="string"
|
||||
@value={{this.start}}
|
||||
name="start"
|
||||
class={{concat "input" (unless this.startDate " has-error")}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-start-input="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-narrow">
|
||||
<label for="end" class="is-label">Through</label>
|
||||
<div class="control">
|
||||
<Input
|
||||
@type="string"
|
||||
@value={{this.end}}
|
||||
name="end"
|
||||
class={{concat "input" (unless this.endDate " has-error")}}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-test-end-input="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button disabled={{this.error}} type="submit" class="button">Query</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{#if this.error}}
|
||||
<FormError>{{this.error}}</FormError>
|
||||
{{/if}}
|
||||
<div class="box is-fullwidth is-shadowless">
|
||||
{{#if (and this.resultStart this.resultEnd)}}
|
||||
<h2 class="title is-4" data-test-pricing-result-dates>
|
||||
{{date-format this.resultStart "MMM dd, yyyy" dateOnly=true}}
|
||||
through
|
||||
{{date-format this.resultEnd "MMM dd, yyyy" dateOnly=true}}
|
||||
</h2>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -1,310 +0,0 @@
|
|||
/**
|
||||
* @module BarChart
|
||||
* BarChart components are used to display data in the form of a stacked bar chart, with accompanying legend and tooltip. Anything passed into the block will display in the top right of the chart header.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <BarChartComponent @title="Top 10 Namespaces" @description="Each namespace's client count includes clients in child namespaces." @labelKey="namespace_path" @dataset={{this.testData}} @mapLegend={{ array (hash key="non_entity_tokens" label="Active direct tokens") (hash key="distinct_entities" label="Unique Entities") }} @onClick={{this.onClick}} >
|
||||
* <button type="button" class="link">
|
||||
* Export all namespace data
|
||||
* </button>/>
|
||||
* </BarChartComponent>
|
||||
*
|
||||
* mapLegendSample = [{
|
||||
* key: "api_key_for_label",
|
||||
* label: "Label Displayed on Legend"
|
||||
* }]
|
||||
* ```
|
||||
*
|
||||
* @param {string} title - title of the chart
|
||||
* @param {array} mapLegend - array of objects with key names 'key' and 'label' for the map legend
|
||||
* @param {object} dataset - dataset for the chart
|
||||
* @param {any} tooltipData - misc. information needed to display tooltip (i.e. total clients from query params)
|
||||
* @param {string} [description] - description of the chart
|
||||
* @param {string} [labelKey=label] - labelKey is the key name in the dataset passed in that corresponds to the value labeling the y-axis
|
||||
* @param {function} [onClick] - takes function from parent and passes it to click event on data bars
|
||||
*
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import layout from '../templates/components/bar-chart';
|
||||
import { setComponentTemplate } from '@ember/component';
|
||||
import { assert } from '@ember/debug';
|
||||
import { action } from '@ember/object';
|
||||
import { guidFor } from '@ember/object/internals';
|
||||
import { scaleLinear, scaleBand } from 'd3-scale';
|
||||
import { axisLeft } from 'd3-axis';
|
||||
import { max } from 'd3-array';
|
||||
import { stack } from 'd3-shape';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { select, event, selectAll } from 'd3-selection';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { transition } from 'd3-transition';
|
||||
|
||||
// SIZING CONSTANTS
|
||||
const CHART_MARGIN = { top: 10, left: 137 }; // makes space for y-axis legend
|
||||
const CHAR_LIMIT = 18; // character count limit for y-axis labels to trigger truncating
|
||||
const LINE_HEIGHT = 24; // each bar w/ padding is 24 pixels thick
|
||||
|
||||
// COLOR THEME:
|
||||
const LIGHT_AND_DARK_BLUE = ['#BFD4FF', '#8AB1FF'];
|
||||
const BAR_COLOR_HOVER = ['#1563FF', '#0F4FD1'];
|
||||
const TOOLTIP_BACKGROUND = '#525761';
|
||||
const GREY = '#EBEEF2';
|
||||
class BarChartComponent extends Component {
|
||||
get labelKey() {
|
||||
return this.args.labelKey || 'label';
|
||||
}
|
||||
|
||||
get mapLegend() {
|
||||
assert(
|
||||
'map legend is required, must be an array of objects with key names of "key" and "label"',
|
||||
this.hasLegend()
|
||||
);
|
||||
return this.args.mapLegend;
|
||||
}
|
||||
|
||||
hasLegend() {
|
||||
if (!this.args.mapLegend || !Array.isArray(this.args.mapLegend)) {
|
||||
return false;
|
||||
} else {
|
||||
let legendKeys = this.args.mapLegend.map((obj) => Object.keys(obj));
|
||||
return legendKeys.map((array) => array.includes('key', 'label')).every((element) => element === true);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
renderBarChart(element, args) {
|
||||
let elementId = guidFor(element);
|
||||
let dataset = args[0];
|
||||
let totalCount = args[1];
|
||||
let handleClick = this.args.onClick;
|
||||
let labelKey = this.labelKey;
|
||||
let stackFunction = stack().keys(this.mapLegend.map((l) => l.key));
|
||||
// creates an array of data for each map legend key
|
||||
// each array contains coordinates for each data bar
|
||||
let stackedData = stackFunction(dataset);
|
||||
|
||||
// creates and appends tooltip
|
||||
let container = select('.bar-chart-container');
|
||||
container
|
||||
.append('div')
|
||||
.attr('class', 'chart-tooltip')
|
||||
.attr('style', 'position: absolute; opacity: 0;')
|
||||
.style('color', 'white')
|
||||
.style('background', `${TOOLTIP_BACKGROUND}`)
|
||||
.style('font-size', '.929rem')
|
||||
.style('padding', '10px')
|
||||
.style('border-radius', '4px');
|
||||
|
||||
let xScale = scaleLinear()
|
||||
.domain([0, max(dataset.map((d) => d.total))])
|
||||
.range([0, 75]); // 25% reserved for margins
|
||||
|
||||
let yScale = scaleBand()
|
||||
.domain(dataset.map((d) => d[labelKey]))
|
||||
.range([0, dataset.length * LINE_HEIGHT])
|
||||
.paddingInner(0.765); // percent of the total width to reserve for padding between bars
|
||||
|
||||
let chartSvg = select(element);
|
||||
chartSvg.attr('viewBox', `0 0 710 ${(dataset.length + 1) * LINE_HEIGHT}`);
|
||||
chartSvg.attr('id', elementId);
|
||||
|
||||
// creates group for each array of stackedData
|
||||
let groups = chartSvg
|
||||
.selectAll('g')
|
||||
.remove()
|
||||
.exit()
|
||||
.data(stackedData)
|
||||
.enter()
|
||||
.append('g')
|
||||
// shifts chart to accommodate y-axis legend
|
||||
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`)
|
||||
.style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]);
|
||||
|
||||
let yAxis = axisLeft(yScale).tickSize(0);
|
||||
yAxis(chartSvg.append('g').attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`));
|
||||
|
||||
let truncate = (selection) =>
|
||||
selection.text((string) =>
|
||||
string.length < CHAR_LIMIT ? string : string.slice(0, CHAR_LIMIT - 3) + '...'
|
||||
);
|
||||
|
||||
chartSvg.selectAll('.tick text').call(truncate);
|
||||
|
||||
groups
|
||||
.selectAll('rect')
|
||||
// iterate through the stacked data and chart respectively
|
||||
.data((stackedData) => stackedData)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.attr('class', 'data-bar')
|
||||
.style('cursor', 'pointer')
|
||||
.attr('width', (chartData) => `${xScale(chartData[1] - chartData[0]) - 0.25}%`)
|
||||
.attr('height', yScale.bandwidth())
|
||||
.attr('x', (chartData) => `${xScale(chartData[0])}%`)
|
||||
.attr('y', ({ data }) => yScale(data[labelKey]))
|
||||
.attr('rx', 3)
|
||||
.attr('ry', 3);
|
||||
|
||||
let actionBars = chartSvg
|
||||
.selectAll('.action-bar')
|
||||
.data(dataset)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.style('cursor', 'pointer')
|
||||
.attr('class', 'action-bar')
|
||||
.attr('width', '100%')
|
||||
.attr('height', `${LINE_HEIGHT}px`)
|
||||
.attr('x', '0')
|
||||
.attr('y', (chartData) => yScale(chartData[labelKey]))
|
||||
.style('fill', `${GREY}`)
|
||||
.style('opacity', '0')
|
||||
.style('mix-blend-mode', 'multiply');
|
||||
|
||||
let yLegendBars = chartSvg
|
||||
.selectAll('.label-bar')
|
||||
.data(dataset)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.style('cursor', 'pointer')
|
||||
.attr('class', 'label-action-bar')
|
||||
.attr('width', CHART_MARGIN.left)
|
||||
.attr('height', `${LINE_HEIGHT}px`)
|
||||
.attr('x', '0')
|
||||
.attr('y', (chartData) => yScale(chartData[labelKey]))
|
||||
.style('opacity', '0')
|
||||
.style('mix-blend-mode', 'multiply');
|
||||
|
||||
let dataBars = chartSvg.selectAll('rect.data-bar');
|
||||
let actionBarSelection = chartSvg.selectAll('rect.action-bar');
|
||||
let compareAttributes = (elementA, elementB, attr) =>
|
||||
select(elementA).attr(`${attr}`) === elementB.getAttribute(`${attr}`);
|
||||
|
||||
// handles click and mouseover/out/move event for data bars
|
||||
actionBars
|
||||
.on('click', function (chartData) {
|
||||
if (handleClick) {
|
||||
handleClick(chartData);
|
||||
}
|
||||
})
|
||||
.on('mouseover', function () {
|
||||
select(this).style('opacity', 1);
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`);
|
||||
select('.chart-tooltip').transition().duration(200).style('opacity', 1);
|
||||
})
|
||||
.on('mouseout', function () {
|
||||
select(this).style('opacity', 0);
|
||||
select('.chart-tooltip').style('opacity', 0);
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`);
|
||||
})
|
||||
.on('mousemove', function (chartData) {
|
||||
select('.chart-tooltip')
|
||||
.style('opacity', 1)
|
||||
.style('max-width', '200px')
|
||||
.style('left', `${event.pageX - 325}px`)
|
||||
.style('top', `${event.pageY - 140}px`)
|
||||
.text(
|
||||
`${Math.round((chartData.total * 100) / totalCount)}% of total client counts:
|
||||
${chartData.non_entity_tokens} non-entity tokens, ${chartData.distinct_entities} unique entities.
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
// handles mouseover/out/move event for y-axis legend
|
||||
yLegendBars
|
||||
.on('click', function (chartData) {
|
||||
if (handleClick) {
|
||||
handleClick(chartData);
|
||||
}
|
||||
})
|
||||
.on('mouseover', function (chartData) {
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${BAR_COLOR_HOVER[i]}`);
|
||||
actionBarSelection
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('opacity', '1');
|
||||
if (chartData.label.length >= CHAR_LIMIT) {
|
||||
select('.chart-tooltip').transition().duration(200).style('opacity', 1);
|
||||
}
|
||||
})
|
||||
.on('mouseout', function () {
|
||||
select('.chart-tooltip').style('opacity', 0);
|
||||
dataBars
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('fill', (b, i) => `${LIGHT_AND_DARK_BLUE[i]}`);
|
||||
actionBarSelection
|
||||
.filter(function () {
|
||||
return compareAttributes(this, event.target, 'y');
|
||||
})
|
||||
.style('opacity', '0');
|
||||
})
|
||||
.on('mousemove', function (chartData) {
|
||||
if (chartData.label.length >= CHAR_LIMIT) {
|
||||
select('.chart-tooltip')
|
||||
.style('left', `${event.pageX - 300}px`)
|
||||
.style('top', `${event.pageY - 100}px`)
|
||||
.text(`${chartData.label}`)
|
||||
.style('max-width', 'fit-content');
|
||||
} else {
|
||||
select('.chart-tooltip').style('opacity', 0);
|
||||
}
|
||||
});
|
||||
|
||||
chartSvg
|
||||
.append('g')
|
||||
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top + 2})`)
|
||||
.selectAll('text')
|
||||
.data(dataset)
|
||||
.enter()
|
||||
.append('text')
|
||||
.text((d) => d.total)
|
||||
.attr('fill', '#000')
|
||||
.attr('class', 'total-value')
|
||||
.style('font-size', '.8rem')
|
||||
.attr('text-anchor', 'start')
|
||||
.attr('alignment-baseline', 'mathematical')
|
||||
.attr('x', (chartData) => `${xScale(chartData.total)}%`)
|
||||
.attr('y', (chartData) => yScale(chartData.label));
|
||||
|
||||
chartSvg.select('.domain').remove();
|
||||
|
||||
// TODO: if mapLegend has more than 4 keys, y attrs ('cy' and 'y') will need to be set to a variable. Currently map keys are centered in the legend SVG (50%)
|
||||
// each map key symbol & label takes up 20% of legend SVG width
|
||||
let startingXCoordinate = 100 - this.mapLegend.length * 20; // subtract from 100% to find starting x-coordinate
|
||||
let legendSvg = select('.legend');
|
||||
this.mapLegend.map((legend, i) => {
|
||||
let xCoordinate = startingXCoordinate + i * 20;
|
||||
legendSvg
|
||||
.append('circle')
|
||||
.attr('cx', `${xCoordinate}%`)
|
||||
.attr('cy', '50%')
|
||||
.attr('r', 6)
|
||||
.style('fill', `${LIGHT_AND_DARK_BLUE[i]}`);
|
||||
legendSvg
|
||||
.append('text')
|
||||
.attr('x', `${xCoordinate + 2}%`)
|
||||
.attr('y', '50%')
|
||||
.text(`${legend.label}`)
|
||||
.style('font-size', '.8rem')
|
||||
.attr('alignment-baseline', 'middle');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default setComponentTemplate(layout, BarChartComponent);
|
|
@ -1,32 +0,0 @@
|
|||
<div data-test-bar-chart class="bar-chart-wrapper">
|
||||
<div class="chart-header has-bottom-margin-l">
|
||||
<div class="header-left">
|
||||
<h2 class="chart-title">{{@title}}</h2>
|
||||
{{#if @description}}
|
||||
<p class="chart-description">{{@description}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{{#if @dataset}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{#if @dataset}}
|
||||
<div class="is-border"></div>
|
||||
<div class="bar-chart-container">
|
||||
<svg
|
||||
{{did-insert this.renderBarChart @dataset @tooltipData}}
|
||||
{{did-update this.renderBarChart @dataset @tooltipData}}
|
||||
class="bar-chart"
|
||||
></svg>
|
||||
</div>
|
||||
<div class="is-border"></div>
|
||||
<div class="legend-container">
|
||||
<svg class="legend"></svg>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="toolbar"></div>
|
||||
<EmptyState @title="No namespace data" @message="There is no data to display for namespaces." />
|
||||
{{/if}}
|
||||
</div>
|
|
@ -1 +0,0 @@
|
|||
export { default } from 'core/components/bar-chart';
|
|
@ -1,37 +0,0 @@
|
|||
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in lib/core/addon/components/bar-chart.js. To make changes, first edit that file and run "yarn gen-story-md bar-chart" to re-generate the content.-->
|
||||
|
||||
## BarChart
|
||||
BarChart components are used to display data in the form of a stacked bar chart, with accompanying legend and tooltip. Anything passed into the block will display in the top right of the chart header.
|
||||
|
||||
**Params**
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| title | <code>string</code> | | title of the chart |
|
||||
| mapLegend | <code>array</code> | | array of objects with key names 'key' and 'label' for the map legend |
|
||||
| dataset | <code>object</code> | | dataset for the chart |
|
||||
| [description] | <code>string</code> | | description of the chart |
|
||||
| [labelKey] | <code>string</code> | <code>"label"</code> | labelKey is the key name in the dataset passed in that corresponds to the value labeling the y-axis |
|
||||
| [onClick] | <code>function</code> | | takes function from parent and passes it to click event on data bars |
|
||||
|
||||
**Example**
|
||||
|
||||
```js
|
||||
<BarChartComponent @title="Top 10 Namespaces" @description="Each namespace's client count includes clients in child namespaces." @labelKey="namespace_path" @dataset={{this.testData}} @mapLegend={{ array (hash key="non_entity_tokens" label="Active direct tokens") (hash key="distinct_entities" label="Unique Entities") }} @onClick={{this.onClick}} >
|
||||
<button type="button" class="link">
|
||||
Export all namespace data
|
||||
</button>/>
|
||||
</BarChartComponent>
|
||||
|
||||
mapLegendSample = [{
|
||||
key: "api_key_for_label",
|
||||
label: "Label Displayed on Legend"
|
||||
}]
|
||||
```
|
||||
|
||||
**See**
|
||||
|
||||
- [Uses of BarChart](https://github.com/hashicorp/vault/search?l=Handlebars&q=BarChart+OR+bar-chart)
|
||||
- [BarChart Source Code](https://github.com/hashicorp/vault/blob/main/ui/lib/core/addon/components/bar-chart.js)
|
||||
|
||||
---
|
|
@ -1,99 +0,0 @@
|
|||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { storiesOf } from '@storybook/ember';
|
||||
import { object, text, withKnobs } from '@storybook/addon-knobs';
|
||||
import notes from './bar-chart.md';
|
||||
|
||||
const dataset = [
|
||||
{
|
||||
namespace_id: 'root',
|
||||
namespace_path: 'root',
|
||||
counts: {
|
||||
distinct_entities: 268,
|
||||
non_entity_tokens: 985,
|
||||
clients: 1253,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace_id: 'O0i4m',
|
||||
namespace_path: 'top-namespace',
|
||||
counts: {
|
||||
distinct_entities: 648,
|
||||
non_entity_tokens: 220,
|
||||
clients: 868,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace_id: '1oihz',
|
||||
namespace_path: 'anotherNamespace',
|
||||
counts: {
|
||||
distinct_entities: 547,
|
||||
non_entity_tokens: 337,
|
||||
clients: 884,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace_id: '1oihz',
|
||||
namespace_path: 'someOtherNamespaceawgagawegawgawgawgaweg',
|
||||
counts: {
|
||||
distinct_entities: 807,
|
||||
non_entity_tokens: 234,
|
||||
clients: 1041,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const flattenData = () => {
|
||||
return dataset.map((d) => {
|
||||
return {
|
||||
label: d['namespace_path'],
|
||||
non_entity_tokens: d['counts']['non_entity_tokens'],
|
||||
distinct_entities: d['counts']['distinct_entities'],
|
||||
total: d['counts']['clients'],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
storiesOf('BarChart', module)
|
||||
.addParameters({ options: { showPanel: true } })
|
||||
.addDecorator(withKnobs())
|
||||
.add(
|
||||
`BarChart`,
|
||||
() => ({
|
||||
template: hbs`
|
||||
<h5 class="title is-5">Bar Chart</h5>
|
||||
|
||||
<p> <code>dataset</code> is passed to a function in the parent to format it appropriately for the chart. Any data passed should be flattened (not nested).</p>
|
||||
<p> The legend typically displays within the bar chart border, below the second grey divider. There is also a tooltip that pops up when hovering over the data bars and overflowing labels. Gotta love storybook :) </p>
|
||||
<div class="chart-container" style="margin-top:24px; max-width:750px; max-height:500px;" >
|
||||
<BarChart
|
||||
@title={{title}}
|
||||
@description={{description}}
|
||||
@dataset={{dataset}}
|
||||
@mapLegend={{array
|
||||
(hash key="non_entity_tokens" label="Active direct tokens")
|
||||
(hash key="distinct_entities" label="Unique Entities")}}
|
||||
>
|
||||
<button type="button" class="link">
|
||||
Export all namespace data
|
||||
</button>
|
||||
</BarChart>
|
||||
<br>
|
||||
<h6 class="title is-6">Legend:</h6>
|
||||
<svg class="legend">
|
||||
<circle cx="60%" cy="10%" r="6" style="fill: rgb(191, 212, 255);"></circle>
|
||||
<text x="62%" y="10%" alignment-baseline="middle" style="font-size: 0.8rem;">Active direct tokens</text>
|
||||
<circle cx="80%" cy="10%" r="6" style="fill: rgb(138, 177, 255);"></circle>
|
||||
<text x="82%" y="10%" alignment-baseline="middle" style="font-size: 0.8rem;">Unique Entities</text></svg>
|
||||
</div>
|
||||
`,
|
||||
context: {
|
||||
title: text('title', 'Top Namespaces'),
|
||||
description: text(
|
||||
'description',
|
||||
'Each namespaces client count includes clients in child namespaces.'
|
||||
),
|
||||
dataset: object('dataset', flattenData()),
|
||||
},
|
||||
}),
|
||||
{ notes }
|
||||
);
|
|
@ -1,85 +0,0 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
|
||||
module('Integration | Component | bar-chart', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
let dataset = [
|
||||
{
|
||||
namespace_id: 'root',
|
||||
namespace_path: 'root',
|
||||
counts: {
|
||||
distinct_entities: 268,
|
||||
non_entity_tokens: 985,
|
||||
clients: 1253,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace_id: 'O0i4m',
|
||||
namespace_path: 'top-namespace',
|
||||
counts: {
|
||||
distinct_entities: 648,
|
||||
non_entity_tokens: 220,
|
||||
clients: 868,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace_id: '1oihz',
|
||||
namespace_path: 'anotherNamespace',
|
||||
counts: {
|
||||
distinct_entities: 547,
|
||||
non_entity_tokens: 337,
|
||||
clients: 884,
|
||||
},
|
||||
},
|
||||
{
|
||||
namespace_id: '1oihz',
|
||||
namespace_path: 'someOtherNamespaceawgagawegawgawgawgaweg',
|
||||
counts: {
|
||||
distinct_entities: 807,
|
||||
non_entity_tokens: 234,
|
||||
clients: 1041,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let flattenData = () => {
|
||||
return dataset.map((d) => {
|
||||
return {
|
||||
label: d['namespace_path'],
|
||||
non_entity_tokens: d['counts']['non_entity_tokens'],
|
||||
distinct_entities: d['counts']['distinct_entities'],
|
||||
total: d['counts']['clients'],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
this.set('title', 'Top Namespaces');
|
||||
this.set('description', 'Each namespaces client count includes clients in child namespaces.');
|
||||
this.set('dataset', flattenData());
|
||||
|
||||
await render(hbs`
|
||||
<BarChart
|
||||
@title={{this.title}}
|
||||
@description={{this.description}}
|
||||
@dataset={{this.dataset}}
|
||||
@mapLegend={{array
|
||||
(hash key="non_entity_tokens" label="Active direct tokens")
|
||||
(hash key="distinct_entities" label="Unique Entities")}}
|
||||
>
|
||||
<button type="button" class="link">
|
||||
Export all namespace data
|
||||
</button>
|
||||
</BarChart>
|
||||
`);
|
||||
assert.dom('[data-test-bar-chart]').exists('bar chart renders');
|
||||
assert.dom('[data-test-bar-chart] .chart-title').hasText('Top Namespaces', 'displays title');
|
||||
assert
|
||||
.dom('[data-test-bar-chart] .chart-description')
|
||||
.hasText('Each namespaces client count includes clients in child namespaces.', 'displays description');
|
||||
assert.dom('.data-bar').exists({ count: 8 }, 'bars render');
|
||||
});
|
||||
});
|
|
@ -1,98 +0,0 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, fillIn } from '@ember/test-helpers';
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import { subMonths, startOfToday, format, endOfMonth } from 'date-fns';
|
||||
|
||||
module('Integration | Component | pricing-metrics-dates', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('by default it sets the start and end inputs', async function (assert) {
|
||||
const expectedEnd = subMonths(startOfToday(), 1);
|
||||
const expectedStart = subMonths(expectedEnd, 12);
|
||||
await render(hbs`
|
||||
<PricingMetricsDates />
|
||||
`);
|
||||
assert.dom('[data-test-end-input]').hasValue(format(expectedEnd, 'MM/yyyy'), 'End input is last month');
|
||||
assert
|
||||
.dom('[data-test-start-input]')
|
||||
.hasValue(format(expectedStart, 'MM/yyyy'), 'Start input is 12 months before last month');
|
||||
});
|
||||
|
||||
test('On init if end date passed, start is calculated', async function (assert) {
|
||||
const expectedStart = subMonths(new Date(2020, 8, 15), 12);
|
||||
this.set('queryEnd', '09-2020');
|
||||
await render(hbs`
|
||||
<PricingMetricsDates @queryEnd={{queryEnd}} />
|
||||
`);
|
||||
assert.dom('[data-test-end-input]').hasValue('09/2020', 'End input matches query');
|
||||
assert
|
||||
.dom('[data-test-start-input]')
|
||||
.hasValue(format(expectedStart, 'MM/yyyy'), 'Start input is 12 months before end input');
|
||||
});
|
||||
|
||||
test('On init if query start date passed, end is default', async function (assert) {
|
||||
const expectedEnd = subMonths(startOfToday(), 1);
|
||||
this.set('queryStart', '01-2020');
|
||||
await render(hbs`
|
||||
<PricingMetricsDates @queryStart={{queryStart}} />
|
||||
`);
|
||||
assert.dom('[data-test-end-input]').hasValue(format(expectedEnd, 'MM/yyyy'), 'End input is last month');
|
||||
assert.dom('[data-test-start-input]').hasValue('01/2020', 'Start input matches query');
|
||||
});
|
||||
|
||||
test('If result and query dates are within 1 day, warning is not shown', async function (assert) {
|
||||
this.set('resultStart', new Date(2020, 1, 1));
|
||||
this.set('resultEnd', new Date(2020, 9, 31));
|
||||
await render(hbs`
|
||||
<PricingMetricsDates
|
||||
@queryStart="2-2020"
|
||||
@queryEnd="10-2020"
|
||||
@resultStart={{resultStart}}
|
||||
@resultEnd={{resultEnd}}
|
||||
/>
|
||||
`);
|
||||
assert.dom('[data-test-results-date-warning]').doesNotExist('Does not show result states warning');
|
||||
});
|
||||
|
||||
test('it shows appropriate errors on input form', async function (assert) {
|
||||
const lastAvailable = endOfMonth(subMonths(startOfToday(), 1));
|
||||
const firstAvailable = subMonths(lastAvailable, 12);
|
||||
await render(hbs`
|
||||
<PricingMetricsDates @retentionMonths=12 @defaultSpan=6 />
|
||||
`);
|
||||
assert.dom('[data-test-form-error]').doesNotExist('No form error shows by default');
|
||||
|
||||
await fillIn('[data-test-start-input]', format(subMonths(firstAvailable, 1), 'MM/yyyy'));
|
||||
assert
|
||||
.dom('[data-test-form-error]')
|
||||
.includesText(
|
||||
`No data retained before ${format(firstAvailable, 'MM/yyyy')}`,
|
||||
'shows the correct error message for starting before the configured retainment period'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-end-input]', format(subMonths(lastAvailable, -1), 'MM/yyyy'));
|
||||
assert
|
||||
.dom('[data-test-form-error]')
|
||||
.includesText(
|
||||
'Data is not available until the end of the month',
|
||||
'shows the correct error message for ending after the end of the last month'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-end-input]', 'not/date');
|
||||
assert
|
||||
.dom('[data-test-form-error]')
|
||||
.includesText(
|
||||
'End date is invalid. Please use format MM/yyyy',
|
||||
'shows the correct error message for non-date input'
|
||||
);
|
||||
|
||||
await fillIn('[data-test-start-input]', `13/${format(lastAvailable, 'yyyy')}`);
|
||||
assert
|
||||
.dom('[data-test-form-error]')
|
||||
.includesText(
|
||||
'Start date is invalid. Please use format MM/yyyy',
|
||||
'shows the correct error message for an invalid month'
|
||||
);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue