diff options
author | 7PH <benjamin.raymond@sonarsource.com> | 2023-05-26 15:33:41 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-05 20:02:47 +0000 |
commit | 9ba61f789adc616883414b7f1dddce058d05fff1 (patch) | |
tree | fb11d9d5152abe57ad02373833a89ed35e6d151a /server/sonar-web/design-system/src/components | |
parent | 0bc5896c6d93610455401dc4caaad3bc6a99a0cc (diff) | |
download | sonarqube-9ba61f789adc616883414b7f1dddce058d05fff1.tar.gz sonarqube-9ba61f789adc616883414b7f1dddce058d05fff1.zip |
SONAR-19386 Add TreeMap Chart to new component library
Diffstat (limited to 'server/sonar-web/design-system/src/components')
5 files changed, 507 insertions, 0 deletions
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<T> { + color?: string; + gradient?: string; + icon?: React.ReactNode; + key: string; + label: string; + size: number; + sourceData?: T; + tooltip?: React.ReactNode; +} + +interface HierarchicalTreemapItem<T> extends TreeMapItem<T> { + children?: Array<TreeMapItem<T>>; +} + +export interface TreeMapProps<T> { + height: number; + items: Array<TreeMapItem<T>>; + onRectangleClick?: (item: TreeMapItem<T>) => void; + width: number; +} + +export function TreeMap<T = unknown>(props: TreeMapProps<T>) { + 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<T>) { + if (props.onRectangleClick) { + props.onRectangleClick(data); + } + } + + const { items, height, width } = props; + const hierarchy = d3Hierarchy({ children: items } as HierarchicalTreemapItem<T>) + .sum((d) => d.size) + .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); + + const treemap = d3Treemap<TreeMapItem<T>>().round(true).size([width, height]); + + const nodes = treemap(hierarchy).leaves(); + const prefix = mostCommitPrefix(items.map((item) => item.label)); + const halfWidth = width / 2; + return ( + <StyledContainer style={{ width, height }}> + {nodes.map(({ data, y0, y1, x0, x1 }) => ( + <TreeMapRect + fill={data.color} + gradient={data.gradient} + height={y1 - y0} + icon={data.icon} + itemKey={data.key} + key={data.key} + label={data.label} + onClick={() => { + handleClick(data); + }} + placement={x0 === 0 || x1 < halfWidth ? PopupPlacement.Right : PopupPlacement.Left} + prefix={prefix} + tooltip={data.tooltip} + width={x1 - x0} + x={x0} + y={y0} + /> + ))} + </StyledContainer> + ); +} + +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<HTMLAnchorElement>) { + 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 ( + <StyledCell style={cellStyles}> + <StyledCellLink + aria-label={props.prefix ? `${props.prefix} ${props.label}` : props.label} + onClick={handleRectClick} + onKeyDown={handleRectKeyDown} + role="link" + tabIndex={0} + > + <StyledCellLabel width={props.width}> + {isIconVisible && <span className="shrink-0">{props.icon}</span>} + {isTextVisible && + (props.prefix ? ( + <span className="treemap-text"> + {props.prefix} + <br /> + {props.label.substring(props.prefix.length)} + </span> + ) : ( + <span className="treemap-text">{props.label}</span> + ))} + </StyledCellLabel> + </StyledCellLink> + </StyledCell> + ); + } + + const { placement, tooltip } = props; + return ( + <Tooltip overlay={tooltip} placement={placement ?? PopupPlacement.Left}> + {renderCell()} + </Tooltip> + ); +} + +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<TreeMapProps<unknown>>) { + return render( + <TreeMap height={100} items={[]} onRectangleClick={jest.fn()} width={100} {...props} /> + ); +} 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; +} + +<div> + <ul + class="emotion-0 emotion-1" + style="width: 100px; height: 100px;" + > + <li + class="emotion-2 emotion-3" + style="left: 0px; top: 0px; width: 83px; height: 60px; background-color: rgb(119, 119, 119); font-size: 12.974358974358974px; line-height: 60px;" + > + <a + aria-label="SonarQube_Web" + class="emotion-4 emotion-5" + role="link" + tabindex="0" + > + <div + class="emotion-6 emotion-7" + width="83" + > + <span + class="shrink-0" + /> + <span + class="treemap-text" + > + SonarQube_Web + </span> + </div> + </a> + </li> + <li + class="emotion-2 emotion-3" + style="left: 0px; top: 60px; width: 83px; height: 40px; font-size: 12.276041666666668px; line-height: 40px;" + > + <a + aria-label="SonarQube_Search" + class="emotion-4 emotion-5" + role="link" + tabindex="0" + > + <div + class="emotion-6 emotion-7" + width="83" + > + <span + class="shrink-0" + /> + </div> + </a> + </li> + <li + class="emotion-2 emotion-3" + style="left: 83px; top: 0px; width: 17px; height: 100px; background-color: rgb(119, 119, 119); font-size: 11px; line-height: 100px;" + > + <a + aria-label="SonarQube_Server" + class="emotion-4 emotion-5" + role="link" + tabindex="0" + > + <div + class="emotion-18 emotion-7" + width="17" + /> + </a> + </li> + </ul> +</div> +`; 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'; |