feat(multi-select): enhance multi-select component with search and load more functionality

- Added onSearch and onLoadMore props to the MultiSelect component for improved user interaction.
- Refactored state management to handle selected items and search queries more effectively.
- Updated the CreateNotificationForm to integrate the new multi-select features for recipient selection.
- Implemented infinite scrolling logic in the useCompaniesInfiniteQuery, useCustomersInfiniteQuery, and useAdminListInfiniteQuery hooks to support dynamic data loading.
- Enhanced the useCreateNotificationFormPresenter to manage recipient search and loading states.
This commit is contained in:
AmirReza Jamali
2026-05-19 12:43:53 +03:30
parent d34d405cd3
commit 99c7e26894
6 changed files with 318 additions and 129 deletions
+123 -67
View File
@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import React, { useEffect, useId, useRef, useState } from "react";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import {
type FieldErrors,
type FieldValues,
@@ -12,7 +12,6 @@ import {
interface Option {
value: string;
label: string;
selected: boolean;
}
type MultiSelectProps<T extends FieldValues> = {
@@ -26,6 +25,11 @@ type MultiSelectProps<T extends FieldValues> = {
disabled?: boolean;
active?: boolean;
height?: "sm" | "default";
onSearch?: (query: string) => void;
onLoadMore?: () => void;
hasMore?: boolean;
isLoading?: boolean;
isLoadingMore?: boolean;
} & UseFormRegisterReturn<Path<T>>;
function MultiSelect<T extends FieldValues>({
@@ -43,84 +47,112 @@ function MultiSelect<T extends FieldValues>({
ref,
value,
required,
onSearch,
onLoadMore,
hasMore,
isLoading,
isLoadingMore,
}: MultiSelectProps<T>) {
const id = useId();
const error = errors && errors[name];
const [options, setOptions] = useState<Option[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [selectedItems, setSelectedItems] = useState<Option[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [show, setShow] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLDivElement | null>(null);
const initializedRef = useRef(false);
// Initialize selectedItems from external value + items (edit mode)
useEffect(() => {
if (initializedRef.current) return;
if (!value || value.length === 0) return;
const safeItems = Array.isArray(items) ? items : [];
const newOptions = safeItems.map((item) => ({
...item,
selected: value?.includes(item.value) ?? false,
}));
setOptions(newOptions);
const newSelectedIndices = safeItems
.map((item, index) => (value?.includes(item.value) ? index : -1))
.filter((index) => index !== -1);
setSelected(newSelectedIndices);
const found = safeItems.filter((item) => value.includes(item.value));
if (found.length > 0) {
setSelectedItems(found);
initializedRef.current = true;
}
}, [items, value]);
const open = () => {
if (!disabled) {
setShow(true);
// Reset when value is cleared externally (form reset)
useEffect(() => {
if (!value || value.length === 0) {
setSelectedItems([]);
initializedRef.current = false;
}
};
}, [value]);
const isOpen = () => {
return show === true;
// Update labels for selected items as new pages load
useEffect(() => {
const safeItems = Array.isArray(items) ? items : [];
if (!safeItems.length) return;
setSelectedItems((prev) => {
const needsUpdate = prev.some((s) => {
const found = safeItems.find((item) => item.value === s.value);
return found && found.label !== s.label;
});
if (!needsUpdate) return prev;
return prev.map((s) => {
const found = safeItems.find((item) => item.value === s.value);
return found ? { value: found.value, label: found.label } : s;
});
});
}, [items]);
const selectedValueSet = new Set(selectedItems.map((i) => i.value));
const options = (Array.isArray(items) ? items : []).map((item) => ({
...item,
selected: selectedValueSet.has(item.value),
}));
const open = () => {
if (!disabled) setShow(true);
};
const combinedRef = (el: HTMLDivElement) => {
trigger.current = el;
if (typeof ref === "function") {
ref(el);
}
if (typeof ref === "function") ref(el);
};
const handleSelect = (index: number) => {
const handleSelect = (option: Option) => {
if (disabled) return;
const newOptions = [...options];
let newSelected = [...selected];
if (!newOptions[index].selected) {
newOptions[index].selected = true;
newSelected.push(index);
let newSelected: Option[];
if (selectedValueSet.has(option.value)) {
newSelected = selectedItems.filter((i) => i.value !== option.value);
} else {
newOptions[index].selected = false;
newSelected = newSelected.filter((i) => i !== index);
newSelected = [...selectedItems, option];
}
setOptions(newOptions);
setSelected(newSelected);
const selectedValues = newSelected.map((i) => newOptions[i].value);
onChange?.({ target: { name, value: selectedValues } });
setSelectedItems(newSelected);
onChange?.({ target: { name, value: newSelected.map((i) => i.value) } });
};
const remove = (index: number, event: React.MouseEvent) => {
const remove = (itemValue: string, event: React.MouseEvent) => {
if (disabled) return;
event.stopPropagation();
const newOptions = [...options];
const newSelected = selected.filter((i) => i !== index);
newOptions[index].selected = false;
setOptions(newOptions);
setSelected(newSelected);
const selectedValues = newSelected.map((i) => newOptions[i].value);
onChange?.({ target: { name, value: selectedValues } });
const newSelected = selectedItems.filter((i) => i.value !== itemValue);
setSelectedItems(newSelected);
onChange?.({ target: { name, value: newSelected.map((i) => i.value) } });
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const q = e.target.value;
setSearchQuery(q);
onSearch?.(q);
};
const handleScroll = useCallback(() => {
if (!listRef.current || !onLoadMore || !hasMore || isLoadingMore) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
if (scrollHeight - scrollTop - clientHeight < 40) {
onLoadMore();
}
}, [onLoadMore, hasMore, isLoadingMore]);
// Close on outside click
useEffect(() => {
const clickHandler = ({ target }: MouseEvent) => {
if (!dropdownRef.current) return;
@@ -132,7 +164,6 @@ function MultiSelect<T extends FieldValues>({
return;
setShow(false);
};
document?.addEventListener("click", clickHandler);
return () => document?.removeEventListener("click", clickHandler);
}, [show]);
@@ -163,16 +194,16 @@ function MultiSelect<T extends FieldValues>({
data-active={active}
>
<div className="flex flex-1 flex-wrap items-center gap-2">
{selected.map((index) => (
{selectedItems.map((item) => (
<span
key={index}
key={item.value}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2.5 py-1 text-sm font-medium text-primary dark:bg-primary/20"
>
{options[index]?.label}
{item.label}
{!disabled && (
<button
type="button"
onClick={(e) => remove(index, e)}
onClick={(e) => remove(item.value, e)}
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-xs hover:bg-primary/20"
>
×
@@ -180,10 +211,8 @@ function MultiSelect<T extends FieldValues>({
)}
</span>
))}
{selected.length === 0 && (
<span className="text-dark-6 dark:text-dark-6">
{placeholder}
</span>
{selectedItems.length === 0 && (
<span className="text-dark-6 dark:text-dark-6">{placeholder}</span>
)}
</div>
@@ -191,7 +220,7 @@ function MultiSelect<T extends FieldValues>({
<svg
className={cn(
"fill-current text-dark-4 transition-transform duration-200 dark:text-dark-6",
isOpen() && "rotate-180",
show && "rotate-180",
)}
width="20"
height="20"
@@ -211,25 +240,46 @@ function MultiSelect<T extends FieldValues>({
<div
className={cn(
"absolute left-0 top-full z-40 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-stroke bg-white shadow-lg dark:border-dark-3 dark:bg-dark-2",
!isOpen() && "hidden",
"absolute left-0 top-full z-40 mt-1 w-full rounded-lg border border-stroke bg-white shadow-lg dark:border-dark-3 dark:bg-dark-2",
!show && "hidden",
)}
ref={dropdownRef}
>
<div className="py-1">
{options.length === 0 ? (
{onSearch && (
<div className="border-b border-stroke p-2 dark:border-dark-3">
<input
type="text"
value={searchQuery}
onChange={handleSearch}
onClick={(e) => e.stopPropagation()}
placeholder="Search..."
className="w-full rounded-md border border-stroke bg-transparent px-3 py-1.5 text-sm outline-none focus:border-primary dark:border-dark-3 dark:text-white dark:focus:border-primary"
/>
</div>
)}
<div
ref={listRef}
onScroll={handleScroll}
className="max-h-52 overflow-y-auto py-1"
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : options.length === 0 && !isLoadingMore ? (
<div className="px-3 py-2 text-sm text-dark-6 dark:text-dark-6">
No items are available
</div>
) : (
options.map((option, index) => (
<>
{options.map((option) => (
<div
key={index}
key={option.value}
className={cn(
"flex cursor-pointer items-center px-3 py-2 text-sm text-dark hover:bg-primary/5 dark:text-white dark:hover:bg-primary/5",
option.selected && "bg-primary/10 dark:bg-primary/20",
)}
onClick={() => handleSelect(index)}
onClick={() => handleSelect(option)}
>
<div
className={cn(
@@ -255,7 +305,13 @@ function MultiSelect<T extends FieldValues>({
</div>
{option.label}
</div>
))
))}
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
</>
)}
</div>
</div>
@@ -19,6 +19,11 @@ const CreateNotificationForm = () => {
recipient,
onSubmit,
onSelectTarget,
onSearchRecipient,
onLoadMoreRecipients,
hasMoreRecipients,
isLoadingMoreRecipients,
isLoadingRecipients,
priorities,
targets,
types,
@@ -85,6 +90,11 @@ const CreateNotificationForm = () => {
recipient && recipient.length > 0 ? "Choose recipients" : ""
}
name="recipient"
onSearch={recipient ? onSearchRecipient : undefined}
onLoadMore={onLoadMoreRecipients}
hasMore={hasMoreRecipients}
isLoading={isLoadingRecipients}
isLoadingMore={isLoadingMoreRecipients}
/>
</div>
<div className="flex flex-col gap-2">
@@ -6,20 +6,29 @@ import {
NotificationTypes,
} from "@/constants/enums";
import {
useCompanyFullList,
useGetAllCustomers,
useGetUsersQuery,
useAdminListInfiniteQuery,
useCompaniesInfiniteQuery,
useCustomersInfiniteQuery,
} from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { useCreateNotificationQuery } from "@/hooks/queries/useNotificationQueries";
import { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
import { ILabelValue } from "@/types/shared";
import { getEnumAsArray } from "@/utils/shared";
import { useRouter } from "next/navigation";
import { ChangeEvent, useState } from "react";
import { ChangeEvent, useCallback, useMemo, useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
const ROLE_ITEMS: ILabelValue[] = [
{ label: "Admin", value: "admin" },
{ label: "Analyst", value: "analyst" },
];
const useCreateNotificationFormPresenter = () => {
const [recipient, setRecipient] = useState<ILabelValue[] | null>(null);
const [target, setTarget] = useState<NotificationTargetGroups | null>(null);
const [recipientSearch, setRecipientSearch] = useState("");
const debouncedSearch = useDebounce(recipientSearch, 400);
const {
register,
formState: { errors },
@@ -30,13 +39,128 @@ const useCreateNotificationFormPresenter = () => {
control,
} = useForm<TCreateNotificationCredentials>();
const router = useRouter();
const { data: companies } = useCompanyFullList();
const { data: users } = useGetUsersQuery();
const { data: customers } = useGetAllCustomers();
const isCompany = target === NotificationTargetGroups.SPECIFIC_COMPANY;
const isUser = target === NotificationTargetGroups.SPECIFIC_USERS;
const isCustomer = target === NotificationTargetGroups.SPECIFIC_CUSTOMER;
const searchParam = debouncedSearch ? { search: debouncedSearch } : undefined;
const companiesQuery = useCompaniesInfiniteQuery(searchParam, {
enabled: isCompany,
});
const usersQuery = useAdminListInfiniteQuery(searchParam, {
enabled: isUser,
});
const customersQuery = useCustomersInfiniteQuery(searchParam, {
enabled: isCustomer,
});
const companiesItems = useMemo(() => {
if (!companiesQuery.data) return null;
return companiesQuery.data.pages.flatMap(
(page) =>
page.data.companies?.map((c) => ({ label: c.name, value: c.id })) ?? [],
);
}, [companiesQuery.data]);
const usersItems = useMemo(() => {
if (!usersQuery.data) return null;
return usersQuery.data.pages.flatMap(
(page) =>
page.data.users?.map((u) => ({
label: u.full_name ?? u.username ?? u.id,
value: u.id,
})) ?? [],
);
}, [usersQuery.data]);
const customersItems = useMemo(() => {
if (!customersQuery.data) return null;
return customersQuery.data.pages.flatMap(
(page) =>
page.data.customers?.map((c) => ({
label: c.full_name,
value: c.id,
})) ?? [],
);
}, [customersQuery.data]);
const recipient = useMemo((): ILabelValue[] | null => {
if (!target) return null;
const map: Partial<Record<NotificationTargetGroups, ILabelValue[] | null>> =
{
[NotificationTargetGroups.SPECIFIC_COMPANY]: companiesItems,
[NotificationTargetGroups.SPECIFIC_USERS]: usersItems,
[NotificationTargetGroups.SPECIFIC_CUSTOMER]: customersItems,
[NotificationTargetGroups.SPECIFIC_ROLE]: ROLE_ITEMS,
};
return map[target] ?? null;
}, [target, companiesItems, usersItems, customersItems]);
const hasMoreRecipients = useMemo(() => {
if (isCompany) return companiesQuery.hasNextPage;
if (isUser) return usersQuery.hasNextPage;
if (isCustomer) return customersQuery.hasNextPage;
return false;
}, [
isCompany,
isUser,
isCustomer,
companiesQuery.hasNextPage,
usersQuery.hasNextPage,
customersQuery.hasNextPage,
]);
const isLoadingMoreRecipients = useMemo(() => {
if (isCompany) return companiesQuery.isFetchingNextPage;
if (isUser) return usersQuery.isFetchingNextPage;
if (isCustomer) return customersQuery.isFetchingNextPage;
return false;
}, [
isCompany,
isUser,
isCustomer,
companiesQuery.isFetchingNextPage,
usersQuery.isFetchingNextPage,
customersQuery.isFetchingNextPage,
]);
// True during initial/search fetches (not load-more)
const isLoadingRecipients = useMemo(() => {
if (isCompany)
return companiesQuery.isFetching && !companiesQuery.isFetchingNextPage;
if (isUser) return usersQuery.isFetching && !usersQuery.isFetchingNextPage;
if (isCustomer)
return customersQuery.isFetching && !customersQuery.isFetchingNextPage;
return false;
}, [
isCompany,
isUser,
isCustomer,
companiesQuery.isFetching,
companiesQuery.isFetchingNextPage,
usersQuery.isFetching,
usersQuery.isFetchingNextPage,
customersQuery.isFetching,
customersQuery.isFetchingNextPage,
]);
const onLoadMoreRecipients = useCallback(() => {
if (isCompany) companiesQuery.fetchNextPage();
else if (isUser) usersQuery.fetchNextPage();
else if (isCustomer) customersQuery.fetchNextPage();
}, [isCompany, isUser, isCustomer, companiesQuery, usersQuery, customersQuery]);
const onSearchRecipient = useCallback((query: string) => {
setRecipientSearch(query);
}, []);
const { mutate } = useCreateNotificationQuery(() => {
router.back();
reset();
});
const onSubmit: SubmitHandler<TCreateNotificationCredentials> = (data) => {
const credentials = {
...data,
@@ -44,6 +168,7 @@ const useCreateNotificationFormPresenter = () => {
};
mutate({ credentials });
};
const handleCancel = () => {
reset();
router.back();
@@ -51,33 +176,11 @@ const useCreateNotificationFormPresenter = () => {
const onSelectTarget = (event: ChangeEvent<HTMLSelectElement>): void => {
const type = event.target.value as NotificationTargetGroups;
const recipientMaps: Partial<
Record<NotificationTargetGroups, Array<ILabelValue> | null>
> = {
[NotificationTargetGroups.SPECIFIC_COMPANY]:
companies?.data.companies?.map((company) => ({
label: company.name,
value: company.id,
})) ?? null,
[NotificationTargetGroups.SPECIFIC_ROLE]: [
{ label: "Admin", value: "admin" },
{ label: "Analyst", value: "analyst" },
],
[NotificationTargetGroups.SPECIFIC_USERS]:
users?.data.users?.map((user) => ({
label: user.full_name ?? "",
value: user.id,
})) ?? null,
[NotificationTargetGroups.SPECIFIC_CUSTOMER]:
customers?.data.customers?.map((customer) => ({
label: customer.full_name,
value: customer.id,
})) ?? null,
setTarget(type);
setRecipientSearch("");
setValue("recipient", undefined);
};
setRecipient(recipientMaps[type] ?? null);
};
return {
handleSubmit,
watch,
@@ -92,6 +195,11 @@ const useCreateNotificationFormPresenter = () => {
onSubmit,
control,
recipient,
onSearchRecipient,
onLoadMoreRecipients,
hasMoreRecipients,
isLoadingMoreRecipients,
isLoadingRecipients,
targets: getEnumAsArray(NotificationTargetGroups),
types: getEnumAsArray(NotificationTypes),
channels: getEnumAsArray(NotificationChannels),
+6 -1
View File
@@ -103,7 +103,11 @@ export const useCompaniesQuery = (params?: Record<string, any>) => {
});
};
export const useCompaniesInfiniteQuery = (params?: Record<string, any>) => {
export const useCompaniesInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(
() => [API_ENDPOINTS.COMPANIES.READ_ALL, params],
[params],
@@ -116,6 +120,7 @@ export const useCompaniesInfiniteQuery = (params?: Record<string, any>) => {
companiesService.getCompanies({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage: any) => {
const { offset, limit, total } = lastPage.meta;
+6 -1
View File
@@ -12,7 +12,11 @@ import { toast } from "react-toastify";
import { API_ENDPOINTS } from "@/lib/api";
import { customersService } from "@/lib/api/services/customers-service";
export const useCustomersInfiniteQuery = (params?: Record<string, any>) => {
export const useCustomersInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(
() => [API_ENDPOINTS.CUSTOMERS.READ_ALL, params],
[params],
@@ -25,6 +29,7 @@ export const useCustomersInfiniteQuery = (params?: Record<string, any>) => {
customersService.getCustomers({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage) => {
if (!lastPage.meta) return;
+6 -1
View File
@@ -100,7 +100,11 @@ export const useAdminDetails = (id: string) => {
});
};
export const useAdminListInfiniteQuery = (params?: Record<string, any>) => {
export const useAdminListInfiniteQuery = (
params?: Record<string, any>,
options?: { enabled?: boolean },
) => {
const enabled = options?.enabled ?? true;
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
return useInfiniteQuery({
@@ -110,6 +114,7 @@ export const useAdminListInfiniteQuery = (params?: Record<string, any>) => {
userService.adminsList({ ...params, offset: pageParam }),
initialPageParam: 0,
enabled,
getNextPageParam: (lastPage) => {
if (!lastPage.meta) {