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,
+ },
+ });
+};
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,
+ },
+ });
+};
} 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';
width?: number;
}
-export type SpotlightTourStep = Pick<JoyrideStep, 'target' | 'content' | 'title'> & {
+export type SpotlightTourStep = JoyrideStep & {
placement?: Placement;
};
(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,
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;
};
}, [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
<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>
)}
);
}
-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`}
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')};
&::before {
${tw`sw-block sw-absolute`}
- width: ${ARROW_LENGTH}px;
+ width: ${({ width }) => width}px;
height: 0.125rem;
background-color: ${themeColor('spotlightPulseBackground')};
left: 100%;
--- /dev/null
+/*
+ * 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,
+ });
+});
--- /dev/null
+/*
+ * 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 };
+}
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"]',
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']}