mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat: implement lazy loading in SelectInput
This commit is contained in:
@@ -9,15 +9,20 @@ import Select, {
|
||||
SingleValue,
|
||||
components as ReactSelectComponents,
|
||||
ControlProps,
|
||||
MenuListProps,
|
||||
} from 'react-select';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
import makeAnimated from 'react-select/animated';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { cn, getByPath } from '@/lib/helper';
|
||||
import useSWR from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { httpClientFetcher } from '@/services/http/client';
|
||||
import { BaseApiResponse } from '@/types/api/api-general';
|
||||
import { isResponseSuccess } from '@/lib/api-helper';
|
||||
import {
|
||||
BaseApiResponse,
|
||||
ErrorApiResponse,
|
||||
SuccessApiResponse,
|
||||
} from '@/types/api/api-general';
|
||||
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
|
||||
|
||||
export interface OptionType {
|
||||
value: string | number;
|
||||
@@ -56,6 +61,7 @@ interface SelectInputBaseProps<T = OptionType> {
|
||||
onInputChange?: (search: string) => void;
|
||||
startAdornment?: ReactNode;
|
||||
menuPortalTarget?: HTMLElement | null;
|
||||
onMenuScrollToBottom?: ((event: WheelEvent | TouchEvent) => void) | undefined;
|
||||
}
|
||||
|
||||
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
|
||||
@@ -93,6 +99,29 @@ const CustomControl = <
|
||||
);
|
||||
};
|
||||
|
||||
const CustomMenuList = <
|
||||
Option,
|
||||
IsMulti extends boolean,
|
||||
Group extends GroupBase<Option>,
|
||||
>(
|
||||
props: MenuListProps<Option, IsMulti, Group>
|
||||
) => {
|
||||
const { children, selectProps, options } = props;
|
||||
const { isLoading } = selectProps;
|
||||
|
||||
return (
|
||||
<ReactSelectComponents.MenuList {...props}>
|
||||
{children}
|
||||
|
||||
{options.length > 0 && isLoading && (
|
||||
<div className='mt-1 px-3 py-2 rounded-md text-center text-gray-400'>
|
||||
<span className='loading loading-spinner loading-md' />
|
||||
</div>
|
||||
)}
|
||||
</ReactSelectComponents.MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
const {
|
||||
label,
|
||||
@@ -119,6 +148,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
onInputChange,
|
||||
startAdornment,
|
||||
menuPortalTarget,
|
||||
onMenuScrollToBottom,
|
||||
} = props;
|
||||
|
||||
const [internalInputValue, setInternalInputValue] = useState('');
|
||||
@@ -256,6 +286,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
components={{
|
||||
...components,
|
||||
...(optionComponent ? { Option: optionComponent } : {}),
|
||||
MenuList: CustomMenuList,
|
||||
}}
|
||||
{...(startAdornment && {
|
||||
shouldShowAdornment,
|
||||
@@ -269,6 +300,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
|
||||
styles={{
|
||||
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||
}}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
/>
|
||||
|
||||
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
|
||||
@@ -288,34 +320,96 @@ const useSelect = <T,>(
|
||||
) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const optionsUrlParams = useMemo(() => {
|
||||
return new URLSearchParams({
|
||||
const pageKey = 'page';
|
||||
const limitKey = 'limit';
|
||||
const limit = params?.['limit'] ?? 10;
|
||||
|
||||
const getKey = (
|
||||
pageIndex: number,
|
||||
previousPageData?: BaseApiResponse<T[]>
|
||||
) => {
|
||||
// stop when backend says no more pages
|
||||
if (previousPageData && isResponseSuccess(previousPageData)) {
|
||||
const meta = previousPageData.meta;
|
||||
if (meta && meta.page >= meta.total_pages) return null;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
...(params ?? {}),
|
||||
[searchKey]: inputValue ?? '',
|
||||
...params,
|
||||
[pageKey]: String(pageIndex + 1),
|
||||
[limitKey]: String(limit),
|
||||
}).toString();
|
||||
}, [inputValue, searchKey, params]);
|
||||
|
||||
const optionsUrl = `${basePath}?${optionsUrlParams}`;
|
||||
return `${basePath}?${qs}`;
|
||||
};
|
||||
|
||||
const { data, isLoading } = useSWR(optionsUrl, async (url) => {
|
||||
return await httpClientFetcher<BaseApiResponse<T[]>>(url);
|
||||
});
|
||||
const {
|
||||
data: pages,
|
||||
isLoading,
|
||||
isValidating,
|
||||
size,
|
||||
setSize,
|
||||
} = useSWRInfinite<BaseApiResponse<T[]>>(getKey, (url) =>
|
||||
httpClientFetcher<BaseApiResponse<T[]>>(url)
|
||||
);
|
||||
|
||||
const options = isResponseSuccess(data)
|
||||
? data.data.map((item) => {
|
||||
return {
|
||||
const options = useMemo(() => {
|
||||
if (!pages) return [];
|
||||
|
||||
return pages.flatMap((page) =>
|
||||
isResponseSuccess(page)
|
||||
? page.data.map((item) => ({
|
||||
value: getByPath<T, number>(item, valueKey as string),
|
||||
label: getByPath<T, string>(item, labelKey as string),
|
||||
}))
|
||||
: []
|
||||
);
|
||||
}, [pages, valueKey, labelKey]);
|
||||
|
||||
const lastPage = pages?.[pages.length - 1];
|
||||
const hasMore =
|
||||
!!lastPage &&
|
||||
isResponseSuccess(lastPage) &&
|
||||
!!lastPage.meta &&
|
||||
lastPage.meta.page < lastPage.meta.total_pages;
|
||||
|
||||
const loadMore = () => {
|
||||
if (!hasMore) return;
|
||||
setSize(size + 1);
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
let formattedSuccessRawData: SuccessApiResponse<T[]> | undefined = undefined;
|
||||
let formattedErrorRawData: ErrorApiResponse | undefined = undefined;
|
||||
|
||||
const latestPagesIndex = pages?.length ? pages.length - 1 : 0;
|
||||
|
||||
if (isResponseSuccess(pages?.[latestPagesIndex])) {
|
||||
formattedSuccessRawData = {
|
||||
...pages?.[latestPagesIndex],
|
||||
data:
|
||||
pages?.flatMap((page) => (isResponseSuccess(page) ? page.data : [])) ??
|
||||
[],
|
||||
};
|
||||
}
|
||||
|
||||
if (isResponseError(pages?.[latestPagesIndex])) {
|
||||
formattedErrorRawData = pages?.[latestPagesIndex];
|
||||
}
|
||||
|
||||
return {
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
options,
|
||||
isLoadingOptions: isLoading,
|
||||
rawData: data,
|
||||
rawData: isResponseSuccess(pages?.[latestPagesIndex])
|
||||
? formattedSuccessRawData
|
||||
: formattedErrorRawData,
|
||||
|
||||
isLoadingOptions: isLoading || isValidating,
|
||||
isLoadingMore: isValidating && size > 1,
|
||||
hasMore,
|
||||
loadMore,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user