feat: vitual lazy load on combobox

This commit is contained in:
2026-06-17 02:32:15 +05:30
parent 1325afdab2
commit db11a512bf
12 changed files with 592 additions and 99 deletions

28
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -3238,6 +3239,23 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": { "node_modules/@tanstack/table-core": {
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
@@ -3251,6 +3269,16 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tanstack/virtual-core": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -33,6 +33,7 @@
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.3",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@@ -1,20 +1,14 @@
'use client'; 'use client';
import { useMemo, useRef, type ComponentProps } from 'react'; import { useRef, type ComponentProps } from 'react';
import type { UseFormRegister } from 'react-hook-form'; import type { UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { RoleCombobox } from '@/components/lookups/RoleCombobox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from '@/components/ui/combobox';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -23,67 +17,64 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { Role } from '@/types';
import type { UserFormValues } from '../hooks/useUserForm';
type RoleComboboxItem = { import { Input } from '@/components/ui/input';
value: string;
label: string; import { Label } from '@/components/ui/label';
};
import type { UserFormValues } from '../hooks/useUserForm';
interface UserSheetProps { interface UserSheetProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
userId?: number; userId?: number;
register: UseFormRegister<UserFormValues>; register: UseFormRegister<UserFormValues>;
onSubmit: ComponentProps<'form'>['onSubmit']; onSubmit: ComponentProps<'form'>['onSubmit'];
roleId: string; roleId: string;
onRoleChange: (roleId: string) => void; onRoleChange: (roleId: string) => void;
roles: Role[];
isRolesLoading: boolean;
isSaving: boolean; isSaving: boolean;
} }
export function UserSheet({ export function UserSheet({
open, open,
onOpenChange, onOpenChange,
userId, userId,
register, register,
onSubmit, onSubmit,
roleId, roleId,
onRoleChange, onRoleChange,
roles,
isRolesLoading,
isSaving, isSaving,
}: UserSheetProps) { }: UserSheetProps) {
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null); const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
const roleItems = useMemo<RoleComboboxItem[]>(
() =>
roles.map((role) => ({
value: String(role.id),
label: role.display_name || role.name,
})),
[roles],
);
const selectedRole = useMemo(
() => roleItems.find((item) => item.value === roleId) ?? null,
[roleItems, roleId],
);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-xl"> <DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle> <DialogTitle>{userId ? 'Edit User' : 'Create User'}</DialogTitle>
<DialogDescription>Assign user details and a role.</DialogDescription> <DialogDescription>Assign user details and a role.</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4"> <form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="first-name">First Name</Label> <Label htmlFor="first-name">First Name</Label>
<Input <Input
id="first-name" id="first-name"
placeholder="Enter first name" placeholder="Enter first name"
@@ -91,8 +82,10 @@ export function UserSheet({
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="last-name">Last Name</Label> <Label htmlFor="last-name">Last Name</Label>
<Input <Input
id="last-name" id="last-name"
placeholder="Enter last name" placeholder="Enter last name"
@@ -104,6 +97,7 @@ export function UserSheet({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
@@ -115,6 +109,7 @@ export function UserSheet({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phone-number">Phone Number</Label> <Label htmlFor="phone-number">Phone Number</Label>
<Input <Input
id="phone-number" id="phone-number"
placeholder="Enter phone number" placeholder="Enter phone number"
@@ -124,44 +119,17 @@ export function UserSheet({
<div className="space-y-2"> <div className="space-y-2">
<Label>Role</Label> <Label>Role</Label>
<Combobox
items={roleItems} <RoleCombobox
value={selectedRole} value={roleId}
onValueChange={(item) => { onValueChange={onRoleChange}
if (item?.value) { enabled={open}
onRoleChange(item.value); disabled={isSaving}
} portalContainer={roleComboboxPortalRef}
}} />
disabled={isRolesLoading}
itemToStringLabel={(item) => item.label}
>
<ComboboxTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-between font-normal"
disabled={isRolesLoading}
>
<ComboboxValue
placeholder={isRolesLoading ? 'Loading roles...' : 'Select role'}
/>
</Button>
}
/>
<ComboboxContent container={roleComboboxPortalRef}>
<ComboboxInput showTrigger={false} placeholder="Search roles..." />
<ComboboxEmpty>No roles found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<div ref={roleComboboxPortalRef} /> <div ref={roleComboboxPortalRef} />
<input <input
type="hidden" type="hidden"
{...register('role_id', { required: true })} {...register('role_id', { required: true })}
@@ -179,8 +147,10 @@ export function UserSheet({
> >
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={isSaving}> <Button type="submit" disabled={isSaving}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null} {isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{userId ? 'Update User' : 'Create User'} {userId ? 'Update User' : 'Create User'}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -3,9 +3,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { roleService, userService } from '@/services/api'; import { userService } from '@/services/api';
import type { RoleListParams, UserListParams } from '@/types'; import type { UserListParams } from '@/types';
import { roleKeys } from '../../roles/queries/roleKeys';
import { userKeys } from '../queries/userKeys'; import { userKeys } from '../queries/userKeys';
import type { StatusFilter } from './useUserFilters'; import type { StatusFilter } from './useUserFilters';
@@ -16,14 +15,6 @@ interface UseUsersQueryParams {
statusFilter: StatusFilter; statusFilter: StatusFilter;
} }
const roleLookupParams: RoleListParams = {
skip: 0,
limit: 500,
effective_status: true,
sort_by: 'display_name',
sort_order: 'asc',
};
function buildUserListParams({ function buildUserListParams({
skip, skip,
limit, limit,
@@ -52,13 +43,3 @@ export function useUsersQuery(params: UseUsersQueryParams) {
queryFn: () => userService.getUsers(listParams), queryFn: () => userService.getUsers(listParams),
}); });
} }
export function useRoleLookupQuery(enabled: boolean) {
return useQuery({
queryKey: roleKeys.list(roleLookupParams),
queryFn: () => roleService.getRoles(roleLookupParams),
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
}

View File

@@ -18,7 +18,7 @@ import { UserTable } from './components/UserTable';
import { useUserFilters } from './hooks/useUserFilters'; import { useUserFilters } from './hooks/useUserFilters';
import { useUserForm } from './hooks/useUserForm'; import { useUserForm } from './hooks/useUserForm';
import { useUserStatusMutation } from './hooks/useUserMutations'; import { useUserStatusMutation } from './hooks/useUserMutations';
import { useRoleLookupQuery, useUsersQuery } from './hooks/useUserQueries'; import { useUsersQuery } from './hooks/useUserQueries';
export default function UsersPage() { export default function UsersPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false); const [isSheetOpen, setIsSheetOpen] = useState(false);
@@ -40,7 +40,6 @@ export default function UsersPage() {
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
statusFilter, statusFilter,
}); });
const rolesQuery = useRoleLookupQuery(isSheetOpen);
const userForm = useUserForm({ const userForm = useUserForm({
onSaved: () => setIsSheetOpen(false), onSaved: () => setIsSheetOpen(false),
}); });
@@ -85,7 +84,6 @@ export default function UsersPage() {
const total = usersQuery.data?.total ?? 0; const total = usersQuery.data?.total ?? 0;
const users = usersQuery.data?.items ?? []; const users = usersQuery.data?.items ?? [];
const roleOptions = rolesQuery.data?.items ?? [];
const toolbar = useMemo( const toolbar = useMemo(
() => ( () => (
<UserFilters <UserFilters
@@ -145,8 +143,6 @@ export default function UsersPage() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
roleId={roleId} roleId={roleId}
onRoleChange={setRoleId} onRoleChange={setRoleId}
roles={roleOptions}
isRolesLoading={rolesQuery.isLoading}
isSaving={isSaving} isSaving={isSaving}
/> />
</> </>

View File

@@ -0,0 +1,156 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { Virtualizer } from '@tanstack/react-virtual';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxList,
ComboboxTrigger,
ComboboxValue,
} from '@/components/ui/combobox';
import type { AsyncComboboxOption, AsyncComboboxProps } from './AsyncCombobox.types';
import { VirtualComboboxList } from './VirtualComboboxList';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
export function AsyncCombobox({
lookup,
onValueChange,
placeholder = 'Select option',
searchPlaceholder = 'Search...',
emptyMessage = 'No items found.',
disabled = false,
portalContainer,
}: AsyncComboboxProps) {
const [open, setOpen] = useState(false);
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const {
options,
selectedOption,
search,
setSearch,
isLoading,
isFetchingNextPage,
isError,
errorMessage,
hasNextPage,
fetchNextPage,
} = lookup;
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setSearch('');
}
},
[setSearch],
);
const handleValueChange = useCallback(
(item: AsyncComboboxOption | null) => {
if (item?.value) {
onValueChange(item.value);
}
},
[onValueChange],
);
const handleItemHighlighted = useCallback(
(
_item: AsyncComboboxOption | undefined,
event: { reason: string; index: number },
) => {
const virtualizer = virtualizerRef.current;
if (!virtualizer) {
return;
}
const { reason, index } = event;
const isStart = index === 0;
const isEnd = index === virtualizer.options.count - 1;
const shouldScroll =
reason === 'none' || (reason === 'keyboard' && (isStart || isEnd));
if (shouldScroll) {
queueMicrotask(() => {
virtualizer.scrollToIndex(index, { align: isEnd ? 'start' : 'end' });
});
}
},
[],
);
const handleLoadMore = useCallback(() => {
void fetchNextPage();
}, [fetchNextPage]);
const isDisabled = disabled || isLoading;
const triggerPlaceholder = isLoading ? 'Loading...' : placeholder;
return (
<Combobox
virtualized
items={options}
value={selectedOption}
onValueChange={handleValueChange}
open={open}
onOpenChange={handleOpenChange}
inputValue={search}
onInputValueChange={setSearch}
filter={null}
disabled={isDisabled}
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(item, currentValue) => item.value === currentValue.value}
onItemHighlighted={handleItemHighlighted}
>
<ComboboxTrigger
render={
<Button
type="button"
variant="outline"
className="w-full justify-between font-normal"
disabled={isDisabled}
>
{isLoading && !selectedOption ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</span>
) : (
<ComboboxValue placeholder={triggerPlaceholder} />
)}
</Button>
}
/>
<ComboboxContent container={portalContainer}>
<ComboboxInput showTrigger={false} placeholder={searchPlaceholder} disabled={isDisabled} />
{isError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage || 'Unable to load options.'}
</div>
) : (
<>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
<ComboboxList className="p-0">
<VirtualComboboxList
open={open}
virtualizerRef={virtualizerRef}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={handleLoadMore}
/>
</ComboboxList>
</>
)}
</ComboboxContent>
</Combobox>
);
}

View File

@@ -0,0 +1,50 @@
import type { RefObject } from 'react';
export type AsyncComboboxOption = {
value: string;
label: string;
};
export type PaginatedResult<T> = {
items: T[];
total: number;
};
export type PaginatedLookupConfig<T> = {
enabled?: boolean;
pageSize?: number;
debounceMs?: number;
selectedValue?: string;
queryKey: (searchTerm: string) => readonly unknown[];
queryFn: (args: {
searchTerm: string;
skip: number;
limit: number;
}) => Promise<PaginatedResult<T>>;
mapOption: (item: T) => AsyncComboboxOption;
resolveSelected?: (value: string) => Promise<T | null>;
resolveSelectedQueryKey?: (value: string) => readonly unknown[];
};
export interface PaginatedLookupState {
options: AsyncComboboxOption[];
selectedOption: AsyncComboboxOption | null;
search: string;
setSearch: (value: string) => void;
isLoading: boolean;
isFetchingNextPage: boolean;
isError: boolean;
errorMessage?: string;
hasNextPage: boolean;
fetchNextPage: () => void | Promise<unknown>;
}
export interface AsyncComboboxProps {
lookup: PaginatedLookupState;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useCallback, useEffect, useImperativeHandle, useRef, type RefObject } from 'react';
import { Combobox } from '@base-ui/react/combobox';
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
import { Loader2 } from 'lucide-react';
import { ComboboxItem } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import type { AsyncComboboxOption } from './AsyncCombobox.types';
type VirtualizerHandle = Virtualizer<HTMLDivElement, Element>;
const LOAD_MORE_THRESHOLD = 3;
interface VirtualComboboxListProps {
open: boolean;
virtualizerRef: RefObject<VirtualizerHandle | null>;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
estimateSize?: number;
}
export function VirtualComboboxList({
open,
virtualizerRef,
hasNextPage,
isFetchingNextPage,
onLoadMore,
estimateSize = 36,
}: VirtualComboboxListProps) {
const filteredItems = Combobox.useFilteredItems<AsyncComboboxOption>();
const scrollElementRef = useRef<HTMLDivElement | null>(null);
const itemCount = filteredItems.length + (hasNextPage ? 1 : 0);
const virtualizer = useVirtualizer({
enabled: open,
count: itemCount,
getScrollElement: () => scrollElementRef.current,
estimateSize: () => estimateSize,
overscan: 8,
paddingStart: 4,
paddingEnd: 4,
});
useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]);
const handleScrollElementRef = useCallback(
(element: HTMLDivElement | null) => {
scrollElementRef.current = element;
if (element) {
virtualizer.measure();
}
},
[virtualizer],
);
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const lastVirtualIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
useEffect(() => {
if (lastVirtualIndex < 0) {
return;
}
if (
lastVirtualIndex >= filteredItems.length - LOAD_MORE_THRESHOLD &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [filteredItems.length, hasNextPage, isFetchingNextPage, lastVirtualIndex, onLoadMore]);
if (!filteredItems.length && !hasNextPage) {
return null;
}
return (
<div
ref={handleScrollElementRef}
className={cn(
'max-h-[min(21.75rem,calc(var(--available-height)-2.25rem))] overflow-y-auto overscroll-contain p-1',
)}
style={{ height: Math.min(totalSize + 8, 348) }}
>
<div className="relative w-full" style={{ height: totalSize }}>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index >= filteredItems.length;
const item = filteredItems[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
height: virtualItem.size,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow ? (
<div className="flex items-center justify-center py-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : (
<ComboboxItem
value={item}
index={virtualItem.index}
aria-setsize={filteredItems.length}
aria-posinset={virtualItem.index + 1}
>
{item.label}
</ComboboxItem>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { AsyncCombobox } from './AsyncCombobox';
export { usePaginatedLookup } from './usePaginatedLookup';
export type {
AsyncComboboxProps,
AsyncComboboxOption,
PaginatedLookupState,
PaginatedLookupConfig,
PaginatedResult,
} from './AsyncCombobox.types';

View File

@@ -0,0 +1,94 @@
'use client';
import { useMemo, useState } from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useDebounce } from '@/hooks/useDebounce';
import type { AsyncComboboxOption, PaginatedLookupConfig } from './AsyncCombobox.types';
export function usePaginatedLookup<T>({
enabled = true,
pageSize = 20,
debounceMs = 400,
selectedValue = '',
queryKey,
queryFn,
mapOption,
resolveSelected,
resolveSelectedQueryKey,
}: PaginatedLookupConfig<T>) {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search.trim(), debounceMs);
const infiniteQuery = useInfiniteQuery({
queryKey: queryKey(debouncedSearch),
queryFn: ({ pageParam }) =>
queryFn({
searchTerm: debouncedSearch,
skip: pageParam,
limit: pageSize,
}),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.items.length === 0) {
return undefined;
}
const loadedCount = allPages.reduce((count, page) => count + page.items.length, 0);
return loadedCount < lastPage.total ? loadedCount : undefined;
},
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
const options = useMemo<AsyncComboboxOption[]>(() => {
const items = infiniteQuery.data?.pages.flatMap((page) => page.items) ?? [];
return items.map(mapOption);
}, [infiniteQuery.data?.pages, mapOption]);
const selectedInList = useMemo(
() => options.find((option) => option.value === selectedValue) ?? null,
[options, selectedValue],
);
const resolvedSelectedQuery = useQuery({
queryKey: resolveSelectedQueryKey?.(selectedValue) ?? [
...queryKey(''),
'selected',
selectedValue,
],
queryFn: async () => {
if (!selectedValue || !resolveSelected) {
return null;
}
const item = await resolveSelected(selectedValue);
return item ? mapOption(item) : null;
},
enabled: enabled && Boolean(selectedValue) && !selectedInList && Boolean(resolveSelected),
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
});
const selectedOption = selectedInList ?? resolvedSelectedQuery.data ?? null;
return {
search,
setSearch,
options,
selectedOption,
isLoading: infiniteQuery.isLoading,
isError: infiniteQuery.isError || resolvedSelectedQuery.isError,
errorMessage:
infiniteQuery.error instanceof Error
? infiniteQuery.error.message
: resolvedSelectedQuery.error instanceof Error
? resolvedSelectedQuery.error.message
: undefined,
isFetchingNextPage: infiniteQuery.isFetchingNextPage,
hasNextPage: infiniteQuery.hasNextPage ?? false,
fetchNextPage: infiniteQuery.fetchNextPage,
};
}

View File

@@ -0,0 +1,74 @@
'use client';
import { AsyncCombobox, usePaginatedLookup } from '@/components/async-combobox';
import type { AsyncComboboxOption } from '@/components/async-combobox';
import { roleService } from '@/services/api';
import type { Role } from '@/types';
import { roleKeys } from '@/app/(modules)/roles/queries/roleKeys';
import type { RoleComboboxProps } from './RoleCombobox.types';
const ROLE_PAGE_SIZE = 20;
function parseRoleId(value: string) {
const roleId = Number(value);
return Number.isFinite(roleId) ? roleId : null;
}
function mapRoleOption(role: Role): AsyncComboboxOption {
return {
value: String(role.id),
label: role.display_name || role.name,
};
}
export function RoleCombobox({
value,
onValueChange,
enabled = true,
disabled = false,
portalContainer,
}: RoleComboboxProps) {
const lookup = usePaginatedLookup<Role>({
enabled,
selectedValue: value,
pageSize: ROLE_PAGE_SIZE,
queryKey: (searchTerm) =>
[
...roleKeys.lists(),
'async-lookup',
searchTerm,
ROLE_PAGE_SIZE,
] as const,
queryFn: ({ searchTerm, skip, limit }) =>
roleService.getRoles({
search_term: searchTerm || undefined,
skip,
limit,
effective_status: true,
sort_by: 'display_name',
sort_order: 'asc',
}),
mapOption: mapRoleOption,
resolveSelected: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleService.getRoleById(numericRoleId) : Promise.resolve(null);
},
resolveSelectedQueryKey: (roleId) => {
const numericRoleId = parseRoleId(roleId);
return numericRoleId ? roleKeys.detail(numericRoleId) : [...roleKeys.details(), 'invalid', roleId];
},
});
return (
<AsyncCombobox
lookup={lookup}
onValueChange={onValueChange}
disabled={disabled}
placeholder="Select role"
searchPlaceholder="Search roles..."
emptyMessage="No roles found."
portalContainer={portalContainer}
/>
);
}

View File

@@ -0,0 +1,9 @@
import type { RefObject } from 'react';
export interface RoleComboboxProps {
value: string;
onValueChange: (value: string) => void;
enabled?: boolean;
disabled?: boolean;
portalContainer?: RefObject<HTMLDivElement | null>;
}