UI/d3 DOM cleanup hover issue (#14493)

* fix duplicate rendering of chart elements

* organize SVG char elements into groups, give data-test attrs

* update tests

* tweak mirage

* add fake client counting start date

* fix test

* add waitUntil

* adds changelog

* add second waituntil
This commit is contained in:
claire bontempo 2022-03-16 11:36:41 -07:00 committed by GitHub
parent 0dfabe7ade
commit a003d9875e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 157 additions and 25 deletions

3
changelog/14493.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:bug
ui: Fixes horizontal bar chart hover issue when filtering namespaces and mounts
```

View File

@ -69,21 +69,26 @@ export default class HorizontalBarChart extends Component {
let chartSvg = select(element); let chartSvg = select(element);
chartSvg.attr('width', '100%').attr('viewBox', `0 0 564 ${(dataset.length + 1) * LINE_HEIGHT}`); chartSvg.attr('width', '100%').attr('viewBox', `0 0 564 ${(dataset.length + 1) * LINE_HEIGHT}`);
// chartSvg.attr('viewBox', `0 0 700 300`);
let groups = chartSvg let dataBarGroup = chartSvg
.selectAll('g') .selectAll('g')
.remove() .remove()
.exit() .exit()
.data(stackedData) .data(stackedData)
.enter() .enter()
.append('g') .append('g')
.attr('data-test-group', (d) => `${d.key}`)
// shifts chart to accommodate y-axis legend // shifts chart to accommodate y-axis legend
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`) .attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`)
.style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]); .style('fill', (d, i) => LIGHT_AND_DARK_BLUE[i]);
let yAxis = axisLeft(yScale).tickSize(0); let yAxis = axisLeft(yScale).tickSize(0);
yAxis(chartSvg.append('g').attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`));
let yLabelsGroup = chartSvg
.append('g')
.attr('data-test-group', 'y-labels')
.attr('transform', `translate(${CHART_MARGIN.left}, ${CHART_MARGIN.top})`);
yAxis(yLabelsGroup);
chartSvg.select('.domain').remove(); chartSvg.select('.domain').remove();
@ -94,8 +99,10 @@ export default class HorizontalBarChart extends Component {
chartSvg.selectAll('.tick text').call(truncate); chartSvg.selectAll('.tick text').call(truncate);
groups dataBarGroup
.selectAll('rect') .selectAll('rect')
.remove()
.exit()
// iterate through the stacked data and chart respectively // iterate through the stacked data and chart respectively
.data((stackedData) => stackedData) .data((stackedData) => stackedData)
.enter() .enter()
@ -109,8 +116,12 @@ export default class HorizontalBarChart extends Component {
.attr('rx', 3) .attr('rx', 3)
.attr('ry', 3); .attr('ry', 3);
let actionBars = chartSvg let actionBarGroup = chartSvg.append('g').attr('data-test-group', 'action-bars');
let actionBars = actionBarGroup
.selectAll('.action-bar') .selectAll('.action-bar')
.remove()
.exit()
.data(dataset) .data(dataset)
.enter() .enter()
.append('rect') .append('rect')
@ -124,8 +135,12 @@ export default class HorizontalBarChart extends Component {
.style('opacity', '0') .style('opacity', '0')
.style('mix-blend-mode', 'multiply'); .style('mix-blend-mode', 'multiply');
let yLegendBars = chartSvg let labelActionBarGroup = chartSvg.append('g').attr('data-test-group', 'label-action-bars');
.selectAll('.label-bar')
let labelActionBar = labelActionBarGroup
.selectAll('.label-action-bar')
.remove()
.exit()
.data(dataset) .data(dataset)
.enter() .enter()
.append('rect') .append('rect')
@ -173,10 +188,10 @@ export default class HorizontalBarChart extends Component {
}); });
// MOUSE EVENTS FOR Y-AXIS LABELS // MOUSE EVENTS FOR Y-AXIS LABELS
yLegendBars labelActionBar
.on('mouseover', (data) => { .on('mouseover', (data) => {
if (data.label.length >= CHAR_LIMIT) { if (data.label.length >= CHAR_LIMIT) {
let hoveredElement = yLegendBars.filter((bar) => bar.label === data.label).node(); let hoveredElement = labelActionBar.filter((bar) => bar.label === data.label).node();
this.tooltipTarget = hoveredElement; this.tooltipTarget = hoveredElement;
this.isLabel = true; this.isLabel = true;
this.tooltipText = data.label; this.tooltipText = data.label;
@ -208,10 +223,13 @@ export default class HorizontalBarChart extends Component {
.style('opacity', '0'); .style('opacity', '0');
}); });
// add client count total values to the right // client count total values to the right
chartSvg let totalValueGroup = chartSvg
.append('g') .append('g')
.attr('transform', `translate(${TRANSLATE.left}, ${TRANSLATE.down})`) .attr('data-test-group', 'total-values')
.attr('transform', `translate(${TRANSLATE.left}, ${TRANSLATE.down})`);
totalValueGroup
.selectAll('text') .selectAll('text')
.data(dataset) .data(dataset)
.enter() .enter()

View File

@ -1,3 +1,5 @@
import { formatISO, isBefore, sub } from 'date-fns';
export default function (server) { export default function (server) {
// 1.10 API response // 1.10 API response
server.get('sys/version-history', function () { server.get('sys/version-history', function () {
@ -88,6 +90,8 @@ export default function (server) {
server.get('/sys/internal/counters/activity', (schema, req) => { server.get('/sys/internal/counters/activity', (schema, req) => {
const { start_time, end_time } = req.queryParams; const { start_time, end_time } = req.queryParams;
// fake client counting start date so warning shows if user queries earlier start date
const counts_start = '2020-10-17T00:00:00Z';
return { return {
request_id: '25f55fbb-f253-9c46-c6f0-3cdd3ada91ab', request_id: '25f55fbb-f253-9c46-c6f0-3cdd3ada91ab',
lease_id: '', lease_id: '',
@ -182,9 +186,9 @@ export default function (server) {
], ],
}, },
], ],
end_time: end_time || '2022-01-31T23:59:59Z', end_time: end_time || formatISO(sub(new Date(), { months: 1 })),
months: [], months: [],
start_time, start_time: isBefore(new Date(start_time), new Date(counts_start)) ? counts_start : start_time,
total: { total: {
distinct_entities: 37389, distinct_entities: 37389,
entity_clients: 37389, entity_clients: 37389,

View File

@ -1,4 +1,13 @@
import { click, findAll, fillIn, settled, visit, triggerKeyEvent } from '@ember/test-helpers'; import {
click,
findAll,
fillIn,
settled,
visit,
triggerKeyEvent,
find,
waitUntil,
} from '@ember/test-helpers';
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
@ -31,7 +40,7 @@ module('Acceptance | auth backend list', function (hooks) {
await click('[data-test-save-config="true"]'); await click('[data-test-save-config="true"]');
await visit(`/vault/access/${path1}/item/user/create`); await visit(`/vault/access/${path1}/item/user/create`);
await waitUntil(() => find('[data-test-input="username"]') && find('[data-test-textarea]'));
await fillIn('[data-test-input="username"]', user1); await fillIn('[data-test-input="username"]', user1);
await triggerKeyEvent('[data-test-input="username"]', 'keyup', 65); await triggerKeyEvent('[data-test-input="username"]', 'keyup', 65);
await fillIn('[data-test-textarea]', user1); await fillIn('[data-test-textarea]', user1);

View File

@ -7,6 +7,7 @@ import { create } from 'ember-cli-page-object';
import { clickTrigger } from 'ember-power-select/test-support/helpers'; import { clickTrigger } from 'ember-power-select/test-support/helpers';
import ss from 'vault/tests/pages/components/search-select'; import ss from 'vault/tests/pages/components/search-select';
import { import {
CHART_ELEMENTS,
generateConfigResponse, generateConfigResponse,
generateCurrentMonthResponse, generateCurrentMonthResponse,
SELECTORS, SELECTORS,
@ -78,7 +79,7 @@ module('Acceptance | clients current', function (hooks) {
assert.dom(SELECTORS.activeTab).hasText('Current month', 'current month tab is active'); assert.dom(SELECTORS.activeTab).hasText('Current month', 'current month tab is active');
assert.dom(SELECTORS.usageStats).exists('usage stats block exists'); assert.dom(SELECTORS.usageStats).exists('usage stats block exists');
assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist'); assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist');
const { clients, entity_clients, non_entity_clients } = monthly.data; const { clients, entity_clients, non_entity_clients, by_namespace } = monthly.data;
assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText(clients.toString()); assert.dom('[data-test-stat-text="total-clients"] .stat-value').hasText(clients.toString());
assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText(entity_clients.toString()); assert.dom('[data-test-stat-text="entity-clients"] .stat-value').hasText(entity_clients.toString());
assert assert
@ -87,7 +88,28 @@ module('Acceptance | clients current', function (hooks) {
assert.dom('[data-test-clients-attribution]').exists('Shows attribution area'); assert.dom('[data-test-clients-attribution]').exists('Shows attribution area');
assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart'); assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart');
assert.dom('[data-test-top-attribution]').includesText('Top namespace'); assert.dom('[data-test-top-attribution]').includesText('Top namespace');
// Filter by namespace
// check chart displays correct elements and values
for (const key in CHART_ELEMENTS) {
let namespaceNumber = by_namespace.length < 10 ? by_namespace.length : 10;
let group = find(CHART_ELEMENTS[key]);
let elementArray = Array.from(group.children);
assert.equal(elementArray.length, namespaceNumber, `renders correct number of ${key}`);
if (key === 'totalValues') {
elementArray.forEach((element, i) => {
assert.equal(element.innerHTML, `${by_namespace[i].counts.clients}`, 'displays correct value');
});
}
if (key === 'yLabels') {
elementArray.forEach((element, i) => {
assert
.dom(element.children[1])
.hasTextContaining(`${by_namespace[i].namespace_path}`, 'displays correct namespace label');
});
}
}
// FILTER BY NAMESPACE
await clickTrigger(); await clickTrigger();
await searchSelect.options.objectAt(0).click(); await searchSelect.options.objectAt(0).click();
await waitUntil(() => { await waitUntil(() => {
@ -98,7 +120,29 @@ module('Acceptance | clients current', function (hooks) {
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10'); assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10');
assert.dom('[data-test-horizontal-bar-chart]').exists('Still shows attribution bar chart'); assert.dom('[data-test-horizontal-bar-chart]').exists('Still shows attribution bar chart');
assert.dom('[data-test-top-attribution]').includesText('Top auth method'); assert.dom('[data-test-top-attribution]').includesText('Top auth method');
// Filter by auth method
// check chart displays correct elements and values
for (const key in CHART_ELEMENTS) {
const { mounts } = by_namespace[0];
let mountNumber = mounts.length < 10 ? mounts.length : 10;
let group = find(CHART_ELEMENTS[key]);
let elementArray = Array.from(group.children);
assert.equal(elementArray.length, mountNumber, `renders correct number of ${key}`);
if (key === 'totalValues') {
elementArray.forEach((element, i) => {
assert.equal(element.innerHTML, `${mounts[i].counts.clients}`, 'displays correct value');
});
}
if (key === 'yLabels') {
elementArray.forEach((element, i) => {
assert
.dom(element.children[1])
.hasTextContaining(`${mounts[i].mount_path}`, 'displays correct auth label');
});
}
}
// FILTER BY AUTH METHOD
await clickTrigger(); await clickTrigger();
await searchSelect.options.objectAt(0).click(); await searchSelect.options.objectAt(0).click();
await waitUntil(() => { await waitUntil(() => {

View File

@ -8,6 +8,7 @@ import { create } from 'ember-cli-page-object';
import { clickTrigger } from 'ember-power-select/test-support/helpers'; import { clickTrigger } from 'ember-power-select/test-support/helpers';
import ss from 'vault/tests/pages/components/search-select'; import ss from 'vault/tests/pages/components/search-select';
import { import {
CHART_ELEMENTS,
generateActivityResponse, generateActivityResponse,
generateConfigResponse, generateConfigResponse,
generateLicenseResponse, generateLicenseResponse,
@ -127,6 +128,7 @@ module('Acceptance | clients history tab', function (hooks) {
'Date range shows dates correctly parsed activity response' 'Date range shows dates correctly parsed activity response'
); );
assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist'); assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist');
const { by_namespace } = activity.data;
const { clients, entity_clients, non_entity_clients } = activity.data.total; const { clients, entity_clients, non_entity_clients } = activity.data.total;
assert assert
.dom('[data-test-stat-text="total-clients"] .stat-value') .dom('[data-test-stat-text="total-clients"] .stat-value')
@ -140,6 +142,26 @@ module('Acceptance | clients history tab', function (hooks) {
assert.dom('[data-test-clients-attribution]').exists('Shows attribution area'); assert.dom('[data-test-clients-attribution]').exists('Shows attribution area');
assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart'); assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart');
assert.dom('[data-test-top-attribution]').includesText('Top namespace'); assert.dom('[data-test-top-attribution]').includesText('Top namespace');
// check chart displays correct elements and values
for (const key in CHART_ELEMENTS) {
let namespaceNumber = by_namespace.length < 10 ? by_namespace.length : 10;
let group = find(CHART_ELEMENTS[key]);
let elementArray = Array.from(group.children);
assert.equal(elementArray.length, namespaceNumber, `renders correct number of ${key}`);
if (key === 'totalValues') {
elementArray.forEach((element, i) => {
assert.equal(element.innerHTML, `${by_namespace[i].counts.clients}`, 'displays correct value');
});
}
if (key === 'yLabels') {
elementArray.forEach((element, i) => {
assert
.dom(element.children[1])
.hasTextContaining(`${by_namespace[i].namespace_path}`, 'displays correct namespace label');
});
}
}
}); });
test('filters correctly on history with full data', async function (assert) { test('filters correctly on history with full data', async function (assert) {
@ -164,8 +186,9 @@ module('Acceptance | clients history tab', function (hooks) {
assert.dom(SELECTORS.activeTab).hasText('History', 'history tab is active'); assert.dom(SELECTORS.activeTab).hasText('History', 'history tab is active');
assert.dom(SELECTORS.usageStats).exists('usage stats block exists'); assert.dom(SELECTORS.usageStats).exists('usage stats block exists');
assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist'); assert.dom('[data-test-stat-text-container]').exists({ count: 3 }, '3 stat texts exist');
const { clients } = activity.data.total; const { total, by_namespace } = activity.data;
// Filter by namespace
// FILTER BY NAMESPACE
await clickTrigger(); await clickTrigger();
await searchSelect.options.objectAt(0).click(); await searchSelect.options.objectAt(0).click();
await waitUntil(() => { await waitUntil(() => {
@ -177,7 +200,29 @@ module('Acceptance | clients history tab', function (hooks) {
assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10'); assert.dom('[data-test-stat-text="non-entity-clients"] .stat-value').hasText('10');
assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart'); assert.dom('[data-test-horizontal-bar-chart]').exists('Shows attribution bar chart');
assert.dom('[data-test-top-attribution]').includesText('Top auth method'); assert.dom('[data-test-top-attribution]').includesText('Top auth method');
// Filter by auth method
// check chart displays correct elements and values
for (const key in CHART_ELEMENTS) {
const { mounts } = by_namespace[0];
let mountNumber = mounts.length < 10 ? mounts.length : 10;
let group = find(CHART_ELEMENTS[key]);
let elementArray = Array.from(group.children);
assert.equal(elementArray.length, mountNumber, `renders correct number of ${key}`);
if (key === 'totalValues') {
elementArray.forEach((element, i) => {
assert.equal(element.innerHTML, `${mounts[i].counts.clients}`, 'displays correct value');
});
}
if (key === 'yLabels') {
elementArray.forEach((element, i) => {
assert
.dom(element.children[1])
.hasTextContaining(`${mounts[i].mount_path}`, 'displays correct auth label');
});
}
}
// FILTER BY AUTH METHOD
await clickTrigger(); await clickTrigger();
await searchSelect.options.objectAt(0).click(); await searchSelect.options.objectAt(0).click();
await settled(); await settled();
@ -192,7 +237,7 @@ module('Acceptance | clients history tab', function (hooks) {
assert.dom('[data-test-top-attribution]').includesText('Top namespace'); assert.dom('[data-test-top-attribution]').includesText('Top namespace');
assert assert
.dom('[data-test-stat-text="total-clients"] .stat-value') .dom('[data-test-stat-text="total-clients"] .stat-value')
.hasText(clients.toString(), 'total clients stat is back to unfiltered value'); .hasText(total.clients.toString(), 'total clients stat is back to unfiltered value');
}); });
test('shows warning if upgrade happened within license period', async function (assert) { test('shows warning if upgrade happened within license period', async function (assert) {
@ -292,7 +337,7 @@ module('Acceptance | clients history tab', function (hooks) {
assert.equal(currentURL(), '/vault/clients/history', 'clients/history URL is correct'); assert.equal(currentURL(), '/vault/clients/history', 'clients/history URL is correct');
assert assert
.dom(SELECTORS.emptyStateTitle) .dom(SELECTORS.emptyStateTitle)
.includesText('No start date found', 'Empty state shows no billing start date'); .includesText('start date found', 'Empty state shows no billing start date');
await click(SELECTORS.monthDropdown); await click(SELECTORS.monthDropdown);
await click(this.element.querySelector('[data-test-month-list] button:not([disabled])')); await click(this.element.querySelector('[data-test-month-list] button:not([disabled])'));
await click(SELECTORS.yearDropdown); await click(SELECTORS.yearDropdown);

View File

@ -27,6 +27,15 @@ export const SELECTORS = {
dateDropdownSubmit: '[data-test-date-dropdown-submit]', dateDropdownSubmit: '[data-test-date-dropdown-submit]',
}; };
export const CHART_ELEMENTS = {
entityClientDataBars: '[data-test-group="entity_clients"]',
nonEntityDataBars: '[data-test-group="non_entity_clients"]',
yLabels: '[data-test-group="y-labels"]',
actionBars: '[data-test-group="action-bars"]',
labelActionBars: '[data-test-group="label-action-bars"]',
totalValues: '[data-test-group="total-values"]',
};
export function sendResponse(data, httpStatus = 200) { export function sendResponse(data, httpStatus = 200) {
if (httpStatus === 403) { if (httpStatus === 403) {
return [ return [
@ -60,7 +69,7 @@ function generateNamespaceBlock(idx = 0, skipMounts = false) {
let mountCount = 1; let mountCount = 1;
const nsBlock = { const nsBlock = {
namespace_id: `${idx}UUID`, namespace_id: `${idx}UUID`,
namespace_path: `my-namespace-${idx}/`, namespace_path: `${idx}/namespace`,
counts: { counts: {
clients: mountCount * 15, clients: mountCount * 15,
entity_clients: mountCount * 5, entity_clients: mountCount * 5,