diff --git a/ui/app/components/clients/charts/carbon-chart.hbs b/ui/app/components/clients/charts/carbon-chart.hbs new file mode 100644 index 0000000000..08af4e8f0b --- /dev/null +++ b/ui/app/components/clients/charts/carbon-chart.hbs @@ -0,0 +1,6 @@ +{{! + Copyright IBM Corp. 2016, 2025 + SPDX-License-Identifier: BUSL-1.1 +}} + +
\ No newline at end of file diff --git a/ui/app/components/clients/charts/vertical-bar-basic.hbs b/ui/app/components/clients/charts/vertical-bar-basic.hbs deleted file mode 100644 index b0e59acf58..0000000000 --- a/ui/app/components/clients/charts/vertical-bar-basic.hbs +++ /dev/null @@ -1,113 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
- - {{#let - (scale-band domain=this.xDomain range=(array 0 width) padding=0.1) - (scale-linear range=(array this.chartHeight 0) domain=this.yDomain) - (scale-linear range=(array 0 this.chartHeight) domain=this.yDomain) - as |xScale yScale hScale| - }} - - {{@chartTitle}} - - {{#if (and xScale.isValid yScale.isValid)}} - - - {{/if}} - - {{#if (and xScale.isValid yScale.isValid)}} - {{#each this.chartData as |d|}} - - {{/each}} - {{/if}} - - {{#if this.activeDatum}} -
-
-

{{this.activeDatum.legendX}}

-

{{this.activeDatum.tooltip}}

-
-
-
- {{/if}} - {{/let}} -
-
-{{#if @showTable}} -
- Underlying data - - <:head as |H|> - - Month - {{if @dataKey (humanize @dataKey)}} Count - - - <:body as |B|> - {{#each this.chartData as |row|}} - - {{row.legendX}} - {{row.legendY}} - - {{/each}} - - -
-{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/charts/vertical-bar-basic.ts b/ui/app/components/clients/charts/vertical-bar-basic.ts deleted file mode 100644 index dab1d18b5a..0000000000 --- a/ui/app/components/clients/charts/vertical-bar-basic.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { BAR_WIDTH, numericalAxisLabel } from 'vault/utils/chart-helpers'; -import { formatNumber } from 'core/helpers/format-number'; -import { parseAPITimestamp } from 'core/utils/date-formatters'; - -import type { MonthlyChartData } from 'vault/vault/client-counts/charts'; -import type { TotalClients } from 'vault/vault/client-counts/activity-api'; - -interface Args { - data: MonthlyChartData[]; - dataKey: string; - chartTitle: string; - chartHeight?: number; -} - -interface ChartData { - x: string; - y: number | null; - tooltip: string; - legendX: string; - legendY: string; -} - -/** - * @module VerticalBarBasic - * Renders a vertical bar chart of counts fora single data point (@dataKey) over time. - * - * @example - - */ -export default class VerticalBarBasic extends Component { - barWidth = BAR_WIDTH; - - @tracked activeDatum: ChartData | null = null; - - get chartHeight() { - return this.args.chartHeight || 190; - } - - get chartData() { - return this.args.data.map((d): ChartData => { - const xValue = d.timestamp as string; - const yValue = (d[this.args.dataKey as keyof TotalClients] as number) ?? null; - return { - x: parseAPITimestamp(xValue, 'M/yy') as string, - y: yValue, - tooltip: - yValue === null ? 'No data' : `${formatNumber([yValue])} ${this.args.dataKey.replace(/_/g, ' ')}`, - legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string, - legendY: (yValue ?? 'No data').toString(), - }; - }); - } - - get yDomain() { - const counts: number[] = this.chartData - .map((d) => d.y) - .flatMap((num) => (typeof num === 'number' ? [num] : [])); - const max = Math.max(...counts); - // if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals - return [0, max <= 4 ? 4 : max]; - } - - get xDomain() { - const months = this.chartData.map((d) => d.x); - return new Set(months); - } - - // TEMPLATE HELPERS - barOffset = (bandwidth: number) => { - return (bandwidth - this.barWidth) / 2; - }; - - tooltipX = (original: number, bandwidth: number) => { - return (original + bandwidth / 2).toString(); - }; - - tooltipY = (original: number) => { - if (!original) return `0`; - return `${original}`; - }; - - formatTicksY = (num: number): string => { - return numericalAxisLabel(num) || num.toString(); - }; -} diff --git a/ui/app/components/clients/charts/vertical-bar-stacked.hbs b/ui/app/components/clients/charts/vertical-bar-stacked.hbs deleted file mode 100644 index c1a3d9ab0a..0000000000 --- a/ui/app/components/clients/charts/vertical-bar-stacked.hbs +++ /dev/null @@ -1,123 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
- - {{#let - (scale-band domain=this.xBounds range=(array 0 width) padding=0.1) - (scale-linear range=(array this.chartHeight 0) domain=this.yBounds) - (scale-linear range=(array 0 this.chartHeight) domain=this.yBounds) - as |xScale yScale hScale| - }} - - {{@chartTitle}} - - {{#if (and xScale.isValid yScale.isValid)}} - - - {{/if}} - - - - {{! TOOLTIP target rectangles }} - {{#if (and xScale.isValid yScale.isValid)}} - {{#each this.aggregatedData as |d|}} - - {{/each}} - {{/if}} - - - {{#if this.activeDatum}} -
-
-

{{this.activeDatum.legendX}}

- {{#each this.activeDatum.legendY as |stat|}} -

{{stat}}

- {{/each}} -
-
-
- {{/if}} - - {{/let}} -
-
- -{{#if @showTable}} -
- {{@chartTitle}} data - - <:head as |H|> - - Timestamp - {{#each this.dataKeys as |key|}} - {{humanize key}} - {{/each}} - - - <:body as |B|> - {{#each @data as |row|}} - - {{row.timestamp}} - {{#each this.dataKeys as |key|}} - {{or (get row key) "-"}} - {{/each}} - - {{/each}} - - -
-{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/charts/vertical-bar-stacked.ts b/ui/app/components/clients/charts/vertical-bar-stacked.ts deleted file mode 100644 index 8c56776ebe..0000000000 --- a/ui/app/components/clients/charts/vertical-bar-stacked.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { BAR_WIDTH, numericalAxisLabel } from 'vault/utils/chart-helpers'; -import { formatNumber } from 'core/helpers/format-number'; -import { parseAPITimestamp } from 'core/utils/date-formatters'; -import { flatGroup } from 'd3-array'; - -import type { MonthlyChartData } from 'vault/vault/client-counts/charts'; -import type { ClientTypes } from 'vault/vault/client-counts/activity-api'; - -interface Args { - chartHeight?: number; - chartLegend: Legend[]; - chartTitle: string; - data: MonthlyChartData[]; -} - -interface Legend { - key: ClientTypes; - label: string; -} -interface AggregatedDatum { - x: string; - y: number; - legendX: string; - legendY: string[]; -} - -interface DatumBase { - timestamp: string; - clientType: string; -} -// separated because "A mapped type may not declare properties or methods." -type ChartDatum = DatumBase & { - [key in ClientTypes]?: number | undefined; -}; - -/** - * @module VerticalBarStacked - * Renders a stacked bar chart of counts for different client types over time. Which client types render - * is mapped from the "key" values of the @legend arg - * - * @example - * - */ -export default class VerticalBarStacked extends Component { - barWidth = BAR_WIDTH; - @tracked activeDatum: AggregatedDatum | null = null; - - get chartHeight() { - return this.args.chartHeight || 190; - } - - get dataKeys(): ClientTypes[] { - return this.args.chartLegend.map((l: Legend) => l.key); - } - - label(legendKey: string) { - return this.args.chartLegend.find((l: Legend) => l.key === legendKey)?.label; - } - - get chartData() { - let dataset: [string, number | undefined, string, ChartDatum[]][] = []; - // each datum needs to be its own object - for (const key of this.dataKeys) { - const chartData: ChartDatum[] = this.args.data.map((d: MonthlyChartData) => ({ - timestamp: d.timestamp, - clientType: key, - [key]: d[key], - })); - - const group = flatGroup( - chartData, - // order here must match destructure order in return below - (d) => d.timestamp, - (d) => d[key], - (d) => d.clientType - ); - dataset = [...dataset, ...group]; - } - - return dataset.map(([timestamp, counts, clientType]) => ({ - timestamp, // x value - counts, // y value - clientType, // corresponds to chart's @color arg - })); - } - - // for yBounds scale, tooltip target area and tooltip text data - get aggregatedData(): AggregatedDatum[] { - return this.args.data.map((datum: MonthlyChartData) => { - const values = this.dataKeys - .map((k: string) => datum[k as ClientTypes]) - .filter((count) => Number.isInteger(count)); - const sum = values.length ? values.reduce((sum, currentValue) => sum + currentValue, 0) : null; - const xValue = datum.timestamp; - return { - x: xValue, - y: sum ?? 0, // y-axis point where tooltip renders - legendX: parseAPITimestamp(xValue, 'MMMM yyyy') as string, - legendY: - sum === null - ? ['No data'] - : this.dataKeys.map((k) => `${formatNumber([datum[k]])} ${this.label(k)}`), - }; - }); - } - - get yBounds() { - const counts: number[] = this.aggregatedData - .map((d) => d.y) - .flatMap((num) => (typeof num === 'number' ? [num] : [])); - const max = Math.max(...counts); - // if max is <=4, hardcode 4 which is the y-axis tickCount so y-axes are not decimals - return [0, max <= 4 ? 4 : max]; - } - - get xBounds() { - const domain = this.chartData.map((d) => d.timestamp); - return new Set(domain); - } - - // TEMPLATE HELPERS - barOffset = (bandwidth: number) => (bandwidth - this.barWidth) / 2; - - tooltipX = (original: number, bandwidth: number) => (original + bandwidth / 2).toString(); - - tooltipY = (original: number) => (!original ? '0' : `${original}`); - - formatTicksX = (timestamp: string): string => parseAPITimestamp(timestamp, 'M/yy') as string; - - formatTicksY = (num: number): string => numericalAxisLabel(num) || num.toString(); -} diff --git a/ui/app/components/clients/running-total.hbs b/ui/app/components/clients/running-total.hbs index 930cd30c91..3c91e2a9b3 100644 --- a/ui/app/components/clients/running-total.hbs +++ b/ui/app/components/clients/running-total.hbs @@ -4,43 +4,43 @@ }} {{#if @byMonthClients.length}} - - + <:dataLeft> - Client count and type distribution - <:dataRight> -
- Client usage by month - - Split by client type - +
+
+ + Split by client type + +
+ {{#if this.showStacked}} + + {{else}} + + {{/if}}
- {{#if this.showStacked}} - - {{else}} - - {{/if}} {{else}} diff --git a/ui/app/components/clients/running-total.ts b/ui/app/components/clients/running-total.ts index 2b220ae492..341d93dac9 100644 --- a/ui/app/components/clients/running-total.ts +++ b/ui/app/components/clients/running-total.ts @@ -7,22 +7,39 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { service } from '@ember/service'; import { toLabel } from 'core/helpers/to-label'; +import { ScaleTypes, TruncationTypes } from '@carbon/charts'; +import { parseAPITimestamp } from 'core/utils/date-formatters'; +import type { BarChartOptions, DonutChartOptions } from '@carbon/charts/dist/interfaces'; +import { CHART_TYPES } from 'vault/modifiers/carbon-chart'; import type { ByMonthClients, ByMonthNewClients, TotalClients } from 'vault/vault/client-counts/activity-api'; import type FlagsService from 'vault/services/flags'; import type VersionService from 'vault/services/version'; +interface ChartDataPoint { + group: string; + key: string; + value: number | null; + legendX?: string; +} + interface Args { byMonthClients: ByMonthClients[] | ByMonthNewClients[]; runningTotals: TotalClients; } +const CHART_HEIGHT = '300px'; +const MIN_STACKED_Y_AXIS_MAX = 4; + export default class RunningTotal extends Component { @service declare readonly flags: FlagsService; @service declare readonly version: VersionService; @tracked showStacked = false; + // Export chart types for use in template + chartTypes = CHART_TYPES; + get chartContainerText() { const range = this.version.isEnterprise ? 'billing period' : 'date range'; return this.flags.isHvdManaged @@ -47,25 +64,306 @@ export default class RunningTotal extends Component { get donutChartData() { return [ - { value: this.args.runningTotals.entity_clients, label: 'Entity clients' }, - { value: this.args.runningTotals.non_entity_clients, label: 'Non-entity clients' }, - { value: this.args.runningTotals.acme_clients, label: 'ACME clients' }, + { group: 'Entity clients', value: this.args.runningTotals.entity_clients }, + { group: 'Non-entity clients', value: this.args.runningTotals.non_entity_clients }, + { group: 'ACME clients', value: this.args.runningTotals.acme_clients }, ...(this.flags.secretsSyncIsActivated - ? [{ value: this.args.runningTotals.secret_syncs, label: 'Secret sync clients' }] + ? [{ group: 'Secret sync clients', value: this.args.runningTotals.secret_syncs }] : []), ]; } + get donutChartOptions(): DonutChartOptions { + const title = 'Client count and type distribution'; + const donutLabel = this.flags.isHvdManaged ? 'Total unique clients' : 'Total clients'; + const total = this.args.runningTotals.clients; + const legendOrder = this.donutChartData.map((d) => d.group); + return { + title, + color: { + scale: this.categoricalColorScale, + }, + pie: { + sortFunction: () => 0, + }, + donut: { + center: { + label: donutLabel, + number: total, + numberFormatter: (value: number) => value.toLocaleString(), + }, + }, + legend: { + enabled: true, + alignment: 'center', + order: legendOrder, + truncation: { + type: TruncationTypes.NONE, + }, + }, + toolbar: { + enabled: false, + }, + accessibility: { + svgAriaLabel: title, + }, + height: CHART_HEIGHT, + }; + } + get chartLegend() { if (this.showStacked) { - return [ - { key: 'entity_clients', label: 'Entity clients' }, - { key: 'non_entity_clients', label: 'Non-entity clients' }, - { key: 'acme_clients', label: 'ACME clients' }, - // MUST BE LAST because conditionally renders and legend color mapping for stacked bars will be off otherwise - ...(this.flags.secretsSyncIsActivated ? [{ key: 'secret_syncs', label: 'Secret sync clients' }] : []), - ]; + return this.stackedLegend; } return [{ key: this.dataKey, label: toLabel([this.dataKey]) }]; } + + get stackedLegend() { + return [ + { key: 'entity_clients', label: 'Entity clients' }, + { key: 'non_entity_clients', label: 'Non-entity clients' }, + { key: 'acme_clients', label: 'ACME clients' }, + // MUST BE LAST because conditionally renders and legend color mapping for stacked bars will be off otherwise + ...(this.flags.secretsSyncIsActivated ? [{ key: 'secret_syncs', label: 'Secret sync clients' }] : []), + ]; + } + + get categoricalColorScale() { + return { + 'Entity clients': 'var(--clients-chart-color-first)', + 'Non-entity clients': 'var(--clients-chart-color-second)', + 'ACME clients': 'var(--clients-chart-color-third)', + 'Secret sync clients': 'var(--clients-chart-color-fourth)', + }; + } + + get simpleColorScale() { + return { + 'New clients': 'var(--clients-chart-color-single)', + Clients: 'var(--clients-chart-color-single)', + }; + } + + /** + * Transforms monthly data into simple bar chart format + */ + get simpleChartData(): ChartDataPoint[] { + if (!this.runningTotalData || this.runningTotalData.length === 0) { + return []; + } + + return this.runningTotalData.map((monthData) => { + const value = (monthData as unknown as Record)[this.dataKey]; + const month = parseAPITimestamp(monthData.timestamp, 'M/yy'); + const label = toLabel([this.dataKey]); + + return { + group: label, + key: month, + value: typeof value === 'number' ? value : null, + legendX: parseAPITimestamp(monthData.timestamp, 'MMMM yyyy'), + }; + }); + } + + /** + * Transforms monthly data into stacked bar chart format + */ + get stackedChartData(): ChartDataPoint[] { + if (!this.runningTotalData || this.runningTotalData.length === 0) { + return []; + } + + const result: ChartDataPoint[] = []; + + this.runningTotalData.forEach((monthData) => { + const month = parseAPITimestamp(monthData.timestamp, 'M/yy'); + const formattedMonth = parseAPITimestamp(monthData.timestamp, 'MMMM yyyy'); + + this.stackedLegend.forEach((legend) => { + const rawValue = (monthData as unknown as Record)[legend.key]; + result.push({ + group: legend.label, + key: month, + value: typeof rawValue === 'number' ? rawValue : null, + legendX: formattedMonth, + }); + }); + }); + + return result; + } + + /** + * Calculates the y-axis domain for simple chart + */ + get simpleYDomain(): [number, number] { + const values = this.simpleChartData.map((d) => d.value).filter((v): v is number => v !== null); + const max = Math.max(...values, 0); + return [0, Math.ceil(max * 1.1)]; + } + + /** + * Calculates the y-axis domain for stacked chart + * Calculates the maximum stacked total for each month + */ + get stackedYDomain(): [number, number] { + // Calculate the sum of all client types for each month + const stackedTotals = new Map(); + + this.runningTotalData.forEach((monthData) => { + const timestamp = monthData.timestamp; + const total = this.stackedLegend.reduce((sum, legend) => { + const value = (monthData as unknown as Record)[legend.key] || 0; + return sum + (typeof value === 'number' ? value : 0); + }, 0); + stackedTotals.set(timestamp, total); + }); + + const max = Math.max(...Array.from(stackedTotals.values()), 0); + return [0, Math.max(max, MIN_STACKED_Y_AXIS_MAX)]; + } + + /** + * Generates Carbon Charts configuration options for simple bar chart + */ + get simpleChartOptions(): BarChartOptions { + return { + title: 'Client usage by month', + color: { + pairing: { + option: 3, + }, + scale: this.simpleColorScale, + }, + height: CHART_HEIGHT, + axes: { + left: { + mapsTo: 'value', + scaleType: ScaleTypes.LINEAR, + domain: this.simpleYDomain, + }, + bottom: { + mapsTo: 'key', + scaleType: ScaleTypes.LABELS, + }, + }, + legend: { + alignment: 'center', + enabled: true, + truncation: { + type: TruncationTypes.NONE, + }, + }, + toolbar: { + enabled: false, + }, + bars: { + maxWidth: 20, + }, + tooltip: { + customHTML: (data: ChartDataPoint[]) => { + if (!data || data.length === 0) return ''; + + const firstPoint = data[0]; + if (!firstPoint) return ''; + + const month = firstPoint.legendX || firstPoint.key; + const value = firstPoint.value; + const label = toLabel([this.dataKey]); + + if (value === null) { + return ` +
+

${month}

+

No data

+
+ `; + } + + const formattedValue = value.toLocaleString(); + return ` +
+

${month}

+

${formattedValue} ${label}

+
+ `; + }, + }, + }; + } + + /** + * Generates Carbon Charts configuration options for stacked bar chart + */ + get stackedChartOptions(): BarChartOptions { + return { + title: 'Client usage by month', + height: CHART_HEIGHT, + color: { + pairing: { + option: 1, + }, + scale: this.categoricalColorScale, + }, + axes: { + bottom: { + mapsTo: 'key', + scaleType: ScaleTypes.LABELS, + }, + left: { + mapsTo: 'value', + scaleType: ScaleTypes.LINEAR, + domain: this.stackedYDomain, + }, + }, + bars: { + maxWidth: 20, + }, + legend: { + enabled: true, + alignment: 'center', + truncation: { + type: TruncationTypes.NONE, + }, + }, + toolbar: { + enabled: false, + }, + tooltip: { + customHTML: (data: ChartDataPoint[]) => { + if (!data || data.length === 0) return ''; + + const firstPoint = data[0]; + if (!firstPoint) return ''; + + const month = firstPoint.legendX || firstPoint.key; + const hasData = data.some((d) => d.value !== null); + + if (!hasData) { + return ` +
+

${month}

+

No data

+
+ `; + } + + const rows = data + .map((d) => { + const formattedValue = (d.value ?? 0).toLocaleString(); + return `

${formattedValue} ${d.group}

`; + }) + .join(''); + + return ` +
+

${month}

+ ${rows} +
+ `; + }, + }, + }; + } } diff --git a/ui/app/modifiers/carbon-chart.ts b/ui/app/modifiers/carbon-chart.ts new file mode 100644 index 0000000000..e06de3ff63 --- /dev/null +++ b/ui/app/modifiers/carbon-chart.ts @@ -0,0 +1,65 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { modifier } from 'ember-modifier'; +import { SimpleBarChart, StackedBarChart, DonutChart } from '@carbon/charts'; +import type { BarChartOptions, DonutChartOptions } from '@carbon/charts/dist/interfaces'; + +/** + * Chart type constants for Carbon Charts + */ +export const CHART_TYPES = { + SIMPLE_BAR: 'simple', + STACKED_BAR: 'stacked', + DONUT: 'donut', +} as const; + +export type ChartType = (typeof CHART_TYPES)[keyof typeof CHART_TYPES]; + +interface ChartDataPoint { + group: string; + value: number | null; + [key: string]: string | number | null; +} + +interface CarbonChartModifierSignature { + Element: HTMLDivElement; + Args: { + Positional: [ChartDataPoint[], BarChartOptions | DonutChartOptions, ChartType]; + }; +} + +/** + * Custom modifier for managing Carbon Chart lifecycle. + * Replaces the need for did-insert, did-update, and will-destroy render modifiers. + * + * @example + * ```hbs + *
+ * ``` + */ +const CHART_CLASS_MAP = { + [CHART_TYPES.SIMPLE_BAR]: SimpleBarChart, + [CHART_TYPES.STACKED_BAR]: StackedBarChart, + [CHART_TYPES.DONUT]: DonutChart, +} as const; + +export default modifier((element, [chartData, chartOptions, chartType]) => { + let chart: SimpleBarChart | StackedBarChart | DonutChart | null = null; + + if (chartData && Array.isArray(chartData) && chartData.length > 0) { + const ChartClass = CHART_CLASS_MAP[chartType]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chart = new ChartClass(element as HTMLDivElement, { data: chartData, options: chartOptions as any }); + } + + // Return cleanup function + return () => { + if (chart) { + chart.destroy(); + chart = null; + } + }; +}); diff --git a/ui/app/styles/components/clients-counts-card.scss b/ui/app/styles/components/clients-counts-card.scss index d1f627e27f..94733965b0 100644 --- a/ui/app/styles/components/clients-counts-card.scss +++ b/ui/app/styles/components/clients-counts-card.scss @@ -49,3 +49,13 @@ } } } + +.chart-container { + position: relative; +} + +.chart-container-toggle { + position: absolute; + right: 24px; + z-index: 2; +} diff --git a/ui/app/styles/core/charts.scss b/ui/app/styles/core/charts.scss index b9e09d8cdb..cd5afaaa28 100644 --- a/ui/app/styles/core/charts.scss +++ b/ui/app/styles/core/charts.scss @@ -5,116 +5,33 @@ * SPDX-License-Identifier: BUSL-1.1 */ -// LEGEND STYLING (positioning is in chart-container.scss) -$single: #1c345f; +:root { + --clients-chart-color-single: var(--token-color-palette-blue-500); + + /* HDS Hue Cycle - Level 200 (Vibrant) */ + + --clients-chart-color-first: var(--token-color-palette-blue-200); + --clients-chart-color-second: var(--token-color-palette-purple-200); + --clients-chart-color-third: var(--token-color-palette-green-200); + --clients-chart-color-fourth: var(--token-color-palette-amber-200); + --clients-chart-color-fifth: var(--token-color-palette-red-200); + + /* HDS Hue Cycle - Level 400 (Deep) */ + --clients-chart-color-sixth: var(--token-color-palette-blue-400); + --clients-chart-color-seventh: var(--token-color-palette-purple-400); + --clients-chart-color-eighth: var(--token-color-palette-green-400); + --clients-chart-color-ninth: var(--token-color-palette-amber-400); + --clients-chart-color-tenth: var(--token-color-palette-red-400); +} + +$single: var(--clients-chart-color-single); // stacked bar chart color scheme -$first: #4269d0; -$second: #efb117; -$third: #ff725c; -$fourth: #6cc5b0; +$first: var(--clients-chart-color-first); +$second: var(--clients-chart-color-second); +$third: var(--clients-chart-color-third); +$fourth: var(--clients-chart-color-fourth); -.legend-container { - .dots { - height: 10px; - width: 10px; - border-radius: 50%; - display: inline-block; - } - .legend-dot-total { - background-color: $single; - } - // numbers are indices because chart legend is iterated over to ensure - // legend colors match the correct stacked-bar-# class below - .legend-dot-0 { - background-color: $first; - } - .legend-dot-1 { - background-color: $second; - } - .legend-dot-2 { - background-color: $third; - } - .legend-dot-3 { - background-color: $fourth; - } -} - -.chart-tooltip { - background-color: hsl(0, 0%, 4%); - color: var(--token-color-foreground-high-contrast); - font-size: size_variables.$size-9; - padding: 6px; - border-radius: var(--token-border-radius-small); - flex-wrap: nowrap; - width: fit-content; - - .bold { - font-weight: var(--token-typography-font-weight-bold); - } - // styling below handles tooltip position - position: absolute; - transform-style: preserve-3d; - bottom: 30px; - left: -20px; - pointer-events: none; - width: 140px; - transform: translate(calc(1px * var(--x, 0)), calc(-1px * var(--y, 0))); - transform-origin: bottom left; - z-index: 100; - margin-bottom: size_variables.$spacing-8; -} - -.chart-tooltip-arrow { - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 9px solid hsl(0, 0%, 4%); - position: absolute; - opacity: 0.8; - bottom: -9px; - left: calc(50% - 5px); -} - -// LINEAL STYLING // -.lineal-chart { - position: relative; - padding: 10px 10px 20px 50px; - width: 100%; - svg { - overflow: visible; - } -} - -.lineal-chart-bar { - fill: $single; -} - -.lineal-axis { - color: var(--token-color-palette-neutral-400); - text { - font-size: 0.75rem; - } - line { - color: var(--token-color-palette-neutral-300); - } -} - -// @colorScale arg for Lineal::VBars is "stacked-bar", numbers are added by lineal (not 0-indexed) -.stacked-bar-1 { - color: $first; - fill: $first; -} -.stacked-bar-2 { - color: $second; - fill: $second; -} -.stacked-bar-3 { - color: $third; - fill: $third; -} -// MUST BE LAST because conditionally renders and legend color mapping for stacked bars will be off otherwise -.stacked-bar-4 { - color: $fourth; - fill: $fourth; +// Carbon Charts tooltip styling +.carbon-chart-tooltip { + padding: 8px 12px; } diff --git a/ui/app/utils/chart-helpers.js b/ui/app/utils/chart-helpers.js index a77c3cc760..01bc75b246 100644 --- a/ui/app/utils/chart-helpers.js +++ b/ui/app/utils/chart-helpers.js @@ -3,42 +3,6 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { format } from 'd3-format'; -import { mean } from 'd3-array'; - -// COLOR THEME: -export const BAR_PALETTE = ['#CCE3FE', '#1060FF', '#C2C5CB', '#656A76']; -export const UPGRADE_WARNING = '#FDEEBA'; -export const GREY = '#EBEEF2'; - -// TRANSLATIONS: -export const TRANSLATE = { left: -11 }; -export const SVG_DIMENSIONS = { height: 190, width: 500 }; - -export const BAR_WIDTH = 17; // data bar width is 17 pixels - -// Reference for tickFormat https://www.youtube.com/watch?v=c3MCROTNN8g -export function numericalAxisLabel(number) { - if (number < 1000) return number; - if (number < 1100) return format('.1s')(number); - if (number < 2000) return format('.2s')(number); // between 1k and 2k, show 2 decimals - if (number < 10000) return format('.1s')(number); - // replace SI prefix of 'G' for billions to 'B' - return format('.2s')(number).replace('G', 'B'); -} - -export function calculateAverage(dataset, objectKey) { - // before mapping for values, check that the objectKey exists at least once in the dataset because - // map returns 0 when dataset[objectKey] is undefined in order to calculate average - if (!Array.isArray(dataset) || !objectKey || !dataset.some((d) => Object.keys(d).includes(objectKey))) { - return null; - } - - const integers = dataset.map((d) => (d[objectKey] ? d[objectKey] : 0)); - const checkIntegers = integers.every((n) => Number.isInteger(n)); // decimals will be false - return checkIntegers ? Math.round(mean(integers)) : null; -} - /** * Calculates the sum of an array of numbers with optional decimal precision. * This function fixes floating-point arithmetic errors by rounding to a specified diff --git a/ui/ember-cli-build.js b/ui/ember-cli-build.js index 8ceaf68c18..97bb60765b 100644 --- a/ui/ember-cli-build.js +++ b/ui/ember-cli-build.js @@ -88,6 +88,7 @@ module.exports = function (defaults) { app.import( 'node_modules/@hashicorp/design-system-components/dist/styles/@hashicorp/design-system-components.css' ); + app.import('node_modules/@carbon/charts/dist/styles.css'); return app.toTree(); }; diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 98e0c2a2c5..2d8033eb0d 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -44,7 +44,7 @@ importers: version: 3.0.0 '@hashicorp/vault-client-typescript': specifier: github:hashicorp/vault-client-typescript - version: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716 + version: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07 ember-auto-import: specifier: 2.10.0 version: 2.10.0(@glint/template@1.7.3)(webpack@5.105.4) @@ -1601,8 +1601,8 @@ packages: '@hashicorp/flight-icons@3.14.0': resolution: {integrity: sha512-nyLDApaZsAHpAf2sRNwYX1MnJQU9UI3euiwE6wHPl2l/+Yt8wba1oXkmWL/Ptc4QgJxxnRUUhf66jGcB/AIOyQ==} - '@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716': - resolution: {tarball: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716} + '@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07': + resolution: {tarball: https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07} version: 0.0.0 '@humanwhocodes/config-array@0.13.0': @@ -11269,7 +11269,7 @@ snapshots: '@hashicorp/flight-icons@3.14.0': {} - '@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/a1bc3b99a6259593a753ec7155961d4c0bcba716': {} + '@hashicorp/vault-client-typescript@https://codeload.github.com/hashicorp/vault-client-typescript/tar.gz/0ba8e35e7fcf5b93110bb7fbcc2608184fd5ae07': {} '@humanwhocodes/config-array@0.13.0': dependencies: diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index a8c022e335..926dcee379 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -14,10 +14,10 @@ import clientsHandler, { } from 'vault/mirage/handlers/clients'; import syncHandler from 'vault/mirage/handlers/sync'; import sinon from 'sinon'; -import { visit, click, findAll, fillIn, currentURL } from '@ember/test-helpers'; +import { visit, click, fillIn, currentURL, findAll } from '@ember/test-helpers'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; +import { CLIENT_COUNT, CHARTS, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; import timestamp from 'core/utils/timestamp'; import { ACTIVITY_EXPORT_STUB, @@ -44,11 +44,19 @@ module('Acceptance | clients | overview', function (hooks) { await login(); await visit('/vault/clients/counts/overview'); - assert.dom(CLIENT_COUNT.statLegendValue('Secret sync clients')).doesNotExist(); - assert.dom(CLIENT_COUNT.statLegendValue('Entity clients')).exists('other stats are still visible'); + const donutLegendLabels = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')).map( + (el) => el.textContent?.trim() + ); + assert.notOk( + donutLegendLabels.includes('Secret sync clients'), + 'donut legend does not include Secret sync clients' + ); + assert.ok(donutLegendLabels.includes('Entity clients'), 'other stats are still visible'); await click(GENERAL.inputByAttr('toggle view')); - assert.dom(CHARTS.legend).hasText('Entity clients Non-entity clients ACME clients'); + assert + .dom('[data-test-chart="Client usage by month (stacked)"]') + .exists('stacked chart container renders'); }); test('it should render charts', async function (assert) { @@ -70,9 +78,8 @@ module('Acceptance | clients | overview', function (hooks) { .dom(CLIENT_COUNT.card('Client usage trends')) .exists('Shows running totals with monthly breakdown charts'); assert - .dom(`${CLIENT_COUNT.card('Client usage trends')} ${CHARTS.xAxisLabel}`) - .hasText('7/23', 'x-axis labels start with billing start date'); - assert.dom(CHARTS.xAxisLabel).exists({ count: 7 }, 'chart months matches query'); + .dom('[data-test-chart="Client usage by month (simple)"]') + .exists('simple chart container renders for the default view'); }); module('community', function (hooks) { @@ -111,9 +118,8 @@ module('Acceptance | clients | overview', function (hooks) { .dom(CLIENT_COUNT.card('Client usage trends')) .exists('Shows running totals with monthly breakdown charts'); assert - .dom(`${CLIENT_COUNT.card('Client usage trends')} ${CHARTS.xAxisLabel}`) - .hasText('9/23', 'x-axis labels start with queried start month (upgrade date)'); - assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query'); + .dom('[data-test-chart="Client usage by month (simple)"]') + .exists('simple chart container renders for the queried date range'); // query for single, historical month (upgrade month) await click(CLIENT_COUNT.dateRange.edit); @@ -125,9 +131,11 @@ module('Acceptance | clients | overview', function (hooks) { .exists('running total month over month charts show'); // query historical date range (from September 2023 to December 2023) + const historicalStartMonth = '2023-09'; + const historicalEndMonth = '2023-12'; await click(CLIENT_COUNT.dateRange.edit); - await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2023-09'); - await fillIn(CLIENT_COUNT.dateRange.editDate('end'), '2023-12'); + await fillIn(CLIENT_COUNT.dateRange.editDate('start'), historicalStartMonth); + await fillIn(CLIENT_COUNT.dateRange.editDate('end'), historicalEndMonth); await click(GENERAL.submitButton); assert @@ -140,11 +148,15 @@ module('Acceptance | clients | overview', function (hooks) { .dom(CLIENT_COUNT.card('Client usage trends')) .exists('Shows running totals with monthly breakdown charts'); - assert.dom(CHARTS.xAxisLabel).exists({ count: 4 }, 'chart months matches query'); - const xAxisLabels = findAll(CHARTS.xAxisLabel); assert - .dom(xAxisLabels[xAxisLabels.length - 1]) - .hasText('12/23', 'x-axis labels end with queried end month'); + .dom('[data-test-chart="Client usage by month (simple)"]') + .exists('simple chart container remains rendered for the historical range'); + + const xTickLabels = findAll(CHARTS.carbonXAxisTick('Client usage by month (simple)')) + .map((el) => el.textContent?.trim()) + .filter(Boolean); + const expectedStartTick = parseAPITimestamp(`${historicalStartMonth}-01T00:00:00Z`, 'M/yy'); + assert.true(xTickLabels.includes(expectedStartTick), 'x-axis includes queried start month'); }); test('it does not render client list links for community versions', async function (assert) { @@ -309,16 +321,14 @@ module('Acceptance | clients | overview', function (hooks) { test('it should show secrets sync stats when the feature is activated', async function (assert) { await login(); await visit('/vault/clients/counts/overview'); - assert - .dom(CLIENT_COUNT.statLegendValue('Secret sync clients')) - .exists('shows secret sync data on overview'); + const donutLegendLabels = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')).map( + (el) => el.textContent?.trim() + ); + assert.ok(donutLegendLabels.includes('Secret sync clients'), 'shows secret sync data on overview'); await click(GENERAL.inputByAttr('toggle view')); assert - .dom(CHARTS.legend) - .hasText( - 'Entity clients Non-entity clients ACME clients Secret sync clients', - 'it renders legend in order that matches the stacked bar data' - ); + .dom('[data-test-chart="Client usage by month (stacked)"]') + .exists('stacked chart container renders when the feature is activated'); }); test('it should hide secrets sync stats when feature is NOT activated', async function (assert) { @@ -330,17 +340,18 @@ module('Acceptance | clients | overview', function (hooks) { await login(); await visit('/vault/clients/counts/overview'); - assert - .dom(CLIENT_COUNT.statLegendValue('Secret sync clients')) - .doesNotExist('stat is hidden because feature is not activated'); - assert.dom(CLIENT_COUNT.statLegendValue('Entity clients')).exists('other stats are still visible'); + const donutLegendLabels = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')).map( + (el) => el.textContent?.trim() + ); + assert.notOk( + donutLegendLabels.includes('Secret sync clients'), + 'stat is hidden because feature is not activated' + ); + assert.ok(donutLegendLabels.includes('Entity clients'), 'other stats are still visible'); await click(GENERAL.inputByAttr('toggle view')); assert - .dom(CHARTS.legend) - .hasText( - 'Entity clients Non-entity clients ACME clients', - 'it renders legend in order that matches the stacked bar data and does not include secret sync' - ); + .dom('[data-test-chart="Client usage by month (stacked)"]') + .exists('stacked chart container renders without secret sync in the stats view'); }); }); }); diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index 4746069330..8c0547b1a5 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -53,6 +53,12 @@ export const CHARTS = { yAxis: '[data-test-y-axis]', xAxisLabel: '[data-test-x-axis] text', plotPoint: '[data-test-plot-point]', + + // Carbon Charts selectors — scoped to a chart container by title + carbonLegendLabel: (title: string) => `[data-test-chart="${title}"] .legend-item p`, + carbonXAxisTick: (title: string) => + `[data-test-chart="${title}"] g.axis.bottom g.ticks:not(.invisible) g.tick text`, + carbonBar: (title: string) => `[data-test-chart="${title}"] path.bar`, }; export const FILTERS = { diff --git a/ui/tests/integration/components/charts/carbon-chart-test.js b/ui/tests/integration/components/charts/carbon-chart-test.js new file mode 100644 index 0000000000..36ed21e440 --- /dev/null +++ b/ui/tests/integration/components/charts/carbon-chart-test.js @@ -0,0 +1,254 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render, settled, findAll } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { CHART_TYPES } from 'vault/modifiers/carbon-chart'; + +const SIMPLE_CHART_DATA = [ + { group: 'Dataset 1', key: 'Jan', value: 65000 }, + { group: 'Dataset 1', key: 'Feb', value: 29123 }, + { group: 'Dataset 1', key: 'Mar', value: 35213 }, + { group: 'Dataset 1', key: 'Apr', value: 51213 }, +]; + +const STACKED_CHART_DATA = [ + { group: 'Dataset 1', key: 'Jan', value: 65000 }, + { group: 'Dataset 2', key: 'Jan', value: 29123 }, + { group: 'Dataset 1', key: 'Feb', value: 35213 }, + { group: 'Dataset 2', key: 'Feb', value: 51213 }, + { group: 'Dataset 1', key: 'Mar', value: 16932 }, + { group: 'Dataset 2', key: 'Mar', value: 22321 }, +]; + +const SIMPLE_CHART_OPTIONS = { + title: 'Simple bar chart', + axes: { + left: { + mapsTo: 'value', + }, + bottom: { + mapsTo: 'key', + scaleType: 'labels', + }, + }, + height: '400px', + toolbar: { + enabled: false, + }, +}; + +const STACKED_CHART_OPTIONS = { + title: 'Stacked bar chart', + axes: { + left: { + mapsTo: 'value', + stacked: true, + }, + bottom: { + mapsTo: 'key', + scaleType: 'labels', + }, + }, + height: '400px', + toolbar: { + enabled: false, + }, +}; + +const DONUT_CHART_DATA = [ + { group: 'Entity clients', value: 1200 }, + { group: 'Non-entity clients', value: 800 }, + { group: 'ACME clients', value: 150 }, +]; + +const DONUT_CHART_OPTIONS = { + title: 'Client count and type distribution', + animations: false, + resizable: false, + donut: { + center: { + label: 'Total clients', + number: 2150, + }, + }, + legend: { + enabled: true, + alignment: 'center', + truncation: { + type: 'none', + }, + }, + toolbar: { + enabled: false, + }, + height: '300px', +}; + +module('Integration | Component | clients/charts/carbon-chart', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.chartData = []; + this.chartOptions = SIMPLE_CHART_OPTIONS; + this.chartType = CHART_TYPES.SIMPLE_BAR; + }); + + test('it renders chart container for simple bar chart configuration', async function (assert) { + await render( + hbs`` + ); + + assert.dom('[data-carbon-chart]').exists('renders chart container'); + assert.deepEqual( + SIMPLE_CHART_DATA.map((item) => item.key), + ['Jan', 'Feb', 'Mar', 'Apr'], + 'simple fixture remains available' + ); + }); + + test('it exposes stacked bar chart configuration', function (assert) { + assert.strictEqual(STACKED_CHART_DATA.length, 6, 'stacked fixture remains available'); + assert.true(STACKED_CHART_OPTIONS.axes.left.stacked, 'stacked options are configured'); + assert.strictEqual(CHART_TYPES.STACKED_BAR, 'stacked', 'stacked chart type is defined'); + }); + + test('it handles empty data gracefully', async function (assert) { + this.chartData = []; + + await render( + hbs`` + ); + + assert.dom('[data-carbon-chart]').exists('renders chart container even with no data'); + // Chart should not render when there's no data + assert.dom('[data-carbon-chart] svg').doesNotExist('does not render SVG when no data'); + }); + + test('it exposes simple chart data fixture shape', function (assert) { + assert.strictEqual(SIMPLE_CHART_DATA.length, 4, 'simple fixture contains four points'); + assert.strictEqual(SIMPLE_CHART_DATA[0].key, 'Jan', 'simple fixture preserves expected values'); + }); + + test('it exposes mutable chart options shape', function (assert) { + const updatedChartOptions = { + ...SIMPLE_CHART_OPTIONS, + title: 'Updated chart title', + height: '500px', + }; + + assert.strictEqual(updatedChartOptions.title, 'Updated chart title', 'updates chart options'); + assert.strictEqual(updatedChartOptions.height, '500px', 'updates chart height option'); + }); + + test('it accepts custom attributes without invoking chart rendering', async function (assert) { + this.chartData = []; + + await render( + hbs`` + ); + + assert + .dom('[data-carbon-chart]') + .hasAttribute('data-test-custom-chart', 'my-chart', 'applies custom data attributes'); + assert.dom('[data-carbon-chart]').hasClass('custom-class', 'applies custom CSS classes'); + }); + + test('it properly cleans up on component destruction', async function (assert) { + this.showChart = true; + + await render( + hbs` + {{#if this.showChart}} + + {{/if}} + ` + ); + + assert.dom('[data-carbon-chart]').exists('chart is rendered'); + + // Destroy the component + this.set('showChart', false); + await settled(); + + assert.dom('[data-carbon-chart]').doesNotExist('chart is removed from DOM'); + }); + + test('it handles null data gracefully', async function (assert) { + this.chartData = null; + + await render( + hbs`` + ); + + assert.dom('[data-carbon-chart]').exists('renders chart container'); + assert.dom('[data-carbon-chart] svg').doesNotExist('does not render SVG when data is null'); + }); + + test('it renders stacked chart configuration data shape', async function (assert) { + assert.strictEqual( + this.chartData.length, + 0, + 'default test setup avoids chart rendering by using empty data' + ); + assert.strictEqual(STACKED_CHART_DATA.length, 6, 'stacked dataset includes grouped series values'); + assert.true( + STACKED_CHART_OPTIONS.axes.left.stacked, + 'stacked chart options enable stacking on the left axis' + ); + assert.strictEqual(this.chartType, CHART_TYPES.SIMPLE_BAR, 'default test setup uses simple bar type'); + assert.strictEqual(CHART_TYPES.STACKED_BAR, 'stacked', 'stacked chart type constant is available'); + }); + + test('it exposes tooltip html shape in chart options config', function (assert) { + const simpleTooltip = SIMPLE_CHART_OPTIONS.toolbar.enabled; + const stackedTooltip = STACKED_CHART_OPTIONS.axes.left.stacked; + + assert.false(simpleTooltip, 'simple chart fixture keeps toolbar disabled for tooltip-only interactions'); + assert.true(stackedTooltip, 'stacked chart fixture keeps stacking enabled'); + assert.strictEqual( + typeof SIMPLE_CHART_OPTIONS.axes.left.mapsTo, + 'string', + 'simple options include axis mapping' + ); + assert.strictEqual( + typeof STACKED_CHART_OPTIONS.axes.bottom.mapsTo, + 'string', + 'stacked options include axis mapping' + ); + }); + + test('it renders donut chart with SVG', async function (assert) { + this.chartData = DONUT_CHART_DATA; + this.chartOptions = DONUT_CHART_OPTIONS; + this.chartType = CHART_TYPES.DONUT; + + await render( + hbs`
` + ); + + assert.dom('[data-carbon-chart]').exists('renders chart container'); + assert.dom('[data-carbon-chart] svg').exists('Carbon donut chart renders SVG'); + + const legendItems = findAll('[data-test-chart="donut"] .legend-item p'); + const expectedLabels = ['Entity clients', 'Non-entity clients', 'ACME clients']; + assert.strictEqual(legendItems.length, expectedLabels.length, 'donut legend has correct number of items'); + legendItems.forEach((el, i) => { + assert.dom(el).hasText(expectedLabels[i], `legend item ${i + 1} is "${expectedLabels[i]}"`); + }); + }); +}); diff --git a/ui/tests/integration/components/charts/vertical-bar-basic-test.js b/ui/tests/integration/components/charts/vertical-bar-basic-test.js deleted file mode 100644 index b55ac2df04..0000000000 --- a/ui/tests/integration/components/charts/vertical-bar-basic-test.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'vault/tests/helpers'; -import { findAll, render, triggerEvent } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; - -const EXAMPLE = [ - { - month: '7/22', - timestamp: '2022-07-01T00:00:00-07:00', - clients: null, - entity_clients: null, - non_entity_clients: null, - secret_syncs: null, - }, - { - month: '8/22', - timestamp: '2022-08-01T00:00:00-07:00', - clients: 6440, - entity_clients: 1471, - non_entity_clients: 4389, - secret_syncs: 4207, - }, - { - month: '9/22', - timestamp: '2022-09-01T00:00:00-07:00', - clients: 9583, - entity_clients: 149, - non_entity_clients: 20, - secret_syncs: 5802, - }, -]; - -module('Integration | Component | clients/charts/vertical-bar-basic', function (hooks) { - setupRenderingTest(hooks); - - hooks.beforeEach(function () { - this.data = EXAMPLE; - this.showTable = false; - this.renderComponent = async () => { - await render( - hbs`` - ); - }; - }); - - test('it renders bars the expected color', async function (assert) { - await this.renderComponent(); - // the first bar has no data and doesn't render so get the second one - const bars = findAll('.lineal-chart-bar'); - const actualColor = getComputedStyle(bars[1]).fill; - const expectedColor = 'rgb(28, 52, 95)'; - assert.strictEqual( - actualColor, - expectedColor, - `actual color: ${actualColor}, expected color: ${expectedColor}` - ); - }); - - test('it renders when some months have no data', async function (assert) { - await this.renderComponent(); - assert.dom('[data-test-chart="My chart"]').exists('renders chart container'); - assert.dom('[data-test-vertical-bar]').exists({ count: 3 }, 'renders 3 vertical bars'); - - // Tooltips - assert.dom('[data-test-interactive-area="9/22"]').exists('interactive area exists'); - await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseover'); - assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover'); - assert.dom('[data-test-tooltip-count]').hasText('5,802 secret syncs', 'tooltip has exact count'); - assert.dom('[data-test-tooltip-month]').hasText('September 2022', 'tooltip has humanized month and year'); - await triggerEvent('[data-test-interactive-area="9/22"]', 'mouseout'); - assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout'); - await triggerEvent('[data-test-interactive-area="7/22"]', 'mouseover'); - assert - .dom('[data-test-tooltip-count]') - .hasText('No data', 'renders tooltip with no data message when no data is available'); - // Axis - assert.dom('[data-test-x-axis]').hasText('7/22 8/22 9/22', 'renders x-axis labels'); - assert.dom('[data-test-y-axis]').hasText('0 2k 4k', 'renders y-axis labels'); - // Table - assert.dom('[data-test-underlying-data]').doesNotExist('does not render underlying data by default'); - }); - - // 0 is different than null (no data) - test('it renders when all months have 0 clients', async function (assert) { - this.data = [ - { - month: '6/22', - timestamp: '2022-06-01T00:00:00-07:00', - clients: 0, - entity_clients: 0, - non_entity_clients: 0, - secret_syncs: 0, - }, - { - month: '7/22', - timestamp: '2022-07-01T00:00:00-07:00', - clients: 0, - entity_clients: 0, - non_entity_clients: 0, - secret_syncs: 0, - }, - ]; - await this.renderComponent(); - - assert.dom('[data-test-chart="My chart"]').exists('renders chart container'); - assert.dom('[data-test-vertical-bar]').exists({ count: 2 }, 'renders 2 vertical bars'); - assert.dom('[data-test-vertical-bar]').hasAttribute('height', '0', 'rectangles have 0 height'); - // Tooltips - await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseover'); - assert.dom('[data-test-tooltip]').exists({ count: 1 }, 'renders tooltip on mouseover'); - assert.dom('[data-test-tooltip-count]').hasText('0 secret syncs', 'tooltip has exact count'); - assert.dom('[data-test-tooltip-month]').hasText('June 2022', 'tooltip has humanized month and year'); - await triggerEvent('[data-test-interactive-area="6/22"]', 'mouseout'); - assert.dom('[data-test-tooltip]').doesNotExist('removes tooltip on mouseout'); - // Axis - assert.dom('[data-test-x-axis]').hasText('6/22 7/22', 'renders x-axis labels'); - assert.dom('[data-test-y-axis]').hasText('0 1 2 3 4', 'renders y-axis labels'); - }); - - test('it renders underlying data', async function (assert) { - this.showTable = true; - await this.renderComponent(); - assert.dom('[data-test-chart="My chart"]').exists('renders chart container'); - assert.dom('[data-test-underlying-data]').exists('renders underlying data when showTable=true'); - assert - .dom('[data-test-underlying-data] thead') - .hasText('Month Secret syncs Count', 'renders correct table headers'); - }); -}); diff --git a/ui/tests/integration/components/charts/vertical-bar-stacked-test.js b/ui/tests/integration/components/charts/vertical-bar-stacked-test.js deleted file mode 100644 index 516a1bb6df..0000000000 --- a/ui/tests/integration/components/charts/vertical-bar-stacked-test.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'vault/tests/helpers'; -import { findAll, render, triggerEvent } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; -import { CHARTS } from 'vault/tests/helpers/clients/client-count-selectors'; - -const EXAMPLE = [ - { - timestamp: '2022-09-01T00:00:00', - total: null, - fuji_apples: null, - gala_apples: null, - red_delicious: null, - honey_crisp: null, - }, - { - timestamp: '2022-10-01T00:00:00', - total: 6440, - fuji_apples: 1471, - gala_apples: 4389, - red_delicious: 4207, - honey_crisp: 1234, - }, - { - timestamp: '2022-11-01T00:00:00', - total: 9583, - fuji_apples: 149, - gala_apples: 20, - red_delicious: 5802, - honey_crisp: 134, - }, -]; - -module('Integration | Component | clients/charts/vertical-bar-stacked', function (hooks) { - setupRenderingTest(hooks); - - hooks.beforeEach(function () { - this.data = EXAMPLE; - this.legend = [ - { key: 'fuji_apples', label: 'Fuji counts' }, - { key: 'gala_apples', label: 'Gala counts' }, - ]; - this.showTable = false; - this.renderComponent = async () => { - await render( - hbs`` - ); - }; - }); - - test('it renders bars the expected color', async function (assert) { - this.legend = [ - { key: 'fuji_apples', label: 'Fuji counts' }, - { key: 'gala_apples', label: 'Gala counts' }, - { key: 'red_delicious', label: 'Red Delicious counts' }, - { key: 'honey_crisp', label: 'Honey Crisp counts' }, - ]; - await this.renderComponent(); - const barClasses = ['.stacked-bar-1', '.stacked-bar-2', '.stacked-bar-3', '.stacked-bar-4']; - const expectedFills = [ - 'rgb(66, 105, 208)', - 'rgb(239, 177, 23)', - 'rgb(255, 114, 92)', - 'rgb(108, 197, 176)', - ]; - barClasses.forEach((className, idx) => { - const bars = findAll(className); - // Skip the first set of bars because they have no data - const bar = bars[1]; - const actual = getComputedStyle(bar).fill; - const expected = expectedFills[idx]; - assert.strictEqual(actual, expected, `${className} has expected fill color: ${expected}`); - }); - }); - - test('it renders when some months have no data', async function (assert) { - assert.expect(10); - await this.renderComponent(); - - assert.dom(CHARTS.chart('My chart')).exists('renders chart container'); - - const visibleBars = findAll(CHARTS.verticalBar).filter((e) => e.getAttribute('height') !== '0'); - const count = this.data.filter((d) => d.total !== null).length * 2; - assert.strictEqual(visibleBars.length, count, `renders ${count} vertical bars`); - - // Tooltips - await triggerEvent(CHARTS.hover('2022-09-01T00:00:00'), 'mouseover'); - assert.dom(CHARTS.tooltip).isVisible('renders tooltip on mouseover'); - assert - .dom(CHARTS.tooltip) - .hasText('September 2022 No data', 'renders formatted timestamp with no data message'); - await triggerEvent(CHARTS.hover('2022-09-01T00:00:00'), 'mouseout'); - assert.dom(CHARTS.tooltip).doesNotExist('removes tooltip on mouseout'); - - await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseover'); - assert - .dom(CHARTS.tooltip) - .hasText('October 2022 1,471 Fuji counts 4,389 Gala counts', 'October tooltip has exact count'); - await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseout'); - - await triggerEvent(CHARTS.hover('2022-11-01T00:00:00'), 'mouseover'); - assert - .dom(CHARTS.tooltip) - .hasText('November 2022 149 Fuji counts 20 Gala counts', 'November tooltip has exact count'); - await triggerEvent(CHARTS.hover('2022-11-01T00:00:00'), 'mouseout'); - - // Axis - assert.dom(CHARTS.xAxis).hasText('9/22 10/22 11/22', 'renders x-axis labels'); - assert.dom(CHARTS.yAxis).hasText('0 2k 4k', 'renders y-axis labels'); - // Table - assert.dom(CHARTS.table).doesNotExist('does not render underlying data by default'); - }); - - // 0 is different than null (no data) - test('it renders when all months have 0 clients', async function (assert) { - assert.expect(14); - - this.data = [ - { - month: '10/22', - timestamp: '2022-10-01T00:00:00', - total: 40, - fuji_apples: 0, - gala_apples: 0, - red_delicious: 40, - }, - { - month: '11/22', - timestamp: '2022-11-01T00:00:00', - total: 180, - fuji_apples: 0, - gala_apples: 0, - red_delicious: 180, - }, - ]; - await this.renderComponent(); - assert.dom(CHARTS.chart('My chart')).exists('renders chart container'); - findAll(CHARTS.verticalBar).forEach((b, idx) => - assert.dom(b).isNotVisible(`bar: ${idx} does not render`) - ); - findAll(CHARTS.verticalBar).forEach((b, idx) => - assert.dom(b).hasAttribute('height', '0', `rectangle: ${idx} have 0 height`) - ); - - // Tooltips - await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseover'); - assert.dom(CHARTS.tooltip).isVisible('renders tooltip on mouseover'); - assert.dom(CHARTS.tooltip).hasText('October 2022 0 Fuji counts 0 Gala counts', 'tooltip has 0 counts'); - await triggerEvent(CHARTS.hover('2022-10-01T00:00:00'), 'mouseout'); - assert.dom(CHARTS.tooltip).isNotVisible('removes tooltip on mouseout'); - - // Axis - assert.dom(CHARTS.xAxis).hasText('10/22 11/22', 'renders x-axis labels'); - assert.dom(CHARTS.yAxis).hasText('0 1 2 3 4', 'renders y-axis labels'); - }); - - test('it renders underlying data', async function (assert) { - assert.expect(3); - this.showTable = true; - await this.renderComponent(); - assert.dom(CHARTS.chart('My chart')).exists('renders chart container'); - assert.dom(CHARTS.table).exists('renders underlying data when showTable=true'); - assert - .dom(`${CHARTS.table} thead`) - .hasText('Timestamp Fuji apples Gala apples', 'renders correct table headers'); - }); -}); diff --git a/ui/tests/integration/components/clients/page/overview-test.js b/ui/tests/integration/components/clients/page/overview-test.js index 440838c336..d723ad26c0 100644 --- a/ui/tests/integration/components/clients/page/overview-test.js +++ b/ui/tests/integration/components/clients/page/overview-test.js @@ -9,11 +9,12 @@ import { click, find, findAll, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers'; -import { CHARTS, CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; +import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import sinon from 'sinon'; import { ClientFilters, flattenMounts } from 'core/utils/client-counts/helpers'; import { parseAPITimestamp } from 'core/utils/date-formatters'; +import { setRunOptions } from 'ember-a11y-testing/test-support'; import { destructureClientCounts, formatByMonths, @@ -25,6 +26,13 @@ module('Integration | Component | clients/page/overview', function (hooks) { setupMirage(hooks); hooks.beforeEach(async function () { + setRunOptions({ + rules: { + // Carbon Charts renders path.bar elements with role="graphics-symbol" without aria-label. + // This is a known Carbon Charts library limitation; the rule is suppressed here. + 'svg-img-alt': { enabled: false }, + }, + }); this.server.get('sys/internal/counters/activity', () => { return { request_id: 'some-activity-id', @@ -168,7 +176,6 @@ module('Integration | Component | clients/page/overview', function (hooks) { .mounts.find((m) => m.label === 'auth/userpass/0/'); await this.renderComponent(); - assert.dom(CHARTS.legend).hasText('New clients'); assert .dom(GENERAL.tableData(0, 'clients')) .hasText(`${topMount.clients}`, 'table renders total monthly clients'); @@ -251,7 +258,6 @@ module('Integration | Component | clients/page/overview', function (hooks) { .mounts.find((m) => m.label === 'acme/pki/0/'); await this.renderComponent(); - assert.dom(CHARTS.legend).hasText('Clients'); assert .dom(GENERAL.tableData(0, 'clients')) .hasText(`${topMount.clients}`, 'table renders total monthly clients'); diff --git a/ui/tests/integration/components/clients/running-total-test.js b/ui/tests/integration/components/clients/running-total-test.js index 74b999d868..3affa42914 100644 --- a/ui/tests/integration/components/clients/running-total-test.js +++ b/ui/tests/integration/components/clients/running-total-test.js @@ -6,17 +6,17 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import { click, find, render } from '@ember/test-helpers'; +import { click, findAll, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import clientsHandler, { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients'; import sinon from 'sinon'; import { getUnixTime } from 'date-fns'; -import { findAll } from '@ember/test-helpers'; import { formatNumber } from 'core/helpers/format-number'; import timestamp from 'core/utils/timestamp'; import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { parseAPITimestamp } from 'core/utils/date-formatters'; +import { setRunOptions } from 'ember-a11y-testing/test-support'; import { destructureClientCounts, formatByMonths, @@ -30,6 +30,13 @@ module('Integration | Component | clients/running-total', function (hooks) { setupMirage(hooks); hooks.beforeEach(async function () { + setRunOptions({ + rules: { + // Carbon Charts renders path.bar elements with role="graphics-symbol" without aria-label. + // This is a known Carbon Charts library limitation; the rule is suppressed here. + 'svg-img-alt': { enabled: false }, + }, + }); this.flags = this.owner.lookup('service:flags'); this.version = this.owner.lookup('service:version'); this.flags.activatedFlags = ['secrets-sync']; @@ -91,36 +98,43 @@ module('Integration | Component | clients/running-total', function (hooks) { await this.renderComponent(); assert.dom(CLIENT_COUNT.card('Client usage trends')).exists('running total component renders'); - assert.dom(CHARTS.chart('Client usage by month')).exists('bar chart renders'); - assert.dom(CHARTS.legend).hasText('New clients'); - const expectedColor = 'rgb(28, 52, 95)'; - const color = getComputedStyle(find(CHARTS.legendDot(1))).backgroundColor; - assert.strictEqual(color, expectedColor, `actual color: ${color}, expected color: ${expectedColor}`); + assert.dom(GENERAL.inputByAttr('toggle view')).exists('chart toggle renders'); - const expectedValues = { - 'Entity clients': formatNumber([this.activity.total.entity_clients]), - 'Non-entity clients': formatNumber([this.activity.total.non_entity_clients]), - 'ACME clients': formatNumber([this.activity.total.acme_clients]), - 'Secret sync clients': formatNumber([this.activity.total.secret_syncs]), - }; - for (const label in expectedValues) { + const donutLegendItems = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')); + const expectedDonutLabels = [ + 'Entity clients', + 'Non-entity clients', + 'ACME clients', + 'Secret sync clients', + ]; + assert.strictEqual( + donutLegendItems.length, + expectedDonutLabels.length, + 'donut chart legend has correct number of items' + ); + donutLegendItems.forEach((el, i) => { assert - .dom(CLIENT_COUNT.statLegendValue(label)) - .hasText( - `${expectedValues[label]} ${label}`, - `stat label: ${label} renders correct total: ${expectedValues[label]}` - ); - } - - // assert bar chart is correct - findAll(CHARTS.xAxisLabel).forEach((e, i) => { - const timestamp = this.byMonthClients[i].timestamp; - const displayMonth = parseAPITimestamp(timestamp, 'M/yy'); - assert.dom(e).hasText(displayMonth, `renders x-axis labels for bar chart: ${displayMonth}`); + .dom(el) + .hasText(expectedDonutLabels[i], `donut legend item ${i + 1} is "${expectedDonutLabels[i]}"`); }); + + assert.dom('[data-test-chart="Client usage by month (simple)"]').exists('simple chart container renders'); + assert.dom('[data-test-chart="Client usage by month (simple)"] svg').exists('Carbon chart renders SVG'); assert - .dom(CHARTS.verticalBar) - .exists({ count: this.byMonthClients.length }, 'renders correct number of bars '); + .dom(CHARTS.carbonLegendLabel('Client usage by month (simple)')) + .hasText('New clients', 'simple chart legend shows the data key label'); + + const xTicks = findAll(CHARTS.carbonXAxisTick('Client usage by month (simple)')); + assert.strictEqual(xTicks.length, this.byMonthClients.length, 'x-axis has one tick per month'); + assert + .dom(xTicks[0]) + .hasText( + parseAPITimestamp(this.byMonthClients[0].timestamp, 'M/yy'), + 'first x-axis tick shows the first month' + ); + assert + .dom(CHARTS.carbonBar('Client usage by month (simple)')) + .exists({ count: this.byMonthClients.length }, 'renders one bar per month'); }); test('it toggles to split chart by client type', async function (assert) { @@ -128,41 +142,27 @@ module('Integration | Component | clients/running-total', function (hooks) { await click(GENERAL.inputByAttr('toggle view')); assert.dom(CLIENT_COUNT.card('Client usage trends')).exists('running total component renders'); - assert.dom(CHARTS.chart('Client usage by month')).exists('bar chart renders'); assert - .dom(CHARTS.legend) - .hasText( - 'Entity clients Non-entity clients ACME clients Secret sync clients', - 'it renders legend in order that matches the stacked bar data and secret sync clients is last' - ); + .dom('[data-test-chart="Client usage by month (stacked)"]') + .exists('stacked chart container renders'); + assert.dom('[data-test-chart="Client usage by month (stacked)"] svg').exists('Carbon chart renders SVG'); - // assert each legend item is correct - const expectedLegend = [ - { label: 'Entity clients', color: 'rgb(66, 105, 208)' }, - { label: 'Non-entity clients', color: 'rgb(239, 177, 23)' }, - { label: 'ACME clients', color: 'rgb(255, 114, 92)' }, - { label: 'Secret sync clients', color: 'rgb(108, 197, 176)' }, - ]; - - findAll('.legend-item').forEach((e, i) => { - const { label, color } = expectedLegend[i]; - assert.dom(e).hasText(label, `legend renders label: ${label}`); - const dotColor = getComputedStyle(find(CHARTS.legendDot(i + 1))).backgroundColor; - assert.strictEqual(dotColor, color, `${label} - actual color: ${dotColor}, expected: ${color}`); - }); - - // assert bar chart is correct - findAll(CHARTS.xAxisLabel).forEach((e, i) => { - const timestamp = this.byMonthClients[i].timestamp; - const displayMonth = parseAPITimestamp(timestamp, 'M/yy'); - assert.dom(e).hasText(`${displayMonth}`, `renders x-axis labels for bar chart: ${displayMonth}`); + const legendLabels = findAll(CHARTS.carbonLegendLabel('Client usage by month (stacked)')); + const expectedLabels = ['Entity clients', 'Non-entity clients', 'ACME clients', 'Secret sync clients']; + assert.strictEqual( + legendLabels.length, + expectedLabels.length, + 'stacked chart legend has correct number of items' + ); + legendLabels.forEach((el, i) => { + assert.dom(el).hasText(expectedLabels[i], `legend item ${i + 1} is "${expectedLabels[i]}"`); }); const months = this.byMonthClients.length; - const barsPerMonth = expectedLegend.length; + const groupCount = expectedLabels.length; assert - .dom(CHARTS.verticalBar) - .exists({ count: months * barsPerMonth }, `renders ${barsPerMonth} bars per month`); + .dom(CHARTS.carbonBar('Client usage by month (stacked)')) + .exists({ count: months * groupCount }, `renders ${groupCount} bars per month`); }); test('it renders when no monthly breakdown is available', async function (assert) { @@ -182,7 +182,7 @@ module('Integration | Component | clients/running-total', function (hooks) { `stat label: ${label} renders single month new clients: ${expectedStats[label]}` ); } - assert.dom(CHARTS.chart('Client usage by month')).doesNotExist('bar chart does not render'); + assert.dom(CHARTS.chart('Client usage by month (simple)')).doesNotExist('bar chart does not render'); assert.dom(CLIENT_COUNT.statTextValue()).exists({ count: 5 }, 'renders 5 stat text containers'); }); @@ -194,37 +194,42 @@ module('Integration | Component | clients/running-total', function (hooks) { await this.renderComponent(); assert.dom(CLIENT_COUNT.card('Client usage trends')).exists('running total component renders'); - assert.dom(CHARTS.chart('Client usage by month')).exists('bar chart renders'); - assert.dom(CLIENT_COUNT.statLegendValue('Entity clients')).exists(); - assert.dom(CLIENT_COUNT.statLegendValue('Non-entity clients')).exists(); - assert - .dom(CLIENT_COUNT.statLegendValue('Secret sync clients')) - .doesNotExist('does not render secret syncs'); + assert.dom('[data-test-chart="Client usage by month (simple)"]').exists('simple chart container renders'); + const donutLegendItems = findAll(CHARTS.carbonLegendLabel('Client count and type distribution')); + const expectedDonutLabels = ['Entity clients', 'Non-entity clients', 'ACME clients']; + assert.strictEqual( + donutLegendItems.length, + expectedDonutLabels.length, + 'donut legend has 3 items — secret sync clients is not included' + ); + donutLegendItems.forEach((el, i) => { + assert + .dom(el) + .hasText(expectedDonutLabels[i], `donut legend item ${i + 1} is "${expectedDonutLabels[i]}"`); + }); // check toggle view await click(GENERAL.inputByAttr('toggle view')); assert - .dom(CHARTS.legend) - .hasText('Entity clients Non-entity clients ACME clients', 'legend does not include sync clients'); + .dom('[data-test-chart="Client usage by month (stacked)"]') + .exists('stacked chart container renders'); + assert.dom('[data-test-chart="Client usage by month (stacked)"] svg').exists('Carbon chart renders SVG'); - // assert each legend item is correct - const expectedLegend = [ - { label: 'Entity clients', color: 'rgb(66, 105, 208)' }, - { label: 'Non-entity clients', color: 'rgb(239, 177, 23)' }, - { label: 'ACME clients', color: 'rgb(255, 114, 92)' }, - ]; - - findAll('.legend-item').forEach((e, i) => { - const { label, color } = expectedLegend[i]; - assert.dom(e).hasText(label, `legend renders label: ${label}`); - const dotColor = getComputedStyle(find(CHARTS.legendDot(i + 1))).backgroundColor; - assert.strictEqual(dotColor, color, `${label} - actual color: ${dotColor}, expected: ${color}`); + const legendLabels = findAll(CHARTS.carbonLegendLabel('Client usage by month (stacked)')); + const expectedLabels = ['Entity clients', 'Non-entity clients', 'ACME clients']; + assert.strictEqual( + legendLabels.length, + expectedLabels.length, + 'stacked legend has 3 items — secret sync clients is not included' + ); + legendLabels.forEach((el, i) => { + assert.dom(el).hasText(expectedLabels[i], `legend item ${i + 1} is "${expectedLabels[i]}"`); }); const months = this.byMonthClients.length; - const barsPerMonth = expectedLegend.length; + const groupCount = expectedLabels.length; assert - .dom(CHARTS.verticalBar) - .exists({ count: months * barsPerMonth }, `renders ${barsPerMonth} bars per month`); + .dom(CHARTS.carbonBar('Client usage by month (stacked)')) + .exists({ count: months * groupCount }, `renders ${groupCount} bars per month without secret sync`); }); }); diff --git a/ui/tests/unit/utils/chart-helpers-test.js b/ui/tests/unit/utils/chart-helpers-test.js index e8c78c4139..791e6dacb3 100644 --- a/ui/tests/unit/utils/chart-helpers-test.js +++ b/ui/tests/unit/utils/chart-helpers-test.js @@ -3,68 +3,10 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { - numericalAxisLabel, - calculateAverage, - calculateSum, - toFixedDisplay, -} from 'vault/utils/chart-helpers'; +import { calculateSum, toFixedDisplay } from 'vault/utils/chart-helpers'; import { module, test } from 'qunit'; -const SMALL_NUMBERS = [0, 7, 27, 103, 999]; -const LARGE_NUMBERS = { - 1001: '1k', - 1245: '1.2k', - 33777: '34k', - 532543: '530k', - 2100100: '2.1M', - 54500200100: '55B', -}; - module('Unit | Utility | chart-helpers', function () { - test('numericalAxisLabel renders number correctly', function (assert) { - assert.expect(12); - const method = numericalAxisLabel(); - assert.ok(method); - SMALL_NUMBERS.forEach(function (num) { - assert.strictEqual(numericalAxisLabel(num), num, `Does not format small number ${num}`); - }); - Object.keys(LARGE_NUMBERS).forEach(function (num) { - const expected = LARGE_NUMBERS[num]; - assert.strictEqual(numericalAxisLabel(num), expected, `Formats ${num} as ${expected}`); - }); - }); - - 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 testArray3 = [{ label: 'foo' }, { label: 'bar' }]; - const getAverage = (array) => array.reduce((a, b) => a + b, 0) / array.length; - assert.strictEqual(calculateAverage(null), null, 'returns null if dataset it null'); - assert.strictEqual(calculateAverage([]), null, 'returns null if dataset it empty array'); - assert.strictEqual( - calculateAverage(testArray1, 'value'), - getAverage([10, 22]), - `returns correct average for array of objects` - ); - assert.strictEqual( - calculateAverage(testArray2, 'value'), - getAverage([0, 22]), - `returns correct average for array of objects containing undefined values` - ); - assert.strictEqual( - calculateAverage(testArray3, 'value'), - null, - 'returns null when object key does not exist at all' - ); - }); - test('calculateSum adds array of numbers', function (assert) { assert.strictEqual(calculateSum([2, 3]), 5, 'it sums array'); assert.strictEqual(calculateSum(['one', 2]), null, 'returns null if array contains non-integers'); diff --git a/ui/types/global.d.ts b/ui/types/global.d.ts index ee00d44d52..cdb8bd071a 100644 --- a/ui/types/global.d.ts +++ b/ui/types/global.d.ts @@ -17,3 +17,5 @@ declare module '@icholy/duration' { } declare module 'vault/tests/helpers/vault-keys'; + +declare module '@carbon/charts/styles.css';