From d98c762501947a1c4e2d23a1fc783e8959312ae8 Mon Sep 17 00:00:00 2001 From: Pranita Fulsundar Date: Wed, 18 Jun 2025 16:23:32 +0530 Subject: [PATCH] fix(ui): infinite scroll for tag listing inside term boost (#21776) * fix: infinite scroll for tag listing for term boost * revert AsyncSelectList changes * fix term boost query call * fix async select for infinite scroll and add tests * address pr comments * minor refactor --------- Co-authored-by: Shailesh Parmar --- .../TermBoost/TermBoost.test.tsx | 10 +- .../SearchSettings/TermBoost/TermBoost.tsx | 17 +- .../common/AsyncSelect/AsyncSelect.test.tsx | 325 ++++++++++++++++++ .../common/AsyncSelect/AsyncSelect.tsx | 122 ++++++- .../AsyncSelect/AsyncSelectList.interface.ts | 23 ++ 5 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelectList.interface.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.test.tsx index a5b0f80d511..50434c05426 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.test.tsx @@ -48,6 +48,9 @@ const mockTagResponse = { }, }, ], + paging: { + total: 1, + }, }; describe('TermBoost Component', () => { @@ -72,10 +75,13 @@ describe('TermBoost Component', () => { fireEvent.mouseDown(select); await waitFor(() => { - expect(tagClassBase.getTags).toHaveBeenCalled(); + expect(tagClassBase.getTags).toHaveBeenCalledWith('', 1, true); }); - expect(screen.getByText('PII.Sensitive')).toBeInTheDocument(); + // Wait for the async select to populate options + await waitFor(() => { + expect(screen.getByText('PII.Sensitive')).toBeInTheDocument(); + }); }); it('Should handle delete tag boost', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx index 347258bbed2..87499cf8a9c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx @@ -58,9 +58,9 @@ const TermBoostComponent: React.FC = ({ } }, [termBoost, isNewBoost]); - const fetchTags = async (searchText: string) => { + const fetchTags = async (searchText: string, page = 1) => { try { - const response = await tagClassBase.getTags(searchText, 1, true); + const response = await tagClassBase.getTags(searchText, page, true); const formattedOptions = response.data.map((item) => { const fqn = item.data.fullyQualifiedName; @@ -93,11 +93,19 @@ const TermBoostComponent: React.FC = ({ }; }); - return formattedOptions; + // Return PagingResponse structure for infinite scroll support + return { + data: formattedOptions, + paging: response.paging, + }; } catch (error) { showErrorToast(error as AxiosError); - return []; + // Return empty PagingResponse structure on error + return { + data: [], + paging: { total: 0 }, + }; } }; @@ -124,6 +132,7 @@ const TermBoostComponent: React.FC = ({ ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../Loader/Loader', () => { + return function MockLoader({ size }: { size?: string }) { + return
Loading...
; + }; +}); + +// Mock debounce to make tests synchronous +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (fn: any) => fn, +})); + +const mockOptions: DefaultOptionType[] = [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + { label: 'Option 3', value: 'option3' }, +]; + +const mockPaginatedResponse: PagingResponse = { + data: mockOptions, + paging: { + total: 10, + after: 'cursor1', + before: 'cursor2', + }, +}; + +describe('AsyncSelect Component', () => { + const mockApi = jest.fn(); + const mockOnSelect = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (showErrorToast as jest.Mock).mockClear(); + }); + + describe('Basic Functionality', () => { + it('should render AsyncSelect component', () => { + mockApi.mockResolvedValue(mockOptions); + + render( + + ); + + expect(screen.getByTestId('async-select')).toBeInTheDocument(); + }); + + it('should call API on mount with empty search', async () => { + mockApi.mockResolvedValue(mockOptions); + + render(); + + await waitFor(() => { + expect(mockApi).toHaveBeenCalledWith('', undefined); + }); + }); + + it('should display loading state initially', async () => { + mockApi.mockImplementation( + () => + new Promise((resolve) => setTimeout(() => resolve(mockOptions), 100)) + ); + + render(); + + expect(screen.getByTestId('loader-small')).toBeInTheDocument(); + }); + + it('should populate options after API call', async () => { + mockApi.mockResolvedValue(mockOptions); + + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + }); + }); + + it('should call API with search text when typing', async () => { + mockApi.mockResolvedValue(mockOptions); + + render(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'test search' } }); + + await waitFor(() => { + expect(mockApi).toHaveBeenCalledWith('test search', undefined); + }); + }); + + it('should handle option selection', async () => { + mockApi.mockResolvedValue(mockOptions); + + render( + + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Option 1')); + + expect(mockOnSelect).toHaveBeenCalled(); + }); + + it('should handle API errors gracefully', async () => { + const error = new Error('API Error'); + mockApi.mockRejectedValue(error); + + render(); + + await waitFor(() => { + expect(showErrorToast).toHaveBeenCalledWith(error); + }); + }); + }); + + describe('Infinite Scroll Functionality', () => { + it('should enable infinite scroll when enableInfiniteScroll is true', async () => { + mockApi.mockResolvedValue(mockPaginatedResponse); + + render( + + ); + + await waitFor(() => { + expect(mockApi).toHaveBeenCalledWith('', 1); + }); + }); + + it('should handle paginated response correctly', async () => { + mockApi.mockResolvedValue(mockPaginatedResponse); + + render( + + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + }); + }); + + it('should not load more when all options are loaded', async () => { + const limitedResponse: PagingResponse = { + data: mockOptions, + paging: { total: 3 }, // Same as data length + }; + + mockApi.mockResolvedValue(limitedResponse); + + render( + + ); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + // Simulate scroll to bottom + const dropdown = screen.getByRole('listbox'); + Object.defineProperty(dropdown, 'scrollTop', { + value: 100, + writable: true, + }); + Object.defineProperty(dropdown, 'offsetHeight', { + value: 100, + writable: true, + }); + Object.defineProperty(dropdown, 'scrollHeight', { + value: 200, + writable: true, + }); + + fireEvent.scroll(dropdown); + + // Should not call API again since all options are loaded + expect(mockApi).toHaveBeenCalledTimes(1); + }); + + it('should reset page when search text changes', async () => { + mockApi.mockResolvedValue(mockPaginatedResponse); + + render( + + ); + + const input = screen.getByRole('combobox'); + + // Initial load + await waitFor(() => { + expect(mockApi).toHaveBeenCalledWith('', 1); + }); + + // Change search text + fireEvent.change(input, { target: { value: 'new search' } }); + + await waitFor(() => { + expect(mockApi).toHaveBeenCalledWith('new search', 1); + }); + }); + }); + + describe('Configuration Options', () => { + it('should use custom debounce timeout', async () => { + mockApi.mockResolvedValue(mockOptions); + + render( + + ); + + // Since we mocked debounce to be synchronous, this just ensures the prop is passed + expect(screen.getByTestId('async-select')).toBeInTheDocument(); + }); + + it('should work without infinite scroll by default', async () => { + mockApi.mockResolvedValue(mockOptions); + + render(); + + await waitFor(() => { + expect(mockApi).toHaveBeenCalledWith('', undefined); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty API response', async () => { + mockApi.mockResolvedValue([]); + + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(mockApi).toHaveBeenCalled(); + }); + + // Should not crash and should show no options + expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); + }); + + it('should handle scroll events when infinite scroll is disabled', async () => { + mockApi.mockResolvedValue(mockOptions); + + render(); + + fireEvent.mouseDown(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + // Simulate scroll - should not trigger additional API calls + const dropdown = screen.getByRole('listbox'); + fireEvent.scroll(dropdown); + + expect(mockApi).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx index 85676ce93c5..6ed02d61d20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AsyncSelect/AsyncSelect.tsx @@ -13,9 +13,19 @@ import { Select, SelectProps } from 'antd'; import { DefaultOptionType } from 'antd/lib/select'; -import { debounce } from 'lodash'; +import { AxiosError } from 'axios'; +import { debounce, isObject } from 'lodash'; import React, { useCallback, useEffect, useState } from 'react'; +import { Paging } from '../../../generated/type/paging'; +import { showErrorToast } from '../../../utils/ToastUtils'; import Loader from '../Loader/Loader'; +import { AsyncSelectListProps } from './AsyncSelectList.interface'; + +// Interface for paginated API response +export interface PagingResponse { + data: T; + paging: Paging; +} /** * AsyncSelect to work with options provided from API directly @@ -27,47 +37,131 @@ import Loader from '../Loader/Loader'; export const AsyncSelect = ({ options, api, + enableInfiniteScroll = false, + debounceTimeout = 400, ...restProps -}: SelectProps & { - api: (queryString: string) => Promise; -}) => { +}: SelectProps & AsyncSelectListProps) => { const [optionsInternal, setOptionsInternal] = useState(); const [loadingOptions, setLoadingOptions] = useState(false); + const [hasContentLoading, setHasContentLoading] = useState(false); const [searchText, setSearchText] = useState(''); + const [paging, setPaging] = useState({} as Paging); + const [currentPage, setCurrentPage] = useState(1); useEffect(() => { setOptionsInternal(options); }, [options]); const fetchOptions = useCallback( - debounce((value: string) => { - setLoadingOptions(true); - api(value).then((res) => { - setOptionsInternal(res); + debounce(async (value: string, page = 1) => { + if (page === 1) { + setLoadingOptions(true); + setOptionsInternal([]); + } else { + setHasContentLoading(true); + } + + try { + const response = await api( + value, + enableInfiniteScroll ? page : undefined + ); + + if ( + enableInfiniteScroll && + response && + isObject(response) && + 'data' in response + ) { + // Handle paginated response + const pagingResponse = response as PagingResponse< + DefaultOptionType[] + >; + if (page === 1) { + setOptionsInternal(pagingResponse.data); + setPaging(pagingResponse.paging); + setCurrentPage(1); + } else { + setOptionsInternal((prev) => [ + ...(prev || []), + ...pagingResponse.data, + ]); + setPaging(pagingResponse.paging); + setCurrentPage(page); + } + } else { + // Handle simple array response + const simpleResponse = response as DefaultOptionType[]; + setOptionsInternal(simpleResponse); + } + } catch (error) { + showErrorToast(error as AxiosError); + } finally { setLoadingOptions(false); - }); - }, 400), - [api] + setHasContentLoading(false); + } + }, debounceTimeout), + [api, enableInfiniteScroll, debounceTimeout] ); const handleSelection = useCallback(() => { setSearchText(''); }, []); + const onScroll = useCallback( + async (e: React.UIEvent) => { + if (!enableInfiniteScroll) { + return; + } + + const { currentTarget } = e; + if ( + currentTarget.scrollTop + currentTarget.offsetHeight === + currentTarget.scrollHeight + ) { + const currentOptionsLength = optionsInternal?.length ?? 0; + if (currentOptionsLength < paging.total && !hasContentLoading) { + await fetchOptions(searchText, currentPage + 1); + } + } + }, + [ + enableInfiniteScroll, + optionsInternal, + paging.total, + hasContentLoading, + searchText, + currentPage, + fetchOptions, + ] + ); + + const dropdownRender = useCallback( + (menu: React.ReactElement) => ( + <> + {menu} + {hasContentLoading && } + + ), + [hasContentLoading] + ); + useEffect(() => { - fetchOptions(searchText); + fetchOptions(searchText, 1); }, [searchText]); return (