feat: implement lazy loading in SelectInput

This commit is contained in:
ValdiANS
2026-01-14 10:35:51 +07:00
parent 6a58be8c67
commit 8d7adbbd27
+115 -21
View File
@@ -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 {
value: getByPath<T, number>(item, valueKey as string),
label: getByPath<T, string>(item, labelKey as string),
};
})
: [];
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,
};
};