]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20948 Fix spotlight tour when resizing
authorMathieu Suen <mathieu.suen@sonarsource.com>
Wed, 22 Nov 2023 08:58:06 +0000 (09:58 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 23 Nov 2023 20:02:58 +0000 (20:02 +0000)
server/sonar-web/config/jest/SetupTestEnvironment.ts
server/sonar-web/design-system/config/jest/SetupTestEnvironment.js
server/sonar-web/design-system/src/components/SpotlightTour.tsx
server/sonar-web/design-system/src/helpers/__tests__/dom-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/dom.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/quality-gates/components/CaYCConditionsSimplificationGuide.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/CaycConditionsTable.tsx

index a10eb5dd7e379258bde9fc406a85a41f3b534026..7dfc1dc58f9c7146bd8c9aa2704a6f4add9b943f 100644 (file)
@@ -50,3 +50,121 @@ const MockIntersectionObserverEntries = [{ isIntersecting: true }];
   callback(MockIntersectionObserverEntries, MockObserver);
   return MockObserver;
 });
+
+// Copied from pollyfill.io
+// To be remove when upgrading jsdom https://github.com/jsdom/jsdom/releases/tag/22.1.0
+// jest-environment-jsdom to v30
+function number(v) {
+  return v === undefined ? 0 : Number(v);
+}
+
+function different(u, v) {
+  return u !== v && !(isNaN(u) && isNaN(v));
+}
+
+global.DOMRect = function DOMRect(xArg, yArg, wArg, hArg) {
+  let x;
+  let y;
+  let width;
+  let height;
+  let left;
+  let right;
+  let top;
+  let bottom;
+
+  x = number(xArg);
+  y = number(yArg);
+  width = number(wArg);
+  height = number(hArg);
+
+  Object.defineProperties(this, {
+    x: {
+      get() {
+        return x;
+      },
+      set(newX) {
+        if (different(x, newX)) {
+          x = newX;
+          left = undefined;
+          right = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    y: {
+      get() {
+        return y;
+      },
+      set(newY) {
+        if (different(y, newY)) {
+          y = newY;
+          top = undefined;
+          bottom = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    width: {
+      get() {
+        return width;
+      },
+      set(newWidth) {
+        if (different(width, newWidth)) {
+          width = newWidth;
+          left = undefined;
+          right = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    height: {
+      get() {
+        return height;
+      },
+      set(newHeight) {
+        if (different(height, newHeight)) {
+          height = newHeight;
+          top = undefined;
+          bottom = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    left: {
+      get() {
+        if (left === undefined) {
+          left = x + Math.min(0, width);
+        }
+        return left;
+      },
+      enumerable: true,
+    },
+    right: {
+      get() {
+        if (right === undefined) {
+          right = x + Math.max(0, width);
+        }
+        return right;
+      },
+      enumerable: true,
+    },
+    top: {
+      get() {
+        if (top === undefined) {
+          top = y + Math.min(0, height);
+        }
+        return top;
+      },
+      enumerable: true,
+    },
+    bottom: {
+      get() {
+        if (bottom === undefined) {
+          bottom = y + Math.max(0, height);
+        }
+        return bottom;
+      },
+      enumerable: true,
+    },
+  });
+};
index 3c7139c2b4ccd54247fa4cab6af710d4d7a88222..a0cd85b5f4cc4dde65747697e2bfb498311e8f7d 100644 (file)
@@ -48,3 +48,110 @@ global.ResizeObserver = jest.fn().mockImplementation((callback) => {
   callback(MockResizeObserverEntries, MockResizeObserver);
   return MockResizeObserver;
 });
+
+// Copied from pollyfill.io
+// To be remove when upgrading jsdom https://github.com/jsdom/jsdom/releases/tag/22.1.0
+// jest-environment-jsdom to v30
+function number(v) {
+  return v === undefined ? 0 : Number(v);
+}
+
+function different(u, v) {
+  return u !== v && !(isNaN(u) && isNaN(v));
+}
+
+global.DOMRect = function DOMRect(xArg, yArg, wArg, hArg) {
+  var x, y, width, height, left, right, top, bottom;
+
+  x = number(xArg);
+  y = number(yArg);
+  width = number(wArg);
+  height = number(hArg);
+
+  Object.defineProperties(this, {
+    x: {
+      get: function () {
+        return x;
+      },
+      set: function (newX) {
+        if (different(x, newX)) {
+          x = newX;
+          left = right = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    y: {
+      get: function () {
+        return y;
+      },
+      set: function (newY) {
+        if (different(y, newY)) {
+          y = newY;
+          top = bottom = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    width: {
+      get: function () {
+        return width;
+      },
+      set: function (newWidth) {
+        if (different(width, newWidth)) {
+          width = newWidth;
+          left = right = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    height: {
+      get: function () {
+        return height;
+      },
+      set: function (newHeight) {
+        if (different(height, newHeight)) {
+          height = newHeight;
+          top = bottom = undefined;
+        }
+      },
+      enumerable: true,
+    },
+    left: {
+      get: function () {
+        if (left === undefined) {
+          left = x + Math.min(0, width);
+        }
+        return left;
+      },
+      enumerable: true,
+    },
+    right: {
+      get: function () {
+        if (right === undefined) {
+          right = x + Math.max(0, width);
+        }
+        return right;
+      },
+      enumerable: true,
+    },
+    top: {
+      get: function () {
+        if (top === undefined) {
+          top = y + Math.min(0, height);
+        }
+        return top;
+      },
+      enumerable: true,
+    },
+    bottom: {
+      get: function () {
+        if (bottom === undefined) {
+          bottom = y + Math.max(0, height);
+        }
+        return bottom;
+      },
+      enumerable: true,
+    },
+  });
+};
index a6018def24b7a8a22c3b0bbad9f0293e3b319f0d..d9753e75d475b149d796fbd464edfa0806c1b4be 100644 (file)
@@ -29,6 +29,7 @@ import ReactJoyride, {
 } from 'react-joyride';
 import tw from 'twin.macro';
 import { GLOBAL_POPUP_Z_INDEX, PopupZLevel, themeColor } from '../helpers';
+import { findAnchor } from '../helpers/dom';
 import { ButtonLink, ButtonPrimary, WrapperButton } from './buttons';
 import { CloseIcon } from './icons';
 import { PopupWrapper } from './popups';
@@ -45,7 +46,7 @@ export interface SpotlightTourProps extends Omit<JoyrideProps, 'steps'> {
   width?: number;
 }
 
-export type SpotlightTourStep = Pick<JoyrideStep, 'target' | 'content' | 'title'> & {
+export type SpotlightTourStep = JoyrideStep & {
   placement?: Placement;
 };
 
@@ -54,9 +55,9 @@ export type SpotlightTourStep = Pick<JoyrideStep, 'target' | 'content' | 'title'
 (window as any).global = (window as any).global ?? {};
 
 const PULSE_SIZE = 8;
-const ARROW_LENGTH = 40;
 const DEFAULT_PLACEMENT = 'bottom';
 const DEFAULT_WIDTH = 315;
+const defultRect = new DOMRect(0, 0, 0, 0);
 
 function TooltipComponent({
   continuous,
@@ -76,7 +77,7 @@ function TooltipComponent({
   stepXofYLabel: SpotlightTourProps['stepXofYLabel'];
   width?: number;
 }) {
-  const [arrowPosition, setArrowPosition] = React.useState({ left: 0, top: 0, rotate: '0deg' });
+  const [timeStamp, setTimeStamp] = React.useState(0);
   const ref = React.useRef<HTMLDivElement | null>(null);
   const setRef = React.useCallback((node: HTMLDivElement) => {
     ref.current = node;
@@ -95,40 +96,33 @@ function TooltipComponent({
     };
   }, [step]);
 
+  const rect = ref.current?.parentElement?.getBoundingClientRect();
+  const targetElement =
+    typeof step.target === 'string'
+      ? document.querySelector<HTMLElement>(step.target)
+      : step.target;
+  const targetRect = targetElement?.getBoundingClientRect();
+
   React.useEffect(() => {
-    // We don't compute for "center"; "center" will simply not show any arrow.
-    if (placement !== 'center' && ref.current?.parentNode) {
-      let left = 0;
-      let top = 0;
-      let rotate = '0deg';
+    const updateScroll = (event: Event) => {
+      // The spotlight is doint transition that would look strange when we
+      // re-render arrow right away.
+      setTimeout(() => {
+        setTimeStamp(event.timeStamp);
+      }, 0);
+    };
 
-      const rect = (ref.current.parentNode as HTMLDivElement).getBoundingClientRect();
-      // In case target is null for some reason we use mocking object
-      const targetRect = (typeof step.target === 'string'
-        ? document.querySelector(step.target)?.getBoundingClientRect()
-        : step.target.getBoundingClientRect()) ?? { height: 0, y: 0, x: 0, width: 0 };
+    document.addEventListener('scroll', updateScroll, { capture: true });
 
-      if (placement === 'right') {
-        left = -ARROW_LENGTH - PULSE_SIZE;
-        top = Math.abs(targetRect.y - rect.y) + targetRect.height / 2 - PULSE_SIZE / 2;
-        rotate = '0deg';
-      } else if (placement === 'left') {
-        left = rect.width + ARROW_LENGTH + PULSE_SIZE;
-        top = Math.abs(targetRect.y - rect.y) + targetRect.height / 2 - PULSE_SIZE / 2;
-        rotate = '180deg';
-      } else if (placement === 'bottom') {
-        left = Math.abs(targetRect.x - rect.x) + targetRect.width / 2 - PULSE_SIZE / 2;
-        top = -ARROW_LENGTH - PULSE_SIZE;
-        rotate = '90deg';
-      } else if (placement === 'top') {
-        left = Math.abs(targetRect.x - rect.x) + targetRect.width / 2 - PULSE_SIZE / 2;
-        top = rect.height + ARROW_LENGTH + PULSE_SIZE;
-        rotate = '-90deg';
-      }
+    return () => {
+      document.removeEventListener('scroll', updateScroll, { capture: true });
+    };
+  }, []);
 
-      setArrowPosition({ left, top, rotate });
-    }
-  }, [step, ref, setArrowPosition, placement]);
+  const arrowPosition = React.useMemo(
+    () => findAnchor(rect ?? defultRect, targetRect ?? defultRect, PULSE_SIZE),
+    [rect, targetRect, timeStamp],
+  );
 
   /**
    * Preventing click events from bubbling to avoid closing other popups, in cases when the guide
@@ -142,14 +136,13 @@ function TooltipComponent({
     <StyledPopupWrapper
       className="sw-p-3 sw-body-sm sw-relative sw-border-0"
       onClick={handleClick}
-      placement={(step.placement as Placement | undefined) ?? DEFAULT_PLACEMENT}
       style={{ width }}
       zLevel={PopupZLevel.Absolute}
       {...tooltipProps}
     >
       {placement !== 'center' && (
         <SpotlightArrowWrapper left={arrowPosition.left} top={arrowPosition.top}>
-          <SpotlightArrow rotate={arrowPosition.rotate} />
+          <SpotlightArrow rotate={arrowPosition.rotate} width={arrowPosition.width} />
         </SpotlightArrowWrapper>
       )}
 
@@ -242,32 +235,12 @@ export function SpotlightTour(props: SpotlightTourProps) {
   );
 }
 
-const StyledPopupWrapper = styled(PopupWrapper)<{ placement: Placement }>`
+const StyledPopupWrapper = styled(PopupWrapper)`
   background-color: ${themeColor('spotlightBackgroundColor')};
   ${tw`sw-overflow-visible`};
   ${tw`sw-rounded-1`};
-  ${({ placement }) => getStyledPopupWrapperMargin(placement)};
 `;
 
-function getStyledPopupWrapperMargin(placement: Placement) {
-  switch (placement) {
-    case 'left':
-      return `margin-right: 2rem`;
-
-    case 'right':
-      return `margin-left: 2rem`;
-
-    case 'bottom':
-      return `margin-top: 2rem`;
-
-    case 'top':
-      return `margin-bottom: 2rem`;
-
-    default:
-      return null;
-  }
-}
-
 const SpotlightArrowWrapper = styled.div<{ left: number; top: number }>`
   ${tw`sw-absolute`}
   ${tw`sw-z-popup`}
@@ -283,7 +256,7 @@ const pulseKeyFrame = keyframes`
   80%, 100% { opacity: 0 }
 `;
 
-const SpotlightArrow = styled.div<{ rotate: string }>`
+const SpotlightArrow = styled.div<{ rotate: string; width: number }>`
   ${tw`sw-w-full sw-h-full`}
   ${tw`sw-rounded-pill`}
   background: ${themeColor('spotlightPulseBackground')};
@@ -306,7 +279,7 @@ const SpotlightArrow = styled.div<{ rotate: string }>`
   &::before {
     ${tw`sw-block sw-absolute`}
 
-    width: ${ARROW_LENGTH}px;
+    width: ${({ width }) => width}px;
     height: 0.125rem;
     background-color: ${themeColor('spotlightPulseBackground')};
     left: 100%;
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/dom-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/dom-test.ts
new file mode 100644 (file)
index 0000000..703bea7
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * 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 { findAnchor } from '../dom';
+
+it('should find the correct anchor', () => {
+  const targetRect = new DOMRect(20, 20, 10, 10);
+
+  let rect = new DOMRect(25, 0, 30, 10);
+  expect(findAnchor(rect, targetRect, 8)).toStrictEqual({
+    left: -1.5,
+    rotate: '-90deg',
+    top: 16,
+    width: 6,
+  });
+
+  rect = new DOMRect(35, 25, 30, 10);
+  expect(findAnchor(rect, targetRect, 8)).toStrictEqual({
+    left: -9,
+    rotate: '0deg',
+    top: -1.5,
+    width: 1,
+  });
+
+  rect = new DOMRect(25, 35, 30, 10);
+  expect(findAnchor(rect, targetRect, 8)).toStrictEqual({
+    left: -1.5,
+    rotate: '90deg',
+    top: -9,
+    width: 1,
+  });
+
+  rect = new DOMRect(0, 25, 10, 30);
+  expect(findAnchor(rect, targetRect, 8)).toStrictEqual({
+    left: 24,
+    rotate: '180deg',
+    top: -1.5,
+    width: 14,
+  });
+});
diff --git a/server/sonar-web/design-system/src/helpers/dom.ts b/server/sonar-web/design-system/src/helpers/dom.ts
new file mode 100644 (file)
index 0000000..800e27a
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+
+/**
+ * This function find the point on the target element using the rect coordinat.
+ * This point will serve as an anchor for rect to be attached
+ *
+ * This function assume that rect side will overlap the target facing side. If not
+ * the case the point would be outside the target rect. For now we don't need to
+ * handle this situation.
+ * @param rect
+ * @param targetRect
+ */
+export function findAnchor(rect: DOMRect, targetRect: DOMRect, offset: number) {
+  const offestTop = rect.top < targetRect.top ? targetRect.top - rect.top : 0;
+  const offestLeft = rect.left < targetRect.left ? targetRect.left - rect.left : 0;
+
+  if (targetRect.right < rect.left) {
+    const left = targetRect.right - rect.left - offset / 2;
+    const top =
+      (Math.min(targetRect.bottom, rect.bottom) - Math.max(targetRect.top, rect.top)) / 2 -
+      offset / 2 +
+      offestTop;
+    const rotate = '0deg';
+    const width = -left - offset;
+
+    return { left, top, rotate, width };
+  } else if (targetRect.left > rect.right) {
+    const left = rect.width + targetRect.left - rect.right + offset / 2;
+    const top =
+      (Math.min(targetRect.bottom, rect.bottom) - Math.max(targetRect.top, rect.top)) / 2 -
+      offset / 2 +
+      offestTop;
+    const rotate = '180deg';
+    const width = left - rect.width;
+    return { left, top, rotate, width };
+  } else if (targetRect.bottom < rect.top) {
+    const left =
+      (Math.min(targetRect.right, rect.right) - Math.max(targetRect.left, rect.left)) / 2 -
+      offset / 2 +
+      offestLeft;
+    const top = targetRect.bottom - rect.top - offset / 2;
+    const rotate = '90deg';
+    const width = -top - offset;
+    return { left, top, rotate, width };
+  } else if (targetRect.top > rect.bottom) {
+    const left =
+      (Math.min(targetRect.right, rect.right) - Math.max(targetRect.left, rect.left)) / 2 -
+      offset / 2 +
+      offestLeft;
+    const top = targetRect.top - rect.top - offset / 2;
+    const rotate = '-90deg';
+    const width = top - rect.height;
+    return { left, top, rotate, width };
+  }
+
+  // When rectagle overlap
+  return { left: 0, top: 0, rotate: '0deg', width: 0 };
+}
index 858e964b1faa62f2036c1c53ca6c2611ea04eb98..bfd84a3db0f8ad02ea1598783bffac2201e956d1 100644 (file)
@@ -34,12 +34,12 @@ export default function CaYCConditionsSimplificationGuide() {
 
   const steps: SpotlightTourStep[] = [
     {
-      target: '[data-guiding-id="caycConditionsSimplification"]',
+      target: '#cayc-hihlight',
       content: (
         <p>{translate('quality_gates.cayc.condition_simplification_tour.page_1.content1')}</p>
       ),
       title: translate('quality_gates.cayc.condition_simplification_tour.page_1.title'),
-      placement: 'right',
+      placement: 'top',
     },
     {
       target: '[data-guiding-id="caycConditionsSimplification"]',
index 13d042e5491b6de470b3afbfc0daf904704cef8d..a5bbb2596d837d5e25b3790e005bee165464b914 100644 (file)
@@ -29,7 +29,7 @@ interface Props {
 
 export default function CaycConditionsTable({ metrics, conditions }: Readonly<Props>) {
   return (
-    <HighlightedSection className="sw-px-4 sw-py-0 sw-my-2">
+    <HighlightedSection className="sw-px-4 sw-py-0 sw-my-2" id="cayc-hihlight">
       <Table
         columnCount={2}
         columnWidths={['auto', '1fr']}