mirror of
https://github.com/open-metadata/OpenMetadata
synced 2026-05-24 09:39:11 +00:00
ui: Fix flaky product tour startup on slow /tour render (#27820)
Some checks are pending
Java Checkstyle / java-checkstyle (push) Waiting to run
Maven Collate Tests / maven-collate-ci (push) Waiting to run
OpenMetadata Service Unit Tests / Detect Changes (push) Waiting to run
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (mysql) (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (postgresql) (push) Blocked by required conditions
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Blocked by required conditions
Some checks are pending
Java Checkstyle / java-checkstyle (push) Waiting to run
Maven Collate Tests / maven-collate-ci (push) Waiting to run
OpenMetadata Service Unit Tests / Detect Changes (push) Waiting to run
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (mysql) (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (postgresql) (push) Blocked by required conditions
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Blocked by required conditions
* ui: Fix flaky product tour startup on slow /tour render * simplify code
This commit is contained in:
parent
5620121e50
commit
a93bfcf3f1
4 changed files with 245 additions and 47 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Props> = ({ children }) => {
|
||||
const location = useCustomLocation();
|
||||
const [isTourOpen, setIsTourOpen] = useState<boolean>(false);
|
||||
const isTourPage = useMemo(
|
||||
() => location.pathname.includes(ROUTES.TOUR),
|
||||
[location.pathname]
|
||||
);
|
||||
const [isTourOpen, setIsTourOpen] = useState<boolean>(isTourPage);
|
||||
const [currentTourPage, setCurrentTourPage] = useState<CurrentTourPageType>(
|
||||
CurrentTourPageType.MY_DATA_PAGE
|
||||
);
|
||||
|
|
@ -51,22 +57,30 @@ const TourProvider: FC<Props> = ({ children }) => {
|
|||
useState<EntityTabs>(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 (
|
||||
<TourContext.Provider
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Tour from '../../components/AppTour/Tour';
|
||||
import { TOUR_SEARCH_TERM } from '../../constants/constants';
|
||||
|
|
@ -22,6 +22,76 @@ import ExplorePageV1Component from '../ExplorePage/ExplorePageV1.component';
|
|||
import MyDataPage from '../MyDataPage/MyDataPage.component';
|
||||
import TableDetailsPageV1 from '../TableDetailsPageV1/TableDetailsPageV1';
|
||||
|
||||
const TOUR_FEED_WIDGET_SELECTOR = '#feedWidgetData';
|
||||
const REQUIRED_STABLE_LAYOUT_FRAMES = 3;
|
||||
|
||||
type TourTargetRect = {
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const getTourFeedWidgetRect = (): TourTargetRect | undefined => {
|
||||
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 && (
|
||||
<Tour
|
||||
steps={getTourSteps({
|
||||
searchTerm: TOUR_SEARCH_TERM,
|
||||
clearSearchTerm,
|
||||
updateActiveTab,
|
||||
updateTourPage,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{isTourReady && <Tour steps={tourSteps} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(<TourPage />);
|
||||
await waitForTourReadyCheck();
|
||||
|
||||
expect(await screen.findByText('Tour.component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clear search term should work correctly', async () => {
|
||||
render(<TourPage />);
|
||||
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(<TourPage />);
|
||||
|
||||
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(<TourPage />);
|
||||
|
||||
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(<TourPage />);
|
||||
await waitForTourReadyCheck();
|
||||
|
||||
expect(await screen.findByText('MyDataPage.component')).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -109,6 +209,7 @@ describe('TourPage component', () => {
|
|||
currentTourPage: CurrentTourPageType.EXPLORE_PAGE,
|
||||
}));
|
||||
render(<TourPage />);
|
||||
await waitForTourReadyCheck();
|
||||
|
||||
expect(
|
||||
await screen.findByText('ExplorePageV1Component.component')
|
||||
|
|
@ -122,6 +223,7 @@ describe('TourPage component', () => {
|
|||
currentTourPage: CurrentTourPageType.DATASET_PAGE,
|
||||
}));
|
||||
render(<TourPage />);
|
||||
await waitForTourReadyCheck();
|
||||
|
||||
expect(
|
||||
await screen.findByText('TableDetailsPageV1.component')
|
||||
|
|
|
|||
Loading…
Reference in a new issue