From d68f214a79b087ba063fceefc4c50c450baf9761 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Fri, 3 Jul 2026 18:43:13 +0530 Subject: [PATCH 1/6] Added lazy loading for enum select field in the advanced search --- .../e2e/Features/AdvancedSearch.spec.ts | 144 ++++++++++++++++++ .../EnumAsyncSelectWidget.component.tsx | 115 ++++++++++++++ .../src/utils/AdvancedSearchClassBase.test.ts | 116 ++++++++++---- .../ui/src/utils/AdvancedSearchClassBase.ts | 56 ++++++- 4 files changed, 393 insertions(+), 38 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/Explore/EnumAsyncSelectWidget/EnumAsyncSelectWidget.component.tsx 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 08d81b3f5a1f..47fabd0ca0ab 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 @@ -1564,3 +1564,147 @@ test.describe( }); } ); + +test.describe( + 'Custom property enum lazy load in Advanced Search', + { tag: ['@advanced-search'] }, + () => { + // 150 values: initial asyncFetch returns items 0-99, scroll triggers items 100-149 + const ENUM_VALUES = Array.from({ length: 150 }, (_, i) => + `enum_val_${String(i).padStart(3, '0')}` + ); + const FIRST_PAGE_VALUE = 'enum_val_000'; + const SECOND_PAGE_VALUE = 'enum_val_100'; + + let enumCPName: string; + let lazyLoadTable: TableClass; + + test.beforeAll( + 'Setup enum custom property with 150 values on table', + async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + lazyLoadTable = new TableClass(); + await lazyLoadTable.create(apiContext); + + const cpMetadataTypeRes = await apiContext.get( + '/api/v1/metadata/types/name/table?fields=customProperties' + ); + const cpMetadataType = await cpMetadataTypeRes.json(); + + const typesRes = await apiContext.get( + '/api/v1/metadata/types?category=field&limit=20' + ); + const types = (await typesRes.json()).data as { + name: string; + id: string; + }[]; + const enumTypeId = + types.find((t: { name: string }) => t.name === 'enum')?.id ?? ''; + + enumCPName = `enum-lazy-${uuid()}`; + + await apiContext.put( + `/api/v1/metadata/types/${cpMetadataType.id}`, + { + data: { + name: enumCPName, + description: 'Enum CP for lazy load test', + propertyType: { name: 'enum', type: 'type', id: enumTypeId }, + customPropertyConfig: { + config: { values: ENUM_VALUES, multiSelect: true }, + }, + }, + } + ); + } finally { + await afterAction(); + } + } + ); + + test.afterAll( + 'Cleanup enum custom property and table', + async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + await lazyLoadTable.delete(apiContext); + } finally { + await afterAction(); + } + } + ); + + test( + 'should lazy-load enum options on scroll in the advanced filter value dropdown', + async ({ page }) => { + test.slow(); + + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.EXPLORE); + await showAdvancedSearchDialog(page); + + const ruleLocator = page.locator('.rule').nth(0); + + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + 'Custom Properties', + true + ); + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + 'Table', + true + ); + await selectOption( + page, + ruleLocator.locator('.rule--field .ant-select'), + enumCPName, + true + ); + await selectOption( + page, + ruleLocator.locator('.rule--operator .ant-select'), + 'Equals', + ); + + // Wait for the value widget to mount after field selection + const valueSelector = ruleLocator.locator( + '.ant-select-selection-overflow' + ); + + await expect(valueSelector).toBeVisible({ timeout: 15000 }); + + // Open the value dropdown + await valueSelector.click(); + + const dropdown = page.locator('.ant-select-dropdown:visible'); + + await expect(dropdown).toBeVisible(); + + // First page items (0-99) must be present; second page item (100) must not yet appear + await expect( + dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) + ).toBeVisible({ timeout: 10000 }); + await expect( + dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + ).not.toBeVisible(); + + // Scroll to the bottom of the virtual list to trigger second page load + const holder = dropdown.locator('.rc-virtual-list-holder').last(); + +await holder.evaluate((el: HTMLElement) => { + el.scrollTop = el.scrollHeight; + el.dispatchEvent(new Event('scroll')); +}); + + // After scroll, second page item must now appear + await expect( + dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + ).toBeVisible({ timeout: 10000 }); + } + ); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EnumAsyncSelectWidget/EnumAsyncSelectWidget.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EnumAsyncSelectWidget/EnumAsyncSelectWidget.component.tsx new file mode 100644 index 000000000000..a7e7150a84cd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EnumAsyncSelectWidget/EnumAsyncSelectWidget.component.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2024 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 { type AsyncFetchListValuesResult, type ListItem } from '@react-awesome-query-builder/antd'; +import { Select } from 'antd'; +import { debounce } from 'lodash'; +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; + +interface SelectOption { + value: string; + label: string; +} + +export interface EnumAsyncSelectWidgetProps { + asyncFetch: ( + search: string | null, + offset?: number + ) => Promise; + value?: string[]; + setValue: (val: string[]) => void; + multiple?: boolean; + placeholder?: string; + disabled?: boolean; +} + +const EnumAsyncSelectWidget: FC = ({ + asyncFetch, + value, + setValue, + multiple, + placeholder, + disabled, +}) => { + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const searchRef = useRef(''); + const loadingRef = useRef(false); + + const loadOptions = useCallback( + async (search: string, offset: number) => { + if (loadingRef.current) { + return; + } + loadingRef.current = true; + setLoading(true); + try { + const result = await asyncFetch(search || null, offset); + const mapped = (result.values as ListItem[]).map((o) => ({ + label: String(o.title ?? o.value), + value: String(o.value), + })); + setOptions((prev) => (offset === 0 ? mapped : [...prev, ...mapped])); + setHasMore(result.hasMore ?? false); + } finally { + loadingRef.current = false; + setLoading(false); + } + }, + [asyncFetch] + ); + + useEffect(() => { + loadOptions('', 0); + }, [loadOptions]); + + const onSearch = useCallback( + debounce((text: string) => { + searchRef.current = text; + loadOptions(text, 0); + }, 300), + [loadOptions] + ); + + const onPopupScroll = useCallback( + (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + const nearBottom = + target.scrollHeight - target.scrollTop - target.clientHeight < 20; + if (nearBottom && hasMore && !loadingRef.current) { + loadOptions(searchRef.current, options.length); + } + }, + [hasMore, options.length, loadOptions] + ); + + return ( + - ); -}; - -export default EnumAsyncSelectWidget; 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 054ea11234cb..4d5af7188e29 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 @@ -357,7 +357,7 @@ describe('getCustomPropertiesSubFields', () => { asyncFetch: expect.any(Function), showSearch: true, useAsyncSearch: true, - useScrollLoad: true, + useLoadMore: true, }, }, }); @@ -877,7 +877,7 @@ describe('getCustomPropertiesSubFields', () => { asyncFetch: expect.any(Function), showSearch: true, useAsyncSearch: true, - useScrollLoad: true, + useLoadMore: true, }, }, }); @@ -916,7 +916,7 @@ describe('getCustomPropertiesSubFields', () => { asyncFetch: expect.any(Function), showSearch: true, useAsyncSearch: true, - useScrollLoad: true, + useLoadMore: true, }, }, }); 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 4f2d9075dcee..487accff3718 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -46,7 +46,6 @@ import type { Config } from '../generated/api/data/createCustomProperty'; import { EntityStatus } from '../generated/entity/data/searchIndex'; import type { CustomPropertySummary } from '../rest/metadataTypeAPI.interface'; import { getAggregateFieldOptions } from '../rest/miscAPI'; -import EnumAsyncSelectWidget from '../components/Explore/EnumAsyncSelectWidget/EnumAsyncSelectWidget.component'; import { renderAdvanceSearchButtons } from './AdvancedSearchUtils'; import { getCustomPropertyMomentFormat } from './CustomProperty.utils'; import { buildTermQuery } from './elasticsearchQueryBuilder'; @@ -111,26 +110,6 @@ class AdvancedSearchClassBase { customProps: { popupClassName: 'w-max-600', }, - factory: (props: any, ctx: any) => { - if (props.useScrollLoad && props.asyncFetch) { - return ctx.RCE(EnumAsyncSelectWidget, { - asyncFetch: props.asyncFetch, - disabled: props.readonly, - multiple: true, - placeholder: props.placeholder, - setValue: props.setValue, - value: props.value, - }); - } - const { - RCE, - W: { AutocompleteWidget, MultiSelectWidget }, - } = ctx; - - return props.asyncFetch || props.showSearch - ? RCE(AutocompleteWidget, { ...props, multiple: true }) - : RCE(MultiSelectWidget, props); - }, }, select: { ...this.baseConfig.widgets.select, @@ -1439,7 +1418,7 @@ class AdvancedSearchClassBase { asyncFetch: this.buildEnumAsyncFetch(enumValues), showSearch: true, useAsyncSearch: true, - useScrollLoad: true, + useLoadMore: true, }, }, }; From 27736d4a535cdd7af2623ea1fa2f9c6877707b53 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 6 Jul 2026 13:05:32 +0530 Subject: [PATCH 3/6] lint fix --- .../e2e/Features/AdvancedSearch.spec.ts | 117 +++++++++--------- 1 file changed, 60 insertions(+), 57 deletions(-) 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 0c6e7777a44b..0148260d92cf 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 @@ -1570,8 +1570,9 @@ test.describe( { tag: ['@advanced-search'] }, () => { // 150 values: initial asyncFetch returns items 0-99, scroll triggers items 100-149 - const ENUM_VALUES = Array.from({ length: 150 }, (_, i) => - `enum_val_${String(i).padStart(3, '0')}` + const ENUM_VALUES = Array.from( + { length: 150 }, + (_, i) => `enum_val_${String(i).padStart(3, '0')}` ); const FIRST_PAGE_VALUE = 'enum_val_000'; const SECOND_PAGE_VALUE = 'enum_val_100'; @@ -1626,7 +1627,9 @@ test.describe( } ); - const openEnumValueDropdown = async (page: Parameters[0]) => { + const openEnumValueDropdown = async ( + page: Parameters[0] + ) => { await redirectToHomePage(page); await sidebarClick(page, SidebarItem.EXPLORE); await showAdvancedSearchDialog(page); @@ -1666,75 +1669,75 @@ test.describe( return { ruleLocator, valueSelector, dropdown }; }; - test( - 'should append page-2 items and make them visible when Load more button is clicked', - async ({ page }) => { - test.slow(); - - const { dropdown } = await openEnumValueDropdown(page); + test('should append page-2 items and make them visible when Load more button is clicked', async ({ + page, + }) => { + test.slow(); - // Page 1 items present; page-2 item not yet visible - await expect( - dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) - ).toBeVisible({ timeout: 10000 }); - await expect( - dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) - ).not.toBeVisible(); + const { dropdown } = await openEnumValueDropdown(page); - // "Load more..." button visible at the bottom of the list - const loadMoreBtn = dropdown.locator('a').filter({ hasText: /load more/i }); + // Page 1 items present; page-2 item not yet visible + await expect( + dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) + ).toBeVisible({ timeout: 10000 }); + await expect( + dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + ).not.toBeVisible(); - await expect(loadMoreBtn).toBeVisible(); + // "Load more..." button visible at the bottom of the list + const loadMoreBtn = dropdown + .locator('a') + .filter({ hasText: /load more/i }); - // Click Load more → page-2 items append - await loadMoreBtn.click(); + await expect(loadMoreBtn).toBeVisible(); - // Hover over the virtual list so mouse wheel events target it - const virtualListHolder = dropdown.locator('.rc-virtual-list-holder'); + // Click Load more → page-2 items append + await loadMoreBtn.click(); - await expect(virtualListHolder).toBeVisible(); - await virtualListHolder.hover(); + // Hover over the virtual list so mouse wheel events target it + const virtualListHolder = dropdown.locator('.rc-virtual-list-holder'); - // Wheel-scroll in small increments until the page-2 item comes into view - const secondPageItem = dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`); - let found = await secondPageItem.isVisible(); + await expect(virtualListHolder).toBeVisible(); + await virtualListHolder.hover(); - for (let i = 0; i < 20 && !found; i++) { - await page.mouse.wheel(0, 200); - found = await secondPageItem.isVisible(); - } + // Wheel-scroll in small increments until the page-2 item comes into view + const secondPageItem = dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`); + let found = await secondPageItem.isVisible(); - await expect(secondPageItem).toBeVisible({ timeout: 5000 }); + for (let i = 0; i < 20 && !found; i++) { + await page.mouse.wheel(0, 200); + found = await secondPageItem.isVisible(); } - ); - test( - 'should find page-2 items via search without clicking Load more', - async ({ page }) => { - test.slow(); + await expect(secondPageItem).toBeVisible({ timeout: 5000 }); + }); - const { ruleLocator, dropdown } = await openEnumValueDropdown(page); + test('should find page-2 items via search without clicking Load more', async ({ + page, + }) => { + test.slow(); - // Page 1 items load; page-2 item is not yet visible - await expect( - dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) - ).toBeVisible({ timeout: 10000 }); - await expect( - dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) - ).not.toBeVisible(); + const { ruleLocator, dropdown } = await openEnumValueDropdown(page); - // Type to search — asyncFetch filters the full values array, not just the loaded page - const searchInput = ruleLocator.locator( - '.rule--widget .ant-select-selection-search-input' - ); + // Page 1 items load; page-2 item is not yet visible + await expect( + dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) + ).toBeVisible({ timeout: 10000 }); + await expect( + dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + ).not.toBeVisible(); - await searchInput.fill(SECOND_PAGE_VALUE); + // Type to search — asyncFetch filters the full values array, not just the loaded page + const searchInput = ruleLocator.locator( + '.rule--widget .ant-select-selection-search-input' + ); - // Item appears immediately without clicking Load more - await expect( - dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) - ).toBeVisible({ timeout: 10000 }); - } - ); + await searchInput.fill(SECOND_PAGE_VALUE); + + // Item appears immediately without clicking Load more + await expect( + dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + ).toBeVisible({ timeout: 10000 }); + }); } ); From aa47e6c66f476eec49fe43cdbd3ce2ec83385dac Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 6 Jul 2026 13:29:59 +0530 Subject: [PATCH 4/6] fixed unit test --- .../main/resources/ui/src/utils/AdvancedSearchClassBase.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4d5af7188e29..9d458a47aa46 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 @@ -1238,7 +1238,7 @@ describe('buildEnumAsyncFetch', () => { }); it('should be case-insensitive when filtering', async () => { - const values = ['Active', 'ACTIVE', 'inactive']; + const values = ['Active', 'ACTIVE', 'Pending']; const fetchFn = advancedSearchClassBase.buildEnumAsyncFetch(values); const result = await fetchFn!('active'); From 4ff6bdd4ceca8ef55a564c0ab1429608d6f1bc75 Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 6 Jul 2026 19:20:07 +0530 Subject: [PATCH 5/6] fix test --- .../ui/playwright/e2e/Features/AdvancedSearch.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 3fb79b8fd235..aacdb392c3e7 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 @@ -1654,15 +1654,20 @@ test.describe( enumCPName, true ); + await selectOption( + page, + ruleLocator.locator('.rule--operator .ant-select'), + "Equals", + ); const valueSelector = ruleLocator.locator( - '.rule--widget .ant-select-selector' + '.ant-select-selection-overflow' ); await expect(valueSelector).toBeVisible({ timeout: 15000 }); await valueSelector.click(); - const dropdown = page.locator('.ant-select-dropdown:visible'); + const dropdown = page.locator('.ant-select-dropdown:visible').last(); await expect(dropdown).toBeVisible(); From 18f2b76e35482e439d7b3f98e676da547addb88a Mon Sep 17 00:00:00 2001 From: Rohit0301 Date: Mon, 6 Jul 2026 19:21:59 +0530 Subject: [PATCH 6/6] lint fix --- .../resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 aacdb392c3e7..b4a482845766 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 @@ -1657,7 +1657,7 @@ test.describe( await selectOption( page, ruleLocator.locator('.rule--operator .ant-select'), - "Equals", + 'Equals' ); const valueSelector = ruleLocator.locator(