From 347c1cabbc2e91cce7606482d554871597cf5326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 19 Oct 2023 15:50:37 +0100 Subject: [PATCH] feat: add new sticky component to handle stacked stickies (#5088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://linear.app/unleash/issue/2-1509/discovery-stacked-sticky-elements Adds a new `Sticky` element that will attempt to stack sticky elements in the DOM in a smart way. This needs a wrapping `StickyProvider` that will keep track of sticky elements. This PR adapts a few components to use this new element: - `DemoBanner` - `FeatureOverviewSidePanel` - `DraftBanner` - `MaintenanceBanner` - `MessageBanner` Pre-existing `top` properties are taken into consideration for the top offset, so we can have nice margins like in the feature overview side panel. ### Before - Sticky elements overlap 😞 ![image](https://github.com/Unleash/unleash/assets/14320932/dd6fa188-6774-4afb-86fd-0eefb9aba93e) ### After - Sticky elements stack 😄 ![image](https://github.com/Unleash/unleash/assets/14320932/c73a84ab-7133-448f-9df6-69bd4c5330c2) --- .../src/component/banners/Banner/Banner.tsx | 45 +++-- .../changeRequest/ChangeRequest.test.tsx | 17 +- .../ChangeRequestPermissions.test.tsx | 12 +- .../component/common/Sticky/Sticky.test.tsx | 112 ++++++++++++ .../src/component/common/Sticky/Sticky.tsx | 80 +++++++++ .../component/common/Sticky/StickyContext.tsx | 12 ++ .../common/Sticky/StickyProvider.test.tsx | 160 ++++++++++++++++++ .../common/Sticky/StickyProvider.tsx | 133 +++++++++++++++ .../component/demo/DemoBanner/DemoBanner.tsx | 5 +- .../FeatureOverviewSidePanel.tsx | 4 +- .../MainLayout/DraftBanner/DraftBanner.tsx | 6 +- .../maintenance/MaintenanceBanner.tsx | 5 +- frontend/src/index.tsx | 11 +- frontend/src/setupTests.ts | 10 ++ 14 files changed, 564 insertions(+), 48 deletions(-) create mode 100644 frontend/src/component/common/Sticky/Sticky.test.tsx create mode 100644 frontend/src/component/common/Sticky/Sticky.tsx create mode 100644 frontend/src/component/common/Sticky/StickyContext.tsx create mode 100644 frontend/src/component/common/Sticky/StickyProvider.test.tsx create mode 100644 frontend/src/component/common/Sticky/StickyProvider.tsx diff --git a/frontend/src/component/banners/Banner/Banner.tsx b/frontend/src/component/banners/Banner/Banner.tsx index 757d97f04724..454085973565 100644 --- a/frontend/src/component/banners/Banner/Banner.tsx +++ b/frontend/src/component/banners/Banner/Banner.tsx @@ -11,29 +11,22 @@ import { BannerDialog } from './BannerDialog/BannerDialog'; import { useState } from 'react'; import ReactMarkdown from 'react-markdown'; import { BannerVariant, IBanner } from 'interfaces/banner'; +import { Sticky } from 'component/common/Sticky/Sticky'; const StyledBar = styled('aside', { - shouldForwardProp: (prop) => prop !== 'variant' && prop !== 'sticky', -})<{ variant: BannerVariant; sticky?: boolean }>( - ({ theme, variant, sticky }) => ({ - position: sticky ? 'sticky' : 'relative', - zIndex: 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: theme.spacing(1), - gap: theme.spacing(1), - borderBottom: '1px solid', - borderColor: theme.palette[variant].border, - background: theme.palette[variant].light, - color: theme.palette[variant].dark, - fontSize: theme.fontSizes.smallBody, - ...(sticky && { - top: 0, - zIndex: theme.zIndex.sticky - 100, - }), - }), -); + shouldForwardProp: (prop) => prop !== 'variant', +})<{ variant: BannerVariant }>(({ theme, variant }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(1), + gap: theme.spacing(1), + borderBottom: '1px solid', + borderColor: theme.palette[variant].border, + background: theme.palette[variant].light, + color: theme.palette[variant].dark, + fontSize: theme.fontSizes.smallBody, +})); const StyledIcon = styled('div', { shouldForwardProp: (prop) => prop !== 'variant', @@ -62,8 +55,8 @@ export const Banner = ({ banner }: IBannerProps) => { dialog, } = banner; - return ( - + const bannerBar = ( + @@ -84,6 +77,12 @@ export const Banner = ({ banner }: IBannerProps) => { ); + + if (sticky) { + return {bannerBar}; + } + + return bannerBar; }; const VariantIcons = { diff --git a/frontend/src/component/changeRequest/ChangeRequest.test.tsx b/frontend/src/component/changeRequest/ChangeRequest.test.tsx index 7075f523cc90..408a8f5ea1a4 100644 --- a/frontend/src/component/changeRequest/ChangeRequest.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest.test.tsx @@ -8,6 +8,7 @@ import { AccessProvider } from '../providers/AccessProvider/AccessProvider'; import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider'; import { testServerRoute, testServerSetup } from '../../utils/testServer'; import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer'; +import { StickyProvider } from 'component/common/Sticky/StickyProvider'; const server = testServerSetup(); @@ -227,12 +228,16 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({ - - {children}} - /> - + + + {children} + } + /> + + diff --git a/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx b/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx index a777c092dcb6..21e70c584e89 100644 --- a/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx +++ b/frontend/src/component/changeRequest/ChangeRequestPermissions.test.tsx @@ -10,6 +10,7 @@ import { FC } from 'react'; import { IPermission } from '../../interfaces/user'; import { SWRConfig } from 'swr'; import { ProjectMode } from '../project/Project/hooks/useProjectEnterpriseSettingsForm'; +import { StickyProvider } from 'component/common/Sticky/StickyProvider'; const server = testServerSetup(); @@ -186,9 +187,14 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({ - - - + + + + + diff --git a/frontend/src/component/common/Sticky/Sticky.test.tsx b/frontend/src/component/common/Sticky/Sticky.test.tsx new file mode 100644 index 000000000000..4b01c04bc4df --- /dev/null +++ b/frontend/src/component/common/Sticky/Sticky.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, cleanup } from '@testing-library/react'; +import { Sticky } from './Sticky'; +import { IStickyContext, StickyContext } from './StickyContext'; +import { vi, expect } from 'vitest'; + +describe('Sticky component', () => { + let originalConsoleError: () => void; + let mockRegisterStickyItem: () => void; + let mockUnregisterStickyItem: () => void; + let mockGetTopOffset: () => number; + let mockContextValue: IStickyContext; + + beforeEach(() => { + originalConsoleError = console.error; + console.error = vi.fn(); + + mockRegisterStickyItem = vi.fn(); + mockUnregisterStickyItem = vi.fn(); + mockGetTopOffset = vi.fn(() => 10); + + mockContextValue = { + registerStickyItem: mockRegisterStickyItem, + unregisterStickyItem: mockUnregisterStickyItem, + getTopOffset: mockGetTopOffset, + stickyItems: [], + }; + }); + + afterEach(() => { + cleanup(); + console.error = originalConsoleError; + }); + + it('renders correctly within StickyContext', () => { + render( + + Content + , + ); + + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('throws error when not wrapped in StickyContext', () => { + console.error = vi.fn(); + + expect(() => render(Content)).toThrow( + 'Sticky component must be used within a StickyProvider', + ); + }); + + it('applies sticky positioning', () => { + render( + + Content + , + ); + + const stickyElement = screen.getByText('Content'); + expect(stickyElement).toHaveStyle({ position: 'sticky' }); + }); + + it('registers and unregisters sticky item on mount/unmount', () => { + const { unmount } = render( + + Content + , + ); + + expect(mockRegisterStickyItem).toHaveBeenCalledTimes(1); + + unmount(); + + expect(mockUnregisterStickyItem).toHaveBeenCalledTimes(1); + }); + + it('correctly sets the top value when mounted', async () => { + render( + + Content + , + ); + + const stickyElement = await screen.findByText('Content'); + expect(stickyElement).toHaveStyle({ top: '10px' }); + }); + + it('updates top offset when stickyItems changes', async () => { + const { rerender } = render( + + Content + , + ); + + let stickyElement = await screen.findByText('Content'); + expect(stickyElement).toHaveStyle({ top: '10px' }); + + const updatedMockContextValue = { + ...mockContextValue, + getTopOffset: vi.fn(() => 20), + }; + + rerender( + + Content + , + ); + + stickyElement = await screen.findByText('Content'); + expect(stickyElement).toHaveStyle({ top: '20px' }); + }); +}); diff --git a/frontend/src/component/common/Sticky/Sticky.tsx b/frontend/src/component/common/Sticky/Sticky.tsx new file mode 100644 index 000000000000..1a8d8c6b19bf --- /dev/null +++ b/frontend/src/component/common/Sticky/Sticky.tsx @@ -0,0 +1,80 @@ +import { + HTMLAttributes, + ReactNode, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { StickyContext } from './StickyContext'; +import { styled } from '@mui/material'; + +const StyledSticky = styled('div', { + shouldForwardProp: (prop) => prop !== 'top', +})<{ top?: number }>(({ theme, top }) => ({ + position: 'sticky', + zIndex: theme.zIndex.sticky - 100, + ...(top !== undefined + ? { + '&': { + top, + }, + } + : {}), +})); + +interface IStickyProps extends HTMLAttributes { + children: ReactNode; +} + +export const Sticky = ({ children, ...props }: IStickyProps) => { + const context = useContext(StickyContext); + const ref = useRef(null); + const [initialTopOffset, setInitialTopOffset] = useState( + null, + ); + const [top, setTop] = useState(); + + if (!context) { + throw new Error( + 'Sticky component must be used within a StickyProvider', + ); + } + + const { registerStickyItem, unregisterStickyItem, getTopOffset } = context; + + useEffect(() => { + // We should only set the initial top offset once - when the component is mounted + // This value will be set based on the initial top that was set for this component + // After that, the top will be calculated based on the height of the previous sticky items + this initial top offset + if (ref.current && initialTopOffset === null) { + setInitialTopOffset( + parseInt(getComputedStyle(ref.current).getPropertyValue('top')), + ); + } + }, []); + + useEffect(() => { + // (Re)calculate the top offset based on the sticky items + setTop(getTopOffset(ref) + (initialTopOffset || 0)); + }, [getTopOffset, initialTopOffset]); + + useEffect(() => { + // We should register the sticky item when it is mounted and unregister it when it is unmounted + if (!ref.current) { + return; + } + + registerStickyItem(ref); + + return () => { + unregisterStickyItem(ref); + }; + }, [ref, registerStickyItem, unregisterStickyItem]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/common/Sticky/StickyContext.tsx b/frontend/src/component/common/Sticky/StickyContext.tsx new file mode 100644 index 000000000000..b6257162851c --- /dev/null +++ b/frontend/src/component/common/Sticky/StickyContext.tsx @@ -0,0 +1,12 @@ +import { RefObject, createContext } from 'react'; + +export interface IStickyContext { + stickyItems: RefObject[]; + registerStickyItem: (ref: RefObject) => void; + unregisterStickyItem: (ref: RefObject) => void; + getTopOffset: (ref: RefObject) => number; +} + +export const StickyContext = createContext( + undefined, +); diff --git a/frontend/src/component/common/Sticky/StickyProvider.test.tsx b/frontend/src/component/common/Sticky/StickyProvider.test.tsx new file mode 100644 index 000000000000..ebe51a24e5a9 --- /dev/null +++ b/frontend/src/component/common/Sticky/StickyProvider.test.tsx @@ -0,0 +1,160 @@ +import { render, cleanup } from '@testing-library/react'; +import { StickyProvider } from './StickyProvider'; +import { IStickyContext, StickyContext } from './StickyContext'; +import { expect } from 'vitest'; + +const defaultGetBoundingClientRect = { + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON() {}, +}; + +describe('StickyProvider component', () => { + afterEach(cleanup); + + it('provides the sticky context with expected functions', () => { + let receivedContext = null; + render( + + + {(context) => { + receivedContext = context; + return null; + }} + + , + ); + + expect(receivedContext).not.toBeNull(); + expect(receivedContext).toHaveProperty('stickyItems'); + expect(receivedContext).toHaveProperty('registerStickyItem'); + expect(receivedContext).toHaveProperty('unregisterStickyItem'); + expect(receivedContext).toHaveProperty('getTopOffset'); + }); + + it('registers and unregisters sticky items', () => { + let contextValues: IStickyContext | undefined; + const refMock = { current: document.createElement('div') }; + + const { rerender } = render( + + + {(context) => { + contextValues = context; + return null; + }} + + , + ); + + contextValues?.registerStickyItem(refMock); + rerender( + + + {(context) => { + contextValues = context; + return null; + }} + + , + ); + + expect(contextValues?.stickyItems).toContain(refMock); + + contextValues?.unregisterStickyItem(refMock); + rerender( + + + {(context) => { + contextValues = context; + return null; + }} + + , + ); + + expect(contextValues?.stickyItems).not.toContain(refMock); + }); + + it('sorts sticky items based on their DOM position', () => { + let contextValues: IStickyContext | undefined; + + const refMockA = { current: document.createElement('div') }; + const refMockB = { current: document.createElement('div') }; + + refMockA.current.getBoundingClientRect = () => ({ + ...defaultGetBoundingClientRect, + top: 200, + }); + refMockB.current.getBoundingClientRect = () => ({ + ...defaultGetBoundingClientRect, + top: 100, + }); + + render( + + + {(context) => { + contextValues = context; + return null; + }} + + , + ); + + contextValues?.registerStickyItem(refMockA); + contextValues?.registerStickyItem(refMockB); + + expect(contextValues?.stickyItems[0]).toBe(refMockB); + expect(contextValues?.stickyItems[1]).toBe(refMockA); + }); + + it('calculates top offset correctly', () => { + let contextValues: IStickyContext | undefined; + const refMockA = { current: document.createElement('div') }; + const refMockB = { current: document.createElement('div') }; + + refMockA.current.getBoundingClientRect = () => ({ + ...defaultGetBoundingClientRect, + height: 100, + }); + + refMockB.current.getBoundingClientRect = () => ({ + ...defaultGetBoundingClientRect, + height: 200, + }); + + const { rerender } = render( + + + {(context) => { + contextValues = context; + return null; + }} + + , + ); + + contextValues?.registerStickyItem(refMockA); + contextValues?.registerStickyItem(refMockB); + rerender( + + + {(context) => { + contextValues = context; + return null; + }} + + , + ); + + const topOffset = contextValues?.getTopOffset(refMockB); + expect(topOffset).toBe(100); + }); +}); diff --git a/frontend/src/component/common/Sticky/StickyProvider.tsx b/frontend/src/component/common/Sticky/StickyProvider.tsx new file mode 100644 index 000000000000..7e61d6fb3b2b --- /dev/null +++ b/frontend/src/component/common/Sticky/StickyProvider.tsx @@ -0,0 +1,133 @@ +import { useState, useCallback, ReactNode, RefObject, useEffect } from 'react'; +import { StickyContext } from './StickyContext'; + +interface IStickyProviderProps { + children: ReactNode; +} + +export const StickyProvider = ({ children }: IStickyProviderProps) => { + const [stickyItems, setStickyItems] = useState[]>( + [], + ); + const [resizeListeners, setResizeListeners] = useState( + new Set>(), + ); + + const registerStickyItem = useCallback( + (item: RefObject) => { + setStickyItems((prevItems) => { + // We should only register a new item if it is not already registered + if (!prevItems.includes(item)) { + // Register resize listener for the item + registerResizeListener(item); + + const newItems = [...prevItems, item]; + // We should try to sort the items by their top on the viewport, so that their order in the DOM is the same as their order in the array + return newItems.sort((a, b) => { + const elementA = a.current; + const elementB = b.current; + if (elementA && elementB) { + return ( + elementA.getBoundingClientRect().top - + elementB.getBoundingClientRect().top + ); + } + return 0; + }); + } + + return prevItems; + }); + }, + [], + ); + + const unregisterStickyItem = useCallback( + (ref: RefObject) => { + unregisterResizeListener(ref); + setStickyItems((prev) => prev.filter((item) => item !== ref)); + }, + [], + ); + + const registerResizeListener = useCallback( + (ref: RefObject) => { + setResizeListeners((prev) => new Set(prev).add(ref)); + }, + [], + ); + + const unregisterResizeListener = useCallback( + (ref: RefObject) => { + setResizeListeners((prev) => { + const newListeners = new Set(prev); + newListeners.delete(ref); + return newListeners; + }); + }, + [], + ); + + const getTopOffset = useCallback( + (ref: RefObject) => { + if (!stickyItems.some((item) => item === ref)) { + // Return 0 in case the item is not registered yet + return 0; + } + const stickyItemsUpToOurItem = stickyItems.slice( + 0, + stickyItems.findIndex((item) => item === ref), + ); + return stickyItemsUpToOurItem.reduce((acc, item) => { + if (item === ref) { + // We should not include the current item in the calculation + return acc; + } + + // Accumulate the height of all sticky items above our item + const itemHeight = + item.current?.getBoundingClientRect().height || 0; + + return acc + itemHeight; + }, 0); + }, + [stickyItems], + ); + + useEffect(() => { + const resizeObserver = new ResizeObserver(() => { + // We should recalculate top offsets whenever there's a resize + // This will trigger the dependency in `getTopOffset` and recalculate the top offsets in the Sticky components + setStickyItems((prev) => [...prev]); + }); + + resizeListeners.forEach((item) => { + if (item.current) { + resizeObserver.observe(item.current); + } + }); + + return () => { + if (resizeListeners.size > 0) { + resizeListeners.forEach((item) => { + if (item.current) { + resizeObserver.unobserve(item.current); + } + }); + } + }; + }, [resizeListeners]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/component/demo/DemoBanner/DemoBanner.tsx b/frontend/src/component/demo/DemoBanner/DemoBanner.tsx index 5d4fdb4349d0..2f09b002eb0d 100644 --- a/frontend/src/component/demo/DemoBanner/DemoBanner.tsx +++ b/frontend/src/component/demo/DemoBanner/DemoBanner.tsx @@ -1,9 +1,8 @@ import { Button, styled } from '@mui/material'; +import { Sticky } from 'component/common/Sticky/Sticky'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; -const StyledBanner = styled('div')(({ theme }) => ({ - position: 'sticky', - top: 0, +const StyledBanner = styled(Sticky)(({ theme }) => ({ zIndex: theme.zIndex.sticky, display: 'flex', gap: theme.spacing(1), diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx index 0853ead22718..2b2587018141 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx @@ -5,9 +5,9 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { FeatureOverviewSidePanelDetails } from './FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails'; import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches'; import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags'; +import { Sticky } from 'component/common/Sticky/Sticky'; -const StyledContainer = styled('div')(({ theme }) => ({ - position: 'sticky', +const StyledContainer = styled(Sticky)(({ theme }) => ({ top: theme.spacing(2), borderRadius: theme.shape.borderRadiusLarge, backgroundColor: theme.palette.background.paper, diff --git a/frontend/src/component/layout/MainLayout/DraftBanner/DraftBanner.tsx b/frontend/src/component/layout/MainLayout/DraftBanner/DraftBanner.tsx index 95f3d9be6c75..1ef42e91301b 100644 --- a/frontend/src/component/layout/MainLayout/DraftBanner/DraftBanner.tsx +++ b/frontend/src/component/layout/MainLayout/DraftBanner/DraftBanner.tsx @@ -5,6 +5,7 @@ import { ChangeRequestSidebar } from 'component/changeRequest/ChangeRequestSideb import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; import { IChangeRequest } from 'component/changeRequest/changeRequest.types'; import { changesCount } from 'component/changeRequest/changesCount'; +import { Sticky } from 'component/common/Sticky/Sticky'; interface IDraftBannerProps { project: string; @@ -98,10 +99,7 @@ const DraftBannerContent: FC<{ ); }; -const StickyBanner = styled(Box)(({ theme }) => ({ - position: 'sticky', - top: -1, - zIndex: 250 /* has to lower than header.zIndex and higher than body.zIndex */, +const StickyBanner = styled(Sticky)(({ theme }) => ({ borderTop: `1px solid ${theme.palette.warning.border}`, borderBottom: `1px solid ${theme.palette.warning.border}`, color: theme.palette.warning.contrastText, diff --git a/frontend/src/component/maintenance/MaintenanceBanner.tsx b/frontend/src/component/maintenance/MaintenanceBanner.tsx index 2298aae03ab0..23041dbc35ae 100644 --- a/frontend/src/component/maintenance/MaintenanceBanner.tsx +++ b/frontend/src/component/maintenance/MaintenanceBanner.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material'; import { ErrorOutlineRounded } from '@mui/icons-material'; +import { Sticky } from 'component/common/Sticky/Sticky'; const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({ color: theme.palette.error.main, @@ -8,7 +9,7 @@ const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({ marginRight: theme.spacing(1), })); -const StyledDiv = styled('div')(({ theme }) => ({ +const StyledDiv = styled(Sticky)(({ theme }) => ({ display: 'flex', fontSize: theme.fontSizes.smallBody, justifyContent: 'center', @@ -18,8 +19,6 @@ const StyledDiv = styled('div')(({ theme }) => ({ height: '65px', borderBottom: `1px solid ${theme.palette.error.border}`, whiteSpace: 'pre-wrap', - position: 'sticky', - top: 0, zIndex: theme.zIndex.sticky - 100, })); diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 886c257df127..442313b697cf 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -13,6 +13,7 @@ import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/Feedb import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider'; import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus'; import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer'; +import { StickyProvider } from 'component/common/Sticky/StickyProvider'; window.global ||= window; @@ -23,10 +24,12 @@ ReactDOM.render( - - - - + + + + + + diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index d15f1080f900..25e7a167846f 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -2,4 +2,14 @@ import '@testing-library/jest-dom'; import 'whatwg-fetch'; import 'regenerator-runtime'; +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +if (!window.ResizeObserver) { + window.ResizeObserver = ResizeObserver; +} + process.env.TZ = 'UTC';