Fix save enable/disable on customize navigation page (#23142)

* Fix save enable/disable on customize navigation page

* add e2e tests

* fix failing and flaky tests

* address comments

* Address comment and fix curated assets e2e tests
This commit is contained in:
Harshit Shah 2025-09-09 22:41:51 +05:30 committed by GitHub
parent 8177e529bc
commit 57d0d70e99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 368 additions and 72 deletions

View File

@ -266,7 +266,7 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator.locator('.rule--operator .ant-select'),
'=='
'Is'
);
await ruleLocator
@ -340,7 +340,7 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator1.locator('.rule--operator .ant-select'),
'Is not null'
'Is Set'
);
await page.getByRole('button', { name: 'Add Condition' }).click();
@ -357,7 +357,7 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator2.locator('.rule--operator .ant-select'),
'=='
'Is'
);
await ruleLocator2
.locator('.rule--value .rule--widget--BOOLEAN .ant-switch')
@ -444,7 +444,7 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator1.locator('.rule--operator .ant-select'),
'=='
'Is'
);
await ruleLocator1
.locator('.rule--value .rule--widget--BOOLEAN .ant-switch')
@ -570,7 +570,8 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator1.locator('.rule--value .ant-select'),
'admin'
'admin',
true
);
await page.getByRole('button', { name: 'Add Condition' }).click();
@ -588,7 +589,7 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator2.locator('.rule--operator .ant-select'),
'=='
'Is'
);
await selectOption(
page,
@ -610,7 +611,7 @@ test.describe('Curated Assets Widget', () => {
await selectOption(
page,
ruleLocator3.locator('.rule--operator .ant-select'),
'!='
'Is Not'
);
await selectOption(
page,

View File

@ -126,7 +126,7 @@ test.describe('Navigation Blocker Tests', () => {
await expect(adminPage.locator('.ant-modal')).toBeVisible();
// Click "Save changes" button (should save changes and then navigate)
const saveResponse = adminPage.waitForResponse('api/v1/docStore/**');
const saveResponse = adminPage.waitForResponse('api/v1/docStore*');
await adminPage.locator('button:has-text("Save changes")').click();
// Wait for save operation to complete

View File

@ -0,0 +1,244 @@
/*
* 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 { GlobalSettingOptions } from '../../constant/settings';
import { PersonaClass } from '../../support/persona/PersonaClass';
import { UserClass } from '../../support/user/UserClass';
import { performAdminLogin } from '../../utils/admin';
import { redirectToHomePage } from '../../utils/common';
import { setUserDefaultPersona } from '../../utils/customizeLandingPage';
import { settingClick } from '../../utils/sidebar';
const adminUser = new UserClass();
const persona = new PersonaClass();
const test = base.extend<{ page: Page }>({
page: async ({ browser }, use) => {
const page = await browser.newPage();
await adminUser.login(page);
await use(page);
await page.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();
});
const navigateToPersonaNavigation = async (page: Page) => {
const getPersonas = page.waitForResponse('/api/v1/personas*');
await settingClick(page, GlobalSettingOptions.PERSONA);
await page.waitForLoadState('networkidle');
await getPersonas;
await page
.getByTestId(`persona-details-card-${persona.responseData.name}`)
.click();
await page.getByTestId('navigation').click();
await page.waitForLoadState('networkidle');
};
test.describe('Settings Navigation Page Tests', () => {
test('should update navigation sidebar', async ({ page }) => {
// Create and set default persona
await redirectToHomePage(page);
await setUserDefaultPersona(page, persona.responseData.displayName);
// Go to navigation in persona
await navigateToPersonaNavigation(page);
// Verify page loads with expected elements
await expect(page.getByRole('tree')).toBeVisible();
await expect(page.getByTestId('save-button')).toBeVisible();
await expect(page.getByTestId('reset-button')).toBeVisible();
// Save button should be disabled initially
await expect(page.getByTestId('save-button')).toBeEnabled();
// Make changes to enable save button
const exploreSwitch = page
.locator('.ant-tree-title:has-text("Explore")')
.locator('.ant-switch');
await exploreSwitch.click();
// Check save is enabled and click save
await expect(page.getByTestId('save-button')).toBeEnabled();
const saveResponse = page.waitForResponse('api/v1/docStore');
await page.getByTestId('save-button').click();
await saveResponse;
// Check the navigation bar if the changes reflect
await redirectToHomePage(page);
// Verify the navigation change is reflected in the sidebar
await expect(page.getByTestId('app-bar-item-explore')).not.toBeVisible();
// Clean up: Restore original state
await navigateToPersonaNavigation(page);
await exploreSwitch.click();
const restoreResponse = page.waitForResponse('api/v1/docStore/*');
await page.getByTestId('save-button').click();
await restoreResponse;
});
test('should show navigation blocker when leaving with unsaved changes', async ({
page,
}) => {
// Create persona and navigate to navigation page
await redirectToHomePage(page);
await setUserDefaultPersona(page, persona.responseData.displayName);
await navigateToPersonaNavigation(page);
// Make changes to trigger unsaved state
const navigateSwitch = page
.locator('.ant-tree-title:has-text("Explore")')
.locator('.ant-switch');
await navigateSwitch.click();
// Verify save button is enabled
await expect(page.getByTestId('save-button')).toBeEnabled();
// Try to navigate away - should show navigation blocker
await page
.getByTestId('left-sidebar')
.getByTestId('app-bar-item-settings')
.click();
// Verify navigation blocker modal appears
await expect(page.getByTestId('unsaved-changes-modal-title')).toContainText(
'Unsaved changes'
);
await expect(
page.getByTestId('unsaved-changes-modal-description')
).toContainText('Do you want to save or discard changes?');
// Verify modal buttons
await expect(page.getByTestId('unsaved-changes-modal-save')).toBeVisible();
await expect(
page.getByTestId('unsaved-changes-modal-discard')
).toBeVisible();
// Test discard changes
await page.getByTestId('unsaved-changes-modal-discard').click();
await page.waitForLoadState('networkidle');
// Should navigate away and changes should be discarded
await expect(page).toHaveURL(/.*settings.*/);
});
test('should save changes and navigate when "Save changes" is clicked in blocker', async ({
page,
}) => {
// Create persona and navigate to navigation page
await redirectToHomePage(page);
await setUserDefaultPersona(page, persona.responseData.displayName);
await navigateToPersonaNavigation(page);
// Make changes
const navigateSwitch = page
.locator('.ant-tree-title:has-text("Insights")')
.locator('.ant-switch');
await navigateSwitch.click();
// Try to navigate away
await page
.getByTestId('left-sidebar')
.getByTestId('app-bar-item-settings')
.click();
// Click "Save changes" to save and navigate
const saveResponse = page.waitForResponse('api/v1/docStore');
await page.getByTestId('unsaved-changes-modal-save').click();
await saveResponse;
await page.waitForLoadState('networkidle');
// Should navigate to settings page
await expect(page).toHaveURL(/.*settings.*/);
// Verify changes were saved by checking navigation bar
await redirectToHomePage(page);
// Check if Insights navigation item visibility changed
const insightsVisible = await page
.getByTestId('left-sidebar')
.getByTestId('app-bar-item-insights')
.isVisible();
expect(insightsVisible).toBe(false);
// Clean up: Restore original state
await navigateToPersonaNavigation(page);
await navigateSwitch.click();
const restoreResponse = page.waitForResponse('api/v1/docStore/*');
await page.getByTestId('save-button').click();
await restoreResponse;
});
test('should handle reset functionality and prevent navigation blocker after save', async ({
page,
}) => {
// Create persona and navigate to navigation page
await redirectToHomePage(page);
await setUserDefaultPersona(page, persona.responseData.displayName);
await navigateToPersonaNavigation(page);
// Make changes
const domainSwitch = page
.locator('.ant-tree-title:has-text("Domains")')
.locator('.ant-switch');
await domainSwitch.click();
// Verify save button is enabled
await expect(page.getByTestId('save-button')).toBeEnabled();
expect(await domainSwitch.isChecked()).toBeFalsy();
// Test reset functionality
await page.getByTestId('reset-button').click();
// Verify navigation blocker modal appears
await expect(page.getByTestId('unsaved-changes-modal-title')).toContainText(
'Reset Default Layout'
);
await expect(
page.getByTestId('unsaved-changes-modal-description')
).toContainText('Are you sure you want to apply the "Default Layout"?');
// Verify modal buttons
await expect(page.getByTestId('unsaved-changes-modal-save')).toBeVisible();
await expect(
page.getByTestId('unsaved-changes-modal-discard')
).toBeVisible();
// Test discard changes
await page.getByTestId('unsaved-changes-modal-save').click();
await page.waitForLoadState('networkidle');
// Verify reset worked - save button disabled and state reverted
expect(await domainSwitch.isChecked()).toBeTruthy();
await expect(page.getByTestId('save-button')).toBeEnabled();
});
});

View File

@ -33,6 +33,7 @@ export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({
centered
closable
className="unsaved-changes-modal-container"
data-testid="unsaved-changes-modal"
footer={null}
open={open}
width={400}
@ -44,21 +45,30 @@ export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({
</div>
<div className="unsaved-changes-modal-content">
<Typography.Title className="unsaved-changes-modal-title" level={5}>
<Typography.Title
className="unsaved-changes-modal-title"
data-testid="unsaved-changes-modal-title"
level={5}>
{title}
</Typography.Title>
<Typography.Text className="unsaved-changes-modal-description">
<Typography.Text
className="unsaved-changes-modal-description"
data-testid="unsaved-changes-modal-description">
{description}
</Typography.Text>
</div>
</div>
<div className="unsaved-changes-modal-actions">
<Button className="unsaved-changes-modal-discard" onClick={onDiscard}>
<Button
className="unsaved-changes-modal-discard"
data-testid="unsaved-changes-modal-discard"
onClick={onDiscard}>
{discardText}
</Button>
<Button
className="unsaved-changes-modal-save"
data-testid="unsaved-changes-modal-save"
loading={loading}
type="primary"
onClick={onSave}>

View File

@ -77,7 +77,7 @@ export const mockCustomizedLayout1: Array<WidgetConfig> = [
{
h: 3,
i: LandingPageWidgetKeys.ACTIVITY_FEED,
w: 3,
w: 2,
x: 0,
y: 0,
static: false,
@ -104,7 +104,7 @@ export const mockCustomizedLayout2: Array<WidgetConfig> = [
{
h: 6,
i: LandingPageWidgetKeys.ACTIVITY_FEED,
w: 3,
w: 2,
x: 0,
y: 0,
static: false,
@ -158,7 +158,7 @@ export const mockCurrentAddWidget = [
{
h: 3,
i: 'KnowledgePanel.ActivityFeed',
w: 3,
w: 2,
x: 0,
y: 0,
static: false,
@ -174,7 +174,7 @@ export const mockCurrentAddWidget = [
{
h: 3,
i: 'ExtraWidget.EmptyWidgetPlaceholder',
w: 4,
w: 2,
x: 0,
y: 6,
isDraggable: false,
@ -187,7 +187,7 @@ export const mockAddWidgetReturnValues = [
h: 3,
i: 'KnowledgePanel.ActivityFeed',
static: false,
w: 3,
w: 2,
x: 0,
y: 0,
},
@ -204,7 +204,7 @@ export const mockAddWidgetReturnValues = [
i: 'ExtraWidget.EmptyWidgetPlaceholder',
isDraggable: false,
static: false,
w: 4,
w: 2,
x: 0,
y: 100,
},
@ -216,7 +216,7 @@ export const mockAddWidgetReturnValues2 = [
h: 3,
i: 'KnowledgePanel.ActivityFeed',
static: false,
w: 3,
w: 2,
x: 0,
y: 0,
},
@ -233,7 +233,7 @@ export const mockAddWidgetReturnValues2 = [
i: 'ExtraWidget.EmptyWidgetPlaceholder',
isDraggable: false,
static: false,
w: 4,
w: 2,
x: 0,
y: 100,
},

View File

@ -38,7 +38,7 @@ export const mockDefaultLayout: Array<WidgetConfig> = [
{
h: 6,
i: LandingPageWidgetKeys.ACTIVITY_FEED,
w: 3,
w: 2,
x: 0,
y: 0,
static: false,
@ -62,7 +62,7 @@ export const mockDefaultLayout: Array<WidgetConfig> = [
{
h: 3,
i: LandingPageWidgetKeys.TOTAL_DATA_ASSETS,
w: 3,
w: 2,
x: 0,
y: 9,
static: false,
@ -89,7 +89,7 @@ export const mockCustomizedLayout: Array<WidgetConfig> = [
{
h: 6,
i: LandingPageWidgetKeys.ACTIVITY_FEED,
w: 3,
w: 2,
x: 0,
y: 0,
static: false,

View File

@ -18,6 +18,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as IconDown } from '../../assets/svg/ic-arrow-down.svg';
import { ReactComponent as IconRight } from '../../assets/svg/ic-arrow-right.svg';
import { NavigationBlocker } from '../../components/common/NavigationBlocker/NavigationBlocker';
import { CustomizablePageHeader } from '../../components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader';
import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1';
import { NavigationItem } from '../../generated/system/ui/uiCustomization';
@ -46,20 +47,11 @@ export const SettingsNavigationPage = ({ onSave }: Props) => {
);
const disableSave = useMemo(() => {
// Get the initial hidden keys from the current navigation
const initialHiddenKeys =
getHiddenKeysFromNavigationItems(currentNavigation);
// Get the current navigation items from the modified tree data
const currentNavigationItems = getNavigationItems(treeData, hiddenKeys);
// Get the current navigation items from the tree data
const currentNavigationItems =
getNavigationItems(treeData, hiddenKeys) || [];
// Get the current hidden keys from the current navigation items
const currentHiddenKeys =
getHiddenKeysFromNavigationItems(currentNavigationItems) || [];
// Check if the initial hidden keys are the same as the current hidden keys
return isEqual(initialHiddenKeys, currentHiddenKeys);
// Compare the entire structure including order, names, hidden status, and all properties
return isEqual(currentNavigation, currentNavigationItems);
}, [currentNavigation, treeData, hiddenKeys]);
const handleSave = async () => {
@ -149,6 +141,7 @@ export const SettingsNavigationPage = ({ onSave }: Props) => {
);
return (
<NavigationBlocker enabled={!disableSave} onConfirm={handleSave}>
<PageLayoutV1 className="bg-grey" pageTitle="Settings Navigation Page">
<Row gutter={[0, 20]}>
<Col span={24}>
@ -181,5 +174,6 @@ export const SettingsNavigationPage = ({ onSave }: Props) => {
</Col>
</Row>
</PageLayoutV1>
</NavigationBlocker>
);
};

View File

@ -575,7 +575,41 @@ class AdvancedSearchClassBase {
...this.baseConfig,
types: this.configTypes,
widgets: this.configWidgets,
operators: this.configOperators,
operators: {
...this.configOperators,
like: {
...this.baseConfig.operators.like,
elasticSearchQueryType: 'wildcard',
},
...(isExplorePage
? {}
: {
equal: {
...this.baseConfig.operators.equal,
label: t('label.is'),
},
not_equal: {
...this.baseConfig.operators.not_equal,
label: t('label.is-not'),
},
select_equals: {
...this.baseConfig.operators.select_equals,
label: t('label.is'),
},
select_not_equals: {
...this.baseConfig.operators.select_not_equals,
label: t('label.is-not'),
},
is_null: {
...this.baseConfig.operators.is_null,
label: t('label.is-not-set'),
},
is_not_null: {
...this.baseConfig.operators.is_not_null,
label: t('label.is-set'),
},
}),
},
settings: {
...this.baseConfig.settings,
showLabels: isExplorePage,

View File

@ -23,6 +23,15 @@ import { Document } from '../generated/entity/docStore/document';
import { WidgetConfig } from '../pages/CustomizablePage/CustomizablePage.interface';
import customizeMyDataPageClassBase from './CustomizeMyDataPageClassBase';
/**
* Ensures widget width doesn't exceed the maximum allowed width of 2
*/
export const getConstrainedWidgetWidth = (width: number): number => {
const maxWidth = 2;
return Math.min(width, maxWidth);
};
export const getNewWidgetPlacement = (
currentLayout: WidgetConfig[],
widgetWidth: number
@ -284,16 +293,18 @@ export const getAddWidgetHandler =
);
if (!currentLayout || currentLayout.length === 0) {
const constrainedWidth = getConstrainedWidgetWidth(widgetWidth);
return [
{
w: widgetWidth,
w: constrainedWidth,
h: widgetHeight,
i: widgetFQN,
static: false,
x: 0,
y: 0,
},
createPlaceholderWidget(widgetWidth, 0),
createPlaceholderWidget(constrainedWidth, 0),
];
}
@ -306,8 +317,9 @@ export const getAddWidgetHandler =
// Calculate position at the end of all existing widgets
const placement = getNewWidgetPlacement(regularWidgets, widgetWidth);
const constrainedWidth = getConstrainedWidgetWidth(widgetWidth);
const newWidget = {
w: widgetWidth,
w: constrainedWidth,
h: widgetHeight,
i: widgetFQN,
static: false,
@ -319,14 +331,15 @@ export const getAddWidgetHandler =
}
// Replace specific placeholder
const constrainedWidth = getConstrainedWidgetWidth(widgetWidth);
const updatedWidgets = currentLayout.map((widget: WidgetConfig) => {
if (widget.i === placeholderWidgetKey) {
return {
...widget,
i: widgetFQN,
h: widgetHeight,
w: widgetWidth,
x: Math.min(widget.x, maxGridSize - widgetWidth),
w: constrainedWidth,
x: Math.min(widget.x, maxGridSize - constrainedWidth),
static: false,
};
}