Support navigation blocker for customize my data page (#22752)

Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com>
This commit is contained in:
Harshit Shah 2025-08-05 20:48:17 +05:30 committed by GitHub
parent c899732799
commit 02e06c75e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 742 additions and 59 deletions

View File

@ -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();
});
});

View File

@ -106,9 +106,13 @@ export const CustomizablePageHeader = ({
);
const handleClose = useCallback(() => {
setConfirmationModalType('close');
setConfirmationModalOpen(true);
}, []);
if (!disableSave) {
setConfirmationModalType('close');
setConfirmationModalOpen(true);
} else {
handleCancel();
}
}, [disableSave]);
return (
<Card

View File

@ -176,6 +176,17 @@ jest.mock('../CustomiseLandingPageHeader/CustomiseLandingPageHeader', () =>
))
);
jest.mock(
'../../../../components/common/NavigationBlocker/NavigationBlocker',
() => ({
NavigationBlocker: jest
.fn()
.mockImplementation(({ children }) => (
<div data-testid="navigation-blocker">{children}</div>
)),
})
);
describe('CustomizeMyData component', () => {
it('CustomizeMyData should render the widgets in the page config', async () => {
await act(async () => {

View File

@ -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 (
<AdvanceSearchProvider isExplorePage={false} updateURL={false}>
<PageLayoutV1
className="p-box customise-my-data"
pageTitle={t('label.customize-entity', {
entity: t('label.landing-page'),
})}>
<CustomizablePageHeader
disableSave={disableSave}
personaName={getEntityName(personaDetails)}
onAddWidget={handleOpenCustomiseHomeModal}
onReset={handleReset}
onSave={handleSave}
/>
<div className="grid-wrapper">
<CustomiseLandingPageHeader
overlappedContainer
addedWidgetsList={addedWidgetsList}
backgroundColor={backgroundColor}
dataTestId="customise-landing-page-header"
handleAddWidget={handleMainPanelAddWidget}
onBackgroundColorUpdate={handleBackgroundColorUpdate}
<NavigationBlocker enabled={!disableSave}>
<AdvanceSearchProvider isExplorePage={false} updateURL={false}>
<PageLayoutV1
className="p-box customise-my-data"
pageTitle={t('label.customize-entity', {
entity: t('label.landing-page'),
})}>
<CustomizablePageHeader
disableSave={disableSave}
personaName={getEntityName(personaDetails)}
onAddWidget={handleOpenCustomiseHomeModal}
onReset={handleReset}
onSave={handleSave}
/>
{/*
<div className="grid-wrapper">
<CustomiseLandingPageHeader
overlappedContainer
addedWidgetsList={addedWidgetsList}
backgroundColor={backgroundColor}
dataTestId="customise-landing-page-header"
handleAddWidget={handleMainPanelAddWidget}
onBackgroundColorUpdate={handleBackgroundColorUpdate}
/>
{/*
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
*/}
<ReactGridLayout
useCSSTransforms
verticalCompact
className="grid-container layout"
cols={customizeMyDataPageClassBase.landingPageMaxGridSize}
compactType="horizontal"
draggableHandle=".drag-widget-icon"
isResizable={false}
margin={[
customizeMyDataPageClassBase.landingPageWidgetMargin,
customizeMyDataPageClassBase.landingPageWidgetMargin,
]}
maxRows={maxRows}
preventCollision={false}
rowHeight={customizeMyDataPageClassBase.landingPageRowHeight}
onLayoutChange={handleLayoutUpdate}>
{widgets}
</ReactGridLayout>
</div>
</PageLayoutV1>
<ReactGridLayout
useCSSTransforms
verticalCompact
className="grid-container layout"
cols={customizeMyDataPageClassBase.landingPageMaxGridSize}
compactType="horizontal"
draggableHandle=".drag-widget-icon"
isResizable={false}
margin={[
customizeMyDataPageClassBase.landingPageWidgetMargin,
customizeMyDataPageClassBase.landingPageWidgetMargin,
]}
maxRows={maxRows}
preventCollision={false}
rowHeight={customizeMyDataPageClassBase.landingPageRowHeight}
onLayoutChange={handleLayoutUpdate}>
{widgets}
</ReactGridLayout>
</div>
</PageLayoutV1>
{isWidgetModalOpen && (
<CustomiseHomeModal
addedWidgetsList={addedWidgetsList}
currentBackgroundColor={backgroundColor}
defaultSelectedKey={CustomiseHomeModalSelectedKey.ALL_WIDGETS}
handleAddWidget={handleMainPanelAddWidget}
open={isWidgetModalOpen}
placeholderWidgetKey={placeholderWidgetKey}
onBackgroundColorUpdate={onBackgroundColorUpdate}
onClose={handleCloseCustomiseHomeModal}
onHomePage={false}
/>
)}
</AdvanceSearchProvider>
{isWidgetModalOpen && (
<CustomiseHomeModal
addedWidgetsList={addedWidgetsList}
currentBackgroundColor={backgroundColor}
defaultSelectedKey={CustomiseHomeModalSelectedKey.ALL_WIDGETS}
handleAddWidget={handleMainPanelAddWidget}
open={isWidgetModalOpen}
placeholderWidgetKey={placeholderWidgetKey}
onBackgroundColorUpdate={onBackgroundColorUpdate}
onClose={handleCloseCustomiseHomeModal}
onHomePage={false}
/>
)}
</AdvanceSearchProvider>
</NavigationBlocker>
);
}

View File

@ -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;
}

View File

@ -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(
<NavigationBlocker enabled={false}>
<div data-testid="test-content">Test Content</div>
</NavigationBlocker>
);
expect(screen.getByTestId('test-content')).toBeInTheDocument();
});
it('should render children when navigation blocking is enabled', () => {
render(
<NavigationBlocker enabled>
<div data-testid="test-content">Test Content</div>
</NavigationBlocker>
);
expect(screen.getByTestId('test-content')).toBeInTheDocument();
});
it('should not show modal initially', () => {
render(
<NavigationBlocker enabled>
<div>Test Content</div>
</NavigationBlocker>
);
// 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(
<NavigationBlocker
enabled
cancelText={customCancelText}
confirmText={customConfirmText}
message={customMessage}
title={customTitle}>
<div>
<a data-testid="test-link" href="/new-page">
Navigate Away
</a>
</div>
</NavigationBlocker>
);
// 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(
<NavigationBlocker enabled onCancel={onCancel}>
<div>
<a data-testid="test-link" href="/new-page">
Navigate Away
</a>
</div>
</NavigationBlocker>
);
// 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(
<NavigationBlocker enabled onConfirm={onConfirm}>
<div>
<a data-testid="test-link" href="/new-page">
Navigate Away
</a>
</div>
</NavigationBlocker>
);
// 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(
<NavigationBlocker enabled={false}>
<div>
<a data-testid="test-link" href="/new-page">
Navigate Away
</a>
</div>
</NavigationBlocker>
);
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(
<NavigationBlocker enabled>
<div>
<a data-testid="test-link" href="/new-page">
Navigate Away
</a>
</div>
</NavigationBlocker>
);
// 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();
});
});

View File

@ -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:
* <NavigationBlocker
* enabled={hasUnsavedChanges}
* message="You have unsaved changes. Are you sure you want to leave?"
* title="Unsaved Changes"
* confirmText="Leave"
* cancelText="Stay"
* >
* <YourContent />
* </NavigationBlocker>
*/
export const NavigationBlocker: React.FC<NavigationBlockerProps> = ({
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<string | null>(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}
<Modal
cancelText={cancelText}
okText={confirmText}
open={isModalVisible}
title={title}
onCancel={handleCancel}
onOk={handleConfirm}>
<p>{blockingMessage}</p>
</Modal>
</>
);
};