From 9ba61f789adc616883414b7f1dddce058d05fff1 Mon Sep 17 00:00:00 2001 From: 7PH Date: Fri, 26 May 2023 15:33:41 +0200 Subject: [PATCH] SONAR-19386 Add TreeMap Chart to new component library --- server/sonar-web/design-system/package.json | 1 + .../design-system/src/components/TreeMap.tsx | 109 +++++++++++ .../src/components/TreeMapRect.tsx | 150 +++++++++++++++ .../src/components/__tests__/TreeMap-test.tsx | 65 +++++++ .../__snapshots__/TreeMap-test.tsx.snap | 181 ++++++++++++++++++ .../design-system/src/components/index.ts | 2 + 6 files changed, 508 insertions(+) create mode 100644 server/sonar-web/design-system/src/components/TreeMap.tsx create mode 100644 server/sonar-web/design-system/src/components/TreeMapRect.tsx create mode 100644 server/sonar-web/design-system/src/components/__tests__/TreeMap-test.tsx create mode 100644 server/sonar-web/design-system/src/components/__tests__/__snapshots__/TreeMap-test.tsx.snap diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index 07cbc22b88c..2ca10017669 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -53,6 +53,7 @@ "classnames": "2.3.2", "clipboard": "2.0.11", "d3-array": "3.2.3", + "d3-hierarchy": "3.1.2", "d3-scale": "4.0.2", "d3-selection": "3.0.0", "d3-shape": "3.2.0", diff --git a/server/sonar-web/design-system/src/components/TreeMap.tsx b/server/sonar-web/design-system/src/components/TreeMap.tsx new file mode 100644 index 00000000000..d66cc0ca772 --- /dev/null +++ b/server/sonar-web/design-system/src/components/TreeMap.tsx @@ -0,0 +1,109 @@ +/* + * 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 styled from '@emotion/styled'; +import { hierarchy as d3Hierarchy, treemap as d3Treemap } from 'd3-hierarchy'; +import { sortBy } from 'lodash'; +import tw from 'twin.macro'; +import { PopupPlacement } from '../helpers/positioning'; +import { TreeMapRect } from './TreeMapRect'; + +export interface TreeMapItem { + color?: string; + gradient?: string; + icon?: React.ReactNode; + key: string; + label: string; + size: number; + sourceData?: T; + tooltip?: React.ReactNode; +} + +interface HierarchicalTreemapItem extends TreeMapItem { + children?: Array>; +} + +export interface TreeMapProps { + height: number; + items: Array>; + onRectangleClick?: (item: TreeMapItem) => void; + width: number; +} + +export function TreeMap(props: TreeMapProps) { + function mostCommitPrefix(labels: string[]) { + const sortedLabels = sortBy(labels.slice(0)); + const firstLabel = sortedLabels[0]; + const firstLabelLength = firstLabel.length; + const lastLabel = sortedLabels[sortedLabels.length - 1]; + let i = 0; + while (i < firstLabelLength && firstLabel.charAt(i) === lastLabel.charAt(i)) { + i++; + } + const prefix = firstLabel.substring(0, i); + const prefixTokens = prefix.split(/[\s\\/]/); + const lastPrefixPart = prefixTokens[prefixTokens.length - 1]; + return prefix.substring(0, prefix.length - lastPrefixPart.length); + } + + function handleClick(data: TreeMapItem) { + if (props.onRectangleClick) { + props.onRectangleClick(data); + } + } + + const { items, height, width } = props; + const hierarchy = d3Hierarchy({ children: items } as HierarchicalTreemapItem) + .sum((d) => d.size) + .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); + + const treemap = d3Treemap>().round(true).size([width, height]); + + const nodes = treemap(hierarchy).leaves(); + const prefix = mostCommitPrefix(items.map((item) => item.label)); + const halfWidth = width / 2; + return ( + + {nodes.map(({ data, y0, y1, x0, x1 }) => ( + { + handleClick(data); + }} + placement={x0 === 0 || x1 < halfWidth ? PopupPlacement.Right : PopupPlacement.Left} + prefix={prefix} + tooltip={data.tooltip} + width={x1 - x0} + x={x0} + y={y0} + /> + ))} + + ); +} + +const StyledContainer = styled.ul` + ${tw`sw-relative`} +`; diff --git a/server/sonar-web/design-system/src/components/TreeMapRect.tsx b/server/sonar-web/design-system/src/components/TreeMapRect.tsx new file mode 100644 index 00000000000..a52879fd9e5 --- /dev/null +++ b/server/sonar-web/design-system/src/components/TreeMapRect.tsx @@ -0,0 +1,150 @@ +/* + * 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 styled from '@emotion/styled'; +import { scaleLinear } from 'd3-scale'; +import React from 'react'; +import tw from 'twin.macro'; +import { themeColor } from '../helpers'; +import { Key } from '../helpers/keyboard'; +import { BasePlacement, PopupPlacement } from '../helpers/positioning'; +import Tooltip from './Tooltip'; + +const SIZE_SCALE = scaleLinear().domain([3, 15]).range([11, 18]).clamp(true); +const TEXT_VISIBLE_AT_WIDTH = 40; +const TEXT_VISIBLE_AT_HEIGHT = 45; +const ICON_VISIBLE_AT_WIDTH = 24; +const ICON_VISIBLE_AT_HEIGHT = 26; + +interface Props { + fill?: string; + gradient?: string; + height: number; + icon?: React.ReactNode; + itemKey: string; + label: string; + onClick: (item: string) => void; + placement?: BasePlacement; + prefix: string; + tooltip?: React.ReactNode; + width: number; + x: number; + y: number; +} + +export function TreeMapRect(props: Props) { + function handleRectClick() { + props.onClick(props.itemKey); + } + + function handleRectKeyDown(event: React.KeyboardEvent) { + if (event.key === Key.Enter) { + props.onClick(props.itemKey); + } + } + + function renderCell() { + const cellStyles = { + left: props.x, + top: props.y, + width: props.width, + height: props.height, + backgroundColor: props.fill, + backgroundImage: props.gradient, + fontSize: SIZE_SCALE(props.width / props.label.length), + lineHeight: `${props.height}px`, + }; + const isTextVisible = + props.width >= TEXT_VISIBLE_AT_WIDTH && props.height >= TEXT_VISIBLE_AT_HEIGHT; + const isIconVisible = + props.width >= ICON_VISIBLE_AT_WIDTH && props.height >= ICON_VISIBLE_AT_HEIGHT; + + return ( + + + + {isIconVisible && {props.icon}} + {isTextVisible && + (props.prefix ? ( + + {props.prefix} +
+ {props.label.substring(props.prefix.length)} +
+ ) : ( + {props.label} + ))} +
+
+
+ ); + } + + const { placement, tooltip } = props; + return ( + + {renderCell()} + + ); +} + +const StyledCell = styled.li` + ${tw`sw-absolute`}; + ${tw`sw-box-border`}; + + border-right: 1px solid #fff; + border-bottom: 1px solid #fff; +`; + +const StyledCellLink = styled.a` + ${tw`sw-w-full sw-h-full`}; + ${tw`sw-border-0`}; + ${tw`sw-flex sw-flex-col sw-items-center sw-justify-center`}; + + color: ${themeColor('pageContent')}; + + &:hover, + &:active, + &:focus { + ${tw`sw-border-0`}; + outline: none; + } + + &:focus .treemap-text, + &:hover .treemap-text { + ${tw`sw-underline`}; + } +`; + +const StyledCellLabel = styled.div<{ width: number }>` + ${tw`sw-flex sw-flex-wrap sw-justify-center sw-items-center sw-gap-2`}; + + line-height: 1.2; + max-width: ${({ width }) => width}px; + + .treemap-text { + ${tw`sw-shrink sw-overflow-hidden sw-whitespace-nowrap sw-text-left sw-text-ellipsis`}; + } +`; diff --git a/server/sonar-web/design-system/src/components/__tests__/TreeMap-test.tsx b/server/sonar-web/design-system/src/components/__tests__/TreeMap-test.tsx new file mode 100644 index 00000000000..645d1499ea6 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/TreeMap-test.tsx @@ -0,0 +1,65 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { render } from '../../helpers/testUtils'; +import { TreeMap, TreeMapProps } from '../TreeMap'; + +it('should render correctly and forward click event', async () => { + const user = userEvent.setup(); + const items = [ + { + key: '1', + size: 10, + color: '#777', + label: 'SonarQube_Server', + }, + { + key: '2', + size: 30, + color: '#777', + label: 'SonarQube_Web', + sourceData: 123, + }, + { + key: '3', + size: 20, + gradient: '#777', + label: 'SonarQube_Search', + }, + ]; + const onRectangleClick = jest.fn(); + const { container } = renderTreeMap({ + items, + onRectangleClick, + }); + + expect(container).toMatchSnapshot(); + + await user.click(screen.getByText('SonarQube_Web')); + expect(onRectangleClick).toHaveBeenCalledTimes(1); + expect(onRectangleClick).toHaveBeenCalledWith(items[1]); +}); + +function renderTreeMap(props: Partial>) { + return render( + + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/TreeMap-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/TreeMap-test.tsx.snap new file mode 100644 index 00000000000..7f3703d9986 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/TreeMap-test.tsx.snap @@ -0,0 +1,181 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly and forward click event 1`] = ` +.emotion-0 { + position: relative; +} + +.emotion-2 { + position: absolute; + box-sizing: border-box; + border-right: 1px solid #fff; + border-bottom: 1px solid #fff; +} + +.emotion-4 { + height: 100%; + width: 100%; + border-width: 0px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + color: rgb(62,67,87); +} + +.emotion-4:hover, +.emotion-4:active, +.emotion-4:focus { + border-width: 0px; + outline: none; +} + +.emotion-4:focus .treemap-text, +.emotion-4:hover .treemap-text { + text-decoration-line: underline; +} + +.emotion-6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + gap: 0.5rem; + line-height: 1.2; + max-width: 83px; +} + +.emotion-6 .treemap-text { + -webkit-flex-shrink: 1; + -ms-flex-negative: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.emotion-18 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + gap: 0.5rem; + line-height: 1.2; + max-width: 17px; +} + +.emotion-18 .treemap-text { + -webkit-flex-shrink: 1; + -ms-flex-negative: 1; + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + + +`; diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 93e78198b4e..0d41427aa9c 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -77,6 +77,8 @@ export * from './TagsSelector'; export * from './Text'; export { ToggleButton } from './ToggleButton'; export { TopBar } from './TopBar'; +export * from './TreeMap'; +export * from './TreeMapRect'; export * from './buttons'; export { ClipboardIconButton } from './clipboard'; export * from './code-line/LineCoverage'; -- 2.39.5