]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19386 Add TreeMap Chart to new component library
author7PH <benjamin.raymond@sonarsource.com>
Fri, 26 May 2023 13:33:41 +0000 (15:33 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 5 Jun 2023 20:02:47 +0000 (20:02 +0000)
server/sonar-web/design-system/package.json
server/sonar-web/design-system/src/components/TreeMap.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/TreeMapRect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/TreeMap-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/__snapshots__/TreeMap-test.tsx.snap [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts

index 07cbc22b88c6b1ab09ea8b75ea56732c5ca405f4..2ca100176697e86b7eb7af36e5f807c69c2fc13d 100644 (file)
@@ -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 (file)
index 0000000..d66cc0c
--- /dev/null
@@ -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 (file)
index 0000000..a52879f
--- /dev/null
@@ -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 (file)
index 0000000..645d149
--- /dev/null
@@ -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 (file)
index 0000000..7f3703d
--- /dev/null
@@ -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>
+`;
index 93e78198b4e2af11c5e136c793f8c1ac904b710a..0d41427aa9c3a2188b75898227c51785e7e79e15 100644 (file)
@@ -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';