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 <shailesh.parmar.webdev@gmail.com>
This commit is contained in:
Pranita Fulsundar 2025-06-18 16:23:32 +05:30 committed by GitHub
parent 27a05b17b0
commit d98c762501
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 477 additions and 20 deletions

View File

@ -48,6 +48,9 @@ const mockTagResponse = {
}, },
}, },
], ],
paging: {
total: 1,
},
}; };
describe('TermBoost Component', () => { describe('TermBoost Component', () => {
@ -72,11 +75,14 @@ describe('TermBoost Component', () => {
fireEvent.mouseDown(select); fireEvent.mouseDown(select);
await waitFor(() => { await waitFor(() => {
expect(tagClassBase.getTags).toHaveBeenCalled(); expect(tagClassBase.getTags).toHaveBeenCalledWith('', 1, true);
}); });
// Wait for the async select to populate options
await waitFor(() => {
expect(screen.getByText('PII.Sensitive')).toBeInTheDocument(); expect(screen.getByText('PII.Sensitive')).toBeInTheDocument();
}); });
});
it('Should handle delete tag boost', () => { it('Should handle delete tag boost', () => {
render(<TermBoostComponent {...mockProps} />); render(<TermBoostComponent {...mockProps} />);

View File

@ -58,9 +58,9 @@ const TermBoostComponent: React.FC<TermBoostProps> = ({
} }
}, [termBoost, isNewBoost]); }, [termBoost, isNewBoost]);
const fetchTags = async (searchText: string) => { const fetchTags = async (searchText: string, page = 1) => {
try { try {
const response = await tagClassBase.getTags(searchText, 1, true); const response = await tagClassBase.getTags(searchText, page, true);
const formattedOptions = response.data.map((item) => { const formattedOptions = response.data.map((item) => {
const fqn = item.data.fullyQualifiedName; const fqn = item.data.fullyQualifiedName;
@ -93,11 +93,19 @@ const TermBoostComponent: React.FC<TermBoostProps> = ({
}; };
}); });
return formattedOptions; // Return PagingResponse structure for infinite scroll support
return {
data: formattedOptions,
paging: response.paging,
};
} catch (error) { } catch (error) {
showErrorToast(error as AxiosError); showErrorToast(error as AxiosError);
return []; // Return empty PagingResponse structure on error
return {
data: [],
paging: { total: 0 },
};
} }
}; };
@ -124,6 +132,7 @@ const TermBoostComponent: React.FC<TermBoostProps> = ({
<Row className="p-box d-flex flex-column"> <Row className="p-box d-flex flex-column">
<Col className="p-y-xs p-l-sm p-r-xss border-radius-card m-b-sm bg-white config-section-content"> <Col className="p-y-xs p-l-sm p-r-xss border-radius-card m-b-sm bg-white config-section-content">
<AsyncSelect <AsyncSelect
enableInfiniteScroll
showSearch showSearch
api={fetchTags} api={fetchTags}
className="w-full custom-select" className="w-full custom-select"

View File

@ -0,0 +1,325 @@
/*
* Copyright 2025 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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { DefaultOptionType } from 'antd/lib/select';
import React from 'react';
import { showErrorToast } from '../../../utils/ToastUtils';
import { AsyncSelect, PagingResponse } from './AsyncSelect';
// Mock dependencies
jest.mock('../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('../Loader/Loader', () => {
return function MockLoader({ size }: { size?: string }) {
return <div data-testid={`loader-${size || 'default'}`}>Loading...</div>;
};
});
// 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<DefaultOptionType[]> = {
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(
<AsyncSelect
api={mockApi}
data-testid="async-select"
placeholder="Select option"
/>
);
expect(screen.getByTestId('async-select')).toBeInTheDocument();
});
it('should call API on mount with empty search', async () => {
mockApi.mockResolvedValue(mockOptions);
render(<AsyncSelect api={mockApi} data-testid="async-select" />);
await waitFor(() => {
expect(mockApi).toHaveBeenCalledWith('', undefined);
});
});
it('should display loading state initially', async () => {
mockApi.mockImplementation(
() =>
new Promise((resolve) => setTimeout(() => resolve(mockOptions), 100))
);
render(<AsyncSelect api={mockApi} data-testid="async-select" />);
expect(screen.getByTestId('loader-small')).toBeInTheDocument();
});
it('should populate options after API call', async () => {
mockApi.mockResolvedValue(mockOptions);
render(<AsyncSelect api={mockApi} data-testid="async-select" />);
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(<AsyncSelect api={mockApi} data-testid="async-select" />);
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(
<AsyncSelect
api={mockApi}
data-testid="async-select"
onSelect={mockOnSelect}
/>
);
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(<AsyncSelect api={mockApi} data-testid="async-select" />);
await waitFor(() => {
expect(showErrorToast).toHaveBeenCalledWith(error);
});
});
});
describe('Infinite Scroll Functionality', () => {
it('should enable infinite scroll when enableInfiniteScroll is true', async () => {
mockApi.mockResolvedValue(mockPaginatedResponse);
render(
<AsyncSelect
enableInfiniteScroll
api={mockApi}
data-testid="async-select"
/>
);
await waitFor(() => {
expect(mockApi).toHaveBeenCalledWith('', 1);
});
});
it('should handle paginated response correctly', async () => {
mockApi.mockResolvedValue(mockPaginatedResponse);
render(
<AsyncSelect
enableInfiniteScroll
api={mockApi}
data-testid="async-select"
/>
);
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<DefaultOptionType[]> = {
data: mockOptions,
paging: { total: 3 }, // Same as data length
};
mockApi.mockResolvedValue(limitedResponse);
render(
<AsyncSelect
enableInfiniteScroll
api={mockApi}
data-testid="async-select"
/>
);
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(
<AsyncSelect
enableInfiniteScroll
api={mockApi}
data-testid="async-select"
/>
);
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(
<AsyncSelect
api={mockApi}
data-testid="async-select"
debounceTimeout={1000}
/>
);
// 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(<AsyncSelect api={mockApi} data-testid="async-select" />);
await waitFor(() => {
expect(mockApi).toHaveBeenCalledWith('', undefined);
});
});
});
describe('Edge Cases', () => {
it('should handle empty API response', async () => {
mockApi.mockResolvedValue([]);
render(<AsyncSelect api={mockApi} data-testid="async-select" />);
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(<AsyncSelect api={mockApi} data-testid="async-select" />);
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);
});
});
});

View File

@ -13,9 +13,19 @@
import { Select, SelectProps } from 'antd'; import { Select, SelectProps } from 'antd';
import { DefaultOptionType } from 'antd/lib/select'; 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 React, { useCallback, useEffect, useState } from 'react';
import { Paging } from '../../../generated/type/paging';
import { showErrorToast } from '../../../utils/ToastUtils';
import Loader from '../Loader/Loader'; import Loader from '../Loader/Loader';
import { AsyncSelectListProps } from './AsyncSelectList.interface';
// Interface for paginated API response
export interface PagingResponse<T> {
data: T;
paging: Paging;
}
/** /**
* AsyncSelect to work with options provided from API directly * AsyncSelect to work with options provided from API directly
@ -27,47 +37,131 @@ import Loader from '../Loader/Loader';
export const AsyncSelect = ({ export const AsyncSelect = ({
options, options,
api, api,
enableInfiniteScroll = false,
debounceTimeout = 400,
...restProps ...restProps
}: SelectProps & { }: SelectProps & AsyncSelectListProps) => {
api: (queryString: string) => Promise<DefaultOptionType[]>;
}) => {
const [optionsInternal, setOptionsInternal] = useState<DefaultOptionType[]>(); const [optionsInternal, setOptionsInternal] = useState<DefaultOptionType[]>();
const [loadingOptions, setLoadingOptions] = useState(false); const [loadingOptions, setLoadingOptions] = useState(false);
const [hasContentLoading, setHasContentLoading] = useState(false);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [paging, setPaging] = useState<Paging>({} as Paging);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { useEffect(() => {
setOptionsInternal(options); setOptionsInternal(options);
}, [options]); }, [options]);
const fetchOptions = useCallback( const fetchOptions = useCallback(
debounce((value: string) => { debounce(async (value: string, page = 1) => {
if (page === 1) {
setLoadingOptions(true); setLoadingOptions(true);
api(value).then((res) => { setOptionsInternal([]);
setOptionsInternal(res); } 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); setLoadingOptions(false);
}); setHasContentLoading(false);
}, 400), }
[api] }, debounceTimeout),
[api, enableInfiniteScroll, debounceTimeout]
); );
const handleSelection = useCallback(() => { const handleSelection = useCallback(() => {
setSearchText(''); setSearchText('');
}, []); }, []);
const onScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => {
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 && <Loader size="small" />}
</>
),
[hasContentLoading]
);
useEffect(() => { useEffect(() => {
fetchOptions(searchText); fetchOptions(searchText, 1);
}, [searchText]); }, [searchText]);
return ( return (
<Select <Select
dropdownRender={enableInfiniteScroll ? dropdownRender : undefined}
filterOption={false} filterOption={false}
notFoundContent={loadingOptions ? <Loader size="small" /> : null} notFoundContent={loadingOptions ? <Loader size="small" /> : null}
options={optionsInternal} options={optionsInternal}
searchValue={searchText} searchValue={searchText}
suffixIcon={loadingOptions && <Loader size="small" />} // Controlling the search value to get the initial suggestions when not typed anything suffixIcon={loadingOptions && <Loader size="small" />}
onPopupScroll={enableInfiniteScroll ? onScroll : undefined}
onSearch={(value: string) => { onSearch={(value: string) => {
setSearchText(value); setSearchText(value);
setLoadingOptions(true); setCurrentPage(1);
}} }}
onSelect={handleSelection} onSelect={handleSelection}
{...restProps} {...restProps}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2025 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 { DefaultOptionType } from 'antd/lib/select';
import { PagingResponse } from './AsyncSelect';
export interface AsyncSelectListProps {
api: (
queryString: string,
page?: number
) => Promise<DefaultOptionType[] | PagingResponse<DefaultOptionType[]>>;
enableInfiniteScroll?: boolean;
debounceTimeout?: number;
}