From 02e06c75e30d6745f4430a9492e445f1f76d0bf6 Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Tue, 5 Aug 2025 20:48:17 +0530 Subject: [PATCH] Support navigation blocker for customize my data page (#22752) Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com> --- .../e2e/Features/NavigationBlocker.spec.ts | 217 ++++++++++++++++ .../CustomizablePageHeader.tsx | 10 +- .../CustomizeMyData/CustomizeMyData.test.tsx | 11 + .../CustomizeMyData/CustomizeMyData.tsx | 115 ++++----- .../NavigationBlocker.interface.ts | 23 ++ .../NavigationBlocker.test.tsx | 193 +++++++++++++++ .../NavigationBlocker/NavigationBlocker.tsx | 232 ++++++++++++++++++ 7 files changed, 742 insertions(+), 59 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NavigationBlocker.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.tsx diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NavigationBlocker.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NavigationBlocker.spec.ts new file mode 100644 index 00000000000..cf728cad8a2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/NavigationBlocker.spec.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { expect, Page, test as base } from '@playwright/test'; +import { PersonaClass } from '../../support/persona/PersonaClass'; +import { UserClass } from '../../support/user/UserClass'; +import { performAdminLogin } from '../../utils/admin'; +import { redirectToHomePage } from '../../utils/common'; +import { + navigateToCustomizeLandingPage, + removeAndCheckWidget, + setUserDefaultPersona, +} from '../../utils/customizeLandingPage'; + +const adminUser = new UserClass(); +const persona = new PersonaClass(); + +const test = base.extend<{ adminPage: Page; userPage: Page }>({ + adminPage: async ({ browser }, use) => { + const adminPage = await browser.newPage(); + await adminUser.login(adminPage); + await use(adminPage); + await adminPage.close(); + }, +}); + +base.beforeAll('Setup pre-requests', async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await adminUser.create(apiContext); + await adminUser.setAdminRole(apiContext); + await persona.create(apiContext, [adminUser.responseData.id]); + await afterAction(); +}); + +base.afterAll('Cleanup', async ({ browser }) => { + const { afterAction, apiContext } = await performAdminLogin(browser); + await adminUser.delete(apiContext); + await persona.delete(apiContext); + await afterAction(); +}); + +test.describe('Navigation Blocker Tests', () => { + test.beforeEach(async ({ adminPage }) => { + await redirectToHomePage(adminPage); + await setUserDefaultPersona(adminPage, persona.responseData.displayName); + }); + + test('should show navigation blocker modal when trying to navigate away with unsaved changes', async ({ + adminPage, + }) => { + // Navigate to customize landing page + await navigateToCustomizeLandingPage(adminPage, { + personaName: persona.responseData.name, + }); + + // Get current URL to verify we're on the customize page + const customizePageUrl = adminPage.url(); + + expect(customizePageUrl).toContain('customize-page'); + + // Make changes to trigger unsaved state - remove a widget + await removeAndCheckWidget(adminPage, { + widgetKey: 'KnowledgePanel.ActivityFeed', + }); + + // Verify save button becomes enabled (indicating unsaved changes) + await expect( + adminPage.locator('[data-testid="save-button"]') + ).toBeEnabled(); + + // Try to navigate to another page by clicking a sidebar link + await adminPage.locator('[data-testid="app-bar-item-settings"]').click(); + + // Navigation blocker modal should appear + await expect(adminPage.locator('.ant-modal')).toBeVisible(); + await expect( + adminPage.locator( + '.ant-modal-title:has-text("Are you sure you want to leave?")' + ) + ).toBeVisible(); + await expect( + adminPage.locator( + 'text=You have unsaved changes which will be discarded.' + ) + ).toBeVisible(); + + // Verify modal has Stay and Leave buttons + await expect(adminPage.locator('button:has-text("Stay")')).toBeVisible(); + await expect(adminPage.locator('button:has-text("Leave")')).toBeVisible(); + }); + + test('should stay on current page when "Stay" is clicked', async ({ + adminPage, + }) => { + // Navigate to customize landing page + await navigateToCustomizeLandingPage(adminPage, { + personaName: persona.responseData.name, + }); + + const originalUrl = adminPage.url(); + + // Make changes to trigger unsaved state + await removeAndCheckWidget(adminPage, { + widgetKey: 'KnowledgePanel.Following', + }); + + // Try to navigate away + await adminPage.locator('[data-testid="app-bar-item-settings"]').click(); + + // Modal should appear + await expect(adminPage.locator('.ant-modal')).toBeVisible(); + + // Click "Stay" button + await adminPage.locator('button:has-text("Stay")').click(); + + // Modal should disappear + await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); + + // Should remain on the same page + expect(adminPage.url()).toBe(originalUrl); + + // Verify we're still on the customize page with our changes + await expect( + adminPage.locator('[data-testid="KnowledgePanel.Following"]') + ).not.toBeVisible(); + await expect( + adminPage.locator('[data-testid="save-button"]') + ).toBeEnabled(); + }); + + test('should navigate to new page when "Leave" is clicked', async ({ + adminPage, + }) => { + // Navigate to customize landing page + await navigateToCustomizeLandingPage(adminPage, { + personaName: persona.responseData.name, + }); + + const originalUrl = adminPage.url(); + + // Make changes to trigger unsaved state + await removeAndCheckWidget(adminPage, { + widgetKey: 'KnowledgePanel.KPI', + }); + + // Try to navigate to settings page + await adminPage.locator('[data-testid="app-bar-item-settings"]').click(); + + // Modal should appear + await expect(adminPage.locator('.ant-modal')).toBeVisible(); + + // Click "Leave" button + await adminPage.locator('button:has-text("Leave")').click(); + + // Modal should disappear + await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); + + // Should navigate to the settings page + await adminPage.waitForLoadState('networkidle'); + + // Verify URL changed from customize page + expect(adminPage.url()).not.toBe(originalUrl); + expect(adminPage.url()).toContain('settings'); + }); + + test('should not show navigation blocker after saving changes', async ({ + adminPage, + }) => { + // Navigate to customize landing page + await navigateToCustomizeLandingPage(adminPage, { + personaName: persona.responseData.name, + }); + + // Make changes + await removeAndCheckWidget(adminPage, { + widgetKey: 'KnowledgePanel.TotalAssets', + }); + + // Verify save button is enabled + await expect( + adminPage.locator('[data-testid="save-button"]') + ).toBeEnabled(); + + // Save changes + const saveResponse = adminPage.waitForResponse('/api/v1/docStore'); + await adminPage.locator('[data-testid="save-button"]').click(); + await saveResponse; + + // Wait for success toast and save button to be disabled + await expect( + adminPage.locator('[data-testid="alert-message"]') + ).toContainText('Page layout created successfully.'); + await expect( + adminPage.locator('[data-testid="save-button"]') + ).toBeDisabled(); + + // Try to navigate away after saving + await adminPage.locator('[data-testid="app-bar-item-settings"]').click(); + + // Navigation should happen immediately without modal + await adminPage.waitForLoadState('networkidle'); + + expect(adminPage.url()).toContain('settings'); + + // Modal should not appear + await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx index 4dd6492d950..6c917255e8b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.tsx @@ -106,9 +106,13 @@ export const CustomizablePageHeader = ({ ); const handleClose = useCallback(() => { - setConfirmationModalType('close'); - setConfirmationModalOpen(true); - }, []); + if (!disableSave) { + setConfirmationModalType('close'); + setConfirmationModalOpen(true); + } else { + handleCancel(); + } + }, [disableSave]); return ( )) ); +jest.mock( + '../../../../components/common/NavigationBlocker/NavigationBlocker', + () => ({ + NavigationBlocker: jest + .fn() + .mockImplementation(({ children }) => ( +
{children}
+ )), + }) +); + describe('CustomizeMyData component', () => { it('CustomizeMyData should render the widgets in the page config', async () => { await act(async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx index 57897032c4d..65d124c92fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizeMyData/CustomizeMyData.tsx @@ -40,6 +40,7 @@ import { } from '../../../../utils/CustomizableLandingPageUtils'; import customizeMyDataPageClassBase from '../../../../utils/CustomizeMyDataPageClassBase'; import { getEntityName } from '../../../../utils/EntityUtils'; +import { NavigationBlocker } from '../../../common/NavigationBlocker/NavigationBlocker'; import { AdvanceSearchProvider } from '../../../Explore/AdvanceSearchProvider/AdvanceSearchProvider.component'; import PageLayoutV1 from '../../../PageLayoutV1/PageLayoutV1'; import CustomiseHomeModal from '../CustomiseHomeModal/CustomiseHomeModal'; @@ -215,69 +216,71 @@ function CustomizeMyData({ useGridLayoutDirection(); return ( - - - -
- + + + - {/* +
+ + {/* ReactGridLayout with optimized drag and drop behavior - verticalCompact: Packs widgets tightly without gaps - preventCollision={false}: Enables automatic widget repositioning on collision - useCSSTransforms: Uses CSS transforms for better performance during drag */} - - {widgets} - -
-
+ + {widgets} + +
+
- {isWidgetModalOpen && ( - - )} -
+ {isWidgetModalOpen && ( + + )} + + ); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.interface.ts new file mode 100644 index 00000000000..5ed86919488 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.interface.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface NavigationBlockerProps { + children: React.ReactNode; + enabled?: boolean; + message?: string; + title?: string; + confirmText?: string; + cancelText?: string; + onConfirm?: () => void; + onCancel?: () => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.test.tsx new file mode 100644 index 00000000000..107cf1822de --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.test.tsx @@ -0,0 +1,193 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { NavigationBlocker } from './NavigationBlocker'; + +describe('NavigationBlocker component', () => { + beforeEach(() => { + // Reset any mocked functions + jest.clearAllMocks(); + }); + + it('should render children when navigation blocking is disabled', () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByTestId('test-content')).toBeInTheDocument(); + }); + + it('should render children when navigation blocking is enabled', () => { + render( + +
Test Content
+
+ ); + + expect(screen.getByTestId('test-content')).toBeInTheDocument(); + }); + + it('should not show modal initially', () => { + render( + +
Test Content
+
+ ); + + // Modal should not be visible initially + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should show correct modal content when blocking is enabled', () => { + const customTitle = 'Custom Title'; + const customMessage = 'Custom message'; + const customConfirmText = 'Custom Confirm'; + const customCancelText = 'Custom Cancel'; + + render( + +
+ + Navigate Away + +
+
+ ); + + // Click link to trigger modal + const link = screen.getByTestId('test-link'); + fireEvent.click(link); + + // Check if modal appears with custom content + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(customTitle)).toBeInTheDocument(); + expect(screen.getByText(customMessage)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: customConfirmText }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: customCancelText }) + ).toBeInTheDocument(); + }); + + it('should call onCancel when cancel button is clicked', async () => { + const onCancel = jest.fn(); + + render( + +
+ + Navigate Away + +
+
+ ); + + // Click link to show modal + const link = screen.getByTestId('test-link'); + fireEvent.click(link); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Click cancel button + const cancelButton = screen.getByRole('button', { name: 'Stay' }); + await act(async () => { + await fireEvent.click(cancelButton); + }); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('should call onConfirm when confirm button is clicked', async () => { + const onConfirm = jest.fn(); + + render( + +
+ + Navigate Away + +
+
+ ); + + // Click link to show modal + const link = screen.getByTestId('test-link'); + fireEvent.click(link); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + + const confirmButton = await screen.findByRole('button', { + name: 'Leave', + }); + // Click confirm button + await act(async () => { + await fireEvent.click(confirmButton); + }); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('should not intercept navigation when blocking is disabled', () => { + render( + +
+ + Navigate Away + +
+
+ ); + + const link = screen.getByTestId('test-link'); + fireEvent.click(link); + + // Modal should not appear + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should handle default props correctly', () => { + render( + +
+ + Navigate Away + +
+
+ ); + + // Click link to trigger modal with default props + const link = screen.getByTestId('test-link'); + fireEvent.click(link); + + // Check default content + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.getByText('Are you sure you want to leave?') + ).toBeInTheDocument(); + expect( + screen.getByText('You have unsaved changes which will be discarded.') + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Leave' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Stay' })).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.tsx new file mode 100644 index 00000000000..1227155349d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/NavigationBlocker/NavigationBlocker.tsx @@ -0,0 +1,232 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Modal } from 'antd'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { NavigationBlockerProps } from './NavigationBlocker.interface'; + +/** + * NavigationBlocker component that wraps content and blocks navigation when enabled + * + * Usage: + * + * + * + */ +export const NavigationBlocker: React.FC = ({ + children, + enabled = false, + message = 'You have unsaved changes which will be discarded.', + title = 'Are you sure you want to leave?', + confirmText = 'Leave', + cancelText = 'Stay', + onConfirm, + onCancel, +}) => { + const [isBlocking, setIsBlocking] = useState(enabled); + const [isModalVisible, setIsModalVisible] = useState(false); + const [blockingMessage, setBlockingMessage] = useState(message); + const pendingNavigationRef = useRef(null); + const isNavigatingRef = useRef(false); + + // Update blocking state when enabled/message changes + useEffect(() => { + setIsBlocking(enabled); + setBlockingMessage(message); + }, [enabled, message]); + + useEffect(() => { + if (!isBlocking || isNavigatingRef.current) { + return; + } + + // Handle page refresh/close - only show browser dialog for actual tab close + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + // Only show for actual tab close, not for programmatic navigation + if (!isNavigatingRef.current) { + event.preventDefault(); + event.returnValue = blockingMessage; + + return blockingMessage; + } + + return undefined; + }; + + // Store original navigation methods + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + // Block programmatic navigation (useNavigate, etc.) + window.history.pushState = function ( + state: unknown, + title: string, + url?: string | URL | null + ) { + if (!isNavigatingRef.current && url && url !== window.location.pathname) { + setIsModalVisible(true); + pendingNavigationRef.current = url.toString(); + + return; // Block the navigation + } + + return originalPushState.call( + window.history, + state, + title, + url as string + ); + }; + + window.history.replaceState = function ( + state: unknown, + title: string, + url?: string | URL | null + ) { + if (!isNavigatingRef.current && url && url !== window.location.pathname) { + setIsModalVisible(true); + pendingNavigationRef.current = url.toString(); + + return; // Block the navigation + } + + return originalReplaceState.call( + window.history, + state, + title, + url as string + ); + }; + + // Block browser back/forward + const handlePopState = () => { + if (!isNavigatingRef.current) { + // Restore current state to prevent navigation + window.history.pushState(null, '', window.location.href); + setIsModalVisible(true); + pendingNavigationRef.current = 'back'; + } + }; + + // Block link clicks + const handleClick = (event: Event) => { + if (isNavigatingRef.current) { + return; + } + + const target = event.target as HTMLElement; + const link = target.closest('a[href]') as HTMLAnchorElement; + + if (link) { + const href = link.getAttribute('href'); + if (href && (href.startsWith('/') || href.startsWith('http'))) { + event.preventDefault(); + event.stopPropagation(); + setIsModalVisible(true); + pendingNavigationRef.current = href; + } + } + }; + + // Block keyboard shortcuts (F5, Ctrl+R) + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !isNavigatingRef.current && + (event.key === 'F5' || + (event.ctrlKey && event.key === 'r') || + (event.metaKey && event.key === 'r')) + ) { + event.preventDefault(); + setIsModalVisible(true); + pendingNavigationRef.current = 'reload'; + } + }; + + // Add all event listeners + window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('popstate', handlePopState); + document.addEventListener('click', handleClick, true); + document.addEventListener('keydown', handleKeyDown); + + return () => { + // Clean up + window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('popstate', handlePopState); + document.removeEventListener('click', handleClick, true); + document.removeEventListener('keydown', handleKeyDown); + + // Restore original methods + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }; + }, [isBlocking, blockingMessage]); + + const handleConfirm = useCallback(() => { + setIsModalVisible(false); + isNavigatingRef.current = true; + + // Call custom onConfirm if provided + onConfirm?.(); + + // Disable blocking to prevent double modals + setIsBlocking(false); + + if (pendingNavigationRef.current) { + const pendingUrl = pendingNavigationRef.current; + pendingNavigationRef.current = null; + + // Handle different navigation types + setTimeout(() => { + if (pendingUrl === 'back') { + window.history.back(); + } else if (pendingUrl === 'reload') { + window.location.reload(); + } else if (pendingUrl.startsWith('http')) { + window.location.href = pendingUrl; + } else { + // For internal routes, use full URL to ensure proper loading + window.location.href = window.location.origin + pendingUrl; + } + }, 50); + } + }, [onConfirm]); + + const handleCancel = useCallback(() => { + setIsModalVisible(false); + pendingNavigationRef.current = null; + + // Call custom onCancel if provided + onCancel?.(); + }, [onCancel]); + + return ( + <> + {children} + +

{blockingMessage}

+
+ + ); +};