mirror of
https://github.com/apache/superset.git
synced 2026-07-29 18:12:29 +00:00
Compare commits
9 Commits
dependabot
...
fix-calend
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b79983f83 | ||
|
|
bd0a217d84 | ||
|
|
9c6c8e231a | ||
|
|
9ffca6df48 | ||
|
|
5c80d38e68 | ||
|
|
e827b0566b | ||
|
|
8d4dfba973 | ||
|
|
251d3f8895 | ||
|
|
059c01fc28 |
@@ -75,7 +75,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
@@ -23,11 +23,23 @@ import { SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import CalHeatMapImport from './vendor/cal-heatmap';
|
||||
import { convertUTCTimestampToLocal } from './utils';
|
||||
import {
|
||||
getCalendarTooltipClassName,
|
||||
removeDisconnectedCalendarTooltips,
|
||||
} from './tooltip';
|
||||
|
||||
// The vendor file is @ts-nocheck, so its export lacks type info.
|
||||
// Define a minimal constructor interface for use in this file.
|
||||
interface CalHeatMapInstance {
|
||||
init(config: Record<string, unknown>): void;
|
||||
destroy(): null;
|
||||
}
|
||||
|
||||
const calendarInstances = new WeakMap<HTMLElement, CalHeatMapInstance[]>();
|
||||
|
||||
function destroyCalendarInstances(element: HTMLElement) {
|
||||
calendarInstances.get(element)?.forEach(calendar => calendar.destroy());
|
||||
calendarInstances.delete(element);
|
||||
}
|
||||
const CalHeatMap = CalHeatMapImport as unknown as new () => CalHeatMapInstance;
|
||||
|
||||
@@ -78,6 +90,13 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
theme,
|
||||
} = props;
|
||||
|
||||
destroyCalendarInstances(element);
|
||||
removeDisconnectedCalendarTooltips();
|
||||
|
||||
const tooltipClassName = getCalendarTooltipClassName(element);
|
||||
const instances: CalHeatMapInstance[] = [];
|
||||
calendarInstances.set(element, instances);
|
||||
|
||||
const container = d3Select(element)
|
||||
.classed('superset-legacy-chart-calendar', true)
|
||||
.style('height', height);
|
||||
@@ -112,12 +131,13 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
const colorScheme = getSequentialSchemeRegistry().get(linearColorScheme);
|
||||
const colorScale = colorScheme
|
||||
? colorScheme.createLinearScale(extents)
|
||||
: (_v: number) => '#ccc'; // fallback if scheme not found
|
||||
: () => '#ccc'; // fallback if scheme not found
|
||||
|
||||
const legend = d3Range(steps).map(i => extents[0] + step * i);
|
||||
const legendColors = legend.map(x => colorScale(x));
|
||||
|
||||
const cal = new CalHeatMap();
|
||||
instances.push(cal);
|
||||
cal.init({
|
||||
start: convertUTCTimestampToLocal(data.start),
|
||||
data: timestamps,
|
||||
@@ -130,6 +150,7 @@ function Calendar(element: HTMLElement, props: CalendarProps) {
|
||||
legendCellPadding: 2,
|
||||
legendCellRadius: cellRadius,
|
||||
tooltip: true,
|
||||
tooltipClassName,
|
||||
domain: domainGranularity,
|
||||
subDomain: subdomainGranularity,
|
||||
range: data.range,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { reactify } from '@superset-ui/core';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
import Component from './Calendar';
|
||||
import { scheduleCalendarTooltipCleanup } from './tooltip';
|
||||
|
||||
// Type-erase the render function to allow flexible prop spreading in the wrapper.
|
||||
// The Calendar render function has typed props, but the wrapper passes props via spread
|
||||
@@ -29,6 +30,7 @@ const ReactComponent = reactify(
|
||||
container: HTMLDivElement,
|
||||
props: Record<string, unknown>,
|
||||
) => void,
|
||||
{ componentWillUnmount: scheduleCalendarTooltipCleanup },
|
||||
);
|
||||
|
||||
interface CalendarWrapperProps {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export const CALENDAR_TOOLTIP_CLASS = 'superset-legacy-chart-calendar-tooltip';
|
||||
|
||||
const tooltipOwners = new Map<string, HTMLElement>();
|
||||
const tooltipClassNames = new WeakMap<HTMLElement, string>();
|
||||
let tooltipId = 0;
|
||||
|
||||
function removeTooltipsByClassName(className: string) {
|
||||
document
|
||||
.querySelectorAll(`.${className}`)
|
||||
.forEach(tooltip => tooltip.remove());
|
||||
}
|
||||
|
||||
export function getCalendarTooltipClassName(element: HTMLElement) {
|
||||
const existingClassName = tooltipClassNames.get(element);
|
||||
if (existingClassName) {
|
||||
// A disconnect sweep may have removed this owner while the WeakMap entry survived.
|
||||
tooltipOwners.set(existingClassName, element);
|
||||
return existingClassName;
|
||||
}
|
||||
|
||||
tooltipId += 1;
|
||||
const className = `${CALENDAR_TOOLTIP_CLASS}-${tooltipId}`;
|
||||
tooltipClassNames.set(element, className);
|
||||
tooltipOwners.set(className, element);
|
||||
return className;
|
||||
}
|
||||
|
||||
export function removeDisconnectedCalendarTooltips() {
|
||||
tooltipOwners.forEach((element, className) => {
|
||||
if (!element.isConnected) {
|
||||
removeTooltipsByClassName(className);
|
||||
tooltipOwners.delete(className);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function scheduleCalendarTooltipCleanup() {
|
||||
// Wait for reactify's passive-effect cleanup to detach the owner before checking
|
||||
// isConnected. Removing a tip node does not reset d3-tip's internal pointer;
|
||||
// redraws separately destroy their prior CalHeatMap instances before reuse.
|
||||
window.setTimeout(removeDisconnectedCalendarTooltips, 0);
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
import d3tip from 'd3-tip';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getContrastingColor } from '@superset-ui/core';
|
||||
import { CALENDAR_TOOLTIP_CLASS } from '../tooltip';
|
||||
|
||||
var d3 = typeof require === 'function' ? require('d3') : window.d3;
|
||||
|
||||
@@ -22,7 +23,7 @@ var CalHeatMap = function () {
|
||||
|
||||
var self = this;
|
||||
self.tip = d3tip()
|
||||
.attr('class', 'd3-tip')
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(
|
||||
@@ -33,7 +34,7 @@ var CalHeatMap = function () {
|
||||
`,
|
||||
);
|
||||
self.legendTip = d3tip()
|
||||
.attr('class', 'd3-tip')
|
||||
.attr('class', `d3-tip ${CALENDAR_TOOLTIP_CLASS}`)
|
||||
.direction('n')
|
||||
.offset([-5, 0])
|
||||
.html(d => self.options.valueFormatter(d));
|
||||
@@ -274,6 +275,8 @@ var CalHeatMap = function () {
|
||||
|
||||
tooltip: false,
|
||||
|
||||
tooltipClassName: null,
|
||||
|
||||
// ================================================
|
||||
// EVENTS CALLBACK
|
||||
// ================================================
|
||||
@@ -780,6 +783,16 @@ var CalHeatMap = function () {
|
||||
if (self.options.paintOnLoad) {
|
||||
_initCalendar();
|
||||
}
|
||||
|
||||
var tooltipClassName = [
|
||||
'd3-tip',
|
||||
CALENDAR_TOOLTIP_CLASS,
|
||||
self.options.tooltipClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
self.tip.attr('class', tooltipClassName);
|
||||
self.legendTip.attr('class', tooltipClassName);
|
||||
self.root.call(self.tip);
|
||||
self.root.call(self.legendTip);
|
||||
|
||||
@@ -3444,6 +3457,14 @@ CalHeatMap.prototype = {
|
||||
destroy: function (callback) {
|
||||
'use strict';
|
||||
|
||||
this.tip.destroy();
|
||||
this.legendTip.destroy();
|
||||
|
||||
// init() can fail validation before creating the calendar root.
|
||||
if (!this.root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.root
|
||||
.transition()
|
||||
.duration(this.options.animationDuration)
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import { act, cleanup, render, screen } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
CALENDAR_TOOLTIP_CLASS,
|
||||
getCalendarTooltipClassName,
|
||||
} from '../src/tooltip';
|
||||
|
||||
interface MockCalHeatMapConfig {
|
||||
itemSelector: Element;
|
||||
tooltipClassName: string;
|
||||
}
|
||||
|
||||
type MetricNameInput = string | string[];
|
||||
|
||||
let mockNextInstanceId = 0;
|
||||
let mockNextOwnerId = 0;
|
||||
let mockInitCallCount = 0;
|
||||
let mockThrowOnInitCall: number | null = null;
|
||||
|
||||
const mockTheme = {
|
||||
colorBgElevated: '#ffffff',
|
||||
};
|
||||
|
||||
jest.mock('../src/vendor/cal-heatmap', () => ({
|
||||
__esModule: true,
|
||||
default: class MockCalHeatMap {
|
||||
private tooltips: HTMLElement[] = [];
|
||||
|
||||
init(config: MockCalHeatMapConfig) {
|
||||
const {
|
||||
CALENDAR_TOOLTIP_CLASS: mockCalendarTooltipClass,
|
||||
} = require('../src/tooltip');
|
||||
|
||||
mockInitCallCount += 1;
|
||||
if (mockThrowOnInitCall === mockInitCallCount) {
|
||||
throw new Error('Mock CalHeatMap init failure');
|
||||
}
|
||||
|
||||
mockNextInstanceId += 1;
|
||||
|
||||
const owner = config.itemSelector.closest(
|
||||
'.superset-legacy-chart-calendar',
|
||||
) as HTMLElement | null;
|
||||
if (!owner) {
|
||||
throw new Error('Expected tooltip owner calendar container');
|
||||
}
|
||||
|
||||
if (!owner.dataset.tooltipOwner) {
|
||||
mockNextOwnerId += 1;
|
||||
owner.dataset.tooltipOwner = `calendar-owner-${mockNextOwnerId}`;
|
||||
}
|
||||
|
||||
const { tooltipOwner } = owner.dataset;
|
||||
if (!tooltipOwner) {
|
||||
throw new Error('Expected owner-specific tooltip marker');
|
||||
}
|
||||
|
||||
this.tooltips = [0, 1].map(index => {
|
||||
const tooltip = global.document.createElement('div');
|
||||
tooltip.className = [
|
||||
'd3-tip',
|
||||
mockCalendarTooltipClass,
|
||||
config.tooltipClassName,
|
||||
].join(' ');
|
||||
tooltip.dataset.tooltipOwner = tooltipOwner;
|
||||
tooltip.dataset.tooltipInstance = String(mockNextInstanceId);
|
||||
tooltip.dataset.tooltipIndex = String(index);
|
||||
tooltip.textContent = `${tooltipOwner}-tooltip-${mockNextInstanceId}-${index}`;
|
||||
global.document.body.appendChild(tooltip);
|
||||
return tooltip;
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.tooltips.forEach(tooltip => tooltip.remove());
|
||||
this.tooltips = [];
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const ReactCalendar = require('../src/ReactCalendar').default;
|
||||
const Calendar = require('../src/Calendar').default;
|
||||
|
||||
interface CalendarHarnessProps {
|
||||
firstMetricNames: MetricNameInput;
|
||||
secondMetricNames?: MetricNameInput;
|
||||
showFirstCalendar?: boolean;
|
||||
}
|
||||
|
||||
const CALENDAR_START = 1704067200000;
|
||||
|
||||
function createCalendarProps(metricNames: MetricNameInput) {
|
||||
const normalizedMetricNames = Array.isArray(metricNames)
|
||||
? metricNames
|
||||
: [metricNames];
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: Object.fromEntries(
|
||||
normalizedMetricNames.map((metricName, index) => [
|
||||
metricName,
|
||||
{
|
||||
[String(CALENDAR_START)]: index * 2 + 1,
|
||||
[String(CALENDAR_START + 86400000)]: index * 2 + 2,
|
||||
},
|
||||
]),
|
||||
),
|
||||
domain: 'month',
|
||||
range: 1,
|
||||
start: CALENDAR_START,
|
||||
subdomain: 'day',
|
||||
},
|
||||
height: 160,
|
||||
domainGranularity: 'month',
|
||||
linearColorScheme: 'schemeRdYlBu',
|
||||
showLegend: false,
|
||||
showMetricName: true,
|
||||
showValues: false,
|
||||
steps: 3,
|
||||
subdomainGranularity: 'day',
|
||||
timeFormatter: (value: number | string) => String(value),
|
||||
valueFormatter: (value: number) => String(value),
|
||||
verboseMap: Object.fromEntries(
|
||||
normalizedMetricNames.map(metricName => [metricName, metricName]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const StableSecondCalendar = memo(function StableSecondCalendar({
|
||||
metricNames,
|
||||
}: {
|
||||
metricNames: MetricNameInput;
|
||||
}) {
|
||||
return (
|
||||
<div data-test="calendar-second">
|
||||
<ReactCalendar {...createCalendarProps(metricNames)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function CalendarHarness({
|
||||
firstMetricNames,
|
||||
secondMetricNames = 'second-metric',
|
||||
showFirstCalendar = true,
|
||||
}: CalendarHarnessProps) {
|
||||
return (
|
||||
<>
|
||||
{showFirstCalendar ? (
|
||||
<div data-test="calendar-first">
|
||||
<ReactCalendar {...createCalendarProps(firstMetricNames)} />
|
||||
</div>
|
||||
) : null}
|
||||
<StableSecondCalendar metricNames={secondMetricNames} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalendarOwner(testId: string) {
|
||||
const owner = screen
|
||||
.getByTestId(testId)
|
||||
.querySelector('.superset-legacy-chart-calendar');
|
||||
|
||||
if (!(owner instanceof HTMLElement)) {
|
||||
throw new Error(`Expected mounted calendar owner for ${testId}`);
|
||||
}
|
||||
|
||||
return owner;
|
||||
}
|
||||
|
||||
function getTooltipOwnerId(owner: HTMLElement) {
|
||||
const tooltipOwnerId = owner.dataset.tooltipOwner;
|
||||
|
||||
if (!tooltipOwnerId) {
|
||||
throw new Error('Expected mounted calendar to have a tooltip owner id');
|
||||
}
|
||||
|
||||
return tooltipOwnerId;
|
||||
}
|
||||
|
||||
function getOwnerTooltips(tooltipOwnerId: string) {
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`.d3-tip[data-tooltip-owner="${tooltipOwnerId}"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getTooltipsForElement(element: HTMLElement) {
|
||||
const className = getCalendarTooltipClassName(element);
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(`.${className}`));
|
||||
}
|
||||
|
||||
function getTooltipInstanceIds(tooltips: HTMLElement[]) {
|
||||
return Array.from(
|
||||
new Set(tooltips.map(tooltip => tooltip.dataset.tooltipInstance)),
|
||||
)
|
||||
.filter((tooltipInstanceId): tooltipInstanceId is string =>
|
||||
Boolean(tooltipInstanceId),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function getSingleTooltipInstanceId(tooltips: HTMLElement[]) {
|
||||
const tooltipInstanceIds = getTooltipInstanceIds(tooltips);
|
||||
|
||||
expect(tooltipInstanceIds).toHaveLength(1);
|
||||
|
||||
return tooltipInstanceIds[0];
|
||||
}
|
||||
|
||||
function flushPendingTimersIfNeeded() {
|
||||
try {
|
||||
act(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
});
|
||||
} catch {
|
||||
// Ignore tests that did not opt into fake timers.
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
flushPendingTimersIfNeeded();
|
||||
jest.useRealTimers();
|
||||
mockNextInstanceId = 0;
|
||||
mockNextOwnerId = 0;
|
||||
mockInitCallCount = 0;
|
||||
mockThrowOnInitCall = null;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('rerender and unmount clean up only the affected calendar tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<CalendarHarness firstMetricNames="first-metric-initial" />,
|
||||
);
|
||||
|
||||
const firstCalendarOwner = getCalendarOwner('calendar-first');
|
||||
const secondCalendarOwner = getCalendarOwner('calendar-second');
|
||||
|
||||
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
|
||||
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
|
||||
|
||||
const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstInitialTooltips).toHaveLength(2);
|
||||
expect(secondInitialTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
|
||||
const firstInitialInstanceId =
|
||||
getSingleTooltipInstanceId(firstInitialTooltips);
|
||||
const secondInitialInstanceId = getSingleTooltipInstanceId(
|
||||
secondInitialTooltips,
|
||||
);
|
||||
|
||||
rerender(<CalendarHarness firstMetricNames="first-metric-rerendered" />);
|
||||
|
||||
const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstRerenderedTooltips).toHaveLength(2);
|
||||
expect(secondPreservedTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getSingleTooltipInstanceId(firstRerenderedTooltips)).not.toEqual(
|
||||
firstInitialInstanceId,
|
||||
);
|
||||
expect(getSingleTooltipInstanceId(secondPreservedTooltips)).toEqual(
|
||||
secondInitialInstanceId,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-rerendered"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(2);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
|
||||
flushPendingTimersIfNeeded();
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
getSingleTooltipInstanceId(getOwnerTooltips(secondTooltipOwnerId)),
|
||||
).toEqual(secondInitialInstanceId);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('multi-metric calendar rerender and unmount clean up every owned tooltip while preserving siblings', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<CalendarHarness
|
||||
firstMetricNames={['first-metric-a', 'first-metric-b']}
|
||||
secondMetricNames="second-metric-initial"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCalendarOwner = getCalendarOwner('calendar-first');
|
||||
const secondCalendarOwner = getCalendarOwner('calendar-second');
|
||||
|
||||
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
|
||||
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
|
||||
|
||||
const firstInitialTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondInitialTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstInitialTooltips).toHaveLength(4);
|
||||
expect(secondInitialTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
6,
|
||||
);
|
||||
|
||||
const firstInitialInstanceIds = getTooltipInstanceIds(firstInitialTooltips);
|
||||
const secondInitialInstanceIds = getTooltipInstanceIds(secondInitialTooltips);
|
||||
|
||||
expect(firstInitialInstanceIds).toHaveLength(2);
|
||||
expect(secondInitialInstanceIds).toHaveLength(1);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames={['first-metric-c', 'first-metric-d']}
|
||||
secondMetricNames="second-metric-initial"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstRerenderedTooltips = getOwnerTooltips(firstTooltipOwnerId);
|
||||
const secondPreservedTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(firstRerenderedTooltips).toHaveLength(4);
|
||||
expect(secondPreservedTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
6,
|
||||
);
|
||||
expect(firstInitialTooltips.every(tooltip => !tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(secondInitialTooltips.every(tooltip => tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(getTooltipInstanceIds(firstRerenderedTooltips)).toHaveLength(2);
|
||||
expect(getTooltipInstanceIds(firstRerenderedTooltips)).not.toEqual(
|
||||
firstInitialInstanceIds,
|
||||
);
|
||||
expect(getTooltipInstanceIds(secondPreservedTooltips)).toEqual(
|
||||
secondInitialInstanceIds,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames={['first-metric-c', 'first-metric-d']}
|
||||
secondMetricNames="second-metric-initial"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(4);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
6,
|
||||
);
|
||||
|
||||
flushPendingTimersIfNeeded();
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(getTooltipInstanceIds(getOwnerTooltips(secondTooltipOwnerId))).toEqual(
|
||||
secondInitialInstanceIds,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('Calendar destroys previously initialized metric instances after a later metric init failure', () => {
|
||||
const calendarOwner = document.createElement('div');
|
||||
document.body.appendChild(calendarOwner);
|
||||
|
||||
const ownerClassName = getCalendarTooltipClassName(calendarOwner);
|
||||
mockThrowOnInitCall = 2;
|
||||
|
||||
expect(() => {
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps(['failing-metric-a', 'failing-metric-b']),
|
||||
theme: mockTheme,
|
||||
});
|
||||
}).toThrow('Mock CalHeatMap init failure');
|
||||
|
||||
const failedRenderTooltips = getTooltipsForElement(calendarOwner);
|
||||
const failedRenderInstanceIds = getTooltipInstanceIds(failedRenderTooltips);
|
||||
|
||||
expect(ownerClassName).toContain(CALENDAR_TOOLTIP_CLASS);
|
||||
expect(failedRenderTooltips).toHaveLength(2);
|
||||
expect(failedRenderInstanceIds).toHaveLength(1);
|
||||
|
||||
mockThrowOnInitCall = null;
|
||||
Calendar(calendarOwner, {
|
||||
...createCalendarProps('recovered-metric'),
|
||||
theme: mockTheme,
|
||||
});
|
||||
|
||||
const recoveredTooltips = getTooltipsForElement(calendarOwner);
|
||||
|
||||
expect(failedRenderTooltips.every(tooltip => !tooltip.isConnected)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(recoveredTooltips).toHaveLength(2);
|
||||
expect(getTooltipInstanceIds(recoveredTooltips)).not.toEqual(
|
||||
failedRenderInstanceIds,
|
||||
);
|
||||
});
|
||||
|
||||
test('surviving calendar render synchronously sweeps disconnected sibling tooltips', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-initial"
|
||||
secondMetricNames="second-metric-initial"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstCalendarOwner = getCalendarOwner('calendar-first');
|
||||
const secondCalendarOwner = getCalendarOwner('calendar-second');
|
||||
|
||||
const firstTooltipOwnerId = getTooltipOwnerId(firstCalendarOwner);
|
||||
const secondTooltipOwnerId = getTooltipOwnerId(secondCalendarOwner);
|
||||
|
||||
const secondInitialInstanceId = getSingleTooltipInstanceId(
|
||||
getOwnerTooltips(secondTooltipOwnerId),
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-initial"
|
||||
secondMetricNames="second-metric-initial"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(2);
|
||||
expect(getOwnerTooltips(secondTooltipOwnerId)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CalendarHarness
|
||||
firstMetricNames="first-metric-initial"
|
||||
secondMetricNames="second-metric-rerendered"
|
||||
showFirstCalendar={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const secondRerenderedTooltips = getOwnerTooltips(secondTooltipOwnerId);
|
||||
|
||||
expect(getOwnerTooltips(firstTooltipOwnerId)).toHaveLength(0);
|
||||
expect(secondRerenderedTooltips).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(getSingleTooltipInstanceId(secondRerenderedTooltips)).not.toEqual(
|
||||
secondInitialInstanceId,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CALENDAR_TOOLTIP_CLASS,
|
||||
getCalendarTooltipClassName,
|
||||
removeDisconnectedCalendarTooltips,
|
||||
} from '../src/tooltip';
|
||||
|
||||
const createSVGPointDescriptor = Object.getOwnPropertyDescriptor(
|
||||
window.SVGSVGElement.prototype,
|
||||
'createSVGPoint',
|
||||
);
|
||||
|
||||
function createCalendarTooltip(className: string) {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = `d3-tip ${CALENDAR_TOOLTIP_CLASS} ${className}`;
|
||||
document.body.appendChild(tooltip);
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function installCreateSVGPointMock() {
|
||||
Object.defineProperty(window.SVGSVGElement.prototype, 'createSVGPoint', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matrixTransform: () => ({ x: 0, y: 0 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function restoreCreateSVGPointMock() {
|
||||
if (createSVGPointDescriptor) {
|
||||
Object.defineProperty(
|
||||
window.SVGSVGElement.prototype,
|
||||
'createSVGPoint',
|
||||
createSVGPointDescriptor,
|
||||
);
|
||||
} else {
|
||||
delete window.SVGSVGElement.prototype.createSVGPoint;
|
||||
}
|
||||
}
|
||||
|
||||
function flushPendingTimersIfNeeded() {
|
||||
try {
|
||||
jest.runOnlyPendingTimers();
|
||||
} catch {
|
||||
// Ignore tests that did not opt into fake timers.
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
flushPendingTimersIfNeeded();
|
||||
jest.useRealTimers();
|
||||
jest.resetModules();
|
||||
restoreCreateSVGPointMock();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('getCalendarTooltipClassName creates stable owner-specific class names', () => {
|
||||
const firstCalendar = document.createElement('div');
|
||||
const secondCalendar = document.createElement('div');
|
||||
|
||||
const firstClassName = getCalendarTooltipClassName(firstCalendar);
|
||||
const secondClassName = getCalendarTooltipClassName(secondCalendar);
|
||||
|
||||
expect(firstClassName).toContain(CALENDAR_TOOLTIP_CLASS);
|
||||
expect(secondClassName).toContain(CALENDAR_TOOLTIP_CLASS);
|
||||
expect(firstClassName).not.toEqual(secondClassName);
|
||||
expect(getCalendarTooltipClassName(firstCalendar)).toEqual(firstClassName);
|
||||
});
|
||||
|
||||
test('removeDisconnectedCalendarTooltips preserves mounted calendar tooltips', () => {
|
||||
const firstCalendar = document.createElement('div');
|
||||
const secondCalendar = document.createElement('div');
|
||||
document.body.append(firstCalendar, secondCalendar);
|
||||
|
||||
const firstClassName = getCalendarTooltipClassName(firstCalendar);
|
||||
const secondClassName = getCalendarTooltipClassName(secondCalendar);
|
||||
|
||||
const firstTooltip = document.createElement('div');
|
||||
firstTooltip.className = `d3-tip ${CALENDAR_TOOLTIP_CLASS} ${firstClassName}`;
|
||||
document.body.appendChild(firstTooltip);
|
||||
|
||||
const secondTooltip = document.createElement('div');
|
||||
secondTooltip.className = `d3-tip ${CALENDAR_TOOLTIP_CLASS} ${secondClassName}`;
|
||||
document.body.appendChild(secondTooltip);
|
||||
|
||||
removeDisconnectedCalendarTooltips();
|
||||
|
||||
expect(document.querySelector(`.${firstClassName}`)).toBe(firstTooltip);
|
||||
expect(document.querySelector(`.${secondClassName}`)).toBe(secondTooltip);
|
||||
});
|
||||
|
||||
test('removeDisconnectedCalendarTooltips removes only disconnected calendar tooltips', () => {
|
||||
const firstCalendar = document.createElement('div');
|
||||
const secondCalendar = document.createElement('div');
|
||||
document.body.append(firstCalendar, secondCalendar);
|
||||
|
||||
const firstClassName = getCalendarTooltipClassName(firstCalendar);
|
||||
const secondClassName = getCalendarTooltipClassName(secondCalendar);
|
||||
firstCalendar.remove();
|
||||
|
||||
const firstTooltip = document.createElement('div');
|
||||
firstTooltip.className = `d3-tip ${CALENDAR_TOOLTIP_CLASS} ${firstClassName}`;
|
||||
document.body.appendChild(firstTooltip);
|
||||
|
||||
const secondTooltip = document.createElement('div');
|
||||
secondTooltip.className = `d3-tip ${CALENDAR_TOOLTIP_CLASS} ${secondClassName}`;
|
||||
document.body.appendChild(secondTooltip);
|
||||
|
||||
const otherTooltip = document.createElement('div');
|
||||
otherTooltip.className = 'd3-tip tooltip-other-chart';
|
||||
document.body.appendChild(otherTooltip);
|
||||
|
||||
removeDisconnectedCalendarTooltips();
|
||||
|
||||
expect(document.querySelector(`.${firstClassName}`)).toBeNull();
|
||||
expect(document.querySelector(`.${secondClassName}`)).toBe(secondTooltip);
|
||||
expect(document.querySelector('.tooltip-other-chart')).toBe(otherTooltip);
|
||||
});
|
||||
|
||||
test('reused calendar owners are re-armed for disconnected tooltip cleanup', () => {
|
||||
const calendar = document.createElement('div');
|
||||
document.body.appendChild(calendar);
|
||||
|
||||
const className = getCalendarTooltipClassName(calendar);
|
||||
const firstTooltip = createCalendarTooltip(className);
|
||||
|
||||
calendar.remove();
|
||||
removeDisconnectedCalendarTooltips();
|
||||
|
||||
expect(firstTooltip.isConnected).toBe(false);
|
||||
|
||||
document.body.appendChild(calendar);
|
||||
|
||||
expect(getCalendarTooltipClassName(calendar)).toBe(className);
|
||||
|
||||
const secondTooltip = createCalendarTooltip(className);
|
||||
|
||||
calendar.remove();
|
||||
removeDisconnectedCalendarTooltips();
|
||||
|
||||
expect(secondTooltip.isConnected).toBe(false);
|
||||
expect(document.querySelector(`.${className}`)).toBeNull();
|
||||
});
|
||||
|
||||
test('CalHeatMap tags tips per instance without removing other mounted calendars', () => {
|
||||
jest.useFakeTimers();
|
||||
installCreateSVGPointMock();
|
||||
|
||||
const firstCalendar = document.createElement('div');
|
||||
const secondCalendar = document.createElement('div');
|
||||
document.body.append(firstCalendar, secondCalendar);
|
||||
|
||||
const firstClassName = getCalendarTooltipClassName(firstCalendar);
|
||||
const secondClassName = getCalendarTooltipClassName(secondCalendar);
|
||||
|
||||
let CalHeatMap: typeof import('../src/vendor/cal-heatmap').default;
|
||||
jest.isolateModules(() => {
|
||||
// eslint-disable-next-line global-require
|
||||
CalHeatMap = require('../src/vendor/cal-heatmap').default;
|
||||
});
|
||||
|
||||
const partiallyInitializedHeatmap = new CalHeatMap();
|
||||
expect(() => partiallyInitializedHeatmap.destroy()).not.toThrow();
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
|
||||
const firstHeatmap = new CalHeatMap();
|
||||
firstHeatmap.init({
|
||||
itemSelector: firstCalendar,
|
||||
paintOnLoad: false,
|
||||
tooltip: true,
|
||||
tooltipClassName: firstClassName,
|
||||
valueFormatter: String,
|
||||
timeFormatter: String,
|
||||
});
|
||||
|
||||
const secondHeatmap = new CalHeatMap();
|
||||
secondHeatmap.init({
|
||||
itemSelector: secondCalendar,
|
||||
paintOnLoad: false,
|
||||
tooltip: true,
|
||||
tooltipClassName: secondClassName,
|
||||
valueFormatter: String,
|
||||
timeFormatter: String,
|
||||
});
|
||||
|
||||
expect(document.querySelectorAll(`.${CALENDAR_TOOLTIP_CLASS}`)).toHaveLength(
|
||||
4,
|
||||
);
|
||||
expect(document.querySelectorAll(`.${firstClassName}`)).toHaveLength(2);
|
||||
expect(document.querySelectorAll(`.${secondClassName}`)).toHaveLength(2);
|
||||
|
||||
firstHeatmap.destroy();
|
||||
flushPendingTimersIfNeeded();
|
||||
|
||||
expect(document.querySelectorAll(`.${firstClassName}`)).toHaveLength(0);
|
||||
expect(document.querySelectorAll(`.${secondClassName}`)).toHaveLength(2);
|
||||
|
||||
secondHeatmap.destroy();
|
||||
flushPendingTimersIfNeeded();
|
||||
});
|
||||
Reference in New Issue
Block a user