From 99c7e26894c0a12f0ccee491aad88a8af7a3c76c Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Tue, 19 May 2026 12:43:53 +0330 Subject: [PATCH] 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. --- src/components/FormElements/MultiSelect.tsx | 240 +++++++++++------- .../notifications/CreateNotification.tsx | 10 + .../useCreateNotificationFormPresenter.ts | 176 ++++++++++--- src/hooks/queries/useCompaniesQueries.ts | 7 +- src/hooks/queries/useCustomerQueries.ts | 7 +- src/hooks/queries/useUsersQueries.ts | 7 +- 6 files changed, 318 insertions(+), 129 deletions(-) diff --git a/src/components/FormElements/MultiSelect.tsx b/src/components/FormElements/MultiSelect.tsx index 83f9723..8a0d7e9 100644 --- a/src/components/FormElements/MultiSelect.tsx +++ b/src/components/FormElements/MultiSelect.tsx @@ -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 = { @@ -26,6 +25,11 @@ type MultiSelectProps = { disabled?: boolean; active?: boolean; height?: "sm" | "default"; + onSearch?: (query: string) => void; + onLoadMore?: () => void; + hasMore?: boolean; + isLoading?: boolean; + isLoadingMore?: boolean; } & UseFormRegisterReturn>; function MultiSelect({ @@ -43,84 +47,112 @@ function MultiSelect({ ref, value, required, + onSearch, + onLoadMore, + hasMore, + isLoading, + isLoadingMore, }: MultiSelectProps) { const id = useId(); const error = errors && errors[name]; - const [options, setOptions] = useState([]); - const [selected, setSelected] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); const [show, setShow] = useState(false); const dropdownRef = useRef(null); + const listRef = useRef(null); const trigger = useRef(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) => { + 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({ return; setShow(false); }; - document?.addEventListener("click", clickHandler); return () => document?.removeEventListener("click", clickHandler); }, [show]); @@ -163,16 +194,16 @@ function MultiSelect({ data-active={active} >
- {selected.map((index) => ( + {selectedItems.map((item) => ( - {options[index]?.label} + {item.label} {!disabled && (
@@ -191,7 +220,7 @@ function MultiSelect({ ({
-
- {options.length === 0 ? ( + {onSearch && ( +
+ 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" + /> +
+ )} +
+ {isLoading ? ( +
+
+
+ ) : options.length === 0 && !isLoadingMore ? (
No items are available
) : ( - options.map((option, index) => ( -
handleSelect(index)} - > + <> + {options.map((option) => (
handleSelect(option)} > - {option.selected && ( - - - - )} +
+ {option.selected && ( + + + + )} +
+ {option.label}
- {option.label} -
- )) + ))} + {isLoadingMore && ( +
+
+
+ )} + )}
diff --git a/src/components/forms/notifications/CreateNotification.tsx b/src/components/forms/notifications/CreateNotification.tsx index 66868dc..96948a1 100644 --- a/src/components/forms/notifications/CreateNotification.tsx +++ b/src/components/forms/notifications/CreateNotification.tsx @@ -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} />
diff --git a/src/components/forms/notifications/useCreateNotificationFormPresenter.ts b/src/components/forms/notifications/useCreateNotificationFormPresenter.ts index 6b9a6c9..a70c2e0 100644 --- a/src/components/forms/notifications/useCreateNotificationFormPresenter.ts +++ b/src/components/forms/notifications/useCreateNotificationFormPresenter.ts @@ -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(null); + const [target, setTarget] = useState(null); + const [recipientSearch, setRecipientSearch] = useState(""); + const debouncedSearch = useDebounce(recipientSearch, 400); + const { register, formState: { errors }, @@ -30,13 +39,128 @@ const useCreateNotificationFormPresenter = () => { control, } = useForm(); 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> = + { + [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 = (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): void => { const type = event.target.value as NotificationTargetGroups; - - const recipientMaps: Partial< - Record | 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, - }; - - setRecipient(recipientMaps[type] ?? null); + setTarget(type); + setRecipientSearch(""); + setValue("recipient", undefined); }; + 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), diff --git a/src/hooks/queries/useCompaniesQueries.ts b/src/hooks/queries/useCompaniesQueries.ts index ae991ed..1192d4e 100644 --- a/src/hooks/queries/useCompaniesQueries.ts +++ b/src/hooks/queries/useCompaniesQueries.ts @@ -103,7 +103,11 @@ export const useCompaniesQuery = (params?: Record) => { }); }; -export const useCompaniesInfiniteQuery = (params?: Record) => { +export const useCompaniesInfiniteQuery = ( + params?: Record, + 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) => { companiesService.getCompanies({ ...params, offset: pageParam }), initialPageParam: 0, + enabled, getNextPageParam: (lastPage: any) => { const { offset, limit, total } = lastPage.meta; diff --git a/src/hooks/queries/useCustomerQueries.ts b/src/hooks/queries/useCustomerQueries.ts index 73ea1b1..c5a4338 100644 --- a/src/hooks/queries/useCustomerQueries.ts +++ b/src/hooks/queries/useCustomerQueries.ts @@ -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) => { +export const useCustomersInfiniteQuery = ( + params?: Record, + 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) => { customersService.getCustomers({ ...params, offset: pageParam }), initialPageParam: 0, + enabled, getNextPageParam: (lastPage) => { if (!lastPage.meta) return; diff --git a/src/hooks/queries/useUsersQueries.ts b/src/hooks/queries/useUsersQueries.ts index 7ad5b2e..7309b99 100644 --- a/src/hooks/queries/useUsersQueries.ts +++ b/src/hooks/queries/useUsersQueries.ts @@ -100,7 +100,11 @@ export const useAdminDetails = (id: string) => { }); }; -export const useAdminListInfiniteQuery = (params?: Record) => { +export const useAdminListInfiniteQuery = ( + params?: Record, + 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) => { userService.adminsList({ ...params, offset: pageParam }), initialPageParam: 0, + enabled, getNextPageParam: (lastPage) => { if (!lastPage.meta) {