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
This commit is contained in:
Harshit Shah 2025-09-05 17:24:50 +05:30
parent 8baa358080
commit e18747946b
48 changed files with 2144 additions and 554 deletions

View File

@ -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<string, TestEntity> = {
'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();

View File

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

View File

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

View File

@ -15,6 +15,8 @@ export interface EntityDataClassCreationConfig {
entityDetails?: boolean;
table?: boolean;
topic?: boolean;
chart?: boolean;
metric?: boolean;
dashboard?: boolean;
mlModel?: boolean;
pipeline?: boolean;

View File

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

View File

@ -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) => {

View File

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

View File

@ -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<UnsavedChangesModalProps> = ({
open,
onDiscard,
onSave,
onCancel,
title = 'Unsaved changes',
description = 'Do you want to save or discard changes?',
discardText = 'Discard',
saveText = 'Save changes',
loading = false,
}) => {
return (
<Modal
centered
closable
className="unsaved-changes-modal-container"
footer={null}
open={open}
width={400}
onCancel={onCancel}>
<div className="unsaved-changes-modal">
<div className="unsaved-changes-modal-body">
<div className="unsaved-changes-modal-icon">
<SaveOutlined />
</div>
<div className="unsaved-changes-modal-content">
<Typography.Title className="unsaved-changes-modal-title" level={5}>
{title}
</Typography.Title>
<Typography.Text className="unsaved-changes-modal-description">
{description}
</Typography.Text>
</div>
</div>
<div className="unsaved-changes-modal-actions">
<Button className="unsaved-changes-modal-discard" onClick={onDiscard}>
{discardText}
</Button>
<Button
className="unsaved-changes-modal-save"
loading={loading}
type="primary"
onClick={onSave}>
{saveText}
</Button>
</div>
</div>
</Modal>
);
};

View File

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

View File

@ -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(<UnsavedChangesModal {...mockProps} />);
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(<UnsavedChangesModal {...customProps} />);
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(<UnsavedChangesModal {...mockProps} />);
fireEvent.click(screen.getByText('Discard'));
expect(mockProps.onDiscard).toHaveBeenCalledTimes(1);
});
it('should call onSave when save button is clicked', () => {
render(<UnsavedChangesModal {...mockProps} />);
fireEvent.click(screen.getByText('Save changes'));
expect(mockProps.onSave).toHaveBeenCalledTimes(1);
});
it('should show loading state on save button', () => {
render(<UnsavedChangesModal {...mockProps} loading />);
const saveButton = screen.getByText('Save changes');
expect(saveButton.closest('.ant-btn')).toHaveClass('ant-btn-loading');
});
it('should not render when open is false', () => {
render(<UnsavedChangesModal {...mockProps} open={false} />);
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
});
});

View File

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

View File

@ -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(
<MemoryRouter>
<CustomizablePageHeader {...mockProps} />
@ -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(
<MemoryRouter>
<CustomizablePageHeader {...mockProps} />
@ -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', () => {

View File

@ -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 (
<Card
@ -143,7 +157,7 @@ export const CustomizablePageHeader = ({
</Typography.Paragraph>
</div>
<Space>
{isLandingPage ? (
{isLandingPage && (
<Button
data-testid="add-widget-button"
icon={<PlusOutlined />}
@ -151,14 +165,6 @@ export const CustomizablePageHeader = ({
onClick={onAddWidget}>
{t('label.add-widget-plural')}
</Button>
) : (
<Button
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleClose}>
{t('label.close')}
</Button>
)}
<Button
data-testid="reset-button"
@ -176,31 +182,27 @@ export const CustomizablePageHeader = ({
onClick={handleSave}>
{t('label.save')}
</Button>
{isLandingPage && (
<Button
className="landing-page-cancel-button"
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleClose}
/>
)}
<Button
className="landing-page-cancel-button"
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleClose}
/>
</Space>
</div>
{confirmationModalOpen && (
<Modal
centered
cancelText={t('label.no')}
data-testid="reset-layout-modal"
okText={t('label.yes')}
open={confirmationModalOpen}
title={modalTitle}
onCancel={handleCloseResetModal}
onOk={handleReset}>
{modalDescription}
</Modal>
)}
<UnsavedChangesModal
description={modalDescription}
discardText={cancelText}
loading={saving}
open={confirmationModalOpen}
saveText={okText}
title={modalTitle}
onCancel={handleCloseResetModal}
onDiscard={handleModalDiscard}
onSave={handleModalSave}
/>
</Card>
);
};

View File

@ -227,7 +227,9 @@ describe('CustomizeMyData component', () => {
const saveButton = screen.getByTestId('save-button');
fireEvent.click(saveButton);
await act(async () => {
fireEvent.click(saveButton);
});
expect(mockProps.onSaveLayout).toHaveBeenCalledTimes(1);

View File

@ -158,6 +158,16 @@ function CustomizeMyData({
return jsonPatch.length === 0;
}, [initialPageData?.layout, layout]);
const handleSave = async (updatedLayout?: WidgetConfig[]) => {
await onSaveLayout({
...(initialPageData ??
({
pageType: PageType.LandingPage,
} as Page)),
layout: getUniqueFilteredLayout(updatedLayout ?? layout),
});
};
const widgets = useMemo(
() =>
layout
@ -173,6 +183,7 @@ function CustomizeMyData({
handleOpenAddWidgetModal: handleOpenCustomiseHomeModal,
handlePlaceholderWidgetKey: handlePlaceholderWidgetKey,
handleRemoveWidget: handleRemoveWidget,
handleSaveLayout: handleSave,
isEditView: true,
personaName: getEntityName(personaDetails),
widgetConfig: widget,
@ -185,19 +196,10 @@ function CustomizeMyData({
handlePlaceholderWidgetKey,
handleRemoveWidget,
handleLayoutUpdate,
handleSave,
]
);
const handleSave = async () => {
await onSaveLayout({
...(initialPageData ??
({
pageType: PageType.LandingPage,
} as Page)),
layout: getUniqueFilteredLayout(layout),
});
};
const handleBackgroundColorUpdate = async (color?: string) => {
await onBackgroundColorUpdate?.(color);
};
@ -216,7 +218,7 @@ function CustomizeMyData({
useGridLayoutDirection();
return (
<NavigationBlocker enabled={!disableSave}>
<NavigationBlocker enabled={!disableSave} onConfirm={handleSave}>
<AdvanceSearchProvider isExplorePage={false} updateURL={false}>
<PageLayoutV1
className="p-box customise-my-data"

View File

@ -102,7 +102,7 @@ const MyFeedWidgetInternal = ({
actionButtonText={t('label.explore-assets')}
description={t('message.activity-feed-no-data-placeholder')}
icon={
<NoDataAssetsPlaceholder height={SIZE.LARGE} width={SIZE.LARGE} />
<NoDataAssetsPlaceholder height={SIZE.MEDIUM} width={SIZE.MEDIUM} />
}
title={t('label.no-recent-activity')}
/>

View File

@ -191,7 +191,7 @@ const MyDataWidgetInternal = ({
actionButtonText={t('label.explore-assets')}
description={t('message.no-owned-data')}
icon={
<NoDataAssetsPlaceholder height={SIZE.LARGE} width={SIZE.LARGE} />
<NoDataAssetsPlaceholder height={SIZE.MEDIUM} width={SIZE.MEDIUM} />
}
title={t('label.no-records')}
/>

View File

@ -207,6 +207,7 @@ export const PersonaSelectableList = ({
label: persona.displayName || persona.name,
value: persona.id,
className: 'font-normal',
'data-testid': `${persona.displayName || persona.name}-option`,
}))}
placeholder="Please select"
popupClassName="persona-custom-dropdown-class"

View File

@ -177,7 +177,7 @@ function FollowingWidget({
actionButtonText={t('label.browse-assets')}
description={t('message.not-followed-anything')}
icon={
<NoDataAssetsPlaceholder height={SIZE.LARGE} width={SIZE.LARGE} />
<NoDataAssetsPlaceholder height={SIZE.MEDIUM} width={SIZE.MEDIUM} />
}
title={t('message.not-following-any-assets-yet')}
/>

View File

@ -65,15 +65,10 @@
}
.drag-widget-icon {
visibility: hidden;
cursor: grab;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
&:active {
cursor: grabbing;
}
}
}

View File

@ -93,10 +93,4 @@
}
}
}
&:hover {
.drag-widget-icon {
visibility: visible;
}
}
}

View File

@ -23,13 +23,12 @@ import { Col, Form, Input, Row, Skeleton } from 'antd';
import { debounce, isEmpty, isUndefined } from 'lodash';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CURATED_ASSETS_LIST } from '../../../../../constants/AdvancedSearch.constants';
import { EntityType } from '../../../../../enums/entity.enum';
import { useFqn } from '../../../../../hooks/useFqn';
import {
AlertMessage,
CuratedAssetsFormSelectedAssetsInfo,
getExploreURLWithFilters,
getExpandedResourceList,
getExploreURLForAdvancedFilter,
getModifiedQueryFilterWithSelectedAssets,
} from '../../../../../utils/CuratedAssetsUtils';
import { elasticSearchFormat } from '../../../../../utils/QueryBuilderElasticsearchFormatUtils';
@ -67,34 +66,28 @@ export const AdvancedAssetsFilterField = ({
const selectedResource: Array<string> =
Form.useWatch('resources', form) || [];
// Helper function to expand 'all' selection to all individual entity types
const getExpandedResourceList = useCallback((resources: Array<string>) => {
if (resources.includes(EntityType.ALL)) {
// Return all entity types except 'all' itself
return CURATED_ASSETS_LIST.filter((type) => type !== EntityType.ALL);
}
return resources;
}, []);
const queryURL = useMemo(() => {
// Expand 'all' selection to individual entity types for the query URL
const expandedResources = getExpandedResourceList(selectedResource);
return getExploreURLWithFilters({
return getExploreURLForAdvancedFilter({
queryFilter,
selectedResource: expandedResources,
selectedResource,
config,
});
}, [queryFilter, config, selectedResource, getExpandedResourceList]);
}, [queryFilter, config, selectedResource]);
const handleChange = useCallback(
(nTree: ImmutableTree, nConfig: Config) => {
onTreeUpdate(nTree, nConfig);
const elasticQuery = elasticSearchFormat(nTree, nConfig);
const queryFilter = {
query: elasticSearchFormat(nTree, nConfig),
query: elasticQuery ?? '',
};
// Update form field with the raw query filter (without entity type filter)
// The entity type filter will be added when needed in getModifiedQueryFilterWithSelectedAssets
form.setFieldValue('queryFilter', JSON.stringify(queryFilter));
// Update local state for entity count calculation
setQueryFilter(JSON.stringify(queryFilter));
},
[onTreeUpdate, form]
);
@ -223,6 +216,7 @@ export const AdvancedAssetsFilterField = ({
<AlertMessage
assetCount={selectedAssetsInfo?.filteredResourceCount}
href={queryURL}
target="_blank"
/>
</Col>
)}

View File

@ -61,7 +61,7 @@ jest.mock('../../../../../utils/CuratedAssetsUtils', () => ({
.mockImplementation(() => (
<div data-testid="alert-message">Alert Message</div>
)),
getExploreURLWithFilters: jest.fn().mockReturnValue('test-url'),
getExploreURLForAdvancedFilter: jest.fn().mockReturnValue('test-url'),
getModifiedQueryFilterWithSelectedAssets: jest.fn().mockReturnValue({}),
}));

View File

@ -36,7 +36,7 @@
.group-or-rule-container.rule-container {
padding: 0px;
height: 32px;
min-height: 32px;
.rule.group-or-rule {
.rule--header {
@ -142,7 +142,7 @@
.ant-btn-group {
button {
height: 32px;
min-height: 32px;
padding-top: 0px;
padding-bottom: 0px;
}

View File

@ -45,6 +45,7 @@ jest.mock('../../../../../utils/CuratedAssetsUtils', () => ({
entityCount: 10,
resourcesWithNonZeroCount: [],
}),
isValidElasticsearchQuery: jest.fn().mockReturnValue(true),
}));
const mockOnCancel = jest.fn();

View File

@ -21,6 +21,7 @@ import { VALIDATION_MESSAGES } from '../../../../../constants/constants';
import {
CuratedAssetsFormSelectedAssetsInfo,
getSelectedResourceCount,
isValidElasticsearchQuery,
} from '../../../../../utils/CuratedAssetsUtils';
import { AdvancedAssetsFilterField } from '../AdvancedAssetsFilterField/AdvancedAssetsFilterField.component';
import { SelectAssetTypeField } from '../SelectAssetTypeField/SelectAssetTypeField.component';
@ -47,7 +48,6 @@ const CuratedAssetsModal = ({
const selectedResource = Form.useWatch('resources', form);
const queryFilter = Form.useWatch('queryFilter', form);
const title = Form.useWatch('title', form);
useEffect(() => {
@ -61,7 +61,11 @@ const CuratedAssetsModal = ({
}, [isOpen, curatedAssetsConfig, form]);
const disableSave = useMemo(() => {
return isEmpty(title) || isEmpty(selectedResource) || isEmpty(queryFilter);
return (
isEmpty(title) ||
isEmpty(selectedResource) ||
!isValidElasticsearchQuery(queryFilter || '{}')
);
}, [title, selectedResource, queryFilter]);
const handleCancel = useCallback(() => {

View File

@ -14,6 +14,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useTranslation } from 'react-i18next';
import { PAGE_SIZE_MEDIUM } from '../../../../constants/constants';
import { SearchIndex } from '../../../../enums/search.enum';
import { WidgetConfig } from '../../../../pages/CustomizablePage/CustomizablePage.interface';
import { searchQuery } from '../../../../rest/searchAPI';
import CuratedAssetsWidget from './CuratedAssetsWidget';
@ -37,7 +38,9 @@ jest.mock('react-router-dom', () => ({
// Mock utility functions
jest.mock('../../../../utils/CuratedAssetsUtils', () => ({
getExploreURLWithFilters: jest.fn().mockReturnValue('/explore?filter=test'),
getExploreURLForAdvancedFilter: jest
.fn()
.mockReturnValue('/explore?filter=test'),
getModifiedQueryFilterWithSelectedAssets: jest.fn().mockReturnValue({}),
getTotalResourceCount: jest.fn().mockReturnValue(15),
}));
@ -144,8 +147,17 @@ jest.mock('../../../common/RichTextEditor/RichTextEditorPreviewerV1', () =>
jest.fn().mockImplementation(({ markdown }) => <div>{markdown}</div>)
);
jest.mock('../../../common/CertificationTag/CertificationTag', () =>
jest
.fn()
.mockImplementation(({ certification }) => (
<div>{certification?.tagLabel?.tagFQN}</div>
))
);
const mockHandleRemoveWidget = jest.fn();
const mockHandleLayoutUpdate = jest.fn();
const mockHandleSaveLayout = jest.fn();
const mockEntityData = [
{
@ -158,6 +170,22 @@ const mockEntityData = [
},
description: 'Test description',
updatedAt: '2023-01-01T00:00:00Z',
certification: {
expiryDate: 1758805135467,
appliedDate: 1756213135467,
tagLabel: {
tagFQN: 'Certification.Gold',
name: 'Gold',
labelType: 'Manual',
description: 'Gold certified Data Asset.',
style: {
color: '#FFCE00',
iconURL: 'GoldCertification.svg',
},
source: 'Classification',
state: 'Confirmed',
},
},
},
];
@ -166,6 +194,7 @@ const defaultProps = {
handleRemoveWidget: mockHandleRemoveWidget,
widgetKey: 'test-widget',
handleLayoutUpdate: mockHandleLayoutUpdate,
handleSaveLayout: mockHandleSaveLayout,
currentLayout: [
{
i: 'test-widget',
@ -296,32 +325,48 @@ describe('CuratedAssetsWidget', () => {
});
});
it('calls handleLayoutUpdate with correct data when saving new widget', () => {
it('calls handleLayoutUpdate and handleSaveLayout when saving new widget', async () => {
render(
<CuratedAssetsWidget {...defaultProps} isEditView currentLayout={[]} />
);
fireEvent.click(screen.getByText('label.create'));
fireEvent.click(screen.getByTestId('saveButton'));
expect(mockHandleLayoutUpdate).toHaveBeenCalledWith([
{
i: 'test-widget',
config: { title: 'Test Widget' },
},
]);
await waitFor(() => {
expect(mockHandleLayoutUpdate).toHaveBeenCalledWith([
{
i: 'test-widget',
config: { title: 'Test Widget' },
},
]);
expect(mockHandleSaveLayout).toHaveBeenCalledWith([
{
i: 'test-widget',
config: { title: 'Test Widget' },
},
]);
});
});
it('calls handleLayoutUpdate with updated config when editing existing widget', () => {
it('calls handleLayoutUpdate and handleSaveLayout when editing existing widget', async () => {
render(<CuratedAssetsWidget {...defaultProps} isEditView />);
fireEvent.click(screen.getByTestId('edit-widget-button'));
fireEvent.click(screen.getByTestId('saveButton'));
expect(mockHandleLayoutUpdate).toHaveBeenCalledWith([
{
...defaultProps.currentLayout[0],
config: { title: 'Test Widget' },
},
]);
await waitFor(() => {
expect(mockHandleLayoutUpdate).toHaveBeenCalledWith([
{
...defaultProps.currentLayout[0],
config: { title: 'Test Widget' },
},
]);
expect(mockHandleSaveLayout).toHaveBeenCalledWith([
{
...defaultProps.currentLayout[0],
config: { title: 'Test Widget' },
},
]);
});
});
it('closes modal and resets data when cancel is clicked', () => {
@ -360,6 +405,7 @@ describe('CuratedAssetsWidget', () => {
expect(
screen.getByTestId('Curated Assets-Test Entity')
).toBeInTheDocument();
expect(screen.getAllByText('Certification.Gold')).toHaveLength(1);
});
});
@ -377,7 +423,7 @@ describe('CuratedAssetsWidget', () => {
query: '',
pageNumber: 1,
pageSize: PAGE_SIZE_MEDIUM,
searchIndex: 'table',
searchIndex: SearchIndex.DATA_ASSET,
sortField: 'updatedAt',
sortOrder: 'desc',
includeDeleted: false,

View File

@ -35,6 +35,7 @@ import {
import { SIZE } from '../../../../enums/common.enum';
import { EntityType } from '../../../../enums/entity.enum';
import { SearchIndex } from '../../../../enums/search.enum';
import { AssetCertification } from '../../../../generated/type/assetCertification';
import {
SearchIndexSearchSourceMapping,
SearchSourceAlias,
@ -44,8 +45,9 @@ import {
WidgetConfig,
} from '../../../../pages/CustomizablePage/CustomizablePage.interface';
import { searchQuery } from '../../../../rest/searchAPI';
import { getTextFromHtmlString } from '../../../../utils/BlockEditorUtils';
import {
getExploreURLWithFilters,
getExploreURLForAdvancedFilter,
getModifiedQueryFilterWithSelectedAssets,
getTotalResourceCount,
} from '../../../../utils/CuratedAssetsUtils';
@ -55,7 +57,7 @@ import { getEntityName } from '../../../../utils/EntityUtils';
import searchClassBase from '../../../../utils/SearchClassBase';
import serviceUtilClassBase from '../../../../utils/ServiceUtilClassBase';
import { showErrorToast } from '../../../../utils/ToastUtils';
import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1';
import CertificationTag from '../../../common/CertificationTag/CertificationTag';
import { useAdvanceSearch } from '../../../Explore/AdvanceSearchProvider/AdvanceSearchProvider.component';
import WidgetEmptyState from '../Common/WidgetEmptyState/WidgetEmptyState';
import WidgetFooter from '../Common/WidgetFooter/WidgetFooter';
@ -73,6 +75,7 @@ const CuratedAssetsWidget = ({
handleRemoveWidget,
widgetKey,
handleLayoutUpdate,
handleSaveLayout,
currentLayout,
}: WidgetCommonProps) => {
const { t } = useTranslation();
@ -123,7 +126,17 @@ const CuratedAssetsWidget = ({
[data, isLoading]
);
const sourceIcon = searchClassBase.getEntityIcon(selectedResource?.[0] ?? '');
const sourceIcon = searchClassBase.getEntityIcon(
selectedResource?.length === 1 ? selectedResource?.[0] : ''
);
const queryURL = useMemo(() => {
return getExploreURLForAdvancedFilter({
queryFilter,
selectedResource,
config,
});
}, [queryFilter, config, selectedResource]);
// Helper function to expand 'all' selection to all individual entity types
const getExpandedResourceList = useCallback((resources: Array<string>) => {
@ -136,7 +149,7 @@ const CuratedAssetsWidget = ({
}, []);
const prepareData = useCallback(async () => {
if (selectedResource?.[0]) {
if (selectedResource?.length) {
try {
setIsLoading(true);
const sortField = getSortField(selectedSortBy);
@ -145,10 +158,17 @@ const CuratedAssetsWidget = ({
// Expand 'all' selection to individual entity types for the API call
const expandedResources = getExpandedResourceList(selectedResource);
// Use SearchIndex.ALL when 'all' is selected, otherwise use the first selected resource
// Use SearchIndex.ALL when 'all' is selected, otherwise use all the selected resource
const searchIndex = selectedResource.includes(EntityType.ALL)
? SearchIndex.ALL
: (selectedResource[0] as SearchIndex);
: SearchIndex.DATA_ASSET;
// Create the modified query filter with selected assets
const parsedQueryFilter = JSON.parse(queryFilter || '{}');
const modifiedQueryFilter = getModifiedQueryFilterWithSelectedAssets(
parsedQueryFilter,
expandedResources
);
const res = await searchQuery({
query: '',
@ -158,10 +178,7 @@ const CuratedAssetsWidget = ({
includeDeleted: false,
trackTotalHits: false,
fetchSource: true,
queryFilter: getModifiedQueryFilterWithSelectedAssets(
JSON.parse(queryFilter),
expandedResources
),
queryFilter: modifiedQueryFilter as Record<string, unknown>,
sortField,
sortOrder,
});
@ -197,10 +214,10 @@ const CuratedAssetsWidget = ({
]);
const handleTitleClick = useCallback(() => {
navigate(ROUTES.EXPLORE);
}, [navigate]);
navigate(queryFilter?.length > 0 ? queryURL : ROUTES.EXPLORE);
}, [navigate, queryFilter, queryURL]);
const handleSave = (value: WidgetConfig['config']) => {
const handleSave = async (value: WidgetConfig['config']) => {
const hasCurrentCuratedAssets = currentLayout?.find(
(layout: WidgetConfig) => layout.i === widgetKey
);
@ -221,6 +238,11 @@ const CuratedAssetsWidget = ({
// Update layout if handleLayoutUpdate is provided
handleLayoutUpdate && handleLayoutUpdate(updatedLayout as Layout[]);
// Automatically save layout after updating
if (handleSaveLayout) {
await handleSaveLayout(updatedLayout as WidgetConfig[]);
}
setCreateCuratedAssetsModalOpen(false);
};
@ -283,24 +305,14 @@ const CuratedAssetsWidget = ({
selectedSortBy,
]);
const queryURL = useMemo(
() =>
getExploreURLWithFilters({
queryFilter,
selectedResource,
config,
}),
[queryFilter, config, selectedResource]
);
const noDataState = useMemo(
() => (
<WidgetEmptyState
icon={
<CuratedAssetsNoDataIcon
data-testid="curated-assets-no-data-icon"
height={SIZE.LARGE}
width={SIZE.LARGE}
height={SIZE.MEDIUM}
width={SIZE.MEDIUM}
/>
}
title={t('message.curated-assets-no-data-message')}
@ -318,8 +330,8 @@ const CuratedAssetsWidget = ({
icon={
<CuratedAssetsEmptyIcon
data-testid="curated-assets-empty-icon"
height={SIZE.LARGE}
width={SIZE.LARGE}
height={SIZE.MEDIUM}
width={SIZE.MEDIUM}
/>
}
onActionClick={handleModalOpen}
@ -332,6 +344,7 @@ const CuratedAssetsWidget = ({
(item: SearchIndexSearchSourceMapping[SearchIndex]) => {
const title = getEntityName(item);
const description = get(item, 'description');
const certification = get(item, 'certification');
return (
<Link
@ -351,18 +364,24 @@ const CuratedAssetsWidget = ({
)}
/>
<div className="flex flex-col curated-assets-list-item-content">
<Typography.Text
className="entity-list-item-title"
ellipsis={{ tooltip: true }}>
{title}
</Typography.Text>
<div className="flex items-center gap-1">
<Typography.Text
className="entity-list-item-title"
ellipsis={{ tooltip: true }}>
{title}
</Typography.Text>
{certification && (
<CertificationTag
certification={certification as AssetCertification}
/>
)}
</div>
{description && (
<RichTextEditorPreviewerV1
className="max-two-lines entity-list-item-description"
enableSeeMoreVariant={false}
markdown={description}
showReadMoreBtn={false}
/>
<Typography.Text
className="max-two-lines entity-list-item-description text-grey-muted"
ellipsis={{ tooltip: true }}>
{getTextFromHtmlString(description)}
</Typography.Text>
)}
</div>
</div>
@ -460,7 +479,7 @@ const CuratedAssetsWidget = ({
</div>
<WidgetFooter
moreButtonLink={queryURL}
moreButtonLink={queryFilter?.length > 0 ? queryURL : ROUTES.EXPLORE}
moreButtonText={t('label.view-more-count', {
countValue: viewMoreCount,
})}

View File

@ -11,17 +11,17 @@
* limitations under the License.
*/
import { Col, Form, Select, Skeleton } from 'antd';
import { DefaultOptionType } from 'antd/lib/select';
import { Col, Form, Skeleton, TreeSelect } from 'antd';
import { isEmpty, isUndefined } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CURATED_ASSETS_LIST } from '../../../../../constants/AdvancedSearch.constants';
import { EntityType } from '../../../../../enums/entity.enum';
import { getSourceOptionsFromResourceList } from '../../../../../utils/Alerts/AlertsUtil';
import {
AlertMessage,
CuratedAssetsFormSelectedAssetsInfo,
getExploreURLWithFilters,
getSimpleExploreURLForAssetTypes,
} from '../../../../../utils/CuratedAssetsUtils';
import searchClassBase from '../../../../../utils/SearchClassBase';
import { useAdvanceSearch } from '../../../../Explore/AdvanceSearchProvider/AdvanceSearchProvider.component';
@ -41,19 +41,48 @@ export const SelectAssetTypeField = ({
const { t } = useTranslation();
const form = Form.useFormInstance<CuratedAssetsConfig>();
const { config, onChangeSearchIndex } = useAdvanceSearch();
const { onChangeSearchIndex } = useAdvanceSearch();
const [isCountLoading, setIsCountLoading] = useState<boolean>(false);
const selectedResource: Array<string> =
Form.useWatch<Array<string>>('resources', form) || [];
const resourcesOptions: DefaultOptionType[] = useMemo(() => {
return getSourceOptionsFromResourceList(
const resourcesOptions = useMemo(() => {
const allOptions = getSourceOptionsFromResourceList(
CURATED_ASSETS_LIST,
false,
selectedResource,
false
);
// Create tree structure with "All" as parent
const allOption = allOptions.find(
(option) => option.value === EntityType.ALL
);
const individualOptions = allOptions.filter(
(option) => option.value !== EntityType.ALL
);
if (allOption) {
return [
{
title: allOption.label,
value: allOption.value,
key: allOption.value,
children: individualOptions.map((option) => ({
title: option.label,
value: option.value,
key: option.value,
})),
},
];
}
return allOptions.map((option) => ({
title: option.label,
value: option.value,
key: option.value,
}));
}, [selectedResource]);
const handleEntityCountChange = useCallback(async () => {
@ -71,13 +100,8 @@ export const SelectAssetTypeField = ({
}, [fetchEntityCount, selectedResource]);
const queryURL = useMemo(
() =>
getExploreURLWithFilters({
queryFilter: '{}',
selectedResource,
config,
}),
[config, selectedResource]
() => getSimpleExploreURLForAssetTypes(selectedResource),
[selectedResource]
);
const showFilteredResourceCount = useMemo(
@ -89,10 +113,12 @@ export const SelectAssetTypeField = ({
);
const handleResourceChange = useCallback(
(val: string | string[]) => {
if (form) {
form.setFieldValue('resources', [val]);
(val: string[]) => {
if (!form) {
return;
}
form.setFieldValue('resources', val);
},
[form]
);
@ -120,9 +146,15 @@ export const SelectAssetTypeField = ({
}}
name="resources"
style={{ marginBottom: 8 }}>
<Select
options={resourcesOptions}
<TreeSelect
treeCheckable
treeDefaultExpandAll
autoClearSearchValue={false}
className="w-full"
maxTagCount="responsive"
placeholder={t('label.select-asset-type')}
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={resourcesOptions}
value={selectedResource}
onChange={handleResourceChange}
/>
@ -142,6 +174,7 @@ export const SelectAssetTypeField = ({
<AlertMessage
assetCount={selectedAssetsInfo?.resourceCount}
href={queryURL}
target="_blank"
/>
</Col>
)}

View File

@ -10,7 +10,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { Form } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
@ -53,15 +59,35 @@ jest.mock('../../../../../utils/CuratedAssetsUtils', () => ({
.mockImplementation(() => (
<div data-testid="alert-message">Alert Message</div>
)),
getExploreURLWithFilters: jest.fn().mockReturnValue('test-url'),
getSimpleExploreURLForAssetTypes: jest.fn().mockReturnValue('test-url'),
}));
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Skeleton: jest
.fn()
.mockImplementation(() => <div data-testid="skeleton">Skeleton</div>),
}));
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
const MockTreeSelect = ({ treeData = [], onChange }: any) => {
return (
<div data-testid="mock-tree-select">
{treeData.map((opt: any) => (
<button
data-testid={`${opt.value}-option`}
key={opt.value}
onClick={() => onChange([opt.value])}>
{typeof opt.title === 'string' ? opt.title : opt.value}
</button>
))}
</div>
);
};
return {
...actual,
TreeSelect: MockTreeSelect,
Skeleton: jest
.fn()
.mockImplementation(() => <div data-testid="skeleton">Skeleton</div>),
};
});
const mockFetchEntityCount = jest.fn();
const mockSelectedAssetsInfo = {
@ -106,9 +132,7 @@ describe('SelectAssetTypeField', () => {
</TestWrapper>
);
expect(
screen.getByLabelText('label.select-asset-type')
).toBeInTheDocument();
expect(screen.getByText('label.select-asset-type')).toBeInTheDocument();
});
it('handles asset type selection', async () => {
@ -118,19 +142,13 @@ describe('SelectAssetTypeField', () => {
</TestWrapper>
);
const select = screen.getByTestId('asset-type-select');
const dashboardOption = await screen.findByTestId('dashboard-option');
await act(async () => {
fireEvent.click(select);
fireEvent.click(dashboardOption);
});
const tableOption = screen.getByText('Table');
await act(async () => {
fireEvent.click(tableOption);
});
expect(tableOption).toBeInTheDocument();
expect(dashboardOption).toBeInTheDocument();
});
it('displays loading skeleton when count is loading', () => {
@ -160,15 +178,15 @@ describe('SelectAssetTypeField', () => {
},
};
await act(async () => {
render(
<TestWrapper>
<SelectAssetTypeField {...propsWithCount} />
</TestWrapper>
);
});
render(
<TestWrapper>
<SelectAssetTypeField {...propsWithCount} />
</TestWrapper>
);
expect(screen.getByTestId('alert-message')).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId('alert-message')).toBeInTheDocument()
);
});
it('does not display alert message when no resource count', () => {

View File

@ -79,24 +79,16 @@ describe('DataAssetsWidget', () => {
it('should fetch dataAssets initially', () => {
renderDataAssetsWidget();
expect(searchData).toHaveBeenCalledWith(
'',
0,
0,
'',
'name.keyword',
'asc',
[
SearchIndex.TABLE,
SearchIndex.TOPIC,
SearchIndex.DASHBOARD,
SearchIndex.PIPELINE,
SearchIndex.MLMODEL,
SearchIndex.CONTAINER,
SearchIndex.SEARCH_INDEX,
SearchIndex.API_ENDPOINT_INDEX,
]
);
expect(searchData).toHaveBeenCalledWith('', 0, 0, '', 'updatedAt', 'desc', [
SearchIndex.TABLE,
SearchIndex.TOPIC,
SearchIndex.DASHBOARD,
SearchIndex.PIPELINE,
SearchIndex.MLMODEL,
SearchIndex.CONTAINER,
SearchIndex.SEARCH_INDEX,
SearchIndex.API_ENDPOINT_INDEX,
]);
});
it('should render DataAssetsWidget with widget wrapper', async () => {

View File

@ -52,7 +52,7 @@ const DataAssetsWidget = ({
const [loading, setLoading] = useState<boolean>(true);
const [services, setServices] = useState<Bucket[]>([]);
const [selectedSortBy, setSelectedSortBy] = useState<string>(
DATA_ASSETS_SORT_BY_KEYS.A_TO_Z
DATA_ASSETS_SORT_BY_KEYS.HIGH_TO_LOW
);
const widgetData = useMemo(
@ -119,7 +119,7 @@ const DataAssetsWidget = ({
() => (
<WidgetEmptyState
icon={
<NoDataAssetsPlaceholder height={SIZE.LARGE} width={SIZE.LARGE} />
<NoDataAssetsPlaceholder height={SIZE.MEDIUM} width={SIZE.MEDIUM} />
}
title={t('message.no-data-assets-yet')}
/>

View File

@ -29,7 +29,7 @@ import {
getSortField,
getSortOrder,
} from '../../../../constants/Widgets.constant';
import { ERROR_PLACEHOLDER_TYPE } from '../../../../enums/common.enum';
import { ERROR_PLACEHOLDER_TYPE, SIZE } from '../../../../enums/common.enum';
import { SearchIndex } from '../../../../enums/search.enum';
import { Domain } from '../../../../generated/entity/domains/domain';
import {
@ -131,7 +131,9 @@ const DomainsWidget = ({
actionButtonLink={ROUTES.DOMAIN}
actionButtonText={t('label.explore-domain')}
description={t('message.domains-no-data-message')}
icon={<DomainNoDataPlaceholder />}
icon={
<DomainNoDataPlaceholder height={SIZE.MEDIUM} width={SIZE.MEDIUM} />
}
title={t('label.no-domains-yet')}
/>
),

View File

@ -247,7 +247,7 @@ const KPIWidget = ({
actionButtonLink={ROUTES.KPI_LIST}
actionButtonText={t('label.set-up-kpi')}
description={t('message.no-kpi')}
icon={<KPINoDataPlaceholder height={SIZE.LARGE} width={SIZE.LARGE} />}
icon={<KPINoDataPlaceholder height={SIZE.MEDIUM} width={SIZE.MEDIUM} />}
title={t('label.no-kpis-yet')}
/>
),

View File

@ -144,8 +144,8 @@ const MyTaskWidget = ({
icon={
<MyTaskNoDataIcon
data-testid="my-task-no-data-icon"
height={SIZE.LARGE}
width={SIZE.LARGE}
height={SIZE.MEDIUM}
width={SIZE.MEDIUM}
/>
}
title={t('label.no-tasks-yet')}

View File

@ -189,7 +189,7 @@ const TotalDataAssetsWidget = ({
actionButtonText={t('label.browse-assets')}
description={t('message.no-data-for-total-assets')}
icon={
<TotalDataAssetsEmptyIcon height={SIZE.LARGE} width={SIZE.LARGE} />
<TotalDataAssetsEmptyIcon height={SIZE.MEDIUM} width={SIZE.MEDIUM} />
}
title={t('label.no-data-assets-to-display')}
/>

View File

@ -18,6 +18,6 @@ export interface NavigationBlockerProps {
title?: string;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onConfirm?: () => Promise<void>;
onCancel?: () => void;
}

View File

@ -51,19 +51,12 @@ describe('NavigationBlocker component', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('should show correct modal content when blocking is enabled', () => {
it('should show custom modal content when props are provided', () => {
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}>
<NavigationBlocker enabled message={customMessage} title={customTitle}>
<div>
<a data-testid="test-link" href="/new-page">
Navigate Away
@ -76,47 +69,18 @@ describe('NavigationBlocker component', () => {
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.getByText('Unsaved changes')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: customConfirmText })
screen.getByText('Do you want to save or discard changes?')
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Discard' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: customCancelText })
screen.getByRole('button', { name: 'Save changes' })
).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 () => {
it('should call onConfirm when save button is clicked', async () => {
const onConfirm = jest.fn();
render(
@ -133,19 +97,45 @@ describe('NavigationBlocker component', () => {
const link = screen.getByTestId('test-link');
fireEvent.click(link);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
const confirmButton = await screen.findByRole('button', {
name: 'Leave',
});
// Click confirm button
// Click save button (which calls onConfirm)
const saveButton = screen.getByRole('button', { name: 'Save changes' });
await act(async () => {
await fireEvent.click(confirmButton);
await fireEvent.click(saveButton);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('should call onCancel when modal is closed', 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(await screen.findByRole('dialog')).toBeInTheDocument();
// Close modal by clicking the X button
const closeButton = screen.getByLabelText('Close');
await act(async () => {
await fireEvent.click(closeButton);
});
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('should not intercept navigation when blocking is disabled', () => {
render(
<NavigationBlocker enabled={false}>
@ -181,13 +171,13 @@ describe('NavigationBlocker component', () => {
// Check default content
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Unsaved changes')).toBeInTheDocument();
expect(
screen.getByText('Are you sure you want to leave?')
screen.getByText('Do you want to save or discard changes?')
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Discard' })).toBeInTheDocument();
expect(
screen.getByText('You have unsaved changes which will be discarded.')
screen.getByRole('button', { name: 'Save changes' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Leave' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Stay' })).toBeInTheDocument();
});
});

View File

@ -11,8 +11,8 @@
* limitations under the License.
*/
import { Modal } from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { UnsavedChangesModal } from '../../Modals/UnsavedChangesModal/UnsavedChangesModal.component';
import { NavigationBlockerProps } from './NavigationBlocker.interface';
/**
@ -32,24 +32,23 @@ import { NavigationBlockerProps } from './NavigationBlocker.interface';
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',
message = 'Do you want to save or discard changes?',
title = 'Unsaved changes',
onConfirm,
onCancel,
}) => {
const [isBlocking, setIsBlocking] = useState(enabled);
const [isModalVisible, setIsModalVisible] = useState(false);
const [blockingMessage, setBlockingMessage] = useState(message);
const [loading, setLoading] = useState(false);
const pendingNavigationRef = useRef<string | null>(null);
const isNavigatingRef = useRef(false);
// Update blocking state when enabled/message changes
// Update blocking state when enabled/message/title changes
useEffect(() => {
setIsBlocking(enabled);
setBlockingMessage(message);
}, [enabled, message]);
}, [enabled, message, title]);
useEffect(() => {
if (!isBlocking || isNavigatingRef.current) {
@ -61,9 +60,10 @@ export const NavigationBlocker: React.FC<NavigationBlockerProps> = ({
// Only show for actual tab close, not for programmatic navigation
if (!isNavigatingRef.current) {
event.preventDefault();
event.returnValue = blockingMessage;
// Modern browsers ignore the custom message and show their own
event.returnValue = '';
return blockingMessage;
return '';
}
return undefined;
@ -135,7 +135,21 @@ export const NavigationBlocker: React.FC<NavigationBlockerProps> = ({
if (link) {
const href = link.getAttribute('href');
if (href && (href.startsWith('/') || href.startsWith('http'))) {
const linkTarget = link.getAttribute('target');
const download = link.getAttribute('download');
// Don't block navigation if:
// 1. Link has target="_blank" (opens in new tab/window)
// 2. Link has target="_parent" or "_top" (opens in parent/top frame)
// 3. Link has download attribute (file download)
// 4. Link is external (starts with http and is different origin)
const shouldBlockNavigation =
href &&
(href.startsWith('/') || href.startsWith('http')) &&
!download &&
(!linkTarget || linkTarget === '_self');
if (shouldBlockNavigation) {
event.preventDefault();
event.stopPropagation();
setIsModalVisible(true);
@ -177,13 +191,10 @@ export const NavigationBlocker: React.FC<NavigationBlockerProps> = ({
};
}, [isBlocking, blockingMessage]);
const handleConfirm = useCallback(() => {
const handleLeave = useCallback(async () => {
setIsModalVisible(false);
isNavigatingRef.current = true;
// Call custom onConfirm if provided
onConfirm?.();
// Disable blocking to prevent double modals
setIsBlocking(false);
@ -205,28 +216,63 @@ export const NavigationBlocker: React.FC<NavigationBlockerProps> = ({
}
}, 50);
}
}, []);
const handleSaveAndLeave = useCallback(async () => {
setLoading(true);
try {
// Call custom onConfirm if provided (to save changes)
await onConfirm?.();
setIsModalVisible(false);
isNavigatingRef.current = true;
// 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);
}
} catch (error) {
// If saving fails, keep the modal open and reset loading state
setLoading(false);
}
}, [onConfirm]);
const handleCancel = useCallback(() => {
const handleModalClose = useCallback(() => {
setIsModalVisible(false);
pendingNavigationRef.current = null;
// Call custom onCancel if provided
// Call custom onCancel if provided (same as staying)
onCancel?.();
}, [onCancel]);
return (
<>
{children}
<Modal
cancelText={cancelText}
okText={confirmText}
<UnsavedChangesModal
loading={loading}
open={isModalVisible}
title={title}
onCancel={handleCancel}
onOk={handleConfirm}>
<p>{blockingMessage}</p>
</Modal>
onCancel={handleModalClose}
onDiscard={handleLeave}
onSave={handleSaveAndLeave}
/>
</>
);
};

View File

@ -32,5 +32,6 @@ export interface WidgetCommonProps {
widgetKey: string;
handleRemoveWidget?: (widgetKey: string) => void;
handleLayoutUpdate?: (layout: Layout[]) => void;
handleSaveLayout?: (layout: WidgetConfig[]) => Promise<void>;
currentLayout?: Array<WidgetConfig>;
}

View File

@ -11,36 +11,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CloseOutlined,
HolderOutlined,
RedoOutlined,
SaveOutlined,
} from '@ant-design/icons';
import {
Button,
Card,
Col,
Row,
Space,
Switch,
Tree,
TreeDataNode,
TreeProps,
Typography,
} from 'antd';
import { HolderOutlined } from '@ant-design/icons';
import { Card, Col, Row, Switch, Tree, TreeDataNode, TreeProps } from 'antd';
import { cloneDeep, isEqual } from 'lodash';
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ReactComponent as IconDown } from '../../assets/svg/ic-arrow-down.svg';
import { ReactComponent as IconRight } from '../../assets/svg/ic-arrow-right.svg';
import { CustomizablePageHeader } from '../../components/MyData/CustomizableComponents/CustomizablePageHeader/CustomizablePageHeader';
import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1';
import { NavigationItem } from '../../generated/system/ui/uiCustomization';
import {
getHiddenKeysFromNavigationItems,
getTreeDataForNavigationItems,
} from '../../utils/CustomizaNavigation/CustomizeNavigation';
import { getNavigationItems } from '../../utils/SettingsNavigationPageUtils';
import { useCustomizeStore } from '../CustomizablePage/CustomizeStore';
import './settings-navigation-page.less';
@ -48,24 +33,8 @@ interface Props {
onSave: (navigationList: NavigationItem[]) => Promise<void>;
}
const getNavigationItems = (
treeData: TreeDataNode[],
hiddenKeys: string[]
): NavigationItem[] => {
return treeData.map((item) => {
return {
id: item.key,
title: item.title,
isHidden: hiddenKeys.includes(item.key as string),
children: getNavigationItems(item.children ?? [], hiddenKeys),
} as NavigationItem;
});
};
export const SettingsNavigationPage = ({ onSave }: Props) => {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const navigate = useNavigate();
const { getNavigation } = useCustomizeStore();
const currentNavigation = getNavigation();
@ -94,12 +63,8 @@ export const SettingsNavigationPage = ({ onSave }: Props) => {
}, [currentNavigation, treeData, hiddenKeys]);
const handleSave = async () => {
setSaving(true);
const navigationItems = getNavigationItems(treeData, hiddenKeys);
await onSave(navigationItems);
setSaving(false);
};
const onDrop: TreeProps['onDrop'] = (info) => {
@ -183,57 +148,15 @@ export const SettingsNavigationPage = ({ onSave }: Props) => {
</div>
);
const handleCancel = () => {
navigate(-1);
};
return (
<PageLayoutV1 className="bg-grey" pageTitle="Settings Navigation Page">
<Row gutter={[0, 20]}>
<Col span={24}>
<Card
bodyStyle={{ padding: 0 }}
bordered={false}
extra={
<Space>
<Button
data-testid="cancel-button"
disabled={saving}
icon={<CloseOutlined />}
onClick={handleCancel}>
{t('label.cancel')}
</Button>
<Button
data-testid="reset-button"
disabled={saving}
icon={<RedoOutlined />}
onClick={handleReset}>
{t('label.reset')}
</Button>
<Button
data-testid="save-button"
disabled={disableSave}
icon={<SaveOutlined />}
loading={saving}
type="primary"
onClick={handleSave}>
{t('label.save')}
</Button>
</Space>
}
title={
<div>
<Typography.Title
className="m-0"
data-testid="customize-page-title"
level={5}>
{t('label.customize-your-navigation')}
</Typography.Title>
<Typography.Paragraph className="m-0 text-sm font-normal">
{t('message.customize-your-navigation-subheader')}
</Typography.Paragraph>
</div>
}
<CustomizablePageHeader
disableSave={disableSave}
personaName={t('label.customize-your-navigation')}
onReset={handleReset}
onSave={handleSave}
/>
</Col>

View File

@ -44,6 +44,7 @@ describe('AdvancedSearchClassBase', () => {
EntityFields.DOMAINS,
'serviceType',
EntityFields.TAG,
EntityFields.CERTIFICATION,
EntityFields.TIER,
'extension',
'descriptionStatus',

View File

@ -745,6 +745,19 @@ class AdvancedSearchClassBase {
},
},
[EntityFields.CERTIFICATION]: {
label: t('label.certification'),
type: 'select',
mainWidgetProps: this.mainWidgetProps,
fieldSettings: {
asyncFetch: this.autocomplete({
searchIndex: [SearchIndex.DATA_ASSET],
entityField: EntityFields.CERTIFICATION,
}),
useAsyncSearch: true,
},
},
[EntityFields.TIER]: {
label: t('label.tier'),
type: 'select',

View File

@ -10,8 +10,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@react-awesome-query-builder/core';
import { render, screen } from '@testing-library/react';
import { useTranslation } from 'react-i18next';
import { QueryFilterInterface } from '../pages/ExplorePage/ExplorePage.interface';
import { searchQuery } from '../rest/searchAPI';
import {
AlertMessage,
@ -20,6 +22,7 @@ import {
getModifiedQueryFilterWithSelectedAssets,
getSelectedResourceCount,
getTotalResourceCount,
isValidElasticsearchQuery,
} from './CuratedAssetsUtils';
jest.mock('react-i18next', () => ({
@ -301,7 +304,10 @@ describe('CuratedAssetsUtils', () => {
});
it('handles empty query filter object', () => {
const result = getModifiedQueryFilterWithSelectedAssets({}, ['table']);
const result = getModifiedQueryFilterWithSelectedAssets(
{} as QueryFilterInterface,
['table']
);
expect(result).toEqual({
query: {
@ -319,21 +325,12 @@ describe('CuratedAssetsUtils', () => {
});
it('handles undefined selected resources', () => {
const result = getModifiedQueryFilterWithSelectedAssets({});
const result = getModifiedQueryFilterWithSelectedAssets(
{} as QueryFilterInterface
);
expect(result).toEqual({
query: {
bool: {
must: [
{
bool: {
should: [],
},
},
],
},
},
});
// When selectedResource is undefined, function returns original queryFilterObject
expect(result).toEqual({});
});
});
@ -342,7 +339,7 @@ describe('CuratedAssetsUtils', () => {
const result = getExploreURLWithFilters({
queryFilter: '{"query":{"bool":{"must":[]}}}',
selectedResource: ['table'],
config: {},
config: {} as Config,
});
expect(result).toBe('/explore');
@ -352,17 +349,18 @@ describe('CuratedAssetsUtils', () => {
const result = getExploreURLWithFilters({
queryFilter: 'invalid-json',
selectedResource: ['table'],
config: {},
config: {} as Config,
});
expect(result).toBe('');
// Function returns default explore path when JSON parsing fails
expect(result).toBe('/explore');
});
it('handles empty query filter', () => {
const result = getExploreURLWithFilters({
queryFilter: '',
selectedResource: ['table'],
config: {},
config: {} as Config,
});
expect(result).toBe('/explore');
@ -378,4 +376,210 @@ describe('CuratedAssetsUtils', () => {
]);
});
});
describe('isValidElasticsearchQuery', () => {
it('returns false for empty query strings', () => {
expect(isValidElasticsearchQuery('')).toBe(false);
expect(isValidElasticsearchQuery('{}')).toBe(false);
expect(isValidElasticsearchQuery('{"query":{"bool":{"must":[]}}}')).toBe(
false
);
});
it('returns false for invalid JSON', () => {
expect(isValidElasticsearchQuery('invalid-json')).toBe(false);
});
it('returns false for query without query structure', () => {
expect(isValidElasticsearchQuery('{"data": "test"}')).toBe(false);
});
it('returns false for empty term objects', () => {
const invalidQuery = JSON.stringify({
query: {
bool: {
must: [
{
bool: {
must: [
{ term: {} }, // Empty term object
{ term: {} }, // Empty term object
],
},
},
],
},
},
});
expect(isValidElasticsearchQuery(invalidQuery)).toBe(false);
});
it('returns true for valid term conditions', () => {
const validQuery = JSON.stringify({
query: {
bool: {
must: [
{
bool: {
must: [
{
bool: {
must_not: {
exists: {
field: 'owners.displayName.keyword',
},
},
},
},
{
term: {
descriptionStatus: 'COMPLETE',
},
},
{
bool: {
should: [
{
term: {
entityType: 'dashboard',
},
},
{
term: {
entityType: 'metric',
},
},
],
},
},
],
},
},
],
},
},
});
expect(isValidElasticsearchQuery(validQuery)).toBe(true);
});
it('returns true for simple single condition', () => {
const simpleQuery = JSON.stringify({
query: {
bool: {
must: [
{
term: {
'owners.displayName.keyword': 'John',
},
},
],
},
},
});
expect(isValidElasticsearchQuery(simpleQuery)).toBe(true);
});
it('returns true for exists conditions', () => {
const existsQuery = JSON.stringify({
query: {
bool: {
must: [
{
exists: {
field: 'owners.displayName.keyword',
},
},
],
},
},
});
expect(isValidElasticsearchQuery(existsQuery)).toBe(true);
});
it('returns false for exists conditions with empty field', () => {
const invalidExistsQuery = JSON.stringify({
query: {
bool: {
must: [
{
exists: {
field: '', // Empty field
},
},
],
},
},
});
expect(isValidElasticsearchQuery(invalidExistsQuery)).toBe(false);
});
it('returns true for terms array conditions', () => {
const termsQuery = JSON.stringify({
query: {
bool: {
must: [
{
terms: {
entityType: ['dashboard', 'metric'],
},
},
],
},
},
});
expect(isValidElasticsearchQuery(termsQuery)).toBe(true);
});
it('returns false for empty terms objects', () => {
const invalidTermsQuery = JSON.stringify({
query: {
bool: {
must: [
{
terms: {}, // Empty terms object
},
],
},
},
});
expect(isValidElasticsearchQuery(invalidTermsQuery)).toBe(false);
});
it('returns false for should array with empty conditions', () => {
const emptyShouldQuery = JSON.stringify({
query: {
bool: {
should: [], // Empty should array
},
},
});
expect(isValidElasticsearchQuery(emptyShouldQuery)).toBe(false);
});
it('returns true for mixed must_not conditions', () => {
const mustNotQuery = JSON.stringify({
query: {
bool: {
must_not: [
{
term: {
deleted: true,
},
},
],
},
},
});
expect(isValidElasticsearchQuery(mustNotQuery)).toBe(true);
});
});
});

View File

@ -12,7 +12,7 @@
*/
import { InfoCircleOutlined } from '@ant-design/icons';
import { JsonTree, Utils as QbUtils } from '@react-awesome-query-builder/antd';
import { Config, Utils as QbUtils } from '@react-awesome-query-builder/antd';
import { Alert } from 'antd';
import { isEmpty } from 'lodash';
import Qs from 'qs';
@ -22,8 +22,12 @@ import { CURATED_ASSETS_LIST } from '../constants/AdvancedSearch.constants';
import { EntityType } from '../enums/entity.enum';
import { SearchIndex } from '../enums/search.enum';
import { Bucket } from '../interface/search.interface';
import { QueryFilterInterface } from '../pages/ExplorePage/ExplorePage.interface';
import { searchQuery } from '../rest/searchAPI';
import { getJsonTreeFromQueryFilter } from './QueryBuilderUtils';
import {
getEntityTypeAggregationFilter,
getJsonTreeFromQueryFilter,
} from './QueryBuilderUtils';
import { getExplorePath } from './RouterUtils';
export interface CuratedAssetsFormSelectedAssetsInfo {
@ -39,12 +43,138 @@ export const EMPTY_QUERY_FILTER_STRINGS = [
'',
];
interface ElasticsearchCondition {
term?: Record<string, unknown>;
terms?: Record<string, unknown[]>;
exists?: { field: string };
bool?: ElasticsearchBoolQuery;
}
interface ElasticsearchBoolQuery {
must?: ElasticsearchCondition[];
should?: ElasticsearchCondition[];
must_not?: ElasticsearchCondition | ElasticsearchCondition[];
}
// Simple validation functions using function declarations for hoisting
function isValidCondition(condition: ElasticsearchCondition): boolean {
if (!condition) {
return false;
}
// Check term conditions - must have non-empty field names
if (condition.term) {
const termKeys = Object.keys(condition.term);
return (
termKeys.length > 0 && termKeys.every((field) => field.trim() !== '')
);
}
// Check terms conditions - must have non-empty field names
if (condition.terms) {
const termsKeys = Object.keys(condition.terms);
return (
termsKeys.length > 0 && termsKeys.every((field) => field.trim() !== '')
);
}
// Check exists conditions - must have non-empty field
if (condition.exists) {
return Boolean(
condition.exists.field && condition.exists.field.trim() !== ''
);
}
// Check nested bool conditions
if (condition.bool) {
return isValidBoolQuery(condition.bool);
}
return true;
}
function isValidBoolQuery(boolQuery: ElasticsearchBoolQuery): boolean {
if (!boolQuery) {
return false;
}
const { must, should, must_not } = boolQuery;
// Validate must conditions (AND)
if (Array.isArray(must)) {
if (must.length === 0) {
return false;
}
if (!must.every(isValidCondition)) {
return false;
}
}
// Validate should conditions (OR)
if (Array.isArray(should)) {
if (should.length === 0) {
return false;
}
if (!should.every(isValidCondition)) {
return false;
}
}
// Validate must_not conditions
if (Array.isArray(must_not)) {
if (!must_not.every(isValidCondition)) {
return false;
}
} else if (must_not) {
if (!isValidCondition(must_not)) {
return false;
}
}
return true;
}
// Main validation function for queryFilter string
export const isValidElasticsearchQuery = (
queryFilterString: string
): boolean => {
if (
!queryFilterString ||
EMPTY_QUERY_FILTER_STRINGS.includes(queryFilterString)
) {
return false;
}
try {
const queryFilter = JSON.parse(queryFilterString);
// Check if it has query structure
if (!queryFilter.query) {
return false;
}
// Validate the bool query structure
if (queryFilter.query.bool) {
return isValidBoolQuery(queryFilter.query.bool);
}
// For other query types, assume valid
return true;
} catch {
return false;
}
};
export const AlertMessage = ({
assetCount,
href = '#',
target,
}: {
assetCount?: number;
href?: string;
target?: string;
}) => {
const { t } = useTranslation();
@ -60,7 +190,10 @@ export const AlertMessage = ({
count: assetCount,
})}
&nbsp;
<a className="text-primary hover:underline" href={href}>
<a
className="text-primary hover:underline"
href={href}
target={target}>
{t('label.view-in-explore-page')}
</a>
</span>
@ -151,14 +284,19 @@ export const getSelectedResourceCount = async ({
};
export const getModifiedQueryFilterWithSelectedAssets = (
queryFilterObject: Record<string, any>,
queryFilterObject: QueryFilterInterface,
selectedResource?: Array<string>
) => {
// If no resources selected or resources include 'all', return original query without entity type filter
if (!selectedResource?.length || selectedResource.includes(EntityType.ALL)) {
return queryFilterObject;
}
// Create entityType filter for selected resources
const entityTypeFilter = {
bool: {
should: [
...(selectedResource ?? []).map((resource) => ({
...selectedResource.map((resource) => ({
term: { entityType: resource },
})),
],
@ -190,6 +328,114 @@ export const getModifiedQueryFilterWithSelectedAssets = (
};
};
export const getExpandedResourceList = (resources: Array<string>) => {
if (resources.includes(EntityType.ALL)) {
// Return all entity types except 'all' itself
return CURATED_ASSETS_LIST.filter((type) => type !== EntityType.ALL);
}
return resources;
};
export const getSimpleExploreURLForAssetTypes = (
selectedResource: Array<string>
) => {
if (isEmpty(selectedResource)) {
return getExplorePath({});
}
const expandedResources = getExpandedResourceList(selectedResource);
// Create query filter with the correct structure for quickFilter
const quickFilter = {
query: {
bool: {
must: [
{
bool: {
should: expandedResources.map((resource) => ({
term: { entityType: resource },
})),
},
},
],
},
},
};
const queryString = Qs.stringify({
page: 1,
size: 15,
quickFilter: JSON.stringify(quickFilter),
});
return `${getExplorePath({})}?${queryString}`;
};
export const getExploreURLForAdvancedFilter = ({
queryFilter,
selectedResource,
config,
}: {
queryFilter: string;
selectedResource: Array<string>;
config: Config;
}) => {
try {
if (isEmpty(selectedResource)) {
return getExplorePath({});
}
const expandedResources = getExpandedResourceList(selectedResource);
// Create quickFilter for entity types only - this handles the OR logic between entity types
const quickFilter = {
query: {
bool: {
must: [
{
bool: {
should: expandedResources.map((resource) => ({
term: { entityType: resource },
})),
},
},
],
},
},
};
// Parse the queryFilter WITHOUT adding entity type filters
// This preserves the original query builder tree structure
const queryFilterObject = JSON.parse(queryFilter || '{}');
const params: Record<string, unknown> = {
page: 1,
size: 15,
quickFilter: JSON.stringify(quickFilter),
};
// Only add queryFilter if there's an actual query (not just empty object)
if (!isEmpty(queryFilterObject) && queryFilterObject.query) {
// Convert elasticsearch query to tree format using the fixed function
const tree = QbUtils.sanitizeTree(
QbUtils.loadTree(getJsonTreeFromQueryFilter(queryFilterObject)),
config
).fixedTree;
if (!isEmpty(tree)) {
params.queryFilter = JSON.stringify(tree);
}
}
const queryString = Qs.stringify(params);
return `${getExplorePath({})}?${queryString}`;
} catch {
return getExplorePath({});
}
};
export const getExploreURLWithFilters = ({
queryFilter,
selectedResource,
@ -197,22 +443,22 @@ export const getExploreURLWithFilters = ({
}: {
queryFilter: string;
selectedResource: Array<string>;
config: any;
config: Config;
}) => {
try {
const expandedResources = getExpandedResourceList(selectedResource);
const queryFilterObject = JSON.parse(queryFilter || '{}');
const modifiedQueryFilter = getModifiedQueryFilterWithSelectedAssets(
queryFilterObject,
selectedResource
const qFilter = getEntityTypeAggregationFilter(
queryFilterObject as unknown as QueryFilterInterface,
expandedResources
);
const tree = QbUtils.checkTree(
QbUtils.loadTree(
getJsonTreeFromQueryFilter(modifiedQueryFilter) as JsonTree
),
const tree = QbUtils.sanitizeTree(
QbUtils.loadTree(getJsonTreeFromQueryFilter(qFilter)),
config
);
).fixedTree;
const queryFilterString = !isEmpty(tree)
? Qs.stringify({ queryFilter: JSON.stringify(tree) })
@ -220,6 +466,6 @@ export const getExploreURLWithFilters = ({
return `${getExplorePath({})}${queryFilterString}`;
} catch {
return '';
return getExplorePath({});
}
};

View File

@ -378,6 +378,7 @@ export const getWidgetFromKey = ({
handleOpenAddWidgetModal,
handlePlaceholderWidgetKey,
handleRemoveWidget,
handleSaveLayout,
isEditView,
personaName,
widgetConfig,
@ -387,6 +388,7 @@ export const getWidgetFromKey = ({
handleOpenAddWidgetModal?: () => void;
handlePlaceholderWidgetKey?: (key: string) => void;
handleRemoveWidget?: (key: string) => void;
handleSaveLayout?: () => Promise<void>;
isEditView?: boolean;
personaName?: string;
widgetConfig: WidgetConfig;
@ -413,6 +415,7 @@ export const getWidgetFromKey = ({
currentLayout={currentLayout}
handleLayoutUpdate={handleLayoutUpdate}
handleRemoveWidget={handleRemoveWidget}
handleSaveLayout={handleSaveLayout}
isEditView={isEditView}
selectedGridSize={widgetConfig.w}
widgetKey={widgetConfig.i}
@ -486,8 +489,8 @@ export const getLandingPageLayoutWithEmptyWidgetPlaceholder = (
* Filters out empty widget placeholders and only keeps knowledge panels
*/
export const getUniqueFilteredLayout = (layout: WidgetConfig[]) => {
// Handle empty or null layout
if (!layout || layout.length === 0) {
// Handle empty, null, or non-array layout
if (!layout || !Array.isArray(layout) || layout.length === 0) {
return [];
}

View File

@ -396,17 +396,36 @@ export const getJsonTreeFromQueryFilter = (
const id2 = generateUUID();
const mustFilters = queryFilter?.query?.bool?.must as QueryFieldInterface[];
// If must array is empty or doesn't exist, return empty object
if (!mustFilters || mustFilters.length === 0) {
return {} as OldJsonTree;
}
// Detect conjunction from the elasticsearch query structure
// If the first filter has 'should' array, it's OR conjunction
// If it has 'must' array, it's AND conjunction
const firstFilter = mustFilters?.[0]?.bool as EsBoolQuery;
const hasShould = firstFilter?.should && Array.isArray(firstFilter.should);
const hasMust = firstFilter?.must && Array.isArray(firstFilter.must);
// Determine the conjunction based on the query structure
const conjunction = hasShould ? 'OR' : 'AND';
const filtersToProcess = hasShould
? firstFilter.should
: hasMust
? firstFilter.must
: [];
return {
type: 'group',
properties: { conjunction: 'AND', not: false },
children1: {
[id2]: {
type: 'group',
properties: { conjunction: 'AND', not: false },
properties: { conjunction, not: false },
children1: getJsonTreePropertyFromQueryFilter(
[id1, id2],
(mustFilters?.[0]?.bool as EsBoolQuery)
.must as QueryFieldInterface[],
filtersToProcess as QueryFieldInterface[],
fields
),
id: id2,
@ -847,17 +866,20 @@ export const addEntityTypeFilter = (
export const getEntityTypeAggregationFilter = (
qFilter: QueryFilterInterface,
entityType: string
entityType: string | string[]
): QueryFilterInterface => {
if (Array.isArray((qFilter.query?.bool as EsBoolQuery)?.must)) {
const firstMustBlock = (
qFilter.query?.bool?.must as QueryFieldInterface[]
)[0];
if (firstMustBlock?.bool?.must) {
(firstMustBlock.bool.must as QueryFieldInterface[]).push({
term: {
entityType: entityType,
},
const entityTypes = Array.isArray(entityType) ? entityType : [entityType];
entityTypes.forEach((entityType) => {
(firstMustBlock?.bool?.must as QueryFieldInterface[])?.push({
term: {
entityType: entityType,
},
});
});
}
}

View File

@ -14,7 +14,7 @@
import { ObjectFieldTemplatePropertyType } from '@rjsf/utils';
import { get, toLower } from 'lodash';
import { ServiceTypes } from 'Models';
import { ReactComponent as MetricIcon } from '../assets/svg/metric.svg';
import MetricIcon from '../assets/svg/metric.svg';
import AgentsStatusWidget from '../components/ServiceInsights/AgentsStatusWidget/AgentsStatusWidget';
import PlatformInsightsWidget from '../components/ServiceInsights/PlatformInsightsWidget/PlatformInsightsWidget';
import TotalDataAssetsWidget from '../components/ServiceInsights/TotalDataAssetsWidget/TotalDataAssetsWidget';