diff options
author | stanislavh <stanislav.honcharov@sonarsource.com> | 2023-05-25 13:37:09 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-05-26 20:03:09 +0000 |
commit | 915462cb80552f7dbf99a06faa358df8401687c9 (patch) | |
tree | 9aa9e6958ebc36e26a7f41f6fb06217f5acebbb9 | |
parent | 9443d2e8130ee0f81b0c5775f3bee1e197df79a0 (diff) | |
download | sonarqube-915462cb80552f7dbf99a06faa358df8401687c9.tar.gz sonarqube-915462cb80552f7dbf99a06faa358df8401687c9.zip |
SONAR-19385 Add BubbleChart to components library
20 files changed, 668 insertions, 1387 deletions
diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index ff65cf5ab5b..07cbc22b88c 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -54,11 +54,13 @@ "clipboard": "2.0.11", "d3-array": "3.2.3", "d3-scale": "4.0.2", + "d3-selection": "3.0.0", "d3-shape": "3.2.0", + "d3-zoom": "3.0.0", "date-fns": "2.29.3", "lodash": "4.17.21", "react": "17.0.2", - "react-day-picker": "8.6.0", + "react-day-picker": "8.7.1", "react-dom": "17.0.2", "react-helmet-async": "1.3.0", "react-highlight-words": "0.20.0", @@ -66,6 +68,7 @@ "react-modal": "3.16.1", "react-router-dom": "6.10.0", "react-select": "5.7.2", + "react-virtualized": "9.22.3", "tailwindcss": "3.3.1" }, "babelMacros": { diff --git a/server/sonar-web/design-system/src/components/BubbleChart.tsx b/server/sonar-web/design-system/src/components/BubbleChart.tsx new file mode 100644 index 00000000000..e35aba51308 --- /dev/null +++ b/server/sonar-web/design-system/src/components/BubbleChart.tsx @@ -0,0 +1,445 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import classNames from 'classnames'; +import { max, min } from 'd3-array'; +import { ScaleLinear, scaleLinear } from 'd3-scale'; +import { select } from 'd3-selection'; +import { D3ZoomEvent, ZoomBehavior, zoom, zoomIdentity } from 'd3-zoom'; +import { sortBy, uniq } from 'lodash'; +import * as React from 'react'; +import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; +import tw from 'twin.macro'; +import { themeColor, themeContrast } from '../helpers'; +import { BubbleColorVal } from '../types/charts'; +import { Note } from './Text'; +import Tooltip from './Tooltip'; +import { ButtonSecondary } from './buttons'; + +const TICKS_COUNT = 5; + +interface BubbleItem<T> { + color?: BubbleColorVal; + data?: T; + key?: string; + size: number; + tooltip?: React.ReactNode; + x: number; + y: number; +} + +export interface BubbleChartProps<T> { + displayXGrid?: boolean; + displayXTicks?: boolean; + displayYGrid?: boolean; + displayYTicks?: boolean; + formatXTick: (tick: number) => string; + formatYTick: (tick: number) => string; + height: number; + items: Array<BubbleItem<T>>; + onBubbleClick?: (ref?: T) => void; + padding: [number, number, number, number]; + sizeDomain?: [number, number]; + sizeRange?: [number, number]; + xDomain?: [number, number]; + yDomain?: [number, number]; + zoomLabel?: string; + zoomResetLabel?: string; + zoomTooltipText?: string; +} + +type Scale = ScaleLinear<number, number>; + +BubbleChart.defaultProps = { + displayXGrid: true, + displayXTicks: true, + displayYGrid: true, + displayYTicks: true, + formatXTick: (d: number) => String(d), + formatYTick: (d: number) => String(d), + padding: [10, 10, 10, 10], + sizeRange: [5, 45], +}; + +export function BubbleChart<T>(props: BubbleChartProps<T>) { + const { + padding, + height, + items, + xDomain, + yDomain, + sizeDomain, + sizeRange, + zoomResetLabel = 'Reset', + zoomTooltipText, + zoomLabel = 'Zoom', + displayXTicks, + displayYTicks, + displayXGrid, + displayYGrid, + formatXTick, + formatYTick, + } = props; + const [transform, setTransform] = React.useState({ x: 0, y: 0, k: 1 }); + const nodeRef = React.useRef<SVGSVGElement>(); + const zoomRef = React.useRef<ZoomBehavior<Element, unknown>>(); + const zoomLevelLabel = `${Math.floor(transform.k * 100)}%`; + + if (zoomRef.current && nodeRef.current) { + const rect = nodeRef.current.getBoundingClientRect(); + zoomRef.current.translateExtent([ + [0, 0], + [rect.width, rect.height], + ]); + } + + const zoomed = React.useCallback( + (event: D3ZoomEvent<SVGSVGElement, void>) => { + const { x, y, k } = event.transform; + setTransform({ + x: x + padding[3] * (k - 1), + y: y + padding[0] * (k - 1), + k, + }); + }, + [padding] + ); + + const boundNode = React.useCallback( + (node: SVGSVGElement) => { + nodeRef.current = node; + zoomRef.current = zoom().scaleExtent([1, 10]).on('zoom', zoomed); + select(nodeRef.current).call(zoomRef.current); + }, + [zoomed] + ); + + const resetZoom = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + e.preventDefault(); + if (zoomRef.current && nodeRef.current) { + select(nodeRef.current).call(zoomRef.current.transform, zoomIdentity); + } + }, []); + + const getXRange = React.useCallback( + (xScale: Scale, sizeScale: Scale, availableWidth: number) => { + const [x1, x2] = xScale.range(); + const minX = min(items, (d) => xScale(d.x) - sizeScale(d.size)) ?? 0; + const maxX = max(items, (d) => xScale(d.x) + sizeScale(d.size)) ?? 0; + const dMinX = minX < 0 ? x1 - minX : x1; + const dMaxX = maxX > x2 ? maxX - x2 : 0; + return [dMinX, availableWidth - dMaxX]; + }, + [items] + ); + + const getYRange = React.useCallback( + (yScale: Scale, sizeScale: Scale, availableHeight: number) => { + const [y1, y2] = yScale.range(); + const minY = min(items, (d) => yScale(d.y) - sizeScale(d.size)) ?? 0; + const maxY = max(items, (d) => yScale(d.y) + sizeScale(d.size)) ?? 0; + const dMinY = minY < 0 ? y2 - minY : y2; + const dMaxY = maxY > y1 ? maxY - y1 : 0; + return [availableHeight - dMaxY, dMinY]; + }, + [items] + ); + + const getTicks = React.useCallback( + (scale: Scale, format: (d: number) => string) => { + const zoomAmount = Math.ceil(transform.k); + const ticks = scale.ticks(TICKS_COUNT * zoomAmount).map((tick) => format(tick)); + const uniqueTicksCount = uniq(ticks).length; + const ticksCount = + uniqueTicksCount < TICKS_COUNT * zoomAmount + ? uniqueTicksCount - 1 + : TICKS_COUNT * zoomAmount; + return scale.ticks(ticksCount); + }, + [transform] + ); + + const renderXGrid = React.useCallback( + (ticks: number[], xScale: Scale, yScale: Scale) => { + if (!displayXGrid) { + return null; + } + + const lines = ticks.map((tick, index) => { + const x = xScale(tick); + const [y1, y2] = yScale.range(); + return ( + <BubbleChartGrid + // eslint-disable-next-line react/no-array-index-key + key={index} + x1={x * transform.k + transform.x} + x2={x * transform.k + transform.x} + y1={y1 * transform.k} + y2={transform.k > 1 ? 0 : y2} + /> + ); + }); + + return <g>{lines}</g>; + }, + [transform, displayXGrid] + ); + + const renderYGrid = React.useCallback( + (ticks: number[], xScale: Scale, yScale: Scale) => { + if (!displayYGrid) { + return null; + } + + const lines = ticks.map((tick, index) => { + const y = yScale(tick); + const [x1, x2] = xScale.range(); + return ( + <BubbleChartGrid + // eslint-disable-next-line react/no-array-index-key + key={index} + x1={transform.k > 1 ? 0 : x1} + x2={x2 * transform.k} + y1={y * transform.k + transform.y} + y2={y * transform.k + transform.y} + /> + ); + }); + + return <g>{lines}</g>; + }, + [displayYGrid, transform] + ); + + const renderXTicks = React.useCallback( + (xTicks: number[], xScale: Scale, yScale: Scale) => { + if (!displayXTicks) { + return null; + } + + const ticks = xTicks.map((tick, index) => { + const x = xScale(tick) * transform.k + transform.x; + const y = yScale.range()[0]; + const innerText = formatXTick(tick); + // as we modified the `x` using `transform`, check that it is inside the range again + return x > 0 && x < xScale.range()[1] ? ( + // eslint-disable-next-line react/no-array-index-key + <BubbleChartTick dy="1.5em" key={index} style={{ '--align': 'middle' }} x={x} y={y}> + {innerText} + </BubbleChartTick> + ) : null; + }); + + return <g>{ticks}</g>; + }, + [displayXTicks, formatXTick, transform] + ); + + const renderYTicks = React.useCallback( + (yTicks: number[], xScale: Scale, yScale: Scale) => { + if (!displayYTicks) { + return null; + } + + const ticks = yTicks.map((tick, index) => { + const x = xScale.range()[0]; + const y = yScale(tick) * transform.k + transform.y; + const innerText = formatYTick(tick); + // as we modified the `y` using `transform`, check that it is inside the range again + return y > 0 && y < yScale.range()[0] ? ( + <BubbleChartTick + dx="-0.5em" + dy="0.3em" + // eslint-disable-next-line react/no-array-index-key + key={index} + style={{ '--align': 'end' }} + x={x} + y={y} + > + {innerText} + </BubbleChartTick> + ) : null; + }); + + return <g>{ticks}</g>; + }, + [displayYTicks, formatYTick, transform] + ); + + const renderChart = (width: number) => { + const availableWidth = width - padding[1] - padding[3]; + const availableHeight = height - padding[0] - padding[2]; + + const xScale = scaleLinear() + .domain(xDomain ?? [0, max(items, (d) => d.x) ?? 0]) + .range([0, availableWidth]) + .nice(); + const yScale = scaleLinear() + .domain(yDomain ?? [0, max(items, (d) => d.y) ?? 0]) + .range([availableHeight, 0]) + .nice(); + const sizeScale = scaleLinear() + .domain(sizeDomain ?? [0, max(items, (d) => d.size) ?? 0]) + .range(sizeRange ?? []); + + const xScaleOriginal = xScale.copy(); + const yScaleOriginal = yScale.copy(); + + xScale.range(getXRange(xScale, sizeScale, availableWidth)); + yScale.range(getYRange(yScale, sizeScale, availableHeight)); + + const bubbles = sortBy(items, (b) => -b.size).map((item, index) => { + return ( + <Bubble + color={item.color} + data={item.data} + key={item.key ?? index} + onClick={props.onBubbleClick} + r={sizeScale(item.size)} + scale={1 / transform.k} + tooltip={item.tooltip} + x={xScale(item.x)} + y={yScale(item.y)} + /> + ); + }); + + const xTicks = getTicks(xScale, props.formatXTick); + const yTicks = getTicks(yScale, props.formatYTick); + + return ( + <svg className={classNames('bubble-chart')} height={height} ref={boundNode} width={width}> + <defs> + <clipPath id="graph-region"> + <rect + // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders + height={availableHeight + 4} + width={availableWidth + 4} + x={-2} + y={-2} + /> + </clipPath> + </defs> + <g transform={`translate(${padding[3]}, ${padding[0]})`}> + <g clipPath="url(#graph-region)"> + {renderXGrid(xTicks, xScale, yScale)} + {renderYGrid(yTicks, xScale, yScale)} + <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}> + {bubbles} + </g> + </g> + {renderXTicks(xTicks, xScale, yScaleOriginal)} + {renderYTicks(yTicks, xScaleOriginal, yScale)} + </g> + </svg> + ); + }; + + return ( + <div> + <div className="sw-flex sw-items-center sw-justify-end sw-h-control sw-mb-4"> + <Tooltip overlay={zoomTooltipText}> + <span> + <Note className="sw-body-sm-highlight">{zoomLabel}</Note> + {': '} + {zoomLevelLabel} + </span> + </Tooltip> + {zoomLevelLabel !== '100%' && ( + <ButtonSecondary + className="sw-ml-2" + disabled={zoomLevelLabel === '100%'} + onClick={resetZoom} + > + {zoomResetLabel} + </ButtonSecondary> + )} + </div> + <AutoSizer disableHeight={true}>{(size) => renderChart(size.width)}</AutoSizer> + </div> + ); +} + +interface BubbleProps<T> { + color?: BubbleColorVal; + data?: T; + onClick?: (ref?: T) => void; + r: number; + scale: number; + tooltip?: string | React.ReactNode; + x: number; + y: number; +} + +function Bubble<T>(props: BubbleProps<T>) { + const theme = useTheme(); + const { color, data, onClick, r, scale, tooltip, x, y } = props; + const handleClick = React.useCallback( + (event: React.MouseEvent<HTMLAnchorElement>) => { + event.stopPropagation(); + event.preventDefault(); + onClick?.(data); + }, + [data, onClick] + ); + + const circle = ( + <a href="#" onClick={handleClick}> + <BubbleStyled + r={r} + style={ + color && { + fill: themeColor(`bubble.${color}`)({ theme }), + stroke: themeContrast(`bubble.${color}`)({ theme }), + } + } + transform={`translate(${x}, ${y}) scale(${scale})`} + /> + </a> + ); + + return <Tooltip overlay={tooltip}>{circle}</Tooltip>; +} + +const BubbleStyled = styled.circle` + ${tw`sw-cursor-pointer`} + + transition: fill-opacity 0.2s ease; + fill: ${themeColor('bubbleDefault')}; + stroke: ${themeContrast('bubbleDefault')}; + + &:hover { + fill-opacity: 0.8; + } +`; + +const BubbleChartGrid = styled.line` + shape-rendering: crispedges; + stroke: ${themeColor('bubbleChartLine')}; +`; + +const BubbleChartTick = styled.text` + ${tw`sw-body-sm`} + ${tw`sw-select-none`} + fill: ${themeColor('pageContentLight')}; + text-anchor: var(--align); +`; diff --git a/server/sonar-web/design-system/src/components/Tooltip.tsx b/server/sonar-web/design-system/src/components/Tooltip.tsx index cb51f9dbb70..2a5d3925957 100644 --- a/server/sonar-web/design-system/src/components/Tooltip.tsx +++ b/server/sonar-web/design-system/src/components/Tooltip.tsx @@ -20,7 +20,7 @@ import { keyframes, ThemeContext } from '@emotion/react'; import styled from '@emotion/styled'; import classNames from 'classnames'; -import { throttle } from 'lodash'; +import { throttle, uniqueId } from 'lodash'; import React from 'react'; import { createPortal, findDOMNode } from 'react-dom'; import tw from 'twin.macro'; @@ -81,6 +81,7 @@ export class TooltipInner extends React.Component<TooltipProps, State> { tooltipNode?: HTMLElement | null; mounted = false; mouseIn = false; + id: string; static defaultProps = { mouseEnterDelay: 0.1, @@ -94,7 +95,7 @@ export class TooltipInner extends React.Component<TooltipProps, State> { placement: props.placement, visible: props.visible !== undefined ? props.visible : false, }; - + this.id = uniqueId('tooltip-'); this.throttledPositionTooltip = throttle(this.positionTooltip, THROTTLE_SCROLL_DELAY); } @@ -289,6 +290,17 @@ export class TooltipInner extends React.Component<TooltipProps, State> { } }; + handleFocus = () => { + this.setState({ visible: true }); + if (this.props.onShow) { + this.props.onShow(); + } + }; + + handleBlur = () => { + this.setState({ visible: false }); + }; + handleOverlayPointerEnter = () => { this.mouseIn = true; }; @@ -355,6 +367,13 @@ export class TooltipInner extends React.Component<TooltipProps, State> { {React.cloneElement(this.props.children, { onPointerEnter: this.handleChildPointerEnter, onPointerLeave: this.handleChildPointerLeave, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + // aria-describedby is the semantically correct property to use, but it's not + // always well supported. We sometimes need to handle this differently, depending + // on the triggering element. We should NOT use aria-labelledby, as this can + // have unintended effects (e.g., this can mess up buttons that need a tooltip). + 'aria-describedby': this.id, })} {this.isVisible() && ( <TooltipPortal> diff --git a/server/sonar-web/design-system/src/components/__tests__/BubbleChart-test.tsx b/server/sonar-web/design-system/src/components/__tests__/BubbleChart-test.tsx new file mode 100644 index 00000000000..cdbe8cb2686 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/BubbleChart-test.tsx @@ -0,0 +1,91 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { screen } from '@testing-library/react'; +import { AutoSizerProps } from 'react-virtualized'; +import { renderWithRouter } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { BubbleChart } from '../BubbleChart'; + +jest.mock('react-virtualized/dist/commonjs/AutoSizer', () => ({ + AutoSizer: ({ children }: AutoSizerProps) => children({ width: 100, height: NaN }), +})); + +jest.mock('d3-zoom', () => ({ + zoom: jest.fn().mockReturnValue({ scaleExtent: jest.fn().mockReturnValue({ on: jest.fn() }) }), +})); + +jest.mock('d3-selection', () => ({ + select: jest.fn().mockReturnValue({ call: jest.fn() }), +})); + +it('should display bubbles with correct chart structure', () => { + renderBubbleChart(); + expect(screen.getAllByRole('link')).toHaveLength(2); + expect(screen.getByText('5')).toBeInTheDocument(); +}); + +it('should allow click on bubbles', async () => { + const onBubbleClick = jest.fn(); + const { user } = renderBubbleChart({ onBubbleClick }); + await user.click(screen.getAllByRole('link')[0]); + expect(onBubbleClick).toHaveBeenCalledWith('foo'); +}); + +it('should navigate between bubbles by tab', async () => { + const { user } = renderBubbleChart(); + await user.tab(); + expect(screen.getAllByRole('link')[0]).toHaveFocus(); + await user.tab(); + expect(screen.getAllByRole('link')[1]).toHaveFocus(); +}); + +it('should not display ticks and grid', () => { + renderBubbleChart({ + displayXGrid: false, + displayYGrid: false, + displayXTicks: false, + displayYTicks: false, + }); + + expect(screen.queryByText('5')).not.toBeInTheDocument(); +}); + +it('renders empty graph', () => { + renderBubbleChart({ + items: [], + }); + + expect(screen.queryByRole('link')).not.toBeInTheDocument(); +}); + +function renderBubbleChart(props: Partial<FCProps<typeof BubbleChart>> = {}) { + return renderWithRouter( + <BubbleChart + height={100} + items={[ + { x: 1, y: 10, size: 7, data: 'foo' }, + { x: 2, y: 30, size: 5, color: 3, data: 'bar' }, + ]} + padding={[0, 0, 0, 0]} + {...props} + /> + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx b/server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx index 2ff49675d18..135a9b9f2d1 100644 --- a/server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/SearchSelectDropdown-test.tsx @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../../helpers/testUtils'; import { FCProps } from '../../types/misc'; @@ -65,7 +65,9 @@ it('should handle key navigation', async () => { expect(screen.queryByText('different')).not.toBeInTheDocument(); await user.keyboard('{Escape}'); expect(await screen.findByText('different')).toBeInTheDocument(); - await user.keyboard('{Escape}'); + await act(async () => { + await user.keyboard('{Escape}'); + }); expect(screen.queryByText('different')).not.toBeInTheDocument(); await user.tab({ shift: true }); await user.keyboard('{ArrowDown}'); diff --git a/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx index 95ce51cc9b0..76385492db0 100644 --- a/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/Tooltip-test.tsx @@ -82,6 +82,15 @@ describe('TooltipInner', () => { expect(screen.getByRole('tooltip')).toHaveClass('bottom'); }); + it('should be opened/hidden using tab navigation', async () => { + const { user } = setupWithProps({}, <a href="#">Link</a>); + + await user.tab(); + expect(await screen.findByRole('tooltip')).toBeInTheDocument(); + await user.tab(); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + function setupWithProps( props: Partial<TooltipInner['props']> = {}, children = <div role="note" /> diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap index 66320711540..5b27cf29f1f 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap @@ -189,6 +189,7 @@ exports[`should show full size when multiline with no editting 1`] = ` class="fs-mask emotion-0 emotion-1" > <button + aria-describedby="tooltip-1" class="sw-select-none emotion-2 emotion-3 emotion-4 emotion-5" data-clipboard-text="foo bar" @@ -429,6 +430,7 @@ exports[`should show reduced size when single line with no editting 1`] = ` class="code-snippet-highlighted-oneline fs-mask emotion-0 emotion-1" > <button + aria-describedby="tooltip-2" class="sw-select-none emotion-2 emotion-3 emotion-4 emotion-5" data-clipboard-text="foobar" type="button" diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap index 166209b1591..8ad1037bf83 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Histogram-test.tsx.snap @@ -290,6 +290,7 @@ exports[`renders correctly with yValues, yTicks, and yTooltips 1`] = ` y="10" /> <text + aria-describedby="tooltip-1" class="emotion-2 emotion-3" dx="1em" dy="0.3em" @@ -319,6 +320,7 @@ exports[`renders correctly with yValues, yTicks, and yTooltips 1`] = ` y="28" /> <text + aria-describedby="tooltip-2" class="emotion-2 emotion-3" dx="1em" dy="0.3em" @@ -348,6 +350,7 @@ exports[`renders correctly with yValues, yTicks, and yTooltips 1`] = ` y="46" /> <text + aria-describedby="tooltip-3" class="emotion-2 emotion-3" dx="1em" dy="0.3em" diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 16fe359ece8..f1880121fde 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -23,6 +23,7 @@ export * from './Avatar'; export { Badge } from './Badge'; export { BarChart } from './BarChart'; export { Breadcrumbs } from './Breadcrumbs'; +export * from './BubbleChart'; export * from './Card'; export * from './Checkbox'; export * from './CodeSnippet'; diff --git a/server/sonar-web/design-system/src/index.ts b/server/sonar-web/design-system/src/index.ts index 9c4c8f50bfb..b37e6479619 100644 --- a/server/sonar-web/design-system/src/index.ts +++ b/server/sonar-web/design-system/src/index.ts @@ -21,5 +21,4 @@ export * from './components'; export * from './helpers'; export * from './theme'; -export * from './types/measures'; -export * from './types/theme'; +export * from './types'; diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.css b/server/sonar-web/design-system/src/types/charts.ts index 77bc547c408..7be16a1b5b3 100644 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.css +++ b/server/sonar-web/design-system/src/types/charts.ts @@ -17,40 +17,17 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -.bubble-chart text { - user-select: none; -} - -.bubble-chart-bubble { - fill: var(--blue); - fill-opacity: 0.2; - stroke: var(--blue); - cursor: pointer; - transition: fill-opacity 0.2s ease; -} - -.bubble-chart-bubble:hover { - fill-opacity: 0.8; -} - -.bubble-chart-grid { - shape-rendering: crispedges; - stroke: #eee; -} -.bubble-chart-tick { - fill: var(--secondFontColor); - font-size: var(--smallFontSize); - text-anchor: middle; +export interface ChartPoint { + x: Date; + y: number | string | undefined; } -.bubble-chart-tick-y { - text-anchor: end; +export interface ChartSerie { + data: ChartPoint[]; + name: string; + translatedName: string; + type: string; } -.bubble-chart-zoom { - position: absolute; - right: 20px; - top: 20px; - z-index: var(--aboveNormalZIndex); -} +export type BubbleColorVal = 1 | 2 | 3 | 4 | 5; diff --git a/server/sonar-web/design-system/src/types/index.ts b/server/sonar-web/design-system/src/types/index.ts new file mode 100644 index 00000000000..60861305544 --- /dev/null +++ b/server/sonar-web/design-system/src/types/index.ts @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +export * from './charts'; +export * from './measures'; +export * from './theme'; diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx index 11ffbd4bc52..c57f2004d5e 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/BubbleChart.tsx @@ -17,13 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { BubbleColorVal, BubbleChart as OriginalBubbleChart } from 'design-system'; import * as React from 'react'; -import theme from '../../../app/theme'; -import OriginalBubbleChart from '../../../components/charts/BubbleChart'; import ColorRatingsLegend from '../../../components/charts/ColorRatingsLegend'; import Link from '../../../components/common/Link'; import HelpTooltip from '../../../components/controls/HelpTooltip'; -import { RATING_COLORS } from '../../../helpers/constants'; import { getLocalizedMetricDomain, getLocalizedMetricName, @@ -140,8 +138,7 @@ export default class BubbleChart extends React.PureComponent<Props, State> { const x = this.getMeasureVal(component, metrics.x); const y = this.getMeasureVal(component, metrics.y); const size = this.getMeasureVal(component, metrics.size); - const colors = - metrics.colors && metrics.colors.map((metric) => this.getMeasureVal(component, metric)); + const colors = metrics.colors?.map((metric) => this.getMeasureVal(component, metric)); if ((!x && x !== 0) || (!y && y !== 0) || (!size && size !== 0)) { return undefined; } @@ -157,10 +154,7 @@ export default class BubbleChart extends React.PureComponent<Props, State> { x, y, size, - color: - colorRating !== undefined - ? RATING_COLORS[colorRating - 1] - : { fill: theme.colors.primary, stroke: theme.colors.primary }, + color: (colorRating as BubbleColorVal) ?? 0, data: component, tooltip: this.getTooltip(component, { x, y, size, colors }, metrics), }; @@ -185,7 +179,7 @@ export default class BubbleChart extends React.PureComponent<Props, State> { height={HEIGHT} items={items} onBubbleClick={this.handleBubbleClick} - padding={[25, 60, 50, 60]} + padding={[0, 4, 50, 60]} yDomain={getBubbleYDomain(this.props.domain)} xDomain={xDomain} /> diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx index 16901b5bc49..cdcca493f8c 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/BubbleChart-test.tsx @@ -17,63 +17,69 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; +import userEvent from '@testing-library/user-event'; +import { MetricsEnum } from 'design-system'; import { keyBy } from 'lodash'; -import * as React from 'react'; -import OriginalBubbleChart from '../../../../components/charts/BubbleChart'; +import React from 'react'; +import { AutoSizerProps } from 'react-virtualized'; +import { byRole, byText } from 'testing-library-selector'; import { mockComponentMeasure } from '../../../../helpers/mocks/component'; import { mockMeasure, mockMetric, mockPaging } from '../../../../helpers/testMocks'; -import { MetricKey } from '../../../../types/metrics'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { MetricKey, MetricType } from '../../../../types/metrics'; import { enhanceComponent } from '../../utils'; import BubbleChart from '../BubbleChart'; +jest.mock('react-virtualized/dist/commonjs/AutoSizer', () => ({ + AutoSizer: ({ children }: AutoSizerProps) => children({ width: 100, height: NaN }), +})); + +jest.mock('d3-zoom', () => ({ + zoom: jest.fn().mockReturnValue({ scaleExtent: jest.fn().mockReturnValue({ on: jest.fn() }) }), +})); + +jest.mock('d3-selection', () => ({ + select: jest.fn().mockReturnValue({ call: jest.fn() }), +})); + const metrics = keyBy( [ - mockMetric({ key: MetricKey.ncloc, type: 'INT' }), - mockMetric({ key: MetricKey.security_remediation_effort, type: 'DATA' }), - mockMetric({ key: MetricKey.vulnerabilities, type: 'INT' }), - mockMetric({ key: MetricKey.security_rating, type: 'RATING' }), + mockMetric({ key: MetricKey.ncloc, type: MetricType.Integer }), + mockMetric({ key: MetricKey.security_remediation_effort, type: MetricType.Data }), + mockMetric({ key: MetricKey.vulnerabilities, type: MetricType.Integer }), + mockMetric({ key: MetricKey.security_rating, type: MetricType.Rating }), ], (m) => m.key ); -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot('default'); - expect(shallowRender({ paging: mockPaging({ total: 1000 }) })).toMatchSnapshot( - 'only showing first 500 files' - ); - expect( - shallowRender({ - components: [ - enhanceComponent( - mockComponentMeasure(true, { - measures: [ - mockMeasure({ value: '0', metric: MetricKey.ncloc }), - mockMeasure({ value: '0', metric: MetricKey.security_remediation_effort }), - mockMeasure({ value: '0', metric: MetricKey.vulnerabilities }), - mockMeasure({ value: '0', metric: MetricKey.security_rating }), - ], - }), - metrics[MetricKey.vulnerabilities], - metrics - ), - ], - }) - ).toMatchSnapshot('all on x=0'); +const ui = { + show500Files: byText(/component_measures.legend.only_first_500_files/), + bubbles: byRole('link', { name: '' }), + filterCheckbox: (name: string) => byRole('checkbox', { name }), +}; + +it('should render correctly', async () => { + renderBubbleChart(); + + expect(await ui.show500Files.find()).toBeInTheDocument(); + expect(ui.bubbles.getAll()).toHaveLength(1); }); -it('should handle filtering', () => { - const wrapper = shallowRender(); - expect(wrapper.find(OriginalBubbleChart).props().items).toHaveLength(1); +it('should filter by rating', async () => { + const user = userEvent.setup(); + renderBubbleChart(); - wrapper.instance().handleRatingFilterClick(2); + expect(await ui.bubbles.findAll()).toHaveLength(1); + Object.keys(MetricsEnum).forEach((rating) => { + expect(ui.filterCheckbox(rating).get()).toBeInTheDocument(); + }); - expect(wrapper.state().ratingFilters).toEqual({ 2: true }); - expect(wrapper.find(OriginalBubbleChart).props().items).toHaveLength(0); + await user.click(ui.filterCheckbox(MetricsEnum.C).get()); + expect(ui.bubbles.getAll()).toHaveLength(1); }); -function shallowRender(overrides: Partial<BubbleChart['props']> = {}) { - return shallow<BubbleChart>( +function renderBubbleChart(overrides: Partial<BubbleChart['props']> = {}) { + return renderComponent( <BubbleChart componentKey="foo" components={[ @@ -92,7 +98,7 @@ function shallowRender(overrides: Partial<BubbleChart['props']> = {}) { ]} domain="Security" metrics={metrics} - paging={mockPaging({ total: 100 })} + paging={mockPaging({ total: 1000 })} updateSelected={jest.fn()} {...overrides} /> diff --git a/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap b/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap deleted file mode 100644 index 5c1419f015f..00000000000 --- a/server/sonar-web/src/main/js/apps/component-measures/drilldown/__tests__/__snapshots__/BubbleChart-test.tsx.snap +++ /dev/null @@ -1,628 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly: all on x=0 1`] = ` -<div - className="measure-overview-bubble-chart" -> - <div - className="measure-overview-bubble-chart-header" - > - <span - className="measure-overview-bubble-chart-title" - > - <div - className="display-flex-center" - > - component_measures.domain_x_overview.Security - <HelpTooltip - className="spacer-left" - overlay={null} - /> - </div> - </span> - <span - className="measure-overview-bubble-chart-legend" - > - <span - className="note" - > - <span - className="spacer-right" - > - component_measures.legend.color_x.security_rating - </span> - component_measures.legend.size_x.vulnerabilities - </span> - <ColorRatingsLegend - className="spacer-top" - filters={{}} - onRatingClick={[Function]} - /> - </span> - </div> - <div - className="measure-overview-bubble-chart-content" - > - <div - className="text-center small spacer-top spacer-bottom" - > - <ForwardRef(Link) - to={ - { - "pathname": "/component_measures", - "search": "?id=foo&metric=vulnerabilities&view=list", - } - } - > - component_measures.overview.see_data_as_list - </ForwardRef(Link)> - </div> - <BubbleChart - displayXGrid={true} - displayXTicks={true} - displayYGrid={true} - displayYTicks={true} - formatXTick={[Function]} - formatYTick={[Function]} - height={500} - items={ - [ - { - "color": undefined, - "data": { - "key": "foo:src/index.tsx", - "leak": "1.0", - "measures": [ - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "ncloc", - "key": "ncloc", - "name": "ncloc", - "type": "INT", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "0", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "security_remediation_effort", - "key": "security_remediation_effort", - "name": "security_remediation_effort", - "type": "DATA", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "0", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "vulnerabilities", - "key": "vulnerabilities", - "name": "vulnerabilities", - "type": "INT", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "0", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "security_rating", - "key": "security_rating", - "name": "security_rating", - "type": "RATING", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "0", - }, - ], - "name": "index.tsx", - "path": "src/index.tsx", - "qualifier": "FIL", - "value": "0", - }, - "size": 0, - "tooltip": <div - className="text-left" - > - <React.Fragment> - index.tsx - <br /> - </React.Fragment> - <React.Fragment> - ncloc: 0 - <br /> - </React.Fragment> - <React.Fragment> - security_remediation_effort: 0 - <br /> - </React.Fragment> - <React.Fragment> - vulnerabilities: 0 - <br /> - </React.Fragment> - <React.Fragment> - security_rating: \` - </React.Fragment> - </div>, - "x": 0, - "y": 0, - }, - ] - } - onBubbleClick={[Function]} - padding={ - [ - 25, - 60, - 50, - 60, - ] - } - sizeRange={ - [ - 5, - 45, - ] - } - xDomain={ - [ - 0, - 100, - ] - } - /> - </div> - <div - className="measure-overview-bubble-chart-axis x" - > - ncloc - </div> - <div - className="measure-overview-bubble-chart-axis y" - > - security_remediation_effort - </div> -</div> -`; - -exports[`should render correctly: default 1`] = ` -<div - className="measure-overview-bubble-chart" -> - <div - className="measure-overview-bubble-chart-header" - > - <span - className="measure-overview-bubble-chart-title" - > - <div - className="display-flex-center" - > - component_measures.domain_x_overview.Security - <HelpTooltip - className="spacer-left" - overlay={null} - /> - </div> - </span> - <span - className="measure-overview-bubble-chart-legend" - > - <span - className="note" - > - <span - className="spacer-right" - > - component_measures.legend.color_x.security_rating - </span> - component_measures.legend.size_x.vulnerabilities - </span> - <ColorRatingsLegend - className="spacer-top" - filters={{}} - onRatingClick={[Function]} - /> - </span> - </div> - <div - className="measure-overview-bubble-chart-content" - > - <div - className="text-center small spacer-top spacer-bottom" - > - <ForwardRef(Link) - to={ - { - "pathname": "/component_measures", - "search": "?id=foo&metric=vulnerabilities&view=list", - } - } - > - component_measures.overview.see_data_as_list - </ForwardRef(Link)> - </div> - <BubbleChart - displayXGrid={true} - displayXTicks={true} - displayYGrid={true} - displayYTicks={true} - formatXTick={[Function]} - formatYTick={[Function]} - height={500} - items={ - [ - { - "color": { - "fill": "#C6E056", - "fillTransparent": "rgba(198, 224, 86, 0.2)", - "stroke": "#809E00", - }, - "data": { - "key": "foo:src/index.tsx", - "leak": "1.0", - "measures": [ - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "ncloc", - "key": "ncloc", - "name": "ncloc", - "type": "INT", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "236", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "security_remediation_effort", - "key": "security_remediation_effort", - "name": "security_remediation_effort", - "type": "DATA", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "10", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "vulnerabilities", - "key": "vulnerabilities", - "name": "vulnerabilities", - "type": "INT", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "3", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "security_rating", - "key": "security_rating", - "name": "security_rating", - "type": "RATING", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "2", - }, - ], - "name": "index.tsx", - "path": "src/index.tsx", - "qualifier": "FIL", - "value": "3", - }, - "size": 3, - "tooltip": <div - className="text-left" - > - <React.Fragment> - index.tsx - <br /> - </React.Fragment> - <React.Fragment> - ncloc: 236 - <br /> - </React.Fragment> - <React.Fragment> - security_remediation_effort: 10 - <br /> - </React.Fragment> - <React.Fragment> - vulnerabilities: 3 - <br /> - </React.Fragment> - <React.Fragment> - security_rating: B - </React.Fragment> - </div>, - "x": 236, - "y": 10, - }, - ] - } - onBubbleClick={[Function]} - padding={ - [ - 25, - 60, - 50, - 60, - ] - } - sizeRange={ - [ - 5, - 45, - ] - } - /> - </div> - <div - className="measure-overview-bubble-chart-axis x" - > - ncloc - </div> - <div - className="measure-overview-bubble-chart-axis y" - > - security_remediation_effort - </div> -</div> -`; - -exports[`should render correctly: only showing first 500 files 1`] = ` -<div - className="measure-overview-bubble-chart" -> - <div - className="measure-overview-bubble-chart-header" - > - <span - className="measure-overview-bubble-chart-title" - > - <div - className="display-flex-center" - > - component_measures.domain_x_overview.Security - <HelpTooltip - className="spacer-left" - overlay={null} - /> - </div> - <div - className="note spacer-top" - > - ( - component_measures.legend.only_first_500_files - ) - </div> - </span> - <span - className="measure-overview-bubble-chart-legend" - > - <span - className="note" - > - <span - className="spacer-right" - > - component_measures.legend.color_x.security_rating - </span> - component_measures.legend.size_x.vulnerabilities - </span> - <ColorRatingsLegend - className="spacer-top" - filters={{}} - onRatingClick={[Function]} - /> - </span> - </div> - <div - className="measure-overview-bubble-chart-content" - > - <div - className="text-center small spacer-top spacer-bottom" - > - <ForwardRef(Link) - to={ - { - "pathname": "/component_measures", - "search": "?id=foo&metric=vulnerabilities&view=list", - } - } - > - component_measures.overview.see_data_as_list - </ForwardRef(Link)> - </div> - <BubbleChart - displayXGrid={true} - displayXTicks={true} - displayYGrid={true} - displayYTicks={true} - formatXTick={[Function]} - formatYTick={[Function]} - height={500} - items={ - [ - { - "color": { - "fill": "#C6E056", - "fillTransparent": "rgba(198, 224, 86, 0.2)", - "stroke": "#809E00", - }, - "data": { - "key": "foo:src/index.tsx", - "leak": "1.0", - "measures": [ - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "ncloc", - "key": "ncloc", - "name": "ncloc", - "type": "INT", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "236", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "security_remediation_effort", - "key": "security_remediation_effort", - "name": "security_remediation_effort", - "type": "DATA", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "10", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "vulnerabilities", - "key": "vulnerabilities", - "name": "vulnerabilities", - "type": "INT", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "3", - }, - { - "bestValue": true, - "leak": "1.0", - "metric": { - "id": "security_rating", - "key": "security_rating", - "name": "security_rating", - "type": "RATING", - }, - "period": { - "bestValue": true, - "index": 1, - "value": "1.0", - }, - "value": "2", - }, - ], - "name": "index.tsx", - "path": "src/index.tsx", - "qualifier": "FIL", - "value": "3", - }, - "size": 3, - "tooltip": <div - className="text-left" - > - <React.Fragment> - index.tsx - <br /> - </React.Fragment> - <React.Fragment> - ncloc: 236 - <br /> - </React.Fragment> - <React.Fragment> - security_remediation_effort: 10 - <br /> - </React.Fragment> - <React.Fragment> - vulnerabilities: 3 - <br /> - </React.Fragment> - <React.Fragment> - security_rating: B - </React.Fragment> - </div>, - "x": 236, - "y": 10, - }, - ] - } - onBubbleClick={[Function]} - padding={ - [ - 25, - 60, - 50, - 60, - ] - } - sizeRange={ - [ - 5, - 45, - ] - } - /> - </div> - <div - className="measure-overview-bubble-chart-axis x" - > - ncloc - </div> - <div - className="measure-overview-bubble-chart-axis y" - > - security_remediation_effort - </div> -</div> -`; diff --git a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx b/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx deleted file mode 100644 index e9124b73452..00000000000 --- a/server/sonar-web/src/main/js/components/charts/BubbleChart.tsx +++ /dev/null @@ -1,377 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import classNames from 'classnames'; -import { max, min } from 'd3-array'; -import { scaleLinear, ScaleLinear } from 'd3-scale'; -import { select } from 'd3-selection'; -import { D3ZoomEvent, zoom, ZoomBehavior, zoomIdentity } from 'd3-zoom'; -import { sortBy, uniq } from 'lodash'; -import * as React from 'react'; -import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; -import { translate } from '../../helpers/l10n'; -import Link from '../common/Link'; -import Tooltip from '../controls/Tooltip'; -import './BubbleChart.css'; - -const TICKS_COUNT = 5; - -interface BubbleItem<T> { - color: { fill: string; stroke: string; hover?: string }; - key?: string; - data?: T; - size: number; - tooltip?: React.ReactNode; - x: number; - y: number; -} - -interface Props<T> { - displayXGrid?: boolean; - displayXTicks?: boolean; - displayYGrid?: boolean; - displayYTicks?: boolean; - formatXTick: (tick: number) => string; - formatYTick: (tick: number) => string; - height: number; - items: BubbleItem<T>[]; - onBubbleClick?: (ref?: T) => void; - padding: [number, number, number, number]; - sizeDomain?: [number, number]; - sizeRange?: [number, number]; - xDomain?: [number, number]; - yDomain?: [number, number]; -} - -interface State { - transform: { x: number; y: number; k: number }; -} - -type Scale = ScaleLinear<number, number>; - -export default class BubbleChart<T> extends React.PureComponent<Props<T>, State> { - private node?: Element; - private zoom?: ZoomBehavior<Element, unknown>; - - static defaultProps = { - displayXGrid: true, - displayXTicks: true, - displayYGrid: true, - displayYTicks: true, - formatXTick: (d: number) => String(d), - formatYTick: (d: number) => String(d), - padding: [10, 10, 10, 10], - sizeRange: [5, 45], - }; - - constructor(props: Props<T>) { - super(props); - this.state = { transform: { x: 0, y: 0, k: 1 } }; - } - - componentDidUpdate() { - if (this.zoom && this.node) { - const rect = this.node.getBoundingClientRect(); - this.zoom.translateExtent([ - [0, 0], - [rect.width, rect.height], - ]); - } - } - - boundNode = (node: SVGSVGElement) => { - this.node = node; - this.zoom = zoom().scaleExtent([1, 10]).on('zoom', this.zoomed); - select(this.node).call(this.zoom as any); - }; - - zoomed = (event: D3ZoomEvent<SVGSVGElement, void>) => { - const { padding } = this.props; - const { x, y, k } = event.transform; - this.setState({ - transform: { - x: x + padding[3] * (k - 1), - y: y + padding[0] * (k - 1), - k, - }, - }); - }; - - resetZoom = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (this.zoom && this.node) { - select(this.node).call(this.zoom.transform as any, zoomIdentity); - } - }; - - getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) { - const minX = min(this.props.items, (d) => xScale(d.x) - sizeScale(d.size)) || 0; - const maxX = max(this.props.items, (d) => xScale(d.x) + sizeScale(d.size)) || 0; - const dMinX = minX < 0 ? xScale.range()[0] - minX : xScale.range()[0]; - const dMaxX = maxX > xScale.range()[1] ? maxX - xScale.range()[1] : 0; - return [dMinX, availableWidth - dMaxX]; - } - - getYRange(yScale: Scale, sizeScale: Scale, availableHeight: number) { - const minY = min(this.props.items, (d) => yScale(d.y) - sizeScale(d.size)) || 0; - const maxY = max(this.props.items, (d) => yScale(d.y) + sizeScale(d.size)) || 0; - const dMinY = minY < 0 ? yScale.range()[1] - minY : yScale.range()[1]; - const dMaxY = maxY > yScale.range()[0] ? maxY - yScale.range()[0] : 0; - return [availableHeight - dMaxY, dMinY]; - } - - getTicks(scale: Scale, format: (d: number) => string) { - const zoomAmount = Math.ceil(this.state.transform.k); - const ticks = scale.ticks(TICKS_COUNT * zoomAmount).map((tick) => format(tick)); - const uniqueTicksCount = uniq(ticks).length; - const ticksCount = - uniqueTicksCount < TICKS_COUNT * zoomAmount ? uniqueTicksCount - 1 : TICKS_COUNT * zoomAmount; - return scale.ticks(ticksCount); - } - - getZoomLevelLabel = () => Math.floor(this.state.transform.k * 100) + '%'; - - renderXGrid = (ticks: number[], xScale: Scale, yScale: Scale) => { - if (!this.props.displayXGrid) { - return null; - } - - const { transform } = this.state; - const lines = ticks.map((tick, index) => { - const x = xScale(tick); - const y1 = yScale.range()[0]; - const y2 = yScale.range()[1]; - return ( - <line - className="bubble-chart-grid" - // eslint-disable-next-line react/no-array-index-key - key={index} - x1={x * transform.k + transform.x} - x2={x * transform.k + transform.x} - y1={y1 * transform.k} - y2={transform.k > 1 ? 0 : y2} - /> - ); - }); - - return <g>{lines}</g>; - }; - - renderYGrid = (ticks: number[], xScale: Scale, yScale: Scale) => { - if (!this.props.displayYGrid) { - return null; - } - - const { transform } = this.state; - const lines = ticks.map((tick, index) => { - const y = yScale(tick); - const x1 = xScale.range()[0]; - const x2 = xScale.range()[1]; - return ( - <line - className="bubble-chart-grid" - // eslint-disable-next-line react/no-array-index-key - key={index} - x1={transform.k > 1 ? 0 : x1} - x2={x2 * transform.k} - y1={y * transform.k + transform.y} - y2={y * transform.k + transform.y} - /> - ); - }); - - return <g>{lines}</g>; - }; - - renderXTicks = (xTicks: number[], xScale: Scale, yScale: Scale) => { - if (!this.props.displayXTicks) { - return null; - } - - const { transform } = this.state; - const ticks = xTicks.map((tick, index) => { - const x = xScale(tick) * transform.k + transform.x; - const y = yScale.range()[0]; - const innerText = this.props.formatXTick(tick); - // as we modified the `x` using `transform`, check that it is inside the range again - return x > 0 && x < xScale.range()[1] ? ( - // eslint-disable-next-line react/no-array-index-key - <text className="bubble-chart-tick" dy="1.5em" key={index} x={x} y={y}> - {innerText} - </text> - ) : null; - }); - - return <g>{ticks}</g>; - }; - - renderYTicks = (yTicks: number[], xScale: Scale, yScale: Scale) => { - if (!this.props.displayYTicks) { - return null; - } - - const { transform } = this.state; - const ticks = yTicks.map((tick, index) => { - const x = xScale.range()[0]; - const y = yScale(tick) * transform.k + transform.y; - const innerText = this.props.formatYTick(tick); - // as we modified the `y` using `transform`, check that it is inside the range again - return y > 0 && y < yScale.range()[0] ? ( - <text - className="bubble-chart-tick bubble-chart-tick-y" - dx="-0.5em" - dy="0.3em" - // eslint-disable-next-line react/no-array-index-key - key={index} - x={x} - y={y} - > - {innerText} - </text> - ) : null; - }); - - return <g>{ticks}</g>; - }; - - renderChart = (width: number) => { - const { transform } = this.state; - const availableWidth = width - this.props.padding[1] - this.props.padding[3]; - const availableHeight = this.props.height - this.props.padding[0] - this.props.padding[2]; - - const xScale = scaleLinear() - .domain(this.props.xDomain || [0, max(this.props.items, (d) => d.x) || 0]) - .range([0, availableWidth]) - .nice(); - const yScale = scaleLinear() - .domain(this.props.yDomain || [0, max(this.props.items, (d) => d.y) || 0]) - .range([availableHeight, 0]) - .nice(); - const sizeScale = scaleLinear() - .domain(this.props.sizeDomain || [0, max(this.props.items, (d) => d.size) || 0]) - .range(this.props.sizeRange || []); - - const xScaleOriginal = xScale.copy(); - const yScaleOriginal = yScale.copy(); - - xScale.range(this.getXRange(xScale, sizeScale, availableWidth)); - yScale.range(this.getYRange(yScale, sizeScale, availableHeight)); - - const bubbles = sortBy(this.props.items, (b) => -b.size).map((item, index) => { - return ( - <Bubble - color={item.color} - data={item.data} - key={item.key || index} - onClick={this.props.onBubbleClick} - r={sizeScale(item.size)} - scale={1 / transform.k} - tooltip={item.tooltip} - x={xScale(item.x)} - y={yScale(item.y)} - /> - ); - }); - - const xTicks = this.getTicks(xScale, this.props.formatXTick); - const yTicks = this.getTicks(yScale, this.props.formatYTick); - - return ( - <svg - className={classNames('bubble-chart')} - height={this.props.height} - ref={this.boundNode} - width={width} - > - <defs> - <clipPath id="graph-region"> - <rect - // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders - height={availableHeight + 4} - width={availableWidth + 4} - x={-2} - y={-2} - /> - </clipPath> - </defs> - <g transform={`translate(${this.props.padding[3]}, ${this.props.padding[0]})`}> - <g clipPath="url(#graph-region)"> - {this.renderXGrid(xTicks, xScale, yScale)} - {this.renderYGrid(yTicks, xScale, yScale)} - <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}> - {bubbles} - </g> - </g> - {this.renderXTicks(xTicks, xScale, yScaleOriginal)} - {this.renderYTicks(yTicks, xScaleOriginal, yScale)} - </g> - </svg> - ); - }; - - render() { - return ( - <div> - <div className="bubble-chart-zoom"> - <Tooltip overlay={translate('component_measures.bubble_chart.zoom_level')}> - <Link onClick={this.resetZoom} to="#"> - {this.getZoomLevelLabel()} - </Link> - </Tooltip> - </div> - <AutoSizer disableHeight={true}>{(size) => this.renderChart(size.width)}</AutoSizer> - </div> - ); - } -} - -interface BubbleProps<T> { - color: { fill: string; stroke: string; hover?: string }; - onClick?: (ref?: T) => void; - data?: T; - r: number; - scale: number; - tooltip?: string | React.ReactNode; - x: number; - y: number; -} - -function Bubble<T>(props: BubbleProps<T>) { - const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => { - if (props.onClick) { - e.stopPropagation(); - e.preventDefault(); - props.onClick(props.data); - } - }; - - const circle = ( - <a onClick={handleClick} href="#"> - <circle - className="bubble-chart-bubble" - r={props.r} - style={{ fill: props.color.fill, stroke: props.color.stroke }} - transform={`translate(${props.x}, ${props.y}) scale(${props.scale})`} - /> - </a> - ); - - return <Tooltip overlay={props.tooltip || undefined}>{circle}</Tooltip>; -} diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/BubbleChart-test.tsx b/server/sonar-web/src/main/js/components/charts/__tests__/BubbleChart-test.tsx deleted file mode 100644 index 955891f2fbf..00000000000 --- a/server/sonar-web/src/main/js/components/charts/__tests__/BubbleChart-test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import { select } from 'd3-selection'; -import { D3ZoomEvent, zoom } from 'd3-zoom'; -import { shallow } from 'enzyme'; -import * as React from 'react'; -import { AutoSizer, AutoSizerProps } from 'react-virtualized/dist/commonjs/AutoSizer'; -import Link from '../../../components/common/Link'; -import { mockComponentMeasureEnhanced } from '../../../helpers/mocks/component'; -import { mockHtmlElement } from '../../../helpers/mocks/dom'; -import { click, mockEvent } from '../../../helpers/testUtils'; -import { ComponentMeasureEnhanced } from '../../../types/types'; -import BubbleChart from '../BubbleChart'; - -jest.mock('react-virtualized/dist/commonjs/AutoSizer', () => ({ - AutoSizer: ({ children }: AutoSizerProps) => children({ width: 100, height: NaN }), -})); - -jest.mock('d3-selection', () => ({ - select: jest.fn().mockReturnValue({ call: jest.fn() }), -})); - -jest.mock('d3-zoom', () => { - return { - zoomidentity: { k: 1, tx: 0, ty: 0 }, - zoom: jest.fn(), - }; -}); - -beforeEach(jest.clearAllMocks); - -it('should display bubbles', () => { - const wrapper = shallowRender(); - wrapper - .find(AutoSizer) - .dive() - .find('Bubble') - .forEach((bubble) => { - expect(bubble.dive()).toMatchSnapshot(); - }); -}); - -it('should render bubble links', () => { - const wrapper = shallowRender({ - items: [ - { x: 1, y: 10, size: 7, color: { fill: 'blue', stroke: 'blue' } }, - { x: 2, y: 30, size: 5, color: { fill: 'green', stroke: 'green' } }, - ], - }); - wrapper - .find(AutoSizer) - .dive() - .find('Bubble') - .forEach((bubble) => { - expect(bubble.dive()).toMatchSnapshot(); - }); -}); - -it('should render bubbles with click handlers', () => { - const onBubbleClick = jest.fn(); - const wrapper = shallowRender({ onBubbleClick }); - wrapper - .find(AutoSizer) - .dive() - .find('Bubble') - .forEach((bubble) => { - click(bubble.dive().find('a')); - expect(bubble.dive()).toMatchSnapshot(); - }); - expect(onBubbleClick).toHaveBeenCalledTimes(2); - expect(onBubbleClick).toHaveBeenLastCalledWith(mockComponentMeasureEnhanced()); -}); - -it('should correctly handle zooming', () => { - class ZoomBehaviorMock { - on = () => this; - scaleExtent = () => this; - translateExtent = () => this; - } - - const call = jest.fn(); - const zoomBehavior = new ZoomBehaviorMock(); - (select as jest.Mock).mockReturnValueOnce({ call }); - (zoom as jest.Mock).mockReturnValueOnce(zoomBehavior); - - return new Promise<void>((resolve, reject) => { - const wrapper = shallowRender({ padding: [5, 5, 5, 5] }); - wrapper.instance().boundNode( - mockHtmlElement<SVGSVGElement>({ - getBoundingClientRect: () => ({ width: 100, height: 100 } as DOMRect), - }) - ); - - // Call zoom event handler. - const mockZoomEvent = { transform: { x: 10, y: 10, k: 20 } } as D3ZoomEvent< - SVGSVGElement, - void - >; - wrapper.instance().zoomed(mockZoomEvent); - expect(wrapper.state().transform).toEqual({ - x: 105, - y: 105, - k: 20, - }); - - // Reset Zoom levels. - const resetZoomClick = wrapper.find('div.bubble-chart-zoom').find(Link).props().onClick; - if (!resetZoomClick) { - reject(); - return; - } - - const stopPropagation = jest.fn(); - const preventDefault = jest.fn(); - resetZoomClick(mockEvent({ stopPropagation, preventDefault })); - expect(stopPropagation).toHaveBeenCalled(); - expect(preventDefault).toHaveBeenCalled(); - expect(call).toHaveBeenCalledWith(zoomBehavior); - - resolve(); - }); -}); - -function shallowRender(props: Partial<BubbleChart<ComponentMeasureEnhanced>['props']> = {}) { - return shallow<BubbleChart<ComponentMeasureEnhanced>>( - <BubbleChart - height={100} - items={[ - { - x: 1, - y: 10, - size: 7, - data: mockComponentMeasureEnhanced(), - color: { fill: 'blue', stroke: 'blue' }, - }, - { - x: 2, - y: 30, - size: 5, - data: mockComponentMeasureEnhanced(), - color: { fill: 'red', stroke: 'red' }, - }, - ]} - padding={[0, 0, 0, 0]} - {...props} - /> - ); -} diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap deleted file mode 100644 index d59030a480e..00000000000 --- a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/BubbleChart-test.tsx.snap +++ /dev/null @@ -1,127 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should display bubbles 1`] = ` -<Tooltip> - <a - href="#" - onClick={[Function]} - > - <circle - className="bubble-chart-bubble" - r={45} - style={ - { - "fill": "blue", - "stroke": "blue", - } - } - transform="translate(33.21428571428571, 70.07936507936509) scale(1)" - /> - </a> -</Tooltip> -`; - -exports[`should display bubbles 2`] = ` -<Tooltip> - <a - href="#" - onClick={[Function]} - > - <circle - className="bubble-chart-bubble" - r={33.57142857142858} - style={ - { - "fill": "red", - "stroke": "red", - } - } - transform="translate(66.42857142857142, 33.57142857142858) scale(1)" - /> - </a> -</Tooltip> -`; - -exports[`should render bubble links 1`] = ` -<Tooltip> - <a - href="#" - onClick={[Function]} - > - <circle - className="bubble-chart-bubble" - r={45} - style={ - { - "fill": "blue", - "stroke": "blue", - } - } - transform="translate(33.21428571428571, 70.07936507936509) scale(1)" - /> - </a> -</Tooltip> -`; - -exports[`should render bubble links 2`] = ` -<Tooltip> - <a - href="#" - onClick={[Function]} - > - <circle - className="bubble-chart-bubble" - r={33.57142857142858} - style={ - { - "fill": "green", - "stroke": "green", - } - } - transform="translate(66.42857142857142, 33.57142857142858) scale(1)" - /> - </a> -</Tooltip> -`; - -exports[`should render bubbles with click handlers 1`] = ` -<Tooltip> - <a - href="#" - onClick={[Function]} - > - <circle - className="bubble-chart-bubble" - r={45} - style={ - { - "fill": "blue", - "stroke": "blue", - } - } - transform="translate(33.21428571428571, 70.07936507936509) scale(1)" - /> - </a> -</Tooltip> -`; - -exports[`should render bubbles with click handlers 2`] = ` -<Tooltip> - <a - href="#" - onClick={[Function]} - > - <circle - className="bubble-chart-bubble" - r={33.57142857142858} - style={ - { - "fill": "red", - "stroke": "red", - } - } - transform="translate(66.42857142857142, 33.57142857142858) scale(1)" - /> - </a> -</Tooltip> -`; diff --git a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/LanguageDistribution-test.tsx.snap b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/LanguageDistribution-test.tsx.snap index 5fed7969987..8d594aa389e 100644 --- a/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/LanguageDistribution-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/charts/__tests__/__snapshots__/LanguageDistribution-test.tsx.snap @@ -35,6 +35,7 @@ exports[`should render correctly 1`] = ` y="13" /> <text + aria-describedby="tooltip-1" class="emotion-2 emotion-3" dx="1em" dy="0.3em" diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 5e21aa42090..36d303c96f0 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -6192,11 +6192,13 @@ __metadata: clipboard: 2.0.11 d3-array: 3.2.3 d3-scale: 4.0.2 + d3-selection: 3.0.0 d3-shape: 3.2.0 + d3-zoom: 3.0.0 date-fns: 2.29.3 lodash: 4.17.21 react: 17.0.2 - react-day-picker: 8.6.0 + react-day-picker: 8.7.1 react-dom: 17.0.2 react-helmet-async: 1.3.0 react-highlight-words: 0.20.0 @@ -6204,6 +6206,7 @@ __metadata: react-modal: 3.16.1 react-router-dom: 6.10.0 react-select: 5.7.2 + react-virtualized: 9.22.3 tailwindcss: 3.3.1 languageName: unknown linkType: soft |