Skip to content

Commit 2e81e97

Browse files
authored
fix: Fixes core legend sync when item highlight exits (#136)
1 parent 512e840 commit 2e81e97

File tree

8 files changed

+244
-18
lines changed

8 files changed

+244
-18
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useRef, useState } from "react";
5+
import Highcharts from "highcharts";
6+
import { omit } from "lodash";
7+
8+
import ColumnLayout from "@cloudscape-design/components/column-layout";
9+
import {
10+
colorChartsPaletteCategorical1,
11+
colorChartsPaletteCategorical2,
12+
colorChartsPaletteCategorical3,
13+
} from "@cloudscape-design/design-tokens";
14+
15+
import { ChartSeriesMarker } from "../../lib/components/internal/components/series-marker";
16+
import CoreChart from "../../lib/components/internal-do-not-use/core-chart";
17+
import { CoreLegend } from "../../lib/components/internal-do-not-use/core-legend";
18+
import { CoreChartProps } from "../../src/core/interfaces";
19+
import { LegendItem } from "../../src/internal/components/interfaces";
20+
import { dateFormatter } from "../common/formatters";
21+
import { useChartSettings } from "../common/page-settings";
22+
import { Page } from "../common/templates";
23+
import pseudoRandom from "../utils/pseudo-random";
24+
25+
function randomInt(min: number, max: number) {
26+
return min + Math.floor(pseudoRandom() * (max - min));
27+
}
28+
29+
const baseline = [
30+
{ x: 1600984800000, y: 58020 },
31+
{ x: 1600985700000, y: 102402 },
32+
{ x: 1600986600000, y: 104920 },
33+
{ x: 1600987500000, y: 94031 },
34+
{ x: 1600988400000, y: 125021 },
35+
{ x: 1600989300000, y: 159219 },
36+
{ x: 1600990200000, y: 193082 },
37+
{ x: 1600991100000, y: 162592 },
38+
{ x: 1600992000000, y: 274021 },
39+
{ x: 1600992900000, y: 264286 },
40+
];
41+
42+
const dataA = baseline.map(({ x, y }) => ({ x, y }));
43+
const dataB = baseline.map(({ x, y }) => ({ x, y: y === null ? null : y + randomInt(-100000, 100000) }));
44+
const dataC = baseline.map(({ x, y }) => ({ x, y: y === null ? null : y + randomInt(-150000, 50000) }));
45+
46+
const series = [
47+
{
48+
name: "Series A",
49+
type: "line" as const,
50+
data: dataA,
51+
color: colorChartsPaletteCategorical1,
52+
},
53+
{
54+
name: "Series B",
55+
type: "line" as const,
56+
data: dataB,
57+
color: colorChartsPaletteCategorical2,
58+
},
59+
{
60+
name: "Series C",
61+
type: "line" as const,
62+
data: dataC,
63+
color: colorChartsPaletteCategorical3,
64+
},
65+
];
66+
67+
export default function LegendEventsDemo() {
68+
const [visibleItems, setVisibleItems] = useState(new Set<string>(["Series A", "Series B", "Series C"]));
69+
const [highlightedItem, setHighlightedItem] = useState<null | string>(null);
70+
71+
const { chartProps } = useChartSettings();
72+
const chart1API = useRef<CoreChartProps.ChartAPI>(null) as React.MutableRefObject<CoreChartProps.ChartAPI>;
73+
const chart2API = useRef<CoreChartProps.ChartAPI>(null) as React.MutableRefObject<CoreChartProps.ChartAPI>;
74+
75+
const createOnItemHighlight =
76+
(referrer: "legend" | "chart1" | "chart2") =>
77+
({ detail }: { detail: { item: LegendItem } }) => {
78+
setHighlightedItem(detail.item.name);
79+
if (referrer !== "chart1") {
80+
chart1API.current.highlightItems([detail.item.name]);
81+
}
82+
if (referrer !== "chart2") {
83+
chart2API.current.highlightItems([detail.item.name]);
84+
}
85+
};
86+
const createOnClearHighlight =
87+
(referrer: "legend" | "chart1" | "chart2") =>
88+
({ detail }: { detail: { isApiCall: boolean } }) => {
89+
if (!detail.isApiCall) {
90+
setHighlightedItem(null);
91+
if (referrer !== "chart1") {
92+
chart1API.current.clearChartHighlight();
93+
}
94+
if (referrer !== "chart2") {
95+
chart2API.current.clearChartHighlight();
96+
}
97+
}
98+
};
99+
const createOnHighlight =
100+
(referrer: "chart1" | "chart2") =>
101+
({ detail }: { detail: { point: null | Highcharts.Point; isApiCall: boolean } }) => {
102+
if (detail.point && !detail.isApiCall) {
103+
setHighlightedItem(detail.point.name);
104+
if (referrer !== "chart1") {
105+
chart1API.current.highlightChartPoint(detail.point);
106+
}
107+
if (referrer !== "chart2") {
108+
chart2API.current.highlightChartPoint(detail.point);
109+
}
110+
}
111+
};
112+
113+
const legendItems: LegendItem[] = series.map((s) => ({
114+
id: s.name,
115+
name: s.name,
116+
marker: <ChartSeriesMarker color={s.color} type="line" />,
117+
visible: visibleItems.has(s.name),
118+
highlighted: highlightedItem === s.name,
119+
}));
120+
121+
return (
122+
<Page
123+
title="Events sync"
124+
subtitle="Demonstrates the highlight synchronization between standalone legends and charts"
125+
>
126+
<ColumnLayout columns={2}>
127+
<CoreLegend
128+
items={legendItems}
129+
title="Standalone legend 1"
130+
onItemHighlight={createOnItemHighlight("legend")}
131+
onClearHighlight={createOnClearHighlight("legend")?.bind(null, { detail: { isApiCall: false } })}
132+
onVisibleItemsChange={({ detail }) => setVisibleItems(new Set(detail.items))}
133+
/>
134+
135+
<CoreLegend
136+
items={legendItems}
137+
title="Standalone legend 2"
138+
onItemHighlight={createOnItemHighlight("legend")}
139+
onClearHighlight={createOnClearHighlight("legend")?.bind(null, { detail: { isApiCall: false } })}
140+
onVisibleItemsChange={({ detail }) => setVisibleItems(new Set(detail.items))}
141+
/>
142+
143+
<CoreChart
144+
{...omit(chartProps.cartesian, "ref")}
145+
highcharts={Highcharts}
146+
callback={(chartApi) => (chart1API.current = chartApi)}
147+
ariaLabel="Chart 1"
148+
options={{
149+
series: series,
150+
xAxis: [{ type: "datetime", title: { text: "Time (UTC)" }, valueFormatter: dateFormatter }],
151+
yAxis: [{ title: { text: "Events" } }],
152+
}}
153+
legend={{ title: "Chart legend 1" }}
154+
onLegendItemHighlight={createOnItemHighlight("chart1")}
155+
onClearHighlight={createOnClearHighlight("chart1")}
156+
onHighlight={createOnHighlight("chart1")}
157+
visibleItems={[...visibleItems]}
158+
onVisibleItemsChange={({ detail }) =>
159+
setVisibleItems(new Set(detail.items.filter((i) => i.visible).map((i) => i.name)))
160+
}
161+
/>
162+
163+
<CoreChart
164+
{...omit(chartProps.cartesian, "ref")}
165+
highcharts={Highcharts}
166+
callback={(chartApi) => (chart2API.current = chartApi)}
167+
ariaLabel="Chart 2"
168+
options={{
169+
series: series,
170+
xAxis: [{ type: "datetime", title: { text: "Time (UTC)" }, valueFormatter: dateFormatter }],
171+
yAxis: [{ title: { text: "Events" } }],
172+
}}
173+
legend={{ title: "Chart legend 2" }}
174+
onLegendItemHighlight={createOnItemHighlight("chart2")}
175+
onClearHighlight={createOnClearHighlight("chart2")}
176+
onHighlight={createOnHighlight("chart2")}
177+
visibleItems={[...visibleItems]}
178+
onVisibleItemsChange={({ detail }) =>
179+
setVisibleItems(new Set(detail.items.filter((i) => i.visible).map((i) => i.name)))
180+
}
181+
/>
182+
</ColumnLayout>
183+
</Page>
184+
);
185+
}

