Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/web/components/chart/base/Legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/web/components/chart/base/Line.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
102 changes: 35 additions & 67 deletions src/web/components/chart/base/ToolTip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -70,74 +66,46 @@ const ToolTipDisplay = React.forwardRef(
),
);

class ToolTip extends React.Component<ToolTipProps, ToolTipState> {
target: React.RefObject<ToolTipTargetElement | null>;
tooltip: React.RefObject<HTMLElement | null>;

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<ToolTipTargetElement | null>(null);
const tooltip = useRef<HTMLElement | null>(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 && (
<Portal>
<ToolTipDisplay ref={this.tooltip}>{content}</ToolTipDisplay>
</Portal>
)}
{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 && (
<Portal>
<ToolTipDisplay ref={tooltip}>{content}</ToolTipDisplay>
</Portal>
)}
{children({show, hide, targetRef: target})}
</>
);
};

export default ToolTip;
82 changes: 52 additions & 30 deletions src/web/components/chart/base/__tests__/Axis.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,84 @@
*/

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<typeof Axis>) => {
const {render} = rendererWith();

render(
<svg>
<Axis {...props} />
</svg>,
);

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(
<svg>
<Axis orientation="bottom" scale={scale} top={20} />
</svg>,
);

const mainContainer = screen.getByTestId('main-container');
expect(mainContainer.querySelector('.axis-line')).toBeInTheDocument();
expect(mainContainer.querySelectorAll('.axis-tick').length).toBeGreaterThan(
0,
);
});

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(
<svg>
<Axis hideTickLabels orientation="left" scale={scale} top={0} />
</svg>,
);

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(
<svg>
<Axis
orientation="bottom"
scale={scale}
tickFormat={value => `v-${String(value)}`}
top={20}
/>
</svg>,
);

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']);
});
});
52 changes: 52 additions & 0 deletions src/web/components/chart/base/__tests__/Group.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Group>,
children?: React.ReactNode,
) => {
const {render} = rendererWith();

render(
<svg>
<Group {...props} data-testid="group">
{children}
</Group>
</svg>,
);

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},
<text>Content</text>,
);
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'});
});
});
50 changes: 50 additions & 0 deletions src/web/components/chart/base/__tests__/Label.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Label>Label text</Label>);

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(
<Label fill="#008000" x={10} y={20}>
Label text
</Label>,
);

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<SVGElement>();
const {render} = rendererWith();

render(<Label ref={ref}>Label text</Label>);

expect(ref.current).toBe(screen.getByText('Label text'));
});
});
Loading