diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts index 8779b308c431..67ac1b201fee 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts @@ -11,9 +11,14 @@ * limitations under the License. */ +import { expect } from '@playwright/test'; +import { EntityStatus } from '../../../src/generated/entity/data/searchIndex'; import { COMMON_TIER_TAG } from '../../constant/common'; +import { DOMAIN_TAGS } from '../../constant/config'; import { SidebarItem } from '../../constant/sidebar'; +import { DataProduct } from '../../support/domain/DataProduct'; import { EntityDataClass } from '../../support/entity/EntityDataClass'; +import { MlModelClass } from '../../support/entity/MlModelClass'; import { TableClass } from '../../support/entity/TableClass'; import { TopicClass } from '../../support/entity/TopicClass'; import { Glossary } from '../../support/glossary/Glossary'; @@ -22,12 +27,17 @@ import { UserClass } from '../../support/user/UserClass'; import { performAdminLogin } from '../../utils/admin'; import { FIELDS, + fillRule, + fillStaticListRule, OPERATOR, runRuleGroupTests, runRuleGroupTestsWithNonExistingValue, + selectOption, + showAdvancedSearchDialog, verifyAllConditions, } from '../../utils/advancedSearch'; import { redirectToHomePage } from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; import { sidebarClick } from '../../utils/sidebar'; import { test } from '../fixtures/pages'; @@ -285,8 +295,8 @@ test.describe('Advanced Search', { tag: ['@advanced-search'] }, () => { table2.entityResponseData.name, ], 'project.keyword': [ - EntityDataClass.dashboardDataModel1.entityResponseData.project, - EntityDataClass.dashboardDataModel2.entityResponseData.project, + EntityDataClass.dashboardDataModel1.entityResponseData.project || '', + EntityDataClass.dashboardDataModel2.entityResponseData.project || '', ], 'charts.displayName.keyword': [ EntityDataClass.dashboard1.chartsResponseData.displayName, @@ -344,3 +354,279 @@ test.describe('Advanced Search', { tag: ['@advanced-search'] }, () => { await runRuleGroupTestsWithNonExistingValue(page); }); }); + +const ENTITY_STATUSES = Object.values(EntityStatus); + +test.describe( + 'Advanced Search - Entity Status Filter', + { tag: [DOMAIN_TAGS.DISCOVERY] }, + () => { + type StatusEntry = { + status: EntityStatus; + endpoint: string; + id: () => string; + displayName: () => string; + fqn: () => string; + }; + + let glossaryForStatus: Glossary; + let glossaryTermApproved: GlossaryTerm; + let mlModelDraft: MlModelClass; + let dataProductInReview: DataProduct; + let statusEntries: StatusEntry[]; + + test.beforeAll( + 'Create mixed entity types with distinct entity statuses', + async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + glossaryForStatus = new Glossary(); + glossaryTermApproved = new GlossaryTerm(glossaryForStatus); + mlModelDraft = new MlModelClass(); + dataProductInReview = new DataProduct(); + + await glossaryForStatus.create(apiContext); + await Promise.all([ + glossaryTermApproved.create(apiContext), + mlModelDraft.create(apiContext), + dataProductInReview.create(apiContext), + ]); + + statusEntries = [ + { + status: EntityStatus.Approved, + endpoint: 'glossaryTerms', + id: () => glossaryTermApproved.responseData.id, + displayName: () => glossaryTermApproved.data.displayName, + fqn: () => glossaryTermApproved.responseData.fullyQualifiedName, + }, + { + status: EntityStatus.Draft, + endpoint: 'mlmodels', + id: () => mlModelDraft.entityResponseData.id, + displayName: () => mlModelDraft.entity.displayName, + fqn: () => mlModelDraft.entityResponseData.fullyQualifiedName, + }, + { + status: EntityStatus.InReview, + endpoint: 'dataProducts', + id: () => dataProductInReview.responseData.id ?? '', + displayName: () => dataProductInReview.data.displayName, + fqn: () => + dataProductInReview.responseData.fullyQualifiedName ?? '', + }, + ]; + + // Patch entityStatus on each entity + await Promise.all( + statusEntries.map(({ id, status, endpoint }) => + apiContext.patch(`/api/v1/${endpoint}/${id()}`, { + data: [{ op: 'add', path: '/entityStatus', value: status }], + headers: { 'Content-Type': 'application/json-patch+json' }, + }) + ) + ); + + await afterAction(); + } + ); + + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.EXPLORE); + }); + + test('All entity status options are visible in the Status dropdown', async ({ + page, + }) => { + await test.step('Open advanced search dialog', async () => { + await showAdvancedSearchDialog(page); + }); + + await test.step('Select Status field and == operator', async () => { + const ruleLocator = page.locator('.rule').nth(0); + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + 'Status', + true + ); + await selectOption( + page, + ruleLocator.locator('.rule--operator .ant-select'), + '==' + ); + }); + + await test.step('Open Status value dropdown and verify all hard-coded options appear', async () => { + const ruleLocator = page.locator('.rule').nth(0); + await ruleLocator.locator('.widget--widget > .ant-select').click(); + + const dropdown = page + .locator('.ant-select-dropdown') + .filter({ hasText: EntityStatus.Approved }) + .last(); + + await expect(dropdown).toBeVisible(); + + for (const status of ENTITY_STATUSES) { + await expect( + dropdown + .locator('.ant-select-item-option') + .filter({ hasText: new RegExp(`^${status}$`, 'i') }) + .first() + ).toBeVisible(); + } + }); + }); + + test('Filtering by status "==" shows matching entity and hides others across entity types', async ({ + page, + }) => { + test.slow(); + + for (const entry of statusEntries) { + const { status } = entry; + const matchedName = entry.displayName(); + + await test.step(`Apply Status == "${status}" AND Name == "${matchedName}"`, async () => { + await showAdvancedSearchDialog(page); + + await fillStaticListRule(page, { + fieldLabel: 'Status', + condition: '==', + value: status, + ruleIndex: 1, + }); + + await page.getByTestId('advanced-search-add-rule').nth(1).click(); + + await fillRule(page, { + condition: '==', + field: { id: 'Display Name', name: 'displayName.keyword' }, + searchCriteria: matchedName, + index: 2, + }); + + const searchRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('apply-btn').click(); + await searchRes; + await waitForAllLoadersToDisappear(page); + }); + + await test.step('Filter chip shows the applied status', async () => { + await expect( + page.getByTestId('advance-search-filter-container') + ).toContainText(`'${status}'`); + }); + + await test.step(`"${matchedName}" (${entry.endpoint}) is visible`, async () => { + await expect( + page.getByTestId(`table-data-card_${entry.fqn()}`) + ).toBeVisible(); + }); + + await test.step('Entities with other statuses are not in results', async () => { + const otherEntries = statusEntries.filter((e) => e.status !== status); + + for (const other of otherEntries) { + await expect( + page.getByTestId(`table-data-card_${other.fqn()}`) + ).not.toBeVisible(); + } + }); + + await page.getByTestId('clear-filters').click(); + } + }); + + test('Filtering by status "!=" excludes matched entity but shows all other entity types', async ({ + page, + }) => { + const targetEntry = statusEntries.find( + (e) => e.status === EntityStatus.Approved + )!; + const approvedName = targetEntry.displayName(); + const otherEntries = statusEntries.filter( + (e) => e.status !== EntityStatus.Approved + ); + + await test.step('Apply Status != "Approved" AND Name == approved entity name', async () => { + await showAdvancedSearchDialog(page); + + await fillStaticListRule(page, { + fieldLabel: 'Status', + condition: '!=', + value: EntityStatus.Approved, + ruleIndex: 1, + }); + + await page.getByTestId('advanced-search-add-rule').nth(1).click(); + + await fillRule(page, { + condition: '==', + field: { id: 'Display Name', name: 'displayName.keyword' }, + searchCriteria: approvedName, + index: 2, + }); + + const searchRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('apply-btn').click(); + await searchRes; + await waitForAllLoadersToDisappear(page); + }); + + await test.step('Filter chip reflects the != condition', async () => { + await expect( + page.getByTestId('advance-search-filter-container') + ).toContainText(`'${EntityStatus.Approved}'`); + }); + + await test.step('GlossaryTerm with Approved status is not visible', async () => { + await expect( + page.getByTestId(`table-data-card_${targetEntry.fqn()}`) + ).not.toBeVisible(); + }); + + await test.step('Draft and In Review entities appear when searched by their name and non-Approved status', async () => { + for (const entry of otherEntries) { + await page.getByTestId('clear-filters').click(); + await showAdvancedSearchDialog(page); + + await fillStaticListRule(page, { + fieldLabel: 'Status', + condition: '!=', + value: EntityStatus.Approved, + ruleIndex: 1, + }); + + await page.getByTestId('advanced-search-add-rule').nth(1).click(); + + await fillRule(page, { + condition: '==', + field: { id: 'Display Name', name: 'displayName.keyword' }, + searchCriteria: entry.displayName(), + index: 2, + }); + + const searchRes = page.waitForResponse( + '/api/v1/search/query?*index=dataAsset*' + ); + await page.getByTestId('apply-btn').click(); + await searchRes; + await waitForAllLoadersToDisappear(page); + + await expect( + page.getByTestId(`table-data-card_${entry.fqn()}`) + ).toBeVisible(); + } + }); + + await page.getByTestId('clear-filters').click(); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index dbe43ce19038..021422169d1b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -692,6 +692,43 @@ export const runRuleGroupTestsWithNonExistingValue = async (page: Page) => { await expect(dropdownText).not.toContainText('Loading...'); }; +// For fields backed by hard-coded listValues (no aggregate API call), options are +// rendered immediately — use selectOption directly instead of fillRule which waits +// for a network response that never comes. +export const fillStaticListRule = async ( + page: Page, + { + fieldLabel, + condition, + value, + ruleIndex, + }: { + fieldLabel: string; + condition: string; + value: string; + ruleIndex: number; + } +) => { + const ruleLocator = page.locator('.rule').nth(ruleIndex - 1); + + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + fieldLabel, + true + ); + await selectOption( + page, + ruleLocator.locator('.rule--operator .ant-select'), + condition + ); + await selectOption( + page, + ruleLocator.locator('.widget--widget > .ant-select'), + value + ); +}; + export const getFieldsSuggestionSearchText = ( fieldLabel: string, data: Record diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts index 0d9b17054288..dffbf5a6986a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/AdvancedSearch.constants.ts @@ -215,7 +215,7 @@ export const GLOSSARY_DROPDOWN_ITEMS = [ }, { label: 'label.status', - key: EntityFields.GLOSSARY_TERM_STATUS, + key: EntityFields.ENTITY_STATUS, }, ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts index 7806809d853e..6d5e025eb082 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/AdvancedSearch.enum.ts @@ -75,7 +75,7 @@ export enum EntityFields { API_COLLECTION = 'apiCollection.displayName.keyword', CHART = 'charts.displayName.keyword', TASK = 'tasks.displayName.keyword', - GLOSSARY_TERM_STATUS = 'entityStatus', + ENTITY_STATUS = 'entityStatus', REQUEST_SCHEMA_FIELD = 'requestSchema.schemaFields.name.keyword', RESPONSE_SCHEMA_FIELD = 'responseSchema.schemaFields.name.keyword', SERVICE_NAME = 'service.name.keyword', diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts index 286eae3812e9..c5eb1f61b55c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts @@ -73,6 +73,7 @@ describe('AdvancedSearchClassBase', () => { 'tags.labelType', 'tier.labelType', 'createdBy', + EntityFields.ENTITY_STATUS, ]); }); }); @@ -177,10 +178,7 @@ describe('getEntitySpecificQueryBuilderFields', () => { SearchIndex.GLOSSARY_TERM, ]); - expect(Object.keys(result)).toEqual([ - EntityFields.GLOSSARY_TERM_STATUS, - EntityFields.GLOSSARY, - ]); + expect(Object.keys(result)).toEqual([EntityFields.GLOSSARY]); }); it('should return databaseSchema specific fields', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 6720080e9fb1..b55708d3140e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -43,6 +43,7 @@ import { } from '../enums/AdvancedSearch.enum'; import { SearchIndex } from '../enums/search.enum'; import { Config } from '../generated/api/data/createCustomProperty'; +import { EntityStatus } from '../generated/entity/data/searchIndex'; import { CustomPropertySummary } from '../rest/metadataTypeAPI.interface'; import { getAggregateFieldOptions } from '../rest/miscAPI'; import { @@ -433,18 +434,6 @@ class AdvancedSearchClassBase { * Fields specific to Glossary */ glossaryTermQueryBuilderFields: Fields = { - [EntityFields.GLOSSARY_TERM_STATUS]: { - label: t('label.status'), - type: 'select', - mainWidgetProps: this.mainWidgetProps, - fieldSettings: { - asyncFetch: this.autocomplete({ - searchIndex: SearchIndex.GLOSSARY_TERM, - entityField: EntityFields.GLOSSARY_TERM_STATUS, - }), - useAsyncSearch: true, - }, - }, [EntityFields.GLOSSARY]: { label: t('label.glossary'), type: 'select', @@ -1048,6 +1037,21 @@ class AdvancedSearchClassBase { useAsyncSearch: true, }, }, + [EntityFields.ENTITY_STATUS]: { + label: t('label.status'), + type: 'select', + operators: LIST_VALUE_OPERATORS, + mainWidgetProps: this.mainWidgetProps, + valueSources: ['value'], + fieldSettings: { + listValues: Object.values(EntityStatus).map((status) => ({ + value: status, + title: status, + })), + showSearch: true, + useAsyncSearch: false, + }, + }, }; }