mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-06 14:26:28 +00:00
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:
parent
27a05b17b0
commit
d98c762501
@ -48,6 +48,9 @@ const mockTagResponse = {
|
||||
},
|
||||
},
|
||||
],
|
||||
paging: {
|
||||
total: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe('TermBoost Component', () => {
|
||||
@ -72,11 +75,14 @@ describe('TermBoost Component', () => {
|
||||
fireEvent.mouseDown(select);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should handle delete tag boost', () => {
|
||||
render(<TermBoostComponent {...mockProps} />);
|
||||
|
@ -58,9 +58,9 @@ const TermBoostComponent: React.FC<TermBoostProps> = ({
|
||||
}
|
||||
}, [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<TermBoostProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
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<TermBoostProps> = ({
|
||||
<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">
|
||||
<AsyncSelect
|
||||
enableInfiniteScroll
|
||||
showSearch
|
||||
api={fetchTags}
|
||||
className="w-full custom-select"
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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<T> {
|
||||
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<DefaultOptionType[]>;
|
||||
}) => {
|
||||
}: SelectProps & AsyncSelectListProps) => {
|
||||
const [optionsInternal, setOptionsInternal] = useState<DefaultOptionType[]>();
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const [hasContentLoading, setHasContentLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [paging, setPaging] = useState<Paging>({} as Paging);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
setOptionsInternal(options);
|
||||
}, [options]);
|
||||
|
||||
const fetchOptions = useCallback(
|
||||
debounce((value: string) => {
|
||||
debounce(async (value: string, page = 1) => {
|
||||
if (page === 1) {
|
||||
setLoadingOptions(true);
|
||||
api(value).then((res) => {
|
||||
setOptionsInternal(res);
|
||||
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<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(() => {
|
||||
fetchOptions(searchText);
|
||||
fetchOptions(searchText, 1);
|
||||
}, [searchText]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
dropdownRender={enableInfiniteScroll ? dropdownRender : undefined}
|
||||
filterOption={false}
|
||||
notFoundContent={loadingOptions ? <Loader size="small" /> : null}
|
||||
options={optionsInternal}
|
||||
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) => {
|
||||
setSearchText(value);
|
||||
setLoadingOptions(true);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onSelect={handleSelection}
|
||||
{...restProps}
|
||||
|
@ -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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user