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 b454c2366860..0e7306ded894 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 @@ -125,6 +125,7 @@ export interface ExploreQuickFilterField { hideSearchBar?: boolean; searchIndex?: SearchIndex; searchKey?: string; + sourceFields?: string; dropdownClassName?: string; singleSelect?: boolean; } 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 6a840f5e3f16..3b47c3996a72 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 @@ -316,7 +316,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - 'pets' + 'pets', + undefined ); }); }); @@ -341,7 +342,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -371,7 +373,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -407,7 +410,8 @@ describe('ExploreQuickFilters component', () => { false, 50, false, - '' + '', + undefined ); }); }); @@ -437,7 +441,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -475,11 +480,113 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined + ); + }); + }); + + it('should pass mapped sourceFields for owners quick filter', async () => { + mockGetAggregationOptions.mockResolvedValue({ + data: { + aggregations: { + 'sterms#ownerDisplayName': { + buckets: [ + { + key: 'data-team', + doc_count: 2, + 'top_hits#top': { + hits: { + hits: [ + { + _source: { + ownerDisplayName: ['Data Team'], + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + }); + + const ownerFields: ExploreQuickFilterField[] = [ + { label: 'Owner', key: EntityFields.OWNERS, value: undefined }, + ]; + + render(); + + await act(async () => { + userEvent.click(screen.getByTestId(`onSearch-${EntityFields.OWNERS}`)); + }); + + await waitFor(() => { + expect(getAggregationOptions).toHaveBeenCalledWith( + SearchIndex.TABLE, + EntityFields.OWNERS, + 'test', + expect.any(String), + false, + false, + undefined, + false, + '', + 'ownerDisplayName' ); }); }); + it('should format entity type option labels with proper casing', async () => { + const entityTypeFields: ExploreQuickFilterField[] = [ + { + label: 'Entity Type', + key: EntityFields.ENTITY_TYPE_KEYWORD, + value: undefined, + }, + ]; + const entityTypeAggregations = { + [EntityFields.ENTITY_TYPE_KEYWORD]: { + buckets: [ + { + key: 'dashboarddatamodel', + doc_count: 2, + }, + { + key: 'apiendpoint', + doc_count: 1, + }, + ], + }, + }; + + render( + + ); + + await act(async () => { + userEvent.click( + screen.getByTestId( + `onGetInitialOptions-${EntityFields.ENTITY_TYPE_KEYWORD}` + ) + ); + }); + + await waitFor(() => { + expect( + screen.getByText('Dashboard Data Model - 2') + ).toBeInTheDocument(); + }); + + expect(screen.getByText('Api Endpoint - 1')).toBeInTheDocument(); + }); + it('should call getInitialOptions when search value is empty', async () => { mockGetAggregationOptions.mockResolvedValue( mockAdvancedFieldDefaultOptions @@ -523,7 +630,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, false, - '' + '', + undefined ); }); }); @@ -552,7 +660,8 @@ describe('ExploreQuickFilters component', () => { false, undefined, true, - '' + '', + undefined ); }); }); @@ -679,7 +788,8 @@ describe('ExploreQuickFilters component', () => { expect.anything(), undefined, expect.anything(), - expect.any(String) + expect.any(String), + undefined ); }); }); @@ -714,7 +824,8 @@ describe('ExploreQuickFilters component', () => { false, 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 ac1dba819ee4..86720a290550 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 @@ -13,10 +13,11 @@ import { Space } from 'antd'; import { AxiosError } from 'axios'; -import { isEqual, uniqWith } from 'lodash'; +import { isEqual, startCase, uniqWith } from 'lodash'; import Qs from 'qs'; import { FC, useCallback, useMemo, useState } from 'react'; import { EntityFields } from '../../enums/AdvancedSearch.enum'; +import { EntityType } from '../../enums/entity.enum'; import { SearchIndex } from '../../enums/search.enum'; import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation'; import { useSearchStore } from '../../hooks/useSearchStore'; @@ -35,6 +36,66 @@ import { useAdvanceSearch } from './AdvanceSearchProvider/AdvanceSearchProvider. import { ExploreSearchIndex } from './ExplorePage.interface'; import { ExploreQuickFiltersProps } from './ExploreQuickFilters.interface'; +const QUICK_FILTER_SOURCE_FIELDS: Record = { + [EntityFields.API_COLLECTION]: 'apiCollection.displayName', + [EntityFields.CHART]: 'charts.displayName', + [EntityFields.DATA_MODEL]: 'dataModels.displayName', + [EntityFields.DATA_PRODUCT]: 'dataProducts.displayName', + [EntityFields.DATABASE]: 'database.displayName', + [EntityFields.DATABASE_SCHEMA]: 'databaseSchema.displayName', + [EntityFields.DIRECTORY]: 'directory.displayName', + [EntityFields.DOMAINS]: 'domains.displayName', + [EntityFields.OWNERS]: 'ownerDisplayName', + [EntityFields.PARENT]: 'parent.displayName', + [EntityFields.SERVICE]: 'service.displayName', + [EntityFields.SPREADSHEET]: 'spreadsheet.displayName', + [EntityFields.TABLE_DISPLAY_NAME]: 'table.displayName', + [EntityFields.TASK]: 'tasks.displayName', +}; + +const ENTITY_TYPE_QUICK_FILTER_FIELDS = new Set([ + EntityFields.ENTITY_TYPE, + EntityFields.ENTITY_TYPE_KEYWORD, +]); + +const ENTITY_TYPE_VALUE_BY_LOWERCASE = Object.values(EntityType).reduce( + (acc, value) => { + acc[value.toLowerCase()] = value; + + return acc; + }, + {} as Record +); + +const getResolvedSourceFields = ( + searchKey: string, + sourceFields?: string +): string | undefined => sourceFields ?? QUICK_FILTER_SOURCE_FIELDS[searchKey]; + +const getFormattedEntityTypeLabel = (rawValue: string): string => { + const canonicalEntityType = + ENTITY_TYPE_VALUE_BY_LOWERCASE[rawValue.toLowerCase()] ?? rawValue; + + return startCase(canonicalEntityType); +}; + +const getBucketOptions = ( + buckets: Parameters[0], + searchKey: string, + sourceFields?: string +): SearchDropdownOption[] => { + const options = getOptionsFromAggregationBucket(buckets, sourceFields); + + if (!ENTITY_TYPE_QUICK_FILTER_FIELDS.has(searchKey)) { + return options; + } + + return options.map((option) => ({ + ...option, + label: getFormattedEntityTypeLabel(option.key), + })); +}; + const ExploreQuickFilters: FC = ({ fields, index, @@ -92,7 +153,8 @@ const ExploreQuickFilters: FC = ({ index: SearchIndex | SearchIndex[], key: string, fieldSearchIndex?: SearchIndex, - fieldSearchKey?: string + fieldSearchKey?: string, + fieldSourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { @@ -105,6 +167,10 @@ const ExploreQuickFilters: FC = ({ const searchIndexToUse = fieldSearchIndex ?? index; // Use field-specific searchKey if provided, otherwise use the key const searchKeyToUse = fieldSearchKey ?? key; + const sourceFieldsToUse = getResolvedSourceFields( + searchKeyToUse, + fieldSourceFields + ); let buckets = aggregations?.[key]?.buckets; if (!buckets) { @@ -117,19 +183,26 @@ const ExploreQuickFilters: FC = ({ showDeleted, optionPageSize, isNLPEnabled, - searchText + searchText, + sourceFieldsToUse ); buckets = res.data.aggregations[`sterms#${searchKeyToUse}`].buckets; } - setOptions(uniqWith(getOptionsFromAggregationBucket(buckets), isEqual)); + setOptions( + uniqWith( + getBucketOptions(buckets, searchKeyToUse, sourceFieldsToUse), + isEqual + ) + ); }; const getInitialOptions = async ( key: string, fieldSearchIndex?: SearchIndex, - fieldSearchKey?: string + fieldSearchKey?: string, + fieldSourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { @@ -141,7 +214,13 @@ const ExploreQuickFilters: FC = ({ setIsOptionsLoading(true); setOptions([]); try { - await fetchDefaultOptions(index, key, fieldSearchIndex, fieldSearchKey); + await fetchDefaultOptions( + index, + key, + fieldSearchIndex, + fieldSearchKey, + fieldSourceFields + ); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -153,7 +232,8 @@ const ExploreQuickFilters: FC = ({ value: string, key: string, fieldSearchIndex?: SearchIndex, - fieldSearchKey?: string + fieldSearchKey?: string, + fieldSourceFields?: string ) => { const staticOptions = getStaticOptions(key); if (staticOptions) { @@ -171,13 +251,22 @@ const ExploreQuickFilters: FC = ({ setOptions([]); try { if (!value) { - getInitialOptions(key, fieldSearchIndex, fieldSearchKey); + getInitialOptions( + key, + fieldSearchIndex, + fieldSearchKey, + fieldSourceFields + ); return; } const searchIndexToUse = fieldSearchIndex ?? index; const searchKeyToUse = fieldSearchKey ?? key; + const sourceFieldsToUse = getResolvedSourceFields( + searchKeyToUse, + fieldSourceFields + ); const res = await getAggregationOptions( searchIndexToUse, @@ -188,11 +277,17 @@ const ExploreQuickFilters: FC = ({ showDeleted, undefined, isNLPEnabled, - searchText + searchText, + sourceFieldsToUse ); const buckets = res.data.aggregations[`sterms#${searchKeyToUse}`].buckets; - setOptions(uniqWith(getOptionsFromAggregationBucket(buckets), isEqual)); + setOptions( + uniqWith( + getBucketOptions(buckets, searchKeyToUse, sourceFieldsToUse), + isEqual + ) + ); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -230,10 +325,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/rest/miscAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/miscAPI.ts index 97d29aef00f1..eb6a193cdb4d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/miscAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/miscAPI.ts @@ -21,7 +21,7 @@ import { AsyncDeleteJob } from '../context/AsyncDeleteProvider/AsyncDeleteProvid import { SearchIndex } from '../enums/search.enum'; import { AuthenticationConfiguration } from '../generated/configuration/authenticationConfiguration'; import { AuthorizerConfiguration } from '../generated/configuration/authorizerConfiguration'; -import { SearchRequest } from '../generated/search/searchRequest'; +import { AggregationRequest } from '../generated/search/aggregationRequest'; import { ValidationResponse } from '../generated/system/validationResponse'; import { Paging } from '../generated/type/paging'; import { SearchResponse } from '../interface/search.interface'; @@ -246,18 +246,18 @@ export const getAggregateFieldOptions = ( /** * Posts aggregate field options request with parameters in the body. - * @param {SearchRequest} body - The search request body containing the parameters. + * @param {AggregationRequest} body - The aggregation request body containing the parameters. * @return {Promise>} A promise that resolves to the search response * containing the aggregate field options. */ export const postAggregateFieldOptions = ({ fieldValue, ...rest -}: SearchRequest) => { +}: AggregationRequest) => { const withWildCardValue = fieldValue ? `.*${escapeESReservedCharacters(fieldValue)}.*` : '.*'; - const body: SearchRequest = { + const body: AggregationRequest = { fieldValue: withWildCardValue, ...rest, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx index 82ff981b7094..c556bc3fd1ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx @@ -12,6 +12,7 @@ */ import { FieldOrGroup } from '@react-awesome-query-builder/antd'; +import { Bucket } from 'Models'; import { SearchOutputType } from '../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface'; import { AssetsOfEntity } from '../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import { SearchDropdownOption } from '../components/SearchDropdown/SearchDropdown.interface'; @@ -263,6 +264,82 @@ describe('AdvancedSearchUtils tests', () => { ]); }); + it('Function getOptionsFromAggregationBucket should preserve original case using sourceFields', () => { + const buckets: Bucket[] = [ + { + key: 'customer-domain', + doc_count: 2, + 'top_hits#top': { + hits: { + hits: [ + { + _source: { + domains: { + displayName: 'Customer Domain', + }, + }, + }, + ], + }, + }, + } as unknown as Bucket, + ]; + + expect( + getOptionsFromAggregationBucket(buckets, 'domains.displayName') + ).toEqual([{ count: 2, key: 'customer-domain', label: 'Customer Domain' }]); + }); + + it('Function getOptionsFromAggregationBucket should select matching value from source arrays', () => { + const buckets: Bucket[] = [ + { + key: 'data-team', + doc_count: 3, + 'top_hits#top': { + hits: { + hits: [ + { + _source: { + ownerDisplayName: ['Data Team', 'Platform Team'], + }, + }, + ], + }, + }, + } as unknown as Bucket, + ]; + + expect( + getOptionsFromAggregationBucket(buckets, 'ownerDisplayName') + ).toEqual([{ count: 3, key: 'data-team', label: 'Data Team' }]); + }); + + it('Function getOptionsFromAggregationBucket should fallback to bucket key when sourceFields path is missing', () => { + const buckets: Bucket[] = [ + { + key: 'airflow', + doc_count: 1, + 'top_hits#top': { + hits: { + hits: [ + { + _source: { + service: { + name: 'Airflow', + }, + }, + }, + ], + }, + }, + } as unknown as Bucket, + ]; + + expect( + getOptionsFromAggregationBucket(buckets, 'service.displayName') + ).toEqual([{ count: 1, key: 'airflow', label: 'airflow' }]); + }); + describe('getEmptyJsonTree', () => { it('should return a default JsonTree structure with OWNERS as the default field', () => { const result = getEmptyJsonTree(); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx index e37b8a3c36c3..e77be1f5fa22 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx @@ -368,7 +368,69 @@ export const getServiceOptions = ( : option.text; }; -export const getOptionsFromAggregationBucket = (buckets: Bucket[]) => { +const flattenStringValues = (value: unknown): string[] => { + if (typeof value === 'string') { + return [value]; + } + + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => flattenStringValues(item)); +}; + +const getValueFromSourcePath = ( + value: unknown, + pathSegments: string[] +): unknown => { + if (pathSegments.length === 0) { + return value; + } + + if (Array.isArray(value)) { + return value + .map((entry) => getValueFromSourcePath(entry, pathSegments)) + .filter((entry) => entry !== undefined); + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const [currentSegment, ...remainingSegments] = pathSegments; + + if (!(currentSegment in value)) { + return undefined; + } + + return getValueFromSourcePath( + (value as Record)[currentSegment], + remainingSegments + ); +}; + +const getDisplayLabel = (sourceValue: unknown, bucketKey: string): string => { + if (typeof sourceValue === 'string') { + return sourceValue; + } + + const candidates = flattenStringValues(sourceValue); + if (candidates.length === 0) { + return bucketKey; + } + + const matchingCandidate = candidates.find( + (candidate) => candidate.toLowerCase() === bucketKey.toLowerCase() + ); + + return matchingCandidate ?? candidates[0]; +}; + +export const getOptionsFromAggregationBucket = ( + buckets: Bucket[], + sourceFields?: string +): SearchDropdownOption[] => { if (!buckets) { return []; } @@ -378,11 +440,34 @@ export const getOptionsFromAggregationBucket = (buckets: Bucket[]) => { (item) => !NOT_INCLUDE_AGGREGATION_QUICK_FILTER.includes(item.key as EntityType) ) - .map((option) => ({ - key: option.key, - label: option.key, - count: option.doc_count ?? 0, - })); + .map((option) => { + const topHitsData = (option as Record)[ + 'top_hits#top' + ] as + | { + hits?: { + hits?: Array<{ + _source?: Record; + }>; + }; + } + | undefined; + const bucketKey = option.key as string; + const sourcePath = sourceFields?.split('.') ?? []; + const topHitSource = topHitsData?.hits?.hits?.[0]?._source; + const sourceValue = + topHitSource && sourcePath.length > 0 + ? getValueFromSourcePath(topHitSource, sourcePath) + : undefined; + + const displayLabel = getDisplayLabel(sourceValue, bucketKey); + + return { + key: option.key, + label: displayLabel, + count: option.doc_count ?? 0, + }; + }); }; export const getTierOptions = async (): Promise => { 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 eaab856bac38..e0ea0c17caa5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ExploreUtils.tsx @@ -364,7 +364,8 @@ export const getAggregationOptions = async ( deleted = false, size = 10, isNLPEnabled = false, - queryText?: string + queryText?: string, + sourceFields?: string ) => { return isIndependent ? postAggregateFieldOptions({ @@ -373,13 +374,14 @@ export const getAggregationOptions = async ( fieldValue: value, query: filter, size, + ...(sourceFields ? { sourceFields: [sourceFields] } : {}), }) : getAggregateFieldOptions( index, key, value, filter, - undefined, + sourceFields, deleted, isNLPEnabled, queryText