src/core/__tests__/chart-core-api.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import { CoreChartProps } from "../../../lib/components/core/interfaces";
99
import { renderChart, selectLegendItem } from "./common";
1010
import { HighchartsTestHelper } from "./highcharts-utils";
1111

12-
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
13-
1412
const hc = new HighchartsTestHelper(highcharts);
1513

1614
const series: Highcharts.SeriesOptionsType[] = [
@@ -68,7 +66,7 @@ describe("CoreChart: API tests", () => {
6866

6967
act(() => hc.highlightChartPoint(0, 0));
7068
act(() => hc.leaveChartPoint(0, 0));
71-
await clearHighlightPause();
69+
await hc.clearHighlightPause();
7270

7371
expect(onClearHighlight).toHaveBeenCalledWith(expect.objectContaining({ detail: { isApiCall: false } }));
7472
});

src/core/__tests__/chart-core-highlight.test.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import { HighchartsTestHelper } from "./highcharts-utils";
1010

1111
const hc = new HighchartsTestHelper(highcharts);
1212

13-
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
14-
1513
describe("CoreChart: highlight", () => {
1614
test("highlights linked errorbar when target series is highlighted", async () => {
1715
renderChart({
@@ -55,7 +53,7 @@ describe("CoreChart: highlight", () => {
5553
expect(hc.getChartSeries(3).state).toBe("hover");
5654

5755
act(() => hc.leaveChartPoint(1, 0));
58-
await clearHighlightPause();
56+
await hc.clearHighlightPause();
5957

6058
expect(hc.getChartSeries(0).state).toBe("");
6159
expect(hc.getChartSeries(1).state).toBe("");
@@ -90,7 +88,7 @@ describe("CoreChart: highlight", () => {
9088
expect(hc.getChartPoint(1, 1).state).toBe("inactive");
9189

9290
act(() => hc.leaveChartPoint(1, 0));
93-
await clearHighlightPause();
91+
await hc.clearHighlightPause();
9492

9593
expect(hc.getChartSeries(0).state).toBe("");
9694
expect(hc.getChartPoint(0, 0).state).toBe("");

src/core/__tests__/chart-core-legend.test.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createChartWrapper,
1212
hoverLegendItem,
1313
hoverSecondaryLegendItem,
14+
leaveLegendItem,
1415
renderChart,
1516
renderStatefulChart,
1617
selectSecondaryLegendItem,
@@ -51,6 +52,8 @@ const series: Highcharts.SeriesOptionsType[] = [
5152
},
5253
];
5354

55+
const lineSeries = series.filter((s) => s.type === "line");
56+
5457
const yAxes: Highcharts.YAxisOptions[] = [
5558
{ id: "primary", opposite: false },
5659
{ id: "secondary", opposite: true },
@@ -88,7 +91,6 @@ const getItem = (index: number, options?: { active?: boolean; dimmed?: boolean }
8891
createChartWrapper().findLegend()!.findAll(getItemSelector(options))[index];
8992
const mouseOver = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
9093
const mouseOut = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
91-
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
9294
const mouseLeavePause = () => new Promise((resolve) => setTimeout(resolve, 300));
9395

9496
describe("CoreChart: legend", () => {
@@ -180,7 +182,7 @@ describe("CoreChart: legend", () => {
180182
expect(hc.getPlotLinesById("L3").map((l) => l.svgElem.opacity)).toEqual([1, 1]);
181183

182184
act(() => mouseOut(getItem(0).getElement()));
183-
await clearHighlightPause();
185+
await hc.clearHighlightPause();
184186
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual([
185187
"L1",
186188
"Line 3",
@@ -213,7 +215,7 @@ describe("CoreChart: legend", () => {
213215
expect(hc.getChartPoint(0, 2).state).toBe(undefined);
214216

215217
act(() => mouseOut(getItem(0).getElement()));
216-
await clearHighlightPause();
218+
await hc.clearHighlightPause();
217219
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["P1", "Pie 3"]);
218220
expect(hc.getChartPoint(0, 0).state).toBe("");
219221
expect(hc.getChartPoint(0, 2).state).toBe("");
@@ -225,7 +227,7 @@ describe("CoreChart: legend", () => {
225227
expect(hc.getChartPoint(0, 2).state).toBe("hover");
226228

227229
act(() => mouseOut(getItem(2).getElement()));
228-
await clearHighlightPause();
230+
await hc.clearHighlightPause();
229231
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["P1", "Pie 3"]);
230232
expect(hc.getChartPoint(0, 0).state).toBe("");
231233
expect(hc.getChartPoint(0, 2).state).toBe("");
@@ -255,7 +257,7 @@ describe("CoreChart: legend", () => {
255257

256258
act(() => hc.leaveChartPoint(2, 0));
257259
await mouseLeavePause();
258-
await clearHighlightPause();
260+
await hc.clearHighlightPause();
259261
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual([
260262
"L1",
261263
"Line 3",
@@ -283,7 +285,7 @@ describe("CoreChart: legend", () => {
283285

284286
act(() => hc.leaveChartPoint(0, 2));
285287
await mouseLeavePause();
286-
await clearHighlightPause();
288+
await hc.clearHighlightPause();
287289
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["P1", "Pie 3"]);
288290
},
289291
);
@@ -388,7 +390,7 @@ describe("CoreChart: legend", () => {
388390

389391
act(() => mouseOut(getItem(2).getElement()));
390392

391-
await clearHighlightPause();
393+
await hc.clearHighlightPause();
392394
expect(wrapper.findLegend()!.findItemTooltip()).toBe(null);
393395
});
394396

@@ -447,7 +449,7 @@ describe("CoreChart: legend", () => {
447449

448450
act(() => mouseOut(getItem(2).getElement()));
449451

450-
await clearHighlightPause();
452+
await hc.clearHighlightPause();
451453
expect(wrapper.findLegend()!.findItemTooltip()).toBe(null);
452454
});
453455

@@ -476,9 +478,10 @@ describe("CoreChart: legend", () => {
476478
});
477479
});
478480

479-
test("calls onLegendItemHighlight when hovering over a legend item", () => {
481+
test("calls onLegendItemHighlight and onClearHighlight when hovering over a legend item", async () => {
480482
const onLegendItemHighlight = vi.fn();
481-
const { wrapper } = renderChart({ highcharts, options: { series }, onLegendItemHighlight });
483+
const onClearHighlight = vi.fn();
484+
const { wrapper } = renderChart({ highcharts, options: { series }, onLegendItemHighlight, onClearHighlight });
482485

483486
hoverLegendItem(0, wrapper);
484487

@@ -495,6 +498,34 @@ describe("CoreChart: legend", () => {
495498
},
496499
}),
497500
);
501+
502+
leaveLegendItem(0, wrapper);
503+
await hc.clearHighlightPause();
504+
505+
expect(onClearHighlight).toHaveBeenCalled();
506+
});
507+
508+
test("legend highlight state stays after re-render", () => {
509+
const { wrapper, rerender } = renderChart({ highcharts, options: { series } });
510+
511+
hoverLegendItem(0, wrapper);
512+
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["L1"]);
513+
514+
rerender({ highcharts, options: { series } });
515+
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["L1"]);
516+
});
517+
518+
test("legend highlight state is reset after re-render if items structure change", () => {
519+
const { wrapper, rerender } = renderChart({ highcharts, options: { series: lineSeries } });
520+
521+
hoverLegendItem(0, wrapper);
522+
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["L1"]);
523+
524+
rerender({ highcharts, options: { series: lineSeries.filter((s) => s.name !== "L2") } });
525+
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["L1"]);
526+
527+
rerender({ highcharts, options: { series: lineSeries.filter((s) => s.name !== "L1") } });
528+
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["L2", "Line 3"]);
498529
});
499530
});
500531

src/core/__tests__/common.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ export function hoverLegendItem(index: number, wrapper: BaseChartWrapper = creat
9393
fireEvent.mouseOver(wrapper.findLegend()!.findItems()[index].getElement());
9494
});
9595
}
96+
export function leaveLegendItem(index: number, wrapper: BaseChartWrapper = createChartWrapper()) {
97+
act(() => {
98+
fireEvent.mouseLeave(wrapper.findLegend()!.findItems()[index].getElement());
99+
});
100+
}
96101

97102
export function selectSecondaryLegendItem(index: number, wrapper: ExtendedTestWrapper = createChartWrapper()) {
98103
act(() => wrapper.findSecondaryLegend()!.findItems()[index].click());

src/core/__tests__/highcharts-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export class HighchartsTestHelper {
4141
.axes.flatMap((axis) => (axis as any).plotLinesAndBands)
4242
.filter((plotLine) => plotLine.options.id === id);
4343
}
44+
45+
public clearHighlightPause() {
46+
return new Promise((resolve) => setTimeout(resolve, 100));
47+
}
4448
}
4549

4650
export class ChartRendererStub {

0 commit comments

Comments
 (0)