From e18747946b670bc8a738df021db44a34b61b5b1b Mon Sep 17 00:00:00 2001 From: Harshit Shah Date: Fri, 5 Sep 2025 17:24:50 +0530 Subject: [PATCH] Allow multi assets selection for curated assets (#23084) * Allow multi assets selection for curated assets * fix failing test * update empty widget icon sizes * fix failing tests * add certification filter * fix failing unit test * address comments * fix e2e test * add curated assets e2e tests * minor fix * Fix curated assets issue and add unit tests * fix failing unit tests * add e2e tests * fix e2e test * fix failing tests * minor fix * address comments * minor fix --- .../e2e/Features/CuratedAssets.spec.ts | 640 ++++++++++++++++-- .../e2e/Features/NavigationBlocker.spec.ts | 92 ++- .../e2e/Flow/CustomizeLandingPage.spec.ts | 3 +- .../entity/EntityDataClass.interface.ts | 2 + .../support/entity/EntityDataClass.ts | 14 + .../resources/ui/playwright/utils/common.ts | 22 +- .../playwright/utils/customizeLandingPage.ts | 175 ++++- .../UnsavedChangesModal.component.tsx | 71 ++ .../UnsavedChangesModal.interface.ts | 24 + .../UnsavedChangesModal.test.tsx | 86 +++ .../unsaved-changes-modal.less | 78 +++ .../CustomizablePageHeader.test.tsx | 81 ++- .../CustomizablePageHeader.tsx | 84 +-- .../CustomizeMyData/CustomizeMyData.test.tsx | 4 +- .../CustomizeMyData/CustomizeMyData.tsx | 24 +- .../FeedWidget/FeedWidget.component.tsx | 2 +- .../MyDataWidget/MyDataWidget.component.tsx | 2 +- .../PersonaSelectableList.component.tsx | 1 + .../MyData/RightSidebar/FollowingWidget.tsx | 2 +- .../Common/WidgetHeader/widget-header.less | 5 - .../Common/WidgetWrapper/widget-wrapper.less | 6 - .../AdvancedAssetsFilterField.component.tsx | 34 +- .../AdvancedAssetsFilterField.test.tsx | 2 +- .../advanced-assets-filter-field.less | 4 +- .../CuratedAssetsModal.test.tsx | 1 + .../CuratedAssetsModal/CuratedAssetsModal.tsx | 8 +- .../CuratedAssetsWidget.test.tsx | 78 ++- .../CuratedAssetsWidget.tsx | 97 +-- .../SelectAssetTypeField.component.tsx | 69 +- .../SelectAssetTypeField.test.tsx | 74 +- .../DataAssetsWidget/DataAssetWidget.test.tsx | 28 +- .../DataAssetsWidget.component.tsx | 4 +- .../Widgets/DomainsWidget/DomainsWidget.tsx | 6 +- .../Widgets/KPIWidget/KPIWidget.component.tsx | 2 +- .../Widgets/MyTaskWidget/MyTaskWidget.tsx | 4 +- .../TotalDataAssetsWidget.component.tsx | 2 +- .../NavigationBlocker.interface.ts | 2 +- .../NavigationBlocker.test.tsx | 96 ++- .../NavigationBlocker/NavigationBlocker.tsx | 94 ++- .../CustomizablePage.interface.ts | 1 + .../SettingsNavigationPage.tsx | 95 +-- .../src/utils/AdvancedSearchClassBase.test.ts | 1 + .../ui/src/utils/AdvancedSearchClassBase.ts | 13 + .../ui/src/utils/CuratedAssetsUtils.test.tsx | 242 ++++++- .../ui/src/utils/CuratedAssetsUtils.tsx | 276 +++++++- .../utils/CustomizableLandingPageUtils.tsx | 7 +- .../ui/src/utils/QueryBuilderUtils.tsx | 38 +- .../ui/src/utils/ServiceUtilClassBase.ts | 2 +- 48 files changed, 2144 insertions(+), 554 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/unsaved-changes-modal.less diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts index 918b83a2ee3..bf718f894bf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts @@ -11,20 +11,63 @@ * limitations under the License. */ import { expect, Page, test as base } from '@playwright/test'; +import { EntityDataClass } from '../../support/entity/EntityDataClass'; import { PersonaClass } from '../../support/persona/PersonaClass'; import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; import { selectOption } from '../../utils/advancedSearch'; -import { redirectToHomePage } from '../../utils/common'; +import { redirectToHomePage, removeLandingBanner } from '../../utils/common'; import { addCuratedAssetPlaceholder, + ENTITY_TYPE_CONFIGS, + navigateToCustomizeLandingPage, + removeAndCheckWidget, saveCustomizeLayoutPage, + selectAssetTypes, setUserDefaultPersona, } from '../../utils/customizeLandingPage'; +import { getEntityDisplayName } from '../../utils/entity'; const adminUser = new UserClass(); const persona = new PersonaClass(); +// Define the type for test entities using EntityDataClass properties +type TestEntity = typeof EntityDataClass[keyof typeof EntityDataClass]; + +// Map entity types to their EntityDataClass properties +const entityTypeToTestEntity: Record = { + 'API Collection': EntityDataClass.apiCollection1, + 'API Endpoint': EntityDataClass.apiEndpoint1, + 'Data Model': EntityDataClass.dashboardDataModel1, + 'Data Product': EntityDataClass.dataProduct1, + 'Database Schema': EntityDataClass.databaseSchema, + 'Glossary Term': EntityDataClass.glossaryTerm1, + 'ML Model': EntityDataClass.mlModel1, + 'Search Index': EntityDataClass.searchIndex1, + 'Stored Procedure': EntityDataClass.storedProcedure1, + Chart: EntityDataClass.chart1, + Container: EntityDataClass.container1, + Dashboard: EntityDataClass.dashboard1, + Database: EntityDataClass.database, + Metric: EntityDataClass.metric1, + Pipeline: EntityDataClass.pipeline1, + Table: EntityDataClass.table1, + Topic: EntityDataClass.topic1, +}; + +function toNameableEntity( + entity: TestEntity +): { name?: string; displayName?: string } | undefined { + if (!entity) { + return undefined; + } + const holder = entity as unknown as { + entity?: { name?: string; displayName?: string }; + }; + + return holder?.entity; +} + const test = base.extend<{ page: Page }>({ page: async ({ browser }, use) => { const page = await browser.newPage(); @@ -38,9 +81,15 @@ base.beforeAll('Setup pre-requests', async ({ browser }) => { test.slow(true); const { afterAction, apiContext } = await performAdminLogin(browser); + + // Create admin user and persona await adminUser.create(apiContext); await adminUser.setAdminRole(apiContext); await persona.create(apiContext, [adminUser.responseData.id]); + + // Use EntityDataClass for creating all test entities + await EntityDataClass.preRequisitesForTests(apiContext, { all: true }); + await afterAction(); }); @@ -48,22 +97,143 @@ base.afterAll('Cleanup', async ({ browser }) => { test.slow(true); const { afterAction, apiContext } = await performAdminLogin(browser); + + // Use EntityDataClass for cleanup + await EntityDataClass.postRequisitesForTests(apiContext, { all: true }); + + // Delete user and persona await adminUser.delete(apiContext); await persona.delete(apiContext); + await afterAction(); }); -test.describe('Curated Assets', () => { +test.describe('Curated Assets Widget', () => { test.beforeAll(async ({ page }) => { test.slow(true); - await redirectToHomePage(page); await setUserDefaultPersona(page, persona.responseData.displayName); + await redirectToHomePage(page); + await removeLandingBanner(page); + + await page.getByTestId('sidebar-toggle').click(); }); - test('Create Curated Asset', async ({ page }) => { + for (const entityType of ENTITY_TYPE_CONFIGS) { + test(`Test ${entityType.displayName} with display name filter`, async ({ + page, + }) => { + test.slow(true); + + const testEntity = entityTypeToTestEntity[entityType.name]; + if (!testEntity) { + return; + } + + // Add a new curated asset placeholder + await addCuratedAssetPlaceholder({ + page, + personaName: persona.responseData.name, + }); + + await page + .getByTestId('KnowledgePanel.CuratedAssets') + .getByText('Create') + .click(); + + // Update widget name + await page.locator('[data-testid="title-input"]').clear(); + await page + .locator('[data-testid="title-input"]') + .fill(`${entityType.displayName} - Display Name Filter`); + + // Select specific entity type + await selectAssetTypes(page, [entityType.name]); + + // Apply Display Name filter with the actual entity's display name + const ruleLocator = page.locator('.rule').nth(0); + + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + 'Display Name' + ); + + await selectOption( + page, + ruleLocator.locator('.rule--operator .ant-select'), + 'Contains' + ); + + const entityDisplayName = + getEntityDisplayName(toNameableEntity(testEntity)) || 'pw'; + await ruleLocator.locator('.rule--value input').clear(); + await ruleLocator.locator('.rule--value input').fill(entityDisplayName); + + // Wait for save button to be enabled + await expect(page.locator('[data-testid="saveButton"]')).toBeEnabled(); + + const queryResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/search/query') && + response.url().includes('index=dataAsset') && + response.url().includes(`entityType%22:%22${entityType.index}`) + ); + + await page.locator('[data-testid="saveButton"]').click(); + await queryResponse; + + await page.waitForLoadState('networkidle'); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .locator('.entity-list-item-title') + .filter({ hasText: entityDisplayName }) + .first() + ).toBeVisible(); + + await redirectToHomePage(page); + await removeLandingBanner(page); + + await page.waitForLoadState('networkidle'); + + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .getByText(`${entityType.displayName} - Display Name Filter`) + ).toBeVisible(); + + await page.waitForLoadState('networkidle'); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .locator('.entity-list-item-title') + .filter({ hasText: entityDisplayName }) + .first() + ).toBeVisible(); + + await navigateToCustomizeLandingPage(page, { + personaName: persona.responseData.name, + }); + + await removeAndCheckWidget(page, { + widgetKey: 'KnowledgePanel.CuratedAssets', + }); + + await saveCustomizeLayoutPage(page); + }); + } + + test('Entity type "ALL" with basic filter', async ({ page }) => { test.slow(true); + // Add curated asset widget placeholder await addCuratedAssetPlaceholder({ page, personaName: persona.responseData.name, @@ -74,120 +244,439 @@ test.describe('Curated Assets', () => { .getByText('Create') .click(); - await page.waitForTimeout(1000); - await expect(page.locator('[role="dialog"].ant-modal')).toBeVisible(); - await page.waitForSelector('[data-testid="title-input"]'); + // Configure widget with ALL entity types + // Fill widget name + await page + .locator('[data-testid="title-input"]') + .fill('All Entity Types - Initial'); - await page.locator('[data-testid="title-input"]').fill('Popular Charts'); - - await page.locator('[data-testid="asset-type-select"]').click(); - - await page.locator('[data-testid="chart-option"]').click(); + // Select ALL asset types + await selectAssetTypes(page, 'all'); + // Add a simple filter condition const ruleLocator = page.locator('.rule').nth(0); await selectOption( page, ruleLocator.locator('.rule--field .ant-select'), - 'Owners', - true + 'Deleted' ); await selectOption( page, ruleLocator.locator('.rule--operator .ant-select'), - 'Not in' + '==' ); - await selectOption( - page, - ruleLocator.locator('.rule--value .ant-select'), - 'admin', - true - ); - - await page.getByRole('button', { name: 'Add Condition' }).click(); - - const ruleLocator2 = page.locator('.rule').nth(1); - await selectOption( - page, - ruleLocator2.locator('.rule--field .ant-select'), - 'Display Name', - true - ); - - await selectOption( - page, - ruleLocator2.locator('.rule--operator .ant-select'), - 'Not in' - ); - - await selectOption( - page, - ruleLocator2.locator('.rule--value .ant-select'), - 'arcs', - true - ); + await ruleLocator + .locator('.rule--value .rule--widget--BOOLEAN .ant-switch') + .click(); await expect(page.locator('[data-testid="saveButton"]')).toBeEnabled(); const queryResponse = page.waitForResponse( - '/api/v1/search/query?q=&index=chart&*' + (response) => + response.url().includes('/api/v1/search/query') && + response.url().includes('index=all') ); await page.locator('[data-testid="saveButton"]').click(); await queryResponse; + await page.waitForLoadState('networkidle'); + + // Save and verify widget creation await expect( page.locator('[data-testid="KnowledgePanel.CuratedAssets"]') ).toBeVisible(); + await page.waitForLoadState('networkidle'); + await expect( page .getByTestId('KnowledgePanel.CuratedAssets') - .getByText('Popular Charts') + .getByText('All Entity Types - Initial') ).toBeVisible(); - await saveCustomizeLayoutPage(page); - - const chartResponsePromise = page.waitForResponse((response) => { - const url = response.url(); - - return ( - url.includes('/api/v1/search/query') && - url.includes('index=chart') && - response.status() === 200 - ); + // Delete the widget at the end + await removeAndCheckWidget(page, { + widgetKey: 'KnowledgePanel.CuratedAssets', }); + await saveCustomizeLayoutPage(page); + }); + + test('Multiple entity types with OR conditions', async ({ page }) => { + test.slow(true); + + // Create a new curated asset widget + await addCuratedAssetPlaceholder({ + page, + personaName: persona.responseData.name, + }); + + await page + .getByTestId('KnowledgePanel.CuratedAssets') + .getByText('Create') + .click(); + + // Configure widget name + await page.locator('[data-testid="title-input"]').clear(); + await page + .locator('[data-testid="title-input"]') + .fill('Charts and Dashboards Bundle'); + + // Select Chart and Dashboard + await selectAssetTypes(page, ['Chart', 'Dashboard']); + + // Add OR conditions + const ruleLocator1 = page.locator('.rule').nth(0); + await selectOption( + page, + ruleLocator1.locator('.rule--field .ant-select'), + 'Owners' + ); + await selectOption( + page, + ruleLocator1.locator('.rule--operator .ant-select'), + 'Is not null' + ); + + await page.getByRole('button', { name: 'Add Condition' }).click(); + + // Switch to OR condition (AND is selected by default, click OR button) + await page.locator('.group--conjunctions button:has-text("OR")').click(); + + const ruleLocator2 = page.locator('.rule').nth(1); + await selectOption( + page, + ruleLocator2.locator('.rule--field .ant-select'), + 'Deleted' + ); + await selectOption( + page, + ruleLocator2.locator('.rule--operator .ant-select'), + '==' + ); + await ruleLocator2 + .locator('.rule--value .rule--widget--BOOLEAN .ant-switch') + .click(); + + const queryResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/search/query') && + response.url().includes('index=dataAsset') && + response.url().includes('entityType%22:%22chart') && + response.url().includes('entityType%22:%22dashboard') + ); + + // Wait for save button to be enabled + await expect(page.locator('[data-testid="saveButton"]')).toBeEnabled(); + + await page.locator('[data-testid="saveButton"]').click(); + + await queryResponse; + + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); + + // Wait for auto-save to complete before navigating + await page.waitForLoadState('networkidle'); + await redirectToHomePage(page); + await removeLandingBanner(page); - const chartResponse = await chartResponsePromise; + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); - expect(chartResponse.status()).toBe(200); - - const chartResponseJson = await chartResponse.json(); - const chartHits = chartResponseJson.hits.hits; - - if (chartHits.length > 0) { - const firstSource = chartHits[0]._source; - const displayName = firstSource.displayName; - const name = firstSource.name; - - await expect( - page.locator(`[data-testid="Curated Assets-${displayName ?? name}"]`) - ).toBeVisible(); - } + await page.waitForLoadState('networkidle'); await expect( page .getByTestId('KnowledgePanel.CuratedAssets') - .getByText('Popular Charts') + .locator('.entity-list-item-title') + .first() ).toBeVisible(); + + // Navigate back, delete the widget and save at the end + await navigateToCustomizeLandingPage(page, { + personaName: persona.responseData.name, + }); + await removeAndCheckWidget(page, { + widgetKey: 'KnowledgePanel.CuratedAssets', + }); + await saveCustomizeLayoutPage(page); }); - test('Curated Asset placeholder is not available in home page', async ({ + test('Multiple entity types with AND conditions', async ({ page }) => { + test.slow(true); + + // Create a new curated asset widget + await addCuratedAssetPlaceholder({ + page, + personaName: persona.responseData.name, + }); + + await page + .getByTestId('KnowledgePanel.CuratedAssets') + .getByText('Create') + .click(); + + // Configure widget name + await page.locator('[data-testid="title-input"]').clear(); + await page + .locator('[data-testid="title-input"]') + .fill('Data Processing Assets'); + + // Select Pipeline, Topic, and ML Model + await selectAssetTypes(page, ['Pipeline', 'Topic', 'ML Model']); + + // Configure conditions + const ruleLocator1 = page.locator('.rule').nth(0); + await selectOption( + page, + ruleLocator1.locator('.rule--field .ant-select'), + 'Deleted' + ); + await selectOption( + page, + ruleLocator1.locator('.rule--operator .ant-select'), + '==' + ); + await ruleLocator1 + .locator('.rule--value .rule--widget--BOOLEAN .ant-switch') + .click(); + + await page.getByRole('button', { name: 'Add Condition' }).click(); + await page.locator('.group--conjunctions button:has-text("AND")').click(); + + const ruleLocator2 = page.locator('.rule').nth(1); + await selectOption( + page, + ruleLocator2.locator('.rule--field .ant-select'), + 'Display Name' + ); + await selectOption( + page, + ruleLocator2.locator('.rule--operator .ant-select'), + 'Contains' + ); + + // Use a common prefix that should match test entities + await ruleLocator2.locator('.rule--value input').clear(); + await ruleLocator2.locator('.rule--value input').fill('pw'); + + await page.waitForLoadState('networkidle'); + + const queryResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/search/query') && + response.url().includes('index=dataAsset') && + response.url().includes('entityType%22:%22pipeline') && + response.url().includes('entityType%22:%22topic') && + response.url().includes('entityType%22:%22mlmodel') + ); + + // Wait for save button to be enabled + await expect(page.locator('[data-testid="saveButton"]')).toBeEnabled(); + + await page.locator('[data-testid="saveButton"]').click(); + await queryResponse; + + await page.waitForLoadState('networkidle'); + + // Verify on customize page: widget and at least one entity item + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .locator('.entity-list-item-title') + .first() + ).toBeVisible(); + + // Wait for auto-save to complete before navigating + await page.waitForLoadState('networkidle'); + + // Navigate to landing page to verify widget + await redirectToHomePage(page); + await removeLandingBanner(page); + + await page.waitForLoadState('networkidle'); + + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); + + await page.waitForLoadState('networkidle'); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .locator('.entity-list-item-title') + .first() + ).toBeVisible(); + + // Navigate back, delete the widget and save at the end + await navigateToCustomizeLandingPage(page, { + personaName: persona.responseData.name, + }); + await removeAndCheckWidget(page, { + widgetKey: 'KnowledgePanel.CuratedAssets', + }); + await saveCustomizeLayoutPage(page); + }); + + test('Complex nested groups', async ({ page }) => { + test.slow(true); + + // Create a new curated asset widget + await addCuratedAssetPlaceholder({ + page, + personaName: persona.responseData.name, + }); + + await page + .getByTestId('KnowledgePanel.CuratedAssets') + .getByText('Create') + .click(); + + // Configure widget name + await page.locator('[data-testid="title-input"]').clear(); + await page + .locator('[data-testid="title-input"]') + .fill('Complex Nested Conditions'); + + // Select all entity types + await selectAssetTypes(page, 'all'); + + // Create first group with OR conditions + const ruleLocator1 = page.locator('.rule').nth(0); + await selectOption( + page, + ruleLocator1.locator('.rule--field .ant-select'), + 'Owners' + ); + await selectOption( + page, + ruleLocator1.locator('.rule--operator .ant-select'), + 'Any in' + ); + await selectOption( + page, + ruleLocator1.locator('.rule--value .ant-select'), + 'admin' + ); + + await page.getByRole('button', { name: 'Add Condition' }).click(); + + // Switch first group to OR condition (AND is default) + await page.locator('.group--conjunctions button:has-text("OR")').click(); + + const ruleLocator2 = page.locator('.rule').nth(1); + await selectOption( + page, + ruleLocator2.locator('.rule--field .ant-select'), + 'Description', + true + ); + await selectOption( + page, + ruleLocator2.locator('.rule--operator .ant-select'), + '==' + ); + await selectOption( + page, + ruleLocator2.locator('.rule--value .ant-select'), + 'Incomplete' + ); + await ruleLocator2.locator('.rule--value input').fill('production'); + + // Add another condition + await page.getByRole('button', { name: 'Add Condition' }).click(); + + const ruleLocator3 = page.locator('.rule').nth(2); + await selectOption( + page, + ruleLocator3.locator('.rule--field .ant-select'), + 'Tier', + true + ); + await selectOption( + page, + ruleLocator3.locator('.rule--operator .ant-select'), + '!=' + ); + await selectOption( + page, + ruleLocator3.locator('.rule--value .ant-select'), + 'Tier.Tier5' + ); + + // Wait for save button to be enabled + await expect(page.locator('[data-testid="saveButton"]')).toBeEnabled(); + + const queryResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/search/query') && + response.url().includes('index=all') + ); + + await page.locator('[data-testid="saveButton"]').click(); + await queryResponse; + + await page.waitForLoadState('networkidle'); + + // Verify on customize page: widget and at least one entity item + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .locator('.entity-list-item-title') + .first() + ).toBeVisible(); + + // Wait for auto-save to complete before navigating + await page.waitForLoadState('networkidle'); + + // Navigate to landing page to verify widget + await redirectToHomePage(page); + await removeLandingBanner(page); + + await page.waitForLoadState('networkidle'); + + await expect( + page.getByTestId('KnowledgePanel.CuratedAssets') + ).toBeVisible(); + + await page.waitForLoadState('networkidle'); + + await expect( + page + .getByTestId('KnowledgePanel.CuratedAssets') + .locator('.entity-list-item-title') + .first() + ).toBeVisible(); + + // Navigate back, delete the widget and save at the end + await navigateToCustomizeLandingPage(page, { + personaName: persona.responseData.name, + }); + await removeAndCheckWidget(page, { + widgetKey: 'KnowledgePanel.CuratedAssets', + }); + await saveCustomizeLayoutPage(page); + }); + + test('Placeholder validation - widget not visible without configuration', async ({ page, }) => { test.slow(true); @@ -197,11 +686,16 @@ test.describe('Curated Assets', () => { personaName: persona.responseData.name, }); + // Save without creating any widget configuration + await expect(page.locator('[data-testid="save-button"]')).toBeEnabled(); + await page.locator('[data-testid="save-button"]').click(); await page.waitForLoadState('networkidle'); await redirectToHomePage(page); + await removeLandingBanner(page); + // Verify placeholder is not visible when no widget is configured await expect( page.locator('[data-testid="KnowledgePanel.CuratedAssets"]') ).not.toBeVisible(); 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 index 654ef8522c8..834bfb3d7bd 100644 --- 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 @@ -88,21 +88,21 @@ test.describe('Navigation Blocker Tests', () => { await expect(adminPage.locator('.ant-modal')).toBeVisible(); await expect( adminPage.locator( - '.ant-modal-title:has-text("Are you sure you want to leave?")' + '.unsaved-changes-modal-title:has-text("Unsaved changes")' ) ).toBeVisible(); await expect( - adminPage.locator( - 'text=You have unsaved changes which will be discarded.' - ) + adminPage.locator('text=Do you want to save or discard changes?') ).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(); + // Verify modal has Save and Discard buttons + await expect( + adminPage.locator('button:has-text("Save changes")') + ).toBeVisible(); + await expect(adminPage.locator('button:has-text("Discard")')).toBeVisible(); }); - test('should stay on current page when "Stay" is clicked', async ({ + test('should confirm navigation when "Save changes" is clicked', async ({ adminPage, }) => { // Navigate to customize landing page @@ -110,8 +110,6 @@ test.describe('Navigation Blocker Tests', () => { personaName: persona.responseData.name, }); - const originalUrl = adminPage.url(); - // Make changes to trigger unsaved state await removeAndCheckWidget(adminPage, { widgetKey: 'KnowledgePanel.Following', @@ -127,22 +125,35 @@ test.describe('Navigation Blocker Tests', () => { // Modal should appear await expect(adminPage.locator('.ant-modal')).toBeVisible(); - // Click "Stay" button - await adminPage.locator('button:has-text("Stay")').click(); + // Click "Save changes" button (should save changes and then navigate) + const saveResponse = adminPage.waitForResponse('api/v1/docStore/**'); + await adminPage.locator('button:has-text("Save changes")').click(); - // Modal should disappear + // Wait for save operation to complete + await saveResponse; + + // Modal should disappear and navigate to settings await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); - // Should remain on the same page - expect(adminPage.url()).toBe(originalUrl); + await adminPage.waitForLoadState('networkidle'); - // Verify we're still on the customize page with our changes + // Should navigate to the settings page + expect(adminPage.url()).toContain('settings'); + + // Verify changes were saved by going back to customize page + await navigateToCustomizeLandingPage(adminPage, { + personaName: persona.responseData.name, + }); + + // Verify the widget was removed (changes were saved) await expect( adminPage.locator('[data-testid="KnowledgePanel.Following"]') ).not.toBeVisible(); + + // Verify save button is disabled (no unsaved changes) await expect( adminPage.locator('[data-testid="save-button"]') - ).toBeEnabled(); + ).toBeDisabled(); }); test('should navigate to new page when "Leave" is clicked', async ({ @@ -170,8 +181,8 @@ test.describe('Navigation Blocker Tests', () => { // Modal should appear await expect(adminPage.locator('.ant-modal')).toBeVisible(); - // Click "Leave" button - await adminPage.locator('button:has-text("Leave")').click(); + // Click "Discard" button (acts as "Leave") + await adminPage.locator('button:has-text("Discard")').click(); // Modal should disappear await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); @@ -230,4 +241,47 @@ test.describe('Navigation Blocker Tests', () => { // Modal should not appear await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); }); + + test('should stay on current page and keep changes when X button 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.DataAssets', + }); + + // Try to navigate away + await adminPage + .locator( + '[data-menu-id*="settings"] [data-testid="app-bar-item-settings"]' + ) + .click(); + + // Modal should appear + await expect(adminPage.locator('.ant-modal')).toBeVisible(); + + // Click X button to close modal + await adminPage.locator('.ant-modal-close-x').click(); + + // Modal should disappear + await expect(adminPage.locator('.ant-modal')).not.toBeVisible(); + + // Should remain on the same page with unsaved changes + expect(adminPage.url()).toBe(originalUrl); + + // Verify changes are still there and save button is enabled + await expect( + adminPage.locator('[data-testid="KnowledgePanel.DataAssets"]') + ).not.toBeVisible(); + await expect( + adminPage.locator('[data-testid="save-button"]') + ).toBeEnabled(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts index 0fb19eae9e4..9eae26d7100 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/CustomizeLandingPage.spec.ts @@ -238,8 +238,7 @@ test.describe('Customize Landing Page Flow', () => { const resetResponse = adminPage.waitForResponse('/api/v1/docStore/*'); await adminPage - .locator('[data-testid="reset-layout-modal"] .ant-modal-footer') - .locator('text=Yes') + .getByRole('button', { name: 'Reset', exact: true }) .click(); await resetResponse; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.interface.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.interface.ts index 46a9737cfec..f34c69d5696 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.interface.ts @@ -15,6 +15,8 @@ export interface EntityDataClassCreationConfig { entityDetails?: boolean; table?: boolean; topic?: boolean; + chart?: boolean; + metric?: boolean; dashboard?: boolean; mlModel?: boolean; pipeline?: boolean; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts index d5eb00f1b2c..d0b5e0ea0e4 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts @@ -21,6 +21,7 @@ import { TeamClass } from '../team/TeamClass'; import { UserClass } from '../user/UserClass'; import { ApiCollectionClass } from './ApiCollectionClass'; import { ApiEndpointClass } from './ApiEndpointClass'; +import { ChartClass } from './ChartClass'; import { ContainerClass } from './ContainerClass'; import { DashboardClass } from './DashboardClass'; import { DashboardDataModelClass } from './DashboardDataModelClass'; @@ -104,6 +105,7 @@ export class EntityDataClass { static readonly dataProduct2 = new DataProduct([this.domain1]); static readonly dataProduct3 = new DataProduct([this.domain2]); static readonly metric1 = new MetricClass(); + static readonly chart1 = new ChartClass(); static async preRequisitesForTests( apiContext: APIRequestContext, @@ -205,6 +207,12 @@ export class EntityDataClass { if (creationConfig?.all || creationConfig?.storageService) { promises.push(this.storageService.create(apiContext)); } + if (creationConfig?.all || creationConfig?.metric) { + promises.push(this.metric1.create(apiContext)); + } + if (creationConfig?.all || creationConfig?.chart) { + promises.push(this.chart1.create(apiContext)); + } await Promise.allSettled(promises); @@ -324,6 +332,12 @@ export class EntityDataClass { if (creationConfig?.all || creationConfig?.storageService) { promises.push(this.storageService.delete(apiContext)); } + if (creationConfig?.all || creationConfig?.metric) { + promises.push(this.metric1.delete(apiContext)); + } + if (creationConfig?.all || creationConfig?.chart) { + promises.push(this.chart1.delete(apiContext)); + } return await Promise.allSettled(promises); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index efb01c39140..53929cf219f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -70,9 +70,25 @@ export const redirectToExplorePage = async (page: Page) => { }; export const removeLandingBanner = async (page: Page) => { - const widgetResponse = page.waitForResponse('/api/v1/search/query?q=**'); - await page.click('[data-testid="welcome-screen-close-btn"]'); - await widgetResponse; + try { + const welcomePageCloseButton = await page + .waitForSelector('[data-testid="welcome-screen-close-btn"]', { + state: 'visible', + timeout: 5000, + }) + .catch(() => { + // Do nothing if the welcome banner does not exist + return; + }); + + // Close the welcome banner if it exists + if (welcomePageCloseButton?.isVisible()) { + await welcomePageCloseButton.click(); + } + } catch { + // Do nothing if the welcome banner does not exist + return; + } }; export const createNewPage = async (browser: Browser) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts index 33acb9fa645..2a34ec9f705 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customizeLandingPage.ts @@ -20,6 +20,118 @@ import { import { waitForAllLoadersToDisappear } from './entity'; import { settingClick } from './sidebar'; +// Entity types mapping from CURATED_ASSETS_LIST +export const ENTITY_TYPE_CONFIGS = [ + { + name: 'Table', + index: 'table', + displayName: 'Tables', + searchTerm: 'Table', + }, + { + name: 'Dashboard', + index: 'dashboard', + displayName: 'Dashboards', + searchTerm: 'Dashboard', + }, + { + name: 'Pipeline', + index: 'pipeline', + displayName: 'Pipelines', + searchTerm: 'Pipeline', + }, + { + name: 'Topic', + index: 'topic', + displayName: 'Topics', + searchTerm: 'Topic', + }, + { + name: 'ML Model', + index: 'mlmodel', + displayName: 'ML Model', + searchTerm: 'ML', + }, + { + name: 'Container', + index: 'container', + displayName: 'Containers', + searchTerm: 'Container', + }, + { + name: 'Search Index', + index: 'searchIndex', + displayName: 'Search Indexes', + searchTerm: 'Search', + }, + { + name: 'Chart', + index: 'chart', + displayName: 'Charts', + searchTerm: 'Chart', + }, + { + name: 'Stored Procedure', + index: 'storedProcedure', + displayName: 'Stored Procedures', + searchTerm: 'Stored', + }, + { + name: 'Data Model', + index: 'dashboardDataModel', + displayName: 'Data Model', + searchTerm: 'Data', + }, + { + name: 'Glossary Term', + index: 'glossaryTerm', + displayName: 'Glossary Terms', + searchTerm: 'Glossary', + }, + { + name: 'Metric', + index: 'metric', + displayName: 'Metrics', + searchTerm: 'Metric', + }, + { + name: 'Database', + index: 'database', + displayName: 'Databases', + searchTerm: 'Database', + }, + { + name: 'Database Schema', + index: 'databaseSchema', + displayName: 'Database Schemas', + searchTerm: 'Database', + }, + { + name: 'API Collection', + index: 'apiCollection', + displayName: 'API Collections', + searchTerm: 'API', + }, + { + name: 'API Endpoint', + index: 'apiEndpoint', + displayName: 'API Endpoints', + searchTerm: 'API', + }, + { + name: 'Data Product', + index: 'dataProduct', + displayName: 'Data Products', + searchTerm: 'Data', + }, + { + name: 'Knowledge Page', + index: 'page', + displayName: 'Knowledge Pages', + searchTerm: 'Knowledge', + }, +]; + export const navigateToCustomizeLandingPage = async ( page: Page, { personaName }: { personaName: string } @@ -81,10 +193,13 @@ export const setUserDefaultPersona = async ( ).toBeVisible(); await page.locator('[data-testid="persona-select-list"]').click(); + await page.waitForLoadState('networkidle'); const setDefaultPersona = page.waitForResponse('/api/v1/users/*'); - await page.getByTitle(personaName).click(); + // Click on the persona option by text within the dropdown + await page.locator(`[data-testid="${personaName}-option"]`).click(); + await page.locator('[data-testid="user-profile-persona-edit-save"]').click(); await setDefaultPersona; @@ -154,9 +269,7 @@ export const addAndVerifyWidget = async ( }); await openAddCustomizeWidgetModal(page); - await page.locator('[data-testid="loader"]').waitFor({ - state: 'detached', - }); + await waitForAllLoadersToDisappear(page); await page.locator(`[data-testid="${widgetKey}"]`).click(); @@ -190,11 +303,61 @@ export const addCuratedAssetPlaceholder = async ({ await page.locator('[data-testid="apply-btn"]').click(); - await page.waitForTimeout(1000); - await expect( page .getByTestId('KnowledgePanel.CuratedAssets') .getByTestId('widget-empty-state') ).toBeVisible(); }; + +// Helper function to select asset types in the dropdown +export const selectAssetTypes = async ( + page: Page, + assetTypes: string[] | 'all' +) => { + // Click on asset type selector to open dropdown + await page.locator('[data-testid="asset-type-select"]').click(); + + // Wait for dropdown to be visible + await page.waitForSelector('.ant-select-dropdown', { + state: 'visible', + timeout: 5000, + }); + + // Wait for the tree to load + await page.waitForSelector('.ant-select-tree', { + state: 'visible', + timeout: 5000, + }); + + if (assetTypes === 'all') { + // Select all asset types using the checkbox + await page.locator('[data-testid="all-option"]').click(); + } else { + // Select specific asset types + for (const assetType of assetTypes) { + // Find the corresponding config for search term + const config = ENTITY_TYPE_CONFIGS.find( + (c) => c.name === assetType || c.displayName === assetType + ); + const searchTerm = config?.searchTerm || assetType; + const index = config?.index || assetType.toLowerCase(); + + // Search for the asset type + await page.keyboard.type(searchTerm); + await page.waitForTimeout(500); + + // Try to click the filtered result + const filteredElement = page.locator(`[data-testid="${index}-option"]`); + + if (await filteredElement.isVisible()) { + await filteredElement.click(); + } + + await page.getByText('Select Asset Type').click(); + } + } + + // Close the dropdown + await page.getByText('Select Asset Type').click(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.component.tsx new file mode 100644 index 00000000000..a29fae6c33d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.component.tsx @@ -0,0 +1,71 @@ +/* + * 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 { SaveOutlined } from '@ant-design/icons'; +import { Button, Modal, Typography } from 'antd'; +import React from 'react'; +import './unsaved-changes-modal.less'; +import { UnsavedChangesModalProps } from './UnsavedChangesModal.interface'; + +export const UnsavedChangesModal: React.FC = ({ + open, + onDiscard, + onSave, + onCancel, + title = 'Unsaved changes', + description = 'Do you want to save or discard changes?', + discardText = 'Discard', + saveText = 'Save changes', + loading = false, +}) => { + return ( + +
+
+
+ +
+ +
+ + {title} + + + {description} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.interface.ts new file mode 100644 index 00000000000..0fff945ec9a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.interface.ts @@ -0,0 +1,24 @@ +/* + * 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 UnsavedChangesModalProps { + open: boolean; + onDiscard: () => void; + onSave: () => void; + onCancel?: () => void; + title?: string; + description?: string; + discardText?: string; + saveText?: string; + loading?: boolean; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.test.tsx new file mode 100644 index 00000000000..30f920f322b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/UnsavedChangesModal.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { UnsavedChangesModal } from './UnsavedChangesModal.component'; + +const mockProps = { + open: true, + onDiscard: jest.fn(), + onSave: jest.fn(), + onCancel: jest.fn(), +}; + +describe('UnsavedChangesModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render with default props', () => { + render(); + + expect(screen.getByText('Unsaved changes')).toBeInTheDocument(); + expect( + screen.getByText('Do you want to save or discard changes?') + ).toBeInTheDocument(); + expect(screen.getByText('Discard')).toBeInTheDocument(); + expect(screen.getByText('Save changes')).toBeInTheDocument(); + }); + + it('should render with custom props', () => { + const customProps = { + ...mockProps, + title: 'Custom Title', + description: 'Custom Description', + discardText: 'Custom Discard', + saveText: 'Custom Save', + }; + + render(); + + expect(screen.getByText('Custom Title')).toBeInTheDocument(); + expect(screen.getByText('Custom Description')).toBeInTheDocument(); + expect(screen.getByText('Custom Discard')).toBeInTheDocument(); + expect(screen.getByText('Custom Save')).toBeInTheDocument(); + }); + + it('should call onDiscard when discard button is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Discard')); + + expect(mockProps.onDiscard).toHaveBeenCalledTimes(1); + }); + + it('should call onSave when save button is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Save changes')); + + expect(mockProps.onSave).toHaveBeenCalledTimes(1); + }); + + it('should show loading state on save button', () => { + render(); + + const saveButton = screen.getByText('Save changes'); + + expect(saveButton.closest('.ant-btn')).toHaveClass('ant-btn-loading'); + }); + + it('should not render when open is false', () => { + render(); + + expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/unsaved-changes-modal.less b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/unsaved-changes-modal.less new file mode 100644 index 00000000000..9e3dcc88a97 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/UnsavedChangesModal/unsaved-changes-modal.less @@ -0,0 +1,78 @@ +/* + * 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 (reference) '../../../styles/variables.less'; + +.unsaved-changes-modal-container { + .ant-modal-content { + .ant-modal-close { + .ant-modal-close-x { + margin-top: @size-md; + margin-right: @size-md; + } + } + border-radius: 16px; + } +} + +.unsaved-changes-modal-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: @size-md; +} + +.unsaved-changes-modal-icon { + width: @size-2xl; + height: @size-2xl; + border-radius: 50%; + background-color: #fef0c7; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .anticon { + font-size: @size-lg; + color: #dc6803; + } +} + +.unsaved-changes-modal-content { + flex: 1; +} + +.unsaved-changes-modal-title { + margin-bottom: 0 !important; + color: @grey-900 !important; +} + +.unsaved-changes-modal-description { + color: @grey-600 !important; +} + +.unsaved-changes-modal-actions { + margin-top: @size-2xl; + display: flex; + justify-content: flex-end; + gap: @size-sm; + + .ant-btn { + padding: @size-xs @size-md; + height: auto; + border-radius: @border-rad-xs; + font-weight: @font-semibold; + font-size: @size-md; + line-height: 1.5; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.test.tsx index 7b7e081de47..bd465eed972 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader.test.tsx @@ -10,7 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useTranslation } from 'react-i18next'; import { MemoryRouter } from 'react-router-dom'; import { PageType } from '../../../../generated/system/ui/page'; @@ -71,7 +77,10 @@ describe('CustomizablePageHeader', () => { ); const saveButton = screen.getByTestId('save-button'); - fireEvent.click(saveButton); + + await act(async () => { + fireEvent.click(saveButton); + }); expect(mockProps.onSave).toHaveBeenCalled(); @@ -101,12 +110,20 @@ describe('CustomizablePageHeader', () => { ); const saveButton = screen.getByTestId('save-button'); - fireEvent.click(saveButton); + const cancelButton = screen.getByTestId('cancel-button'); + const resetButton = screen.getByTestId('reset-button'); - // Check if cancel and reset buttons are disabled during save - expect(screen.getByTestId('cancel-button')).toBeDisabled(); - expect(screen.getByTestId('reset-button')).toBeDisabled(); + act(() => { + fireEvent.click(saveButton); + }); + // During save, buttons should be disabled + await waitFor(() => { + expect(cancelButton).toBeDisabled(); + expect(resetButton).toBeDisabled(); + }); + + // Wait for saving to complete await waitFor(() => { expect(saveButton).not.toHaveAttribute('loading'); }); @@ -121,13 +138,13 @@ describe('CustomizablePageHeader', () => { fireEvent.click(screen.getByTestId('reset-button')); - expect(screen.getByTestId('reset-layout-modal')).toBeInTheDocument(); + expect(screen.getByText('label.reset-default-layout')).toBeInTheDocument(); expect( screen.getByText('message.reset-layout-confirmation') ).toBeInTheDocument(); }); - it('should handle reset confirmation', () => { + it('should handle reset confirmation', async () => { render( @@ -135,18 +152,29 @@ describe('CustomizablePageHeader', () => { ); // Open reset modal - fireEvent.click(screen.getByTestId('reset-button')); + act(() => { + fireEvent.click(screen.getByTestId('reset-button')); + }); - // Click yes on the modal - const modal = screen.getByTestId('reset-layout-modal'); - const okButton = modal.querySelector('.ant-btn-primary') as HTMLElement; - fireEvent.click(okButton); + // Wait for modal to appear and find the reset button in modal by text + await waitFor(() => { + expect( + screen.getByText('label.reset-default-layout') + ).toBeInTheDocument(); + }); + + const modalResetButton = screen.getByRole('button', { + name: 'label.reset', + }); + + act(() => { + fireEvent.click(modalResetButton); + }); expect(mockProps.onReset).toHaveBeenCalled(); - expect(screen.queryByTestId('reset-layout-modal')).not.toBeInTheDocument(); }); - it('should handle reset cancellation', () => { + it('should handle reset cancellation', async () => { render( @@ -154,17 +182,24 @@ describe('CustomizablePageHeader', () => { ); // Open reset modal - fireEvent.click(screen.getByTestId('reset-button')); + act(() => { + fireEvent.click(screen.getByTestId('reset-button')); + }); - // Click no on the modal - const modal = screen.getByTestId('reset-layout-modal'); - const cancelButton = modal.querySelector( - '.ant-btn:not(.ant-btn-primary)' - ) as HTMLElement; - fireEvent.click(cancelButton); + // Wait for modal to appear and find cancel button by role and text + await waitFor(() => { + expect( + screen.getByText('label.reset-default-layout') + ).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: 'label.cancel' }); + + act(() => { + fireEvent.click(cancelButton); + }); expect(mockProps.onReset).not.toHaveBeenCalled(); - expect(screen.queryByTestId('reset-layout-modal')).not.toBeInTheDocument(); }); it('should render different titles for different page types', () => { 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 6c917255e8b..15a372d0f68 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 @@ -16,7 +16,7 @@ import { RedoOutlined, SaveOutlined, } from '@ant-design/icons'; -import { Button, Card, Modal, Space, Typography } from 'antd'; +import { Button, Card, Space, Typography } from 'antd'; import { kebabCase } from 'lodash'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,6 +26,7 @@ import { useFqn } from '../../../../hooks/useFqn'; import { useCustomizeStore } from '../../../../pages/CustomizablePage/CustomizeStore'; import { Transi18next } from '../../../../utils/CommonUtils'; import { getPersonaDetailsPath } from '../../../../utils/RouterUtils'; +import { UnsavedChangesModal } from '../../../Modals/UnsavedChangesModal/UnsavedChangesModal.component'; import './customizable-page-header.less'; export const CustomizablePageHeader = ({ @@ -59,19 +60,19 @@ export const CustomizablePageHeader = ({ navigate(-1); }; - const { modalTitle, modalDescription } = useMemo(() => { + const { modalTitle, modalDescription, okText, cancelText } = useMemo(() => { if (confirmationModalType === 'reset') { return { modalTitle: t('label.reset-default-layout'), modalDescription: t('message.reset-layout-confirmation'), + okText: t('label.reset'), + cancelText: t('label.cancel'), }; } return { - modalTitle: t('message.are-you-sure-want-to-text', { - text: t('label.close'), - }), - modalDescription: t('message.unsaved-changes-warning'), + modalTitle: undefined, + modalDescription: undefined, }; }, [confirmationModalType]); @@ -90,10 +91,23 @@ export const CustomizablePageHeader = ({ setSaving(false); }, [onSave]); - const handleReset = useCallback(async () => { - confirmationModalType === 'reset' ? onReset() : handleCancel(); + const handleModalSave = useCallback(async () => { + if (confirmationModalType === 'reset') { + onReset(); + } else { + await handleSave(); + } setConfirmationModalOpen(false); - }, [onReset, confirmationModalType, handleCancel]); + }, [confirmationModalType, onReset, handleSave]); + + const handleModalDiscard = useCallback(() => { + if (confirmationModalType === 'reset') { + handleCloseResetModal(); + } else { + handleCancel(); + } + setConfirmationModalOpen(false); + }, [confirmationModalType, handleCancel, onReset]); const i18Values = useMemo( () => ({ @@ -112,7 +126,7 @@ export const CustomizablePageHeader = ({ } else { handleCancel(); } - }, [disableSave]); + }, [disableSave, handleCancel]); return ( - {isLandingPage ? ( + {isLandingPage && ( - ) : ( - )} - {isLandingPage && ( -