From e08661bc2fa49f58c548dbbaa3218ab2c5f0653d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 09:16:16 +0200 Subject: [PATCH 1/7] Change: Convert ToolTip into a function component Also add tests for the component. --- src/web/components/chart/base/ToolTip.tsx | 102 ++++++------------ .../chart/base/__tests__/ToolTip.test.tsx | 70 ++++++++++++ 2 files changed, 105 insertions(+), 67 deletions(-) create mode 100644 src/web/components/chart/base/__tests__/ToolTip.test.tsx diff --git a/src/web/components/chart/base/ToolTip.tsx b/src/web/components/chart/base/ToolTip.tsx index b7120c5189..87393d90fe 100644 --- a/src/web/components/chart/base/ToolTip.tsx +++ b/src/web/components/chart/base/ToolTip.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import styled from 'styled-components'; import {hasValue} from 'gmp/utils/identity'; import Portal from 'web/components/portal/Portal'; @@ -24,10 +24,6 @@ interface ToolTipProps { children: (args: ToolTipRenderProps) => React.ReactNode; } -interface ToolTipState { - visible: boolean; -} - const ToolTipText = styled.div` box-sizing: border-box; font-weight: bold; @@ -70,74 +66,46 @@ const ToolTipDisplay = React.forwardRef( ), ); -class ToolTip extends React.Component { - target: React.RefObject; - tooltip: React.RefObject; - - constructor(props: ToolTipProps) { - super(props); - - this.state = { - visible: false, - }; - - this.hide = this.hide.bind(this); - this.show = this.show.bind(this); - - this.target = React.createRef(); - this.tooltip = React.createRef(); - } - - show() { - this.setState({visible: true}); - } +const ToolTip = ({children, content}: ToolTipProps) => { + const [visible, setVisible] = useState(false); + const target = useRef(null); + const tooltip = useRef(null); - hide() { - this.setState({visible: false}); - } + const show = () => setVisible(true); + const hide = () => setVisible(false); - setPosition() { - const target = this.target.current; - const tooltip = this.tooltip.current; + useEffect(() => { + if (!visible) { + return; + } - if (!hasValue(target) || !hasValue(tooltip)) { - // ensure both refs have been set to not crash + const targetElement = target.current; + const tooltipElement = tooltip.current; + if (!hasValue(targetElement) || !hasValue(tooltipElement)) { return; } - const rect = target.getBoundingClientRect(); - const top = rect.top - tooltip.offsetHeight + window.scrollY; + const rect = targetElement.getBoundingClientRect(); + const top = rect.top - tooltipElement.offsetHeight + window.scrollY; const left = - rect.left + (rect.width - tooltip.offsetWidth) / 2 + window.scrollX; - - tooltip.style.top = `${top}px`; - tooltip.style.left = `${left}px`; - } - - componentDidUpdate() { - if (this.state.visible) { - this.setPosition(); - } - } - - render() { - const {children, content} = this.props; - const {visible} = this.state; - return ( - <> - {content && visible && ( - - {content} - - )} - {children({ - show: this.show, - hide: this.hide, - targetRef: this.target, - })} - - ); - } -} + rect.left + + (rect.width - tooltipElement.offsetWidth) / 2 + + window.scrollX; + + tooltipElement.style.top = `${top}px`; + tooltipElement.style.left = `${left}px`; + }, [visible]); + + return ( + <> + {content && visible && ( + + {content} + + )} + {children({show, hide, targetRef: target})} + + ); +}; export default ToolTip; diff --git a/src/web/components/chart/base/__tests__/ToolTip.test.tsx b/src/web/components/chart/base/__tests__/ToolTip.test.tsx new file mode 100644 index 0000000000..8fb050b01d --- /dev/null +++ b/src/web/components/chart/base/__tests__/ToolTip.test.tsx @@ -0,0 +1,70 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test} from '@gsa/testing'; +import {fireEvent, rendererWith, screen} from 'web/testing'; +import ToolTip from 'web/components/chart/base/ToolTip'; + +const TestTarget = () => ( + + {({show, hide, targetRef}) => ( + <> + + + )} + , + ); + + fireEvent.click(screen.getByTestId('target')); + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument(); + }); +}); From 8660b629b2d611cf69b4bc8a08aeb660305f9be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 09:16:26 +0200 Subject: [PATCH 2/7] test: Improve and extend Axis component tests --- .../chart/base/__tests__/Axis.test.tsx | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/src/web/components/chart/base/__tests__/Axis.test.tsx b/src/web/components/chart/base/__tests__/Axis.test.tsx index 4b54ee1c03..761474822e 100644 --- a/src/web/components/chart/base/__tests__/Axis.test.tsx +++ b/src/web/components/chart/base/__tests__/Axis.test.tsx @@ -4,22 +4,27 @@ */ import {describe, test, expect} from '@gsa/testing'; -import {rendererWith, screen} from 'web/testing'; +import {screen, rendererWith} from 'web/testing'; import {scaleLinear} from 'd3-scale'; import Axis from 'web/components/chart/base/Axis'; +const renderAxis = (props: React.ComponentProps) => { + const {render} = rendererWith(); + + render( + + + , + ); + + return screen.getByTestId('main-container'); +}; + describe('Axis tests', () => { test('should render ticks and axis class names', () => { - const {render} = rendererWith(); const scale = scaleLinear().range([0, 200]).domain([0, 100]); + const mainContainer = renderAxis({orientation: 'bottom', scale, top: 20}); - render( - - - , - ); - - const mainContainer = screen.getByTestId('main-container'); expect(mainContainer.querySelector('.axis-line')).toBeInTheDocument(); expect(mainContainer.querySelectorAll('.axis-tick').length).toBeGreaterThan( 0, @@ -27,39 +32,56 @@ describe('Axis tests', () => { }); test('should hide tick labels when hideTickLabels is true', () => { - const {render} = rendererWith(); const scale = scaleLinear().range([0, 100]).domain([0, 10]); + const mainContainer = renderAxis({ + hideTickLabels: true, + orientation: 'left', + scale, + top: 0, + }); - render( - - - , - ); - - const mainContainer = screen.getByTestId('main-container'); expect(mainContainer.querySelectorAll('.tick text').length).toEqual(0); }); test('should apply custom tick formatter', () => { - const {render} = rendererWith(); const scale = scaleLinear().range([0, 100]).domain([0, 10]); + const mainContainer = renderAxis({ + orientation: 'bottom', + scale, + tickFormat: value => `v-${String(value)}`, + top: 20, + }); - render( - - `v-${String(value)}`} - top={20} - /> - , - ); - - const mainContainer = screen.getByTestId('main-container'); const tickTexts = Array.from(mainContainer.querySelectorAll('.tick text')); expect(tickTexts.length).toBeGreaterThan(0); expect(tickTexts.every(node => node.textContent?.startsWith('v-'))).toBe( true, ); }); + + test('should render a top axis label with the expected position', () => { + const scale = scaleLinear().range([0, 200]).domain([0, 100]); + renderAxis({ + dataTestId: 'top-axis', + label: 'Top axis', + orientation: 'top', + scale, + }); + + const axis = screen.getByTestId('top-axis'); + expect(axis.querySelector('.axis-label')).toHaveTextContent('Top axis'); + expect(axis.querySelector('.axis-label')).toHaveAttribute('y', '-23'); + }); + + test('should render only the provided tick values', () => { + const scale = scaleLinear().range([0, 100]).domain([0, 10]); + const mainContainer = renderAxis({ + orientation: 'bottom', + scale, + tickValues: [0, 5, 10], + }); + + const tickTexts = Array.from(mainContainer.querySelectorAll('.tick text')); + expect(tickTexts.map(tick => tick.textContent)).toEqual(['0', '5', '10']); + }); }); From cbda9f68d7fd7b4a015712d8e94fb1707a934faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 09:21:37 +0200 Subject: [PATCH 3/7] test: Add tests for the Group component --- .../chart/base/__tests__/Group.test.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/web/components/chart/base/__tests__/Group.test.tsx diff --git a/src/web/components/chart/base/__tests__/Group.test.tsx b/src/web/components/chart/base/__tests__/Group.test.tsx new file mode 100644 index 0000000000..a97b7b5e50 --- /dev/null +++ b/src/web/components/chart/base/__tests__/Group.test.tsx @@ -0,0 +1,52 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen} from 'web/testing'; +import Group from 'web/components/chart/base/Group'; + +const renderGroup = ( + props: React.ComponentProps, + children?: React.ReactNode, +) => { + const {render} = rendererWith(); + + render( + + + {children} + + , + ); + + return screen.getByTestId('group'); +}; + +describe('Group', () => { + test('should render the default transform', () => { + expect(renderGroup({})).toHaveAttribute( + 'transform', + 'translate(0, 0),scale(1)', + ); + }); + + test('should render custom position, scale, and children', () => { + const group = renderGroup( + {left: 10, scale: 2, top: 20}, + Content, + ); + expect(group).toHaveAttribute('transform', 'translate(10, 20),scale(2)'); + expect(group).toHaveTextContent('Content'); + }); + + test('should forward click handlers and pointer styling', () => { + const onClick = testing.fn(); + const group = renderGroup({onClick}); + fireEvent.click(group); + + expect(onClick).toHaveBeenCalledOnce(); + expect(group).toHaveStyle({cursor: 'pointer'}); + }); +}); From f6aae89ebe2a14640e5d8e443e3cbb0b88978e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 09:41:25 +0200 Subject: [PATCH 4/7] test: Add tests for the Label component Ensure the behavior of this component. --- .../chart/base/__tests__/Label.test.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/web/components/chart/base/__tests__/Label.test.tsx diff --git a/src/web/components/chart/base/__tests__/Label.test.tsx b/src/web/components/chart/base/__tests__/Label.test.tsx new file mode 100644 index 0000000000..9d43e38050 --- /dev/null +++ b/src/web/components/chart/base/__tests__/Label.test.tsx @@ -0,0 +1,50 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {createRef} from 'react'; +import {describe, expect, test} from '@gsa/testing'; +import {rendererWith, screen} from 'web/testing'; +import Label from 'web/components/chart/base/Label'; +import Theme from 'web/utils/theme'; + +describe('Label', () => { + test('should render children with default label styles', () => { + const {render} = rendererWith(); + + render(); + + const label = screen.getByText('Label text'); + expect(label).toHaveClass('pie-label'); + expect(label).toHaveAttribute('dy', '.33em'); + expect(label).toHaveAttribute('fill', Theme.dialogGray); + expect(label).toHaveAttribute('font-size', Theme.Font.default); + expect(label).toHaveAttribute('font-weight', 'bold'); + expect(label).toHaveAttribute('text-anchor', 'middle'); + }); + + test('should forward SVG props and allow overrides', () => { + const {render} = rendererWith(); + + render( + , + ); + + const label = screen.getByText('Label text'); + expect(label).toHaveAttribute('fill', '#008000'); + expect(label).toHaveAttribute('x', '10'); + expect(label).toHaveAttribute('y', '20'); + }); + + test('should forward the ref to the text element', () => { + const ref = createRef(); + const {render} = rendererWith(); + + render(); + + expect(ref.current).toBe(screen.getByText('Label text')); + }); +}); From f65e79871cb55e165f2b0415a1a0d3a4cde88a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 10:24:40 +0200 Subject: [PATCH 5/7] test: Improve tests for LegendLine --- .../chart/base/__tests__/LegendLine.test.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/web/components/chart/base/__tests__/LegendLine.test.tsx b/src/web/components/chart/base/__tests__/LegendLine.test.tsx index 8ab96a1e7e..5a23053957 100644 --- a/src/web/components/chart/base/__tests__/LegendLine.test.tsx +++ b/src/web/components/chart/base/__tests__/LegendLine.test.tsx @@ -41,4 +41,19 @@ describe('LegendLine tests', () => { expect(svg).toHaveAttribute('height', '15'); expect(svg).toHaveAttribute('width', '20'); }); + + test('should center the default line with default styling', () => { + const {render} = rendererWith(); + + render(); + + const line = screen.getByTestId('main-container').querySelector('line'); + expect(line).toHaveAttribute('stroke', '#008000'); + expect(line).toHaveAttribute('stroke-width', '1'); + expect(line).toHaveAttribute('x1', '0'); + expect(line).toHaveAttribute('x2', '40'); + expect(line).toHaveAttribute('y1', '10'); + expect(line).toHaveAttribute('y2', '10'); + expect(line).not.toHaveAttribute('stroke-dasharray'); + }); }); From 6297b514d644315bcf6b66a4a43ecfa0c7bda4bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 10:26:37 +0200 Subject: [PATCH 6/7] Fix typo in module name LagendLabel => LegendLabel --- src/web/components/chart/base/Legend.tsx | 2 +- .../components/chart/base/{LagendLabel.tsx => LegendLabel.tsx} | 0 src/web/components/chart/base/Line.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/web/components/chart/base/{LagendLabel.tsx => LegendLabel.tsx} (100%) diff --git a/src/web/components/chart/base/Legend.tsx b/src/web/components/chart/base/Legend.tsx index e726b0006b..11b8095944 100644 --- a/src/web/components/chart/base/Legend.tsx +++ b/src/web/components/chart/base/Legend.tsx @@ -7,7 +7,7 @@ import {type RefObject, type ReactNode, type Ref} from 'react'; import styled from 'styled-components'; import {type ToString} from 'gmp/types'; import {isDefined} from 'gmp/utils/identity'; -import LegendLabel from 'web/components/chart/base/LagendLabel'; +import LegendLabel from 'web/components/chart/base/LegendLabel'; import {DEFAULT_SHAPE_SIZE} from 'web/components/chart/base/LegendLine'; import ToolTip, {type ToolTipRef} from 'web/components/chart/base/ToolTip'; import Theme from 'web/utils/theme'; diff --git a/src/web/components/chart/base/LagendLabel.tsx b/src/web/components/chart/base/LegendLabel.tsx similarity index 100% rename from src/web/components/chart/base/LagendLabel.tsx rename to src/web/components/chart/base/LegendLabel.tsx diff --git a/src/web/components/chart/base/Line.tsx b/src/web/components/chart/base/Line.tsx index cc58b07a35..eb5267d338 100644 --- a/src/web/components/chart/base/Line.tsx +++ b/src/web/components/chart/base/Line.tsx @@ -19,12 +19,12 @@ import {type ToString} from 'gmp/types'; import {isDefined} from 'gmp/utils/identity'; import Axis from 'web/components/chart/base/Axis'; import Group from 'web/components/chart/base/Group'; -import LegendLabel from 'web/components/chart/base/LagendLabel'; import Legend, { Item, type LegendData, type LegendRef, } from 'web/components/chart/base/Legend'; +import LegendLabel from 'web/components/chart/base/LegendLabel'; import LegendLine from 'web/components/chart/base/LegendLine'; import Svg from 'web/components/chart/base/Svg'; import {MENU_PLACEHOLDER_WIDTH} from 'web/components/chart/utils/constants'; From 423a5602bf1a84a13954025f18aaef109c2a2f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Tue, 18 Aug 2026 10:29:27 +0200 Subject: [PATCH 7/7] test: Add tests for the Legend component --- .../chart/base/__tests__/Legend.test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/web/components/chart/base/__tests__/Legend.test.tsx diff --git a/src/web/components/chart/base/__tests__/Legend.test.tsx b/src/web/components/chart/base/__tests__/Legend.test.tsx new file mode 100644 index 0000000000..e573764f21 --- /dev/null +++ b/src/web/components/chart/base/__tests__/Legend.test.tsx @@ -0,0 +1,72 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {describe, expect, test, testing} from '@gsa/testing'; +import {fireEvent, rendererWith, screen} from 'web/testing'; +import Legend, {type LegendData} from 'web/components/chart/base/Legend'; + +const data: LegendData[] = [ + {color: '#008000', label: 'First', toolTip: 'First tooltip'}, + {color: '#0000aa', label: 'Second', toolTip: 'Second tooltip'}, +]; + +describe('Legend', () => { + test('should render labels with their colors', () => { + const {render} = rendererWith(); + + render(); + + expect(screen.getByText('First')).toBeVisible(); + expect(screen.getByText('Second')).toBeVisible(); + expect(screen.getByText('First').previousElementSibling).toHaveStyle({ + backgroundColor: '#008000', + }); + expect(screen.getByText('Second').previousElementSibling).toHaveStyle({ + backgroundColor: '#0000aa', + }); + }); + + test('should call onItemClick with the selected legend item', () => { + const onItemClick = testing.fn(); + const {render} = rendererWith(); + + render(); + fireEvent.click(screen.getByText('First')); + + expect(onItemClick).toHaveBeenCalledExactlyOnceWith(data[0]); + }); + + test('should render custom children with the item and callbacks', () => { + const children = testing.fn(({d, onItemClick}) => ( + + )); + const onItemClick = testing.fn(); + const {render} = rendererWith(); + + render( + + {children} + , + ); + fireEvent.click(screen.getByTestId('legend-First')); + + expect(children).toHaveBeenCalledTimes(2); + expect(onItemClick).toHaveBeenCalledExactlyOnceWith(data[0]); + }); + + test('should render no items for empty data', () => { + const {render} = rendererWith(); + + render(); + + expect(screen.queryByText('First')).not.toBeInTheDocument(); + expect(screen.queryByText('Second')).not.toBeInTheDocument(); + }); +});