diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Tour.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Tour.spec.ts index b4283d5318b..d375fb08cd9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Tour.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/Tour.spec.ts @@ -189,15 +189,29 @@ test.describe( }); test('Tour should work from welcome screen', async ({ page }) => { - await page + test.slow(); + + const isAlertVisible = await page .getByTestId('whats-new-alert-card') - .locator('.whats-new-alert-close') - .click(); + .isVisible(); + if (isAlertVisible) { + await page + .getByTestId('whats-new-alert-card') + .locator('.whats-new-alert-close') + .click(); + } await page.getByText('Take a product tour to get started!').click(); await page.waitForURL('**/tour'); await waitForAllLoadersToDisappear(page); await waitForAllLoadersToDisappear(page, 'entity-list-skeleton'); + const isWelcomeScreenVisible = await page + .getByTestId('welcome-screen') + .isVisible(); + if (isWelcomeScreenVisible) { + await page.getByTestId('welcome-screen-close-btn').click(); + } + await page.locator('#feedWidgetData').waitFor(); // Since the tour steps are already tested in the first test, // here we only validate whether the tour is loading or not. diff --git a/openmetadata-ui/src/main/resources/ui/src/context/TourProvider/TourProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/context/TourProvider/TourProvider.tsx index d40995564f0..0b08b594612 100644 --- a/openmetadata-ui/src/main/resources/ui/src/context/TourProvider/TourProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/context/TourProvider/TourProvider.tsx @@ -14,7 +14,9 @@ import { createContext, FC, ReactNode, + useCallback, useContext, + useEffect, useMemo, useState, } from 'react'; @@ -43,7 +45,11 @@ export const TourContext = createContext({} as TourProviderContextProps); const TourProvider: FC = ({ children }) => { const location = useCustomLocation(); - const [isTourOpen, setIsTourOpen] = useState(false); + const isTourPage = useMemo( + () => location.pathname.includes(ROUTES.TOUR), + [location.pathname] + ); + const [isTourOpen, setIsTourOpen] = useState(isTourPage); const [currentTourPage, setCurrentTourPage] = useState( CurrentTourPageType.MY_DATA_PAGE ); @@ -51,22 +57,30 @@ const TourProvider: FC = ({ children }) => { useState(EntityTabs.SCHEMA); const [searchValue, setSearchValue] = useState(''); - const isTourPage = useMemo( - () => location.pathname.includes(ROUTES.TOUR), - [location.pathname] + useEffect(() => { + if (isTourPage) { + setIsTourOpen(true); + } + }, [isTourPage]); + + const handleIsTourOpen = useCallback((value: boolean) => { + setIsTourOpen(value); + }, []); + + const handleTourPageChange = useCallback( + (value: CurrentTourPageType) => setCurrentTourPage(value), + [] ); - const handleIsTourOpen = (value: boolean) => { - setIsTourOpen(value); - }; + const handleActiveTabChange = useCallback( + (value: EntityTabs) => setActiveTabForTourDatasetPage(value), + [] + ); - const handleTourPageChange = (value: CurrentTourPageType) => - setCurrentTourPage(value); - - const handleActiveTabChange = (value: EntityTabs) => - setActiveTabForTourDatasetPage(value); - - const handleUpdateTourSearch = (value: string) => setSearchValue(value); + const handleUpdateTourSearch = useCallback( + (value: string) => setSearchValue(value), + [] + ); return ( { + const feedWidget = document.querySelector(TOUR_FEED_WIDGET_SELECTOR); + const feedWidgetRect = feedWidget?.getBoundingClientRect(); + + if (!feedWidgetRect?.width || !feedWidgetRect.height) { + return; + } + + return { + height: feedWidgetRect.height, + left: feedWidgetRect.left, + top: feedWidgetRect.top, + width: feedWidgetRect.width, + }; +}; + +const isSameWidgetRect = ( + currentRect?: TourTargetRect, + previousRect?: TourTargetRect +) => { + return ( + currentRect?.height === previousRect?.height && + currentRect?.left === previousRect?.left && + currentRect?.top === previousRect?.top && + currentRect?.width === previousRect?.width + ); +}; + +const waitForTourFeedWidget = (onReady: () => void) => { + let animationFrameId = 0; + let stableLayoutFrames = 0; + let previousRect: TourTargetRect | undefined; + + const waitForFeedWidget = () => { + const currentRect = getTourFeedWidgetRect(); + + if (currentRect && isSameWidgetRect(currentRect, previousRect)) { + stableLayoutFrames += 1; + } else { + stableLayoutFrames = 0; + } + + previousRect = currentRect; + + if (stableLayoutFrames >= REQUIRED_STABLE_LAYOUT_FRAMES) { + onReady(); + + return; + } + + animationFrameId = window.requestAnimationFrame(waitForFeedWidget); + }; + + waitForFeedWidget(); + + return () => { + window.cancelAnimationFrame(animationFrameId); + }; +}; + const TourPage = () => { const { updateIsTourOpen, @@ -33,28 +103,24 @@ const TourPage = () => { const { t } = useTranslation(); const [isTourReady, setIsTourReady] = useState(false); - const clearSearchTerm = () => { + const clearSearchTerm = useCallback(() => { updateTourSearch(''); - }; + }, [updateTourSearch]); useEffect(() => { - updateIsTourOpen(true); - - let attempts = 0; - const maxAttempts = 60; - - const waitForElement = () => { - const el = document.querySelector('#feedWidgetData'); - if (el) { + let tourMountFrameId = 0; + const cancelFeedWidgetWait = waitForTourFeedWidget(() => { + updateIsTourOpen(true); + tourMountFrameId = window.requestAnimationFrame(() => { setIsTourReady(true); - } else if (attempts < maxAttempts) { - attempts++; - setTimeout(waitForElement, 100); - } - }; + }); + }); - waitForElement(); - }, []); + return () => { + cancelFeedWidgetWait(); + window.cancelAnimationFrame(tourMountFrameId); + }; + }, [updateIsTourOpen]); const currentPageComponent = useMemo(() => { switch (currentTourPage) { @@ -72,19 +138,21 @@ const TourPage = () => { } }, [currentTourPage]); + const tourSteps = useMemo( + () => + getTourSteps({ + searchTerm: TOUR_SEARCH_TERM, + clearSearchTerm, + updateActiveTab, + updateTourPage, + }), + [clearSearchTerm, updateActiveTab, updateTourPage] + ); + return ( <> {currentPageComponent} - {isTourReady && ( - - )} + {isTourReady && } ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TourPage/TourPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TourPage/TourPage.test.tsx index 45b35f1836c..76619903dab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TourPage/TourPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TourPage/TourPage.test.tsx @@ -10,12 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { useTourProvider } from '../../context/TourProvider/TourProvider'; import { CurrentTourPageType } from '../../enums/tour.enum'; import TourPage from './TourPage.component'; const mockUseTourProvider = { + isTourOpen: true, updateIsTourOpen: jest.fn(), currentTourPage: CurrentTourPageType.MY_DATA_PAGE, updateActiveTab: jest.fn(), @@ -29,6 +30,29 @@ Object.defineProperty(document, 'querySelector', { writable: true, }); +const createReadyFeedWidget = () => { + const feedWidget = document.createElement('div'); + jest.spyOn(feedWidget, 'getBoundingClientRect').mockReturnValue({ + bottom: 100, + height: 100, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: jest.fn(), + }); + + return feedWidget; +}; + +const waitForTourReadyCheck = async (time = 80) => { + await act(async () => { + jest.advanceTimersByTime(time); + }); +}; + jest.mock('../../context/TourProvider/TourProvider', () => ({ useTourProvider: jest.fn().mockImplementation(() => mockUseTourProvider), })); @@ -62,9 +86,11 @@ jest.mock('../../utils/TourUtils', () => ({ describe('TourPage component', () => { beforeEach(() => { jest.useFakeTimers(); + + const feedWidget = createReadyFeedWidget(); mockQuerySelector.mockImplementation((selector) => { if (selector === '#feedWidgetData') { - return document.createElement('div'); + return feedWidget; } return null; @@ -78,12 +104,14 @@ describe('TourPage component', () => { it('should render correctly', async () => { render(); + await waitForTourReadyCheck(); expect(await screen.findByText('Tour.component')).toBeInTheDocument(); }); it('clear search term should work correctly', async () => { render(); + await waitForTourReadyCheck(); const clearBtn = await screen.findByTestId('clear-btn'); fireEvent.click(clearBtn); @@ -91,6 +119,77 @@ describe('TourPage component', () => { expect(mockUseTourProvider.updateTourSearch).toHaveBeenCalledWith(''); }); + it('should render tour when feed widget appears after initial render', async () => { + const feedWidget = createReadyFeedWidget(); + mockQuerySelector.mockReturnValue(null); + + render(); + + expect(screen.queryByText('Tour.component')).not.toBeInTheDocument(); + + mockQuerySelector.mockImplementation((selector) => { + if (selector === '#feedWidgetData') { + return feedWidget; + } + + return null; + }); + await act(async () => { + document.body.appendChild(feedWidget); + }); + await waitForTourReadyCheck(116); + + expect(screen.getByText('Tour.component')).toBeInTheDocument(); + + feedWidget.remove(); + }); + + it('should render tour when existing feed widget becomes layout ready', async () => { + const feedWidget = document.createElement('div'); + const getBoundingClientRect = jest + .spyOn(feedWidget, 'getBoundingClientRect') + .mockReturnValueOnce({ + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON: jest.fn(), + }) + .mockReturnValue({ + bottom: 100, + height: 100, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + toJSON: jest.fn(), + }); + + mockQuerySelector.mockImplementation((selector) => { + if (selector === '#feedWidgetData') { + return feedWidget; + } + + return null; + }); + + render(); + + expect(screen.queryByText('Tour.component')).not.toBeInTheDocument(); + + await waitForTourReadyCheck(); + await waitForTourReadyCheck(); + + expect(screen.getByText('Tour.component')).toBeInTheDocument(); + expect(getBoundingClientRect).toHaveBeenCalled(); + }); + it('MyDataPage Component should be visible, if currentTourPage is myDataPage', async () => { (useTourProvider as jest.Mock).mockReset(); (useTourProvider as jest.Mock).mockImplementation(() => ({ @@ -98,6 +197,7 @@ describe('TourPage component', () => { currentTourPage: CurrentTourPageType.MY_DATA_PAGE, })); render(); + await waitForTourReadyCheck(); expect(await screen.findByText('MyDataPage.component')).toBeInTheDocument(); }); @@ -109,6 +209,7 @@ describe('TourPage component', () => { currentTourPage: CurrentTourPageType.EXPLORE_PAGE, })); render(); + await waitForTourReadyCheck(); expect( await screen.findByText('ExplorePageV1Component.component') @@ -122,6 +223,7 @@ describe('TourPage component', () => { currentTourPage: CurrentTourPageType.DATASET_PAGE, })); render(); + await waitForTourReadyCheck(); expect( await screen.findByText('TableDetailsPageV1.component')