mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-21 05:42:35 +00:00
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:
parent
8baa358080
commit
e18747946b
@ -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();
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -15,6 +15,8 @@ export interface EntityDataClassCreationConfig {
|
||||
entityDetails?: boolean;
|
||||
table?: boolean;
|
||||
topic?: boolean;
|
||||
chart?: boolean;
|
||||
metric?: boolean;
|
||||
dashboard?: boolean;
|
||||
mlModel?: boolean;
|
||||
pipeline?: boolean;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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', () => {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -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"
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -93,10 +93,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.drag-widget-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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({}),
|
||||
}));
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -45,6 +45,7 @@ jest.mock('../../../../../utils/CuratedAssetsUtils', () => ({
|
||||
entityCount: 10,
|
||||
resourcesWithNonZeroCount: [],
|
||||
}),
|
||||
isValidElasticsearchQuery: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockOnCancel = jest.fn();
|
||||
|
@ -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(() => {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
})}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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', () => {
|
||||
|
@ -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 () => {
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -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')}
|
||||
/>
|
||||
),
|
||||
|
@ -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')}
|
||||
/>
|
||||
),
|
||||
|
@ -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')}
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -18,6 +18,6 @@ export interface NavigationBlockerProps {
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm?: () => void;
|
||||
onConfirm?: () => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -44,6 +44,7 @@ describe('AdvancedSearchClassBase', () => {
|
||||
EntityFields.DOMAINS,
|
||||
'serviceType',
|
||||
EntityFields.TAG,
|
||||
EntityFields.CERTIFICATION,
|
||||
EntityFields.TIER,
|
||||
'extension',
|
||||
'descriptionStatus',
|
||||
|
@ -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',
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
})}
|
||||
|
||||
<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({});
|
||||
}
|
||||
};
|
||||
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
Loading…
x
Reference in New Issue
Block a user