diff --git a/package-lock.json b/package-lock.json index 06df9cc..8d5b90e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.3", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -3238,6 +3239,23 @@ "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": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -3251,6 +3269,16 @@ "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": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/package.json b/package.json index b2be3b2..c8251d2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@tanstack/react-query": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.3", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/(modules)/users/components/UserSheet.tsx b/src/app/(modules)/users/components/UserSheet.tsx index 18250ad..e912367 100644 --- a/src/app/(modules)/users/components/UserSheet.tsx +++ b/src/app/(modules)/users/components/UserSheet.tsx @@ -1,20 +1,14 @@ 'use client'; -import { useMemo, useRef, type ComponentProps } from 'react'; +import { useRef, type ComponentProps } from 'react'; + import type { UseFormRegister } from 'react-hook-form'; + import { Loader2 } from 'lucide-react'; +import { RoleCombobox } from '@/components/lookups/RoleCombobox'; import { Button } from '@/components/ui/button'; -import { - Combobox, - ComboboxContent, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, - ComboboxTrigger, - ComboboxValue, -} from '@/components/ui/combobox'; + import { Dialog, DialogContent, @@ -23,67 +17,64 @@ import { DialogHeader, DialogTitle, } 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 = { - value: string; - label: string; -}; +import { Input } from '@/components/ui/input'; + +import { Label } from '@/components/ui/label'; + +import type { UserFormValues } from '../hooks/useUserForm'; interface UserSheetProps { open: boolean; + onOpenChange: (open: boolean) => void; + userId?: number; + register: UseFormRegister; + onSubmit: ComponentProps<'form'>['onSubmit']; + roleId: string; + onRoleChange: (roleId: string) => void; - roles: Role[]; - isRolesLoading: boolean; + isSaving: boolean; } export function UserSheet({ open, + onOpenChange, + userId, + register, + onSubmit, + roleId, + onRoleChange, - roles, - isRolesLoading, + isSaving, }: UserSheetProps) { const roleComboboxPortalRef = useRef(null); - const roleItems = useMemo( - () => - 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 ( {userId ? 'Edit User' : 'Create User'} + Assign user details and a role. +
+
+
+ + + - { - if (item?.value) { - onRoleChange(item.value); - } - }} - disabled={isRolesLoading} - itemToStringLabel={(item) => item.label} - > - - - - } - /> - - - No roles found. - - {(item) => ( - - {item.label} - - )} - - - + + +
+ Cancel + diff --git a/src/app/(modules)/users/hooks/useUserQueries.ts b/src/app/(modules)/users/hooks/useUserQueries.ts index 953379b..2c6f589 100644 --- a/src/app/(modules)/users/hooks/useUserQueries.ts +++ b/src/app/(modules)/users/hooks/useUserQueries.ts @@ -3,9 +3,8 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { roleService, userService } from '@/services/api'; -import type { RoleListParams, UserListParams } from '@/types'; -import { roleKeys } from '../../roles/queries/roleKeys'; +import { userService } from '@/services/api'; +import type { UserListParams } from '@/types'; import { userKeys } from '../queries/userKeys'; import type { StatusFilter } from './useUserFilters'; @@ -16,14 +15,6 @@ interface UseUsersQueryParams { statusFilter: StatusFilter; } -const roleLookupParams: RoleListParams = { - skip: 0, - limit: 500, - effective_status: true, - sort_by: 'display_name', - sort_order: 'asc', -}; - function buildUserListParams({ skip, limit, @@ -52,13 +43,3 @@ export function useUsersQuery(params: UseUsersQueryParams) { 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, - }); -} diff --git a/src/app/(modules)/users/page.tsx b/src/app/(modules)/users/page.tsx index 32f17f5..792df4d 100644 --- a/src/app/(modules)/users/page.tsx +++ b/src/app/(modules)/users/page.tsx @@ -18,7 +18,7 @@ import { UserTable } from './components/UserTable'; import { useUserFilters } from './hooks/useUserFilters'; import { useUserForm } from './hooks/useUserForm'; import { useUserStatusMutation } from './hooks/useUserMutations'; -import { useRoleLookupQuery, useUsersQuery } from './hooks/useUserQueries'; +import { useUsersQuery } from './hooks/useUserQueries'; export default function UsersPage() { const [isSheetOpen, setIsSheetOpen] = useState(false); @@ -40,7 +40,6 @@ export default function UsersPage() { searchTerm: debouncedSearchTerm, statusFilter, }); - const rolesQuery = useRoleLookupQuery(isSheetOpen); const userForm = useUserForm({ onSaved: () => setIsSheetOpen(false), }); @@ -85,7 +84,6 @@ export default function UsersPage() { const total = usersQuery.data?.total ?? 0; const users = usersQuery.data?.items ?? []; - const roleOptions = rolesQuery.data?.items ?? []; const toolbar = useMemo( () => ( diff --git a/src/components/async-combobox/AsyncCombobox.tsx b/src/components/async-combobox/AsyncCombobox.tsx new file mode 100644 index 0000000..f6f4d14 --- /dev/null +++ b/src/components/async-combobox/AsyncCombobox.tsx @@ -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; + +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(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 ( + item.label} + isItemEqualToValue={(item, currentValue) => item.value === currentValue.value} + onItemHighlighted={handleItemHighlighted} + > + + {isLoading && !selectedOption ? ( + + + Loading... + + ) : ( + + )} + + } + /> + + + {isError ? ( +
+ {errorMessage || 'Unable to load options.'} +
+ ) : ( + <> + {emptyMessage} + + + + + )} +
+
+ ); +} diff --git a/src/components/async-combobox/AsyncCombobox.types.ts b/src/components/async-combobox/AsyncCombobox.types.ts new file mode 100644 index 0000000..d8224b7 --- /dev/null +++ b/src/components/async-combobox/AsyncCombobox.types.ts @@ -0,0 +1,50 @@ +import type { RefObject } from 'react'; + +export type AsyncComboboxOption = { + value: string; + label: string; +}; + +export type PaginatedResult = { + items: T[]; + total: number; +}; + +export type PaginatedLookupConfig = { + enabled?: boolean; + pageSize?: number; + debounceMs?: number; + selectedValue?: string; + queryKey: (searchTerm: string) => readonly unknown[]; + queryFn: (args: { + searchTerm: string; + skip: number; + limit: number; + }) => Promise>; + mapOption: (item: T) => AsyncComboboxOption; + resolveSelected?: (value: string) => Promise; + 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; +} + +export interface AsyncComboboxProps { + lookup: PaginatedLookupState; + onValueChange: (value: string) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + disabled?: boolean; + portalContainer?: RefObject; +} diff --git a/src/components/async-combobox/VirtualComboboxList.tsx b/src/components/async-combobox/VirtualComboboxList.tsx new file mode 100644 index 0000000..cd847da --- /dev/null +++ b/src/components/async-combobox/VirtualComboboxList.tsx @@ -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; +const LOAD_MORE_THRESHOLD = 3; + +interface VirtualComboboxListProps { + open: boolean; + virtualizerRef: RefObject; + hasNextPage: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; + estimateSize?: number; +} + +export function VirtualComboboxList({ + open, + virtualizerRef, + hasNextPage, + isFetchingNextPage, + onLoadMore, + estimateSize = 36, +}: VirtualComboboxListProps) { + const filteredItems = Combobox.useFilteredItems(); + const scrollElementRef = useRef(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 ( +
+
+ {virtualItems.map((virtualItem) => { + const isLoaderRow = virtualItem.index >= filteredItems.length; + const item = filteredItems[virtualItem.index]; + + return ( +
+ {isLoaderRow ? ( +
+ +
+ ) : ( + + {item.label} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/async-combobox/index.ts b/src/components/async-combobox/index.ts new file mode 100644 index 0000000..a53d063 --- /dev/null +++ b/src/components/async-combobox/index.ts @@ -0,0 +1,9 @@ +export { AsyncCombobox } from './AsyncCombobox'; +export { usePaginatedLookup } from './usePaginatedLookup'; +export type { + AsyncComboboxProps, + AsyncComboboxOption, + PaginatedLookupState, + PaginatedLookupConfig, + PaginatedResult, +} from './AsyncCombobox.types'; diff --git a/src/components/async-combobox/usePaginatedLookup.ts b/src/components/async-combobox/usePaginatedLookup.ts new file mode 100644 index 0000000..6cdec8b --- /dev/null +++ b/src/components/async-combobox/usePaginatedLookup.ts @@ -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({ + enabled = true, + pageSize = 20, + debounceMs = 400, + selectedValue = '', + queryKey, + queryFn, + mapOption, + resolveSelected, + resolveSelectedQueryKey, +}: PaginatedLookupConfig) { + 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(() => { + 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, + }; +} diff --git a/src/components/lookups/RoleCombobox.tsx b/src/components/lookups/RoleCombobox.tsx new file mode 100644 index 0000000..faf2127 --- /dev/null +++ b/src/components/lookups/RoleCombobox.tsx @@ -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({ + 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 ( + + ); +} diff --git a/src/components/lookups/RoleCombobox.types.ts b/src/components/lookups/RoleCombobox.types.ts new file mode 100644 index 0000000..5423ad5 --- /dev/null +++ b/src/components/lookups/RoleCombobox.types.ts @@ -0,0 +1,9 @@ +import type { RefObject } from 'react'; + +export interface RoleComboboxProps { + value: string; + onValueChange: (value: string) => void; + enabled?: boolean; + disabled?: boolean; + portalContainer?: RefObject; +}