diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts index b5ccf5466a8a..52e317a5f39e 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ExploreQuickFilters.spec.ts @@ -16,6 +16,7 @@ import { Domain } from '../../support/domain/Domain'; import { MetricClass } from '../../support/entity/MetricClass'; import { TableClass } from '../../support/entity/TableClass'; import { TagClass } from '../../support/tag/TagClass'; +import { UserClass } from '../../support/user/UserClass'; import { clickOutside, createNewPage, @@ -39,6 +40,7 @@ const tier = new TagClass({ const tierWithoutAsset = new TagClass({ classification: 'Tier', }); +let user: UserClass; test.beforeAll('Setup pre-requests', async ({ browser }) => { test.slow(); @@ -49,6 +51,8 @@ test.beforeAll('Setup pre-requests', async ({ browser }) => { await tier.create(apiContext); // Create second tier but do NOT assign it to any asset await tierWithoutAsset.create(apiContext); + user = new UserClass(); + await user.create(apiContext); await table.patch({ apiContext, @@ -77,11 +81,22 @@ test.beforeAll('Setup pre-requests', async ({ browser }) => { displayName: domain.responseData.displayName, }, }, + { + op: 'add', + path: '/owners/0', + value: { id: user.responseData.id, type: 'user' }, + }, ], }); await afterAction(); }); +test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await createNewPage(browser); + await user.delete(apiContext); + await afterAction(); +}); + test.beforeEach(async ({ page }) => { await redirectToHomePage(page); await sidebarClick(page, SidebarItem.EXPLORE); @@ -436,6 +451,155 @@ test.describe('Filter persistence after bug fixes', () => { }); }); +test.describe('Quick filter options - proper casing from top_hits', () => { + test('domain filter option label uses original casing from _source', async ({ + page, + }) => { + const domainName = domain.responseData.displayName as string; + + await test.step('Open Domains filter and wait for aggregate response', async () => { + const aggRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword*' + ); + await page.click('[data-testid="search-dropdown-Domains"]'); + await aggRes; + await waitForAllLoadersToDisappear(page); + }); + + await test.step('Option label matches original casing, not lowercased bucket key', async () => { + const searchRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=domains.displayName.keyword*' + ); + await page.fill('[data-testid="search-input"]', domainName); + await searchRes; + + // The rendered option text must match the original-cased displayName + const optionEl = page.getByTestId(domainName.toLowerCase()); + + await expect(optionEl).toBeVisible(); + await expect(optionEl).toContainText(domainName); + }); + + await clickOutside(page); + }); + + test('tier filter option label uses original casing from _source', async ({ + page, + }) => { + const tierFqn = tier.responseData.fullyQualifiedName as string; + + await test.step('Open Tier filter and wait for aggregate response', async () => { + const aggRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=tier.tagFQN*' + ); + await page.click('[data-testid="search-dropdown-Tier"]'); + await aggRes; + await waitForAllLoadersToDisappear(page); + }); + + await test.step('Option label matches original FQN casing', async () => { + const searchRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=tier.tagFQN*' + ); + await page.fill('[data-testid="search-input"]', tierFqn); + await searchRes; + + const optionEl = page.getByTestId(tierFqn.toLowerCase()); + + await expect(optionEl).toBeVisible(); + await expect(optionEl).toContainText(tierFqn); + }); + + await clickOutside(page); + }); + + test('tag filter option label uses original casing from _source', async ({ + page, + }) => { + const tagFqn = 'PersonalData.Personal'; + + await test.step('Open Tag filter and search for the tag', async () => { + await page.click('[data-testid="search-dropdown-Tag"]'); + const searchRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=tags.tagFQN*' + ); + await page.fill('[data-testid="search-input"]', tagFqn); + await searchRes; + }); + + await test.step('Option label matches original FQN casing', async () => { + const optionEl = page.getByTestId(tagFqn.toLowerCase()); + + await expect(optionEl).toBeVisible(); + await expect(optionEl).toContainText(tagFqn); + }); + + await clickOutside(page); + }); + + test('owner filter option label uses original casing from _source', async ({ + page, + }) => { + const ownerName = (user.responseData.displayName ?? + user.responseData.name) as string; + + await test.step('Open Owners filter and wait for aggregate response', async () => { + const aggRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=ownerDisplayName*' + ); + await page.click('[data-testid="search-dropdown-Owners"]'); + await aggRes; + await waitForAllLoadersToDisappear(page); + }); + + await test.step('Option label matches original casing, not lowercased bucket key', async () => { + const searchRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=ownerDisplayName*' + ); + await page.fill('[data-testid="search-input"]', ownerName); + await searchRes; + + const optionEl = page.getByTestId(ownerName.toLowerCase()); + + await expect(optionEl).toBeVisible(); + await expect(optionEl).toContainText(ownerName); + }); + + await clickOutside(page); + }); + + test('service filter option label uses original casing from _source', async ({ + page, + }) => { + const serviceName = (table.serviceResponseData.displayName ?? + table.serviceResponseData.name) as string; + + await test.step('Open Service filter and wait for aggregate response', async () => { + const aggRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=service.displayName.keyword*' + ); + await page.click('[data-testid="search-dropdown-Service"]'); + await aggRes; + await waitForAllLoadersToDisappear(page); + }); + + await test.step('Option label matches original casing', async () => { + const searchRes = page.waitForResponse( + '/api/v1/search/aggregate?index=dataAsset&field=service.displayName.keyword*' + ); + await page.fill('[data-testid="search-input"]', serviceName); + await searchRes; + + const optionEl = page.getByTestId(serviceName.toLowerCase()); + + await expect(optionEl).toBeVisible(); + await expect(optionEl).toContainText(serviceName); + }); + + await clickOutside(page); + }); +}); + test.describe('Metric search result highlight', () => { const metric = new MetricClass(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index 022359ac3663..c0854deb5b4b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -1048,7 +1048,7 @@ export const verifyAssetModalFilters = async ( page, filterWrapper, 'serviceType', - 'mysql-checkbox', + 'MySQL-checkbox', 'mysql' ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts index 169243f654c9..de0a79adede2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExplorePage.interface.ts @@ -142,6 +142,7 @@ export interface ExploreQuickFilterField { searchKey?: string; dropdownClassName?: string; singleSelect?: boolean; + sourceFields?: string; } // Type for all the explore tab entities diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx index 5f53f0b31ff1..b345836e90a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.test.tsx @@ -343,7 +343,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - 'pets' + 'pets', + undefined ); }); }); @@ -368,7 +369,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -398,7 +400,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -434,7 +437,8 @@ describe('ExploreQuickFilters component', () => { false, 50, false, - '' + '', + undefined ); }); }); @@ -464,7 +468,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -502,7 +507,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -550,7 +556,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -579,7 +586,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, true, - '' + '', + undefined ); }); }); @@ -766,7 +774,8 @@ describe('ExploreQuickFilters component', () => { expect.anything(), undefined, expect.anything(), - expect.any(String) + expect.any(String), + undefined ); }); }); @@ -801,7 +810,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -925,7 +935,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -955,7 +966,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -986,7 +998,8 @@ describe('ExploreQuickFilters component', () => { true, undefined, false, - '' + '', + undefined ); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx index 47e653dd2a57..f1c3a753cdbf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreQuickFilters.tsx @@ -172,7 +172,8 @@ const ExploreQuickFilters: FC = ({ index: SearchIndex | SearchIndex[], key: string, fieldSearchIndex?: SearchIndex, - fieldSearchKey?: string + fieldSearchKey?: string, + sourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { @@ -191,7 +192,7 @@ const ExploreQuickFilters: FC = ({ // field has a value to exclude from its own facet — even when only the // browse filter is active. A per-facet fetch is only needed once a field // value must be excluded from its own aggregation. - const canUsePageAggregations = !hasSelectedFieldValues; + const canUsePageAggregations = !hasSelectedFieldValues && !sourceFields; let buckets = canUsePageAggregations ? aggregations?.[key]?.buckets @@ -206,7 +207,8 @@ const ExploreQuickFilters: FC = ({ showDeleted, optionPageSize, isNLPActive, - searchText + searchText, + sourceFields ); buckets = @@ -219,7 +221,8 @@ const ExploreQuickFilters: FC = ({ uniqWith( getOptionsFromAggregationBucket( buckets, - getOptionLabelFormatter(key, untitledDropdown) + getOptionLabelFormatter(key, untitledDropdown), + sourceFields ), isEqual ) @@ -230,7 +233,8 @@ const ExploreQuickFilters: FC = ({ const getInitialOptions = async ( key: string, fieldSearchIndex?: SearchIndex, - fieldSearchKey?: string + fieldSearchKey?: string, + sourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { @@ -242,7 +246,13 @@ const ExploreQuickFilters: FC = ({ setIsOptionsLoading(true); setOptions([]); try { - await fetchDefaultOptions(index, key, fieldSearchIndex, fieldSearchKey); + await fetchDefaultOptions( + index, + key, + fieldSearchIndex, + fieldSearchKey, + sourceFields + ); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -254,7 +264,8 @@ const ExploreQuickFilters: FC = ({ value: string, key: string, fieldSearchIndex?: SearchIndex, - fieldSearchKey?: string + fieldSearchKey?: string, + sourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { @@ -272,7 +283,7 @@ const ExploreQuickFilters: FC = ({ setOptions([]); try { if (!value) { - getInitialOptions(key, fieldSearchIndex, fieldSearchKey); + getInitialOptions(key, fieldSearchIndex, fieldSearchKey, sourceFields); return; } @@ -289,7 +300,8 @@ const ExploreQuickFilters: FC = ({ showDeleted, undefined, isNLPActive, - searchText + searchText, + sourceFields ); const buckets = @@ -300,7 +312,8 @@ const ExploreQuickFilters: FC = ({ uniqWith( getOptionsFromAggregationBucket( buckets, - getOptionLabelFormatter(key, untitledDropdown) + getOptionLabelFormatter(key, untitledDropdown), + sourceFields ), isEqual ) @@ -339,10 +352,21 @@ const ExploreQuickFilters: FC = ({ onFieldValueSelect({ ...field, value: updatedValues }) } onGetInitialOptions={(key) => - getInitialOptions(key, field.searchIndex, field.searchKey) + getInitialOptions( + key, + field.searchIndex, + field.searchKey, + field.sourceFields + ) } onSearch={(value, key) => - getFilterOptions(value, key, field.searchIndex, field.searchKey) + getFilterOptions( + value, + key, + field.searchIndex, + field.searchKey, + field.sourceFields + ) } /> ) : ( @@ -369,10 +393,21 @@ const ExploreQuickFilters: FC = ({ onFieldValueSelect({ ...field, value: updatedValues }); }} onGetInitialOptions={(key) => - getInitialOptions(key, field.searchIndex, field.searchKey) + getInitialOptions( + key, + field.searchIndex, + field.searchKey, + field.sourceFields + ) } onSearch={(value, key) => - getFilterOptions(value, key, field.searchIndex, field.searchKey) + getFilterOptions( + value, + key, + field.searchIndex, + field.searchKey, + field.sourceFields + ) } /> ); 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 e825d58aa7d5..757c16d5379b 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 @@ -21,26 +21,32 @@ export const COMMON_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: 'label.tag', key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, { label: 'label.tier', key: EntityFields.TIER, + sourceFields: 'tier.tagFQN', }, { label: 'label.service', key: EntityFields.SERVICE, + sourceFields: 'service.displayName', }, { label: 'label.service-type', key: EntityFields.SERVICE_TYPE, + sourceFields: 'serviceType', }, ]; @@ -52,30 +58,37 @@ export const DATA_ASSET_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: 'label.tag', key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, { label: 'label.tier', key: EntityFields.TIER, + sourceFields: 'tier.tagFQN', }, { label: 'label.certification', key: EntityFields.CERTIFICATION, + sourceFields: 'certification.tagLabel.tagFQN', }, { label: 'label.service', key: EntityFields.SERVICE, + sourceFields: 'service.displayName', }, { label: 'label.service-type', key: EntityFields.SERVICE_TYPE, + sourceFields: 'serviceType', }, ]; @@ -83,10 +96,12 @@ export const TABLE_DROPDOWN_ITEMS = [ { label: 'label.database', key: EntityFields.DATABASE, + sourceFields: 'database.displayName', }, { label: 'label.schema', key: EntityFields.DATABASE_SCHEMA, + sourceFields: 'databaseSchema.displayName', }, { label: 'label.column', @@ -106,10 +121,12 @@ export const DASHBOARD_DROPDOWN_ITEMS = [ { label: 'label.data-model', key: EntityFields.DATA_MODEL, + sourceFields: 'dataModels.displayName', }, { label: 'label.chart', key: EntityFields.CHART, + sourceFields: 'charts.displayName', }, { label: 'label.project', @@ -136,6 +153,7 @@ export const PIPELINE_DROPDOWN_ITEMS = [ { label: 'label.task', key: EntityFields.TASK, + sourceFields: 'tasks.displayName', }, ]; @@ -200,18 +218,22 @@ export const GLOSSARY_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: 'label.tag', key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, { label: 'label.glossary-plural', key: EntityFields.GLOSSARY, + sourceFields: 'glossary.name', }, { label: 'label.status', @@ -223,10 +245,12 @@ export const TAG_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.classification', key: EntityFields.CLASSIFICATION, + sourceFields: 'classification.name', }, ]; @@ -234,10 +258,12 @@ export const DATA_PRODUCT_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, ]; @@ -252,22 +278,27 @@ export const DOMAIN_DATAPRODUCT_DROPDOWN_ITEMS = [ { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: 'label.tag', key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, { label: 'label.tier', key: EntityFields.TIER, + sourceFields: 'tier.tagFQN', }, { label: 'label.service', key: EntityFields.SERVICE, + sourceFields: 'service.displayName', }, { label: 'label.service-type', key: EntityFields.SERVICE_TYPE, + sourceFields: 'serviceType', }, ]; @@ -282,26 +313,32 @@ export const GLOSSARY_ASSETS_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: 'label.tag', key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, { label: 'label.tier', key: EntityFields.TIER, + sourceFields: 'tier.tagFQN', }, { label: 'label.service', key: EntityFields.SERVICE, + sourceFields: 'service.displayName', }, { label: 'label.service-type', key: EntityFields.SERVICE_TYPE, + sourceFields: 'serviceType', }, ]; @@ -316,26 +353,32 @@ export const TAG_ASSETS_DROPDOWN_ITEMS = [ { label: 'label.domain-plural', key: EntityFields.DOMAINS, + sourceFields: 'domains.displayName', }, { label: 'label.owner-plural', key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: 'label.tag', key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, { label: 'label.tier', key: EntityFields.TIER, + sourceFields: 'tier.tagFQN', }, { label: 'label.service', key: EntityFields.SERVICE, + sourceFields: 'service.displayName', }, { label: 'label.service-type', key: EntityFields.SERVICE_TYPE, + sourceFields: 'serviceType', }, ]; @@ -359,10 +402,12 @@ export const KNOWLEDGE_PAGE_DROPDOWN_ITEMS = [ { label: t('label.owner-plural'), key: EntityFields.OWNERS, + sourceFields: 'ownerDisplayName', }, { label: t('label.tag'), key: EntityFields.TAG, + sourceFields: 'tags.tagFQN', }, ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/constants/ColumnGrid.constants.ts b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/constants/ColumnGrid.constants.ts index f1ec81356ded..48c4abb00814 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/constants/ColumnGrid.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ColumnBulkOperations/ColumnGrid/constants/ColumnGrid.constants.ts @@ -43,6 +43,7 @@ export const COLUMN_GRID_FILTERS: ExploreQuickFilterField[] = [ label: i18n.t('label.service'), key: EntityFields.SERVICE, hideCounts: true, + sourceFields: 'service.displayName', }, { label: i18n.t('label.service-type'), @@ -53,6 +54,7 @@ export const COLUMN_GRID_FILTERS: ExploreQuickFilterField[] = [ label: i18n.t('label.domain-plural'), key: EntityFields.DOMAINS, hideCounts: true, + sourceFields: 'domains.displayName', }, { label: i18n.t('label.asset-type'), diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts index 5f5a9f257d30..64836fc4df39 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.test.ts @@ -80,4 +80,140 @@ describe('getOptionsFromAggregationBucket', () => { expect(option.count).toBe(0); }); + + describe('sourceFields - top_hits label extraction', () => { + it('reads label from flat _source field when sourceFields is set', () => { + const bucket = { + key: 'john doe', + doc_count: 3, + 'top_hits#top': { + hits: { hits: [{ _source: { ownerDisplayName: 'John Doe' } }] }, + }, + } as unknown as Bucket; + + const [option] = getOptionsFromAggregationBucket( + [bucket], + undefined, + 'ownerDisplayName' + ); + + expect(option.key).toBe('john doe'); + expect(option.label).toBe('John Doe'); + }); + + it('reads label from nested single-object _source path', () => { + const bucket = { + key: 'tier.tier1', + doc_count: 2, + 'top_hits#top': { + hits: { + hits: [{ _source: { tier: { tagFQN: 'Tier.Tier1' } } }], + }, + }, + } as unknown as Bucket; + + const [option] = getOptionsFromAggregationBucket( + [bucket], + undefined, + 'tier.tagFQN' + ); + + expect(option.key).toBe('tier.tier1'); + expect(option.label).toBe('Tier.Tier1'); + }); + + it('matches the correct array element by bucket key (not always [0])', () => { + const bucket = { + key: 'my domain', + doc_count: 1, + 'top_hits#top': { + hits: { + hits: [ + { + _source: { + domains: [ + { displayName: 'Other Domain' }, + { displayName: 'My Domain' }, + ], + }, + }, + ], + }, + }, + } as unknown as Bucket; + + const [option] = getOptionsFromAggregationBucket( + [bucket], + undefined, + 'domains.displayName' + ); + + expect(option.key).toBe('my domain'); + expect(option.label).toBe('My Domain'); + }); + + it('falls back to bucket key when no top_hits data is present', () => { + const bucket = { + key: 'my domain', + doc_count: 1, + } as unknown as Bucket; + + const [option] = getOptionsFromAggregationBucket( + [bucket], + undefined, + 'domains.displayName' + ); + + expect(option.key).toBe('my domain'); + expect(option.label).toBe('my domain'); + }); + + it('reads label from string-array _source field (ownerDisplayName pattern)', () => { + const bucket = { + key: 'aaron johnson', + doc_count: 1, + 'top_hits#top': { + hits: { + hits: [{ _source: { ownerDisplayName: ['Aaron Johnson'] } }], + }, + }, + } as unknown as Bucket; + + const [option] = getOptionsFromAggregationBucket( + [bucket], + undefined, + 'ownerDisplayName' + ); + + expect(option.key).toBe('aaron johnson'); + expect(option.label).toBe('Aaron Johnson'); + }); + + it('picks the matching entry from a multi-value string-array by bucket key', () => { + const bucket = { + key: 'aaron johnson', + doc_count: 1, + 'top_hits#top': { + hits: { + hits: [ + { + _source: { + ownerDisplayName: ['Bob Smith', 'Aaron Johnson'], + }, + }, + ], + }, + }, + } as unknown as Bucket; + + const [option] = getOptionsFromAggregationBucket( + [bucket], + undefined, + 'ownerDisplayName' + ); + + expect(option.key).toBe('aaron johnson'); + expect(option.label).toBe('Aaron Johnson'); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts index f489d9f5ce77..d5a27f13a97a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts @@ -207,9 +207,65 @@ export const getServiceOptions = ( : option.text; }; +const extractSourceValue = ( + src: Record, + path: string, + bucketKey: string +): string | undefined => { + const parts = path.split('.'); + let val: unknown = src; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (Array.isArray(val)) { + // When we hit an array mid-traversal, find the element whose resolved + // leaf value case-insensitively equals the bucket key, falling back to [0]. + const remainingPath = parts.slice(i).join('.'); + const match = (val as unknown[]).find((item) => { + const leaf = extractSourceValue( + item as Record, + remainingPath, + bucketKey + ); + + return leaf?.toLowerCase() === bucketKey.toLowerCase(); + }); + const chosen = match ?? (val as unknown[])[0]; + if (chosen === undefined) { + return undefined; + } + + return extractSourceValue( + chosen as Record, + remainingPath, + bucketKey + ); + } else if (val && typeof val === 'object' && part in (val as object)) { + val = (val as Record)[part]; + } else { + return undefined; + } + } + + // Terminal value may be a string[] (e.g. ownerDisplayName: ["Aaron Johnson"]) + if (Array.isArray(val)) { + const strings = (val as unknown[]).filter( + (item): item is string => typeof item === 'string' + ); + const match = strings.find( + (s) => s.toLowerCase() === bucketKey.toLowerCase() + ); + + return match ?? strings[0]; + } + + return typeof val === 'string' ? val : undefined; +}; + export const getOptionsFromAggregationBucket = ( buckets: Bucket[], - labelFormatter?: (key: string) => string + labelFormatter?: (key: string) => string, + sourceFields?: string ) => { if (!buckets) { return []; @@ -220,11 +276,30 @@ export const getOptionsFromAggregationBucket = ( (item) => !NOT_INCLUDE_AGGREGATION_QUICK_FILTER.includes(item.key as EntityType) ) - .map((option) => ({ - key: option.key, - label: labelFormatter ? labelFormatter(option.key) : option.key, - count: option.doc_count ?? 0, - })); + .map((option) => { + let label = labelFormatter ? labelFormatter(option.key) : option.key; + + if (sourceFields) { + const topHitsData = (option as Record)[ + 'top_hits#top' + ] as + | { + hits?: { + hits?: Array<{ _source?: Record }>; + }; + } + | undefined; + const src = topHitsData?.hits?.hits?.[0]?._source; + const extracted = src + ? extractSourceValue(src, sourceFields, option.key) + : undefined; + if (extracted) { + label = extracted; + } + } + + return { key: option.key, label, count: option.doc_count ?? 0 }; + }); }; export const formatQueryValueBasedOnType = ( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx index 1fbe70f80dff..6d0ba51c8c17 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx @@ -74,7 +74,8 @@ export const getAggregationOptions = async ( deleted = false, size = 10, isNLPEnabled = false, - queryText?: string + queryText?: string, + sourceFields?: string ) => { return isIndependent ? postAggregateFieldOptions({ @@ -84,13 +85,14 @@ export const getAggregationOptions = async ( query: filter, ...(queryText ? { queryText } : {}), size, + ...(sourceFields ? { topHits: { size: 1 } } : {}), }) : getAggregateFieldOptions( index, key, value, filter, - undefined, + sourceFields, deleted, isNLPEnabled, queryText