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:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import React, { useEffect, useId, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
type FieldErrors,
|
type FieldErrors,
|
||||||
type FieldValues,
|
type FieldValues,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
selected: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MultiSelectProps<T extends FieldValues> = {
|
type MultiSelectProps<T extends FieldValues> = {
|
||||||
@@ -26,6 +25,11 @@ type MultiSelectProps<T extends FieldValues> = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
height?: "sm" | "default";
|
height?: "sm" | "default";
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isLoadingMore?: boolean;
|
||||||
} & UseFormRegisterReturn<Path<T>>;
|
} & UseFormRegisterReturn<Path<T>>;
|
||||||
|
|
||||||
function MultiSelect<T extends FieldValues>({
|
function MultiSelect<T extends FieldValues>({
|
||||||
@@ -43,84 +47,112 @@ function MultiSelect<T extends FieldValues>({
|
|||||||
ref,
|
ref,
|
||||||
value,
|
value,
|
||||||
required,
|
required,
|
||||||
|
onSearch,
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
isLoading,
|
||||||
|
isLoadingMore,
|
||||||
}: MultiSelectProps<T>) {
|
}: MultiSelectProps<T>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const error = errors && errors[name];
|
const error = errors && errors[name];
|
||||||
|
|
||||||
const [options, setOptions] = useState<Option[]>([]);
|
const [selectedItems, setSelectedItems] = useState<Option[]>([]);
|
||||||
const [selected, setSelected] = useState<number[]>([]);
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const trigger = useRef<HTMLDivElement | null>(null);
|
const trigger = useRef<HTMLDivElement | null>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
// Initialize selectedItems from external value + items (edit mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
if (!value || value.length === 0) return;
|
||||||
const safeItems = Array.isArray(items) ? items : [];
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
|
const found = safeItems.filter((item) => value.includes(item.value));
|
||||||
const newOptions = safeItems.map((item) => ({
|
if (found.length > 0) {
|
||||||
...item,
|
setSelectedItems(found);
|
||||||
selected: value?.includes(item.value) ?? false,
|
initializedRef.current = true;
|
||||||
}));
|
}
|
||||||
setOptions(newOptions);
|
|
||||||
|
|
||||||
const newSelectedIndices = safeItems
|
|
||||||
.map((item, index) => (value?.includes(item.value) ? index : -1))
|
|
||||||
.filter((index) => index !== -1);
|
|
||||||
setSelected(newSelectedIndices);
|
|
||||||
}, [items, value]);
|
}, [items, value]);
|
||||||
|
|
||||||
const open = () => {
|
// Reset when value is cleared externally (form reset)
|
||||||
if (!disabled) {
|
useEffect(() => {
|
||||||
setShow(true);
|
if (!value || value.length === 0) {
|
||||||
|
setSelectedItems([]);
|
||||||
|
initializedRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
}, [value]);
|
||||||
|
|
||||||
const isOpen = () => {
|
// Update labels for selected items as new pages load
|
||||||
return show === true;
|
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) => {
|
const combinedRef = (el: HTMLDivElement) => {
|
||||||
trigger.current = el;
|
trigger.current = el;
|
||||||
if (typeof ref === "function") {
|
if (typeof ref === "function") ref(el);
|
||||||
ref(el);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (index: number) => {
|
const handleSelect = (option: Option) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
const newOptions = [...options];
|
let newSelected: Option[];
|
||||||
let newSelected = [...selected];
|
if (selectedValueSet.has(option.value)) {
|
||||||
|
newSelected = selectedItems.filter((i) => i.value !== option.value);
|
||||||
if (!newOptions[index].selected) {
|
|
||||||
newOptions[index].selected = true;
|
|
||||||
newSelected.push(index);
|
|
||||||
} else {
|
} else {
|
||||||
newOptions[index].selected = false;
|
newSelected = [...selectedItems, option];
|
||||||
newSelected = newSelected.filter((i) => i !== index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptions(newOptions);
|
setSelectedItems(newSelected);
|
||||||
setSelected(newSelected);
|
onChange?.({ target: { name, value: newSelected.map((i) => i.value) } });
|
||||||
|
|
||||||
const selectedValues = newSelected.map((i) => newOptions[i].value);
|
|
||||||
onChange?.({ target: { name, value: selectedValues } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = (index: number, event: React.MouseEvent) => {
|
const remove = (itemValue: string, event: React.MouseEvent) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const newOptions = [...options];
|
const newSelected = selectedItems.filter((i) => i.value !== itemValue);
|
||||||
const newSelected = selected.filter((i) => i !== index);
|
setSelectedItems(newSelected);
|
||||||
newOptions[index].selected = false;
|
onChange?.({ target: { name, value: newSelected.map((i) => i.value) } });
|
||||||
|
|
||||||
setOptions(newOptions);
|
|
||||||
setSelected(newSelected);
|
|
||||||
|
|
||||||
const selectedValues = newSelected.map((i) => newOptions[i].value);
|
|
||||||
onChange?.({ target: { name, value: selectedValues } });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
const clickHandler = ({ target }: MouseEvent) => {
|
const clickHandler = ({ target }: MouseEvent) => {
|
||||||
if (!dropdownRef.current) return;
|
if (!dropdownRef.current) return;
|
||||||
@@ -132,7 +164,6 @@ function MultiSelect<T extends FieldValues>({
|
|||||||
return;
|
return;
|
||||||
setShow(false);
|
setShow(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
document?.addEventListener("click", clickHandler);
|
document?.addEventListener("click", clickHandler);
|
||||||
return () => document?.removeEventListener("click", clickHandler);
|
return () => document?.removeEventListener("click", clickHandler);
|
||||||
}, [show]);
|
}, [show]);
|
||||||
@@ -163,16 +194,16 @@ function MultiSelect<T extends FieldValues>({
|
|||||||
data-active={active}
|
data-active={active}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
{selected.map((index) => (
|
{selectedItems.map((item) => (
|
||||||
<span
|
<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"
|
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 && (
|
{!disabled && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
</span>
|
||||||
))}
|
))}
|
||||||
{selected.length === 0 && (
|
{selectedItems.length === 0 && (
|
||||||
<span className="text-dark-6 dark:text-dark-6">
|
<span className="text-dark-6 dark:text-dark-6">{placeholder}</span>
|
||||||
{placeholder}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,7 +220,7 @@ function MultiSelect<T extends FieldValues>({
|
|||||||
<svg
|
<svg
|
||||||
className={cn(
|
className={cn(
|
||||||
"fill-current text-dark-4 transition-transform duration-200 dark:text-dark-6",
|
"fill-current text-dark-4 transition-transform duration-200 dark:text-dark-6",
|
||||||
isOpen() && "rotate-180",
|
show && "rotate-180",
|
||||||
)}
|
)}
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
@@ -211,51 +240,78 @@ function MultiSelect<T extends FieldValues>({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
!isOpen() && "hidden",
|
!show && "hidden",
|
||||||
)}
|
)}
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
>
|
>
|
||||||
<div className="py-1">
|
{onSearch && (
|
||||||
{options.length === 0 ? (
|
<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">
|
<div className="px-3 py-2 text-sm text-dark-6 dark:text-dark-6">
|
||||||
No items are available
|
No items are available
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
options.map((option, index) => (
|
<>
|
||||||
<div
|
{options.map((option) => (
|
||||||
key={index}
|
|
||||||
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)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
key={option.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-3 h-4 w-4 rounded border-2",
|
"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
|
option.selected && "bg-primary/10 dark:bg-primary/20",
|
||||||
? "border-primary bg-primary"
|
|
||||||
: "border-stroke dark:border-dark-3",
|
|
||||||
)}
|
)}
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
>
|
>
|
||||||
{option.selected && (
|
<div
|
||||||
<svg
|
className={cn(
|
||||||
className="h-full w-full text-white"
|
"mr-3 h-4 w-4 rounded border-2",
|
||||||
fill="currentColor"
|
option.selected
|
||||||
viewBox="0 0 20 20"
|
? "border-primary bg-primary"
|
||||||
>
|
: "border-stroke dark:border-dark-3",
|
||||||
<path
|
)}
|
||||||
fillRule="evenodd"
|
>
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
{option.selected && (
|
||||||
clipRule="evenodd"
|
<svg
|
||||||
/>
|
className="h-full w-full text-white"
|
||||||
</svg>
|
fill="currentColor"
|
||||||
)}
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{option.label}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ const CreateNotificationForm = () => {
|
|||||||
recipient,
|
recipient,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onSelectTarget,
|
onSelectTarget,
|
||||||
|
onSearchRecipient,
|
||||||
|
onLoadMoreRecipients,
|
||||||
|
hasMoreRecipients,
|
||||||
|
isLoadingMoreRecipients,
|
||||||
|
isLoadingRecipients,
|
||||||
priorities,
|
priorities,
|
||||||
targets,
|
targets,
|
||||||
types,
|
types,
|
||||||
@@ -85,6 +90,11 @@ const CreateNotificationForm = () => {
|
|||||||
recipient && recipient.length > 0 ? "Choose recipients" : ""
|
recipient && recipient.length > 0 ? "Choose recipients" : ""
|
||||||
}
|
}
|
||||||
name="recipient"
|
name="recipient"
|
||||||
|
onSearch={recipient ? onSearchRecipient : undefined}
|
||||||
|
onLoadMore={onLoadMoreRecipients}
|
||||||
|
hasMore={hasMoreRecipients}
|
||||||
|
isLoading={isLoadingRecipients}
|
||||||
|
isLoadingMore={isLoadingMoreRecipients}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -6,20 +6,29 @@ import {
|
|||||||
NotificationTypes,
|
NotificationTypes,
|
||||||
} from "@/constants/enums";
|
} from "@/constants/enums";
|
||||||
import {
|
import {
|
||||||
useCompanyFullList,
|
useAdminListInfiniteQuery,
|
||||||
useGetAllCustomers,
|
useCompaniesInfiniteQuery,
|
||||||
useGetUsersQuery,
|
useCustomersInfiniteQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import useDebounce from "@/hooks/useDebounce";
|
||||||
import { useCreateNotificationQuery } from "@/hooks/queries/useNotificationQueries";
|
import { useCreateNotificationQuery } from "@/hooks/queries/useNotificationQueries";
|
||||||
import { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
|
import { TCreateNotificationCredentials } from "@/lib/api/types/NotificationHistory";
|
||||||
import { ILabelValue } from "@/types/shared";
|
import { ILabelValue } from "@/types/shared";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ChangeEvent, useState } from "react";
|
import { ChangeEvent, useCallback, useMemo, useState } from "react";
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
const ROLE_ITEMS: ILabelValue[] = [
|
||||||
|
{ label: "Admin", value: "admin" },
|
||||||
|
{ label: "Analyst", value: "analyst" },
|
||||||
|
];
|
||||||
|
|
||||||
const useCreateNotificationFormPresenter = () => {
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
@@ -30,13 +39,128 @@ const useCreateNotificationFormPresenter = () => {
|
|||||||
control,
|
control,
|
||||||
} = useForm<TCreateNotificationCredentials>();
|
} = useForm<TCreateNotificationCredentials>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: companies } = useCompanyFullList();
|
|
||||||
const { data: users } = useGetUsersQuery();
|
const isCompany = target === NotificationTargetGroups.SPECIFIC_COMPANY;
|
||||||
const { data: customers } = useGetAllCustomers();
|
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(() => {
|
const { mutate } = useCreateNotificationQuery(() => {
|
||||||
router.back();
|
router.back();
|
||||||
reset();
|
reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<TCreateNotificationCredentials> = (data) => {
|
const onSubmit: SubmitHandler<TCreateNotificationCredentials> = (data) => {
|
||||||
const credentials = {
|
const credentials = {
|
||||||
...data,
|
...data,
|
||||||
@@ -44,6 +168,7 @@ const useCreateNotificationFormPresenter = () => {
|
|||||||
};
|
};
|
||||||
mutate({ credentials });
|
mutate({ credentials });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
reset();
|
reset();
|
||||||
router.back();
|
router.back();
|
||||||
@@ -51,33 +176,11 @@ const useCreateNotificationFormPresenter = () => {
|
|||||||
|
|
||||||
const onSelectTarget = (event: ChangeEvent<HTMLSelectElement>): void => {
|
const onSelectTarget = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||||
const type = event.target.value as NotificationTargetGroups;
|
const type = event.target.value as NotificationTargetGroups;
|
||||||
|
setTarget(type);
|
||||||
const recipientMaps: Partial<
|
setRecipientSearch("");
|
||||||
Record<NotificationTargetGroups, Array<ILabelValue> | null>
|
setValue("recipient", undefined);
|
||||||
> = {
|
|
||||||
[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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
@@ -92,6 +195,11 @@ const useCreateNotificationFormPresenter = () => {
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
control,
|
control,
|
||||||
recipient,
|
recipient,
|
||||||
|
onSearchRecipient,
|
||||||
|
onLoadMoreRecipients,
|
||||||
|
hasMoreRecipients,
|
||||||
|
isLoadingMoreRecipients,
|
||||||
|
isLoadingRecipients,
|
||||||
targets: getEnumAsArray(NotificationTargetGroups),
|
targets: getEnumAsArray(NotificationTargetGroups),
|
||||||
types: getEnumAsArray(NotificationTypes),
|
types: getEnumAsArray(NotificationTypes),
|
||||||
channels: getEnumAsArray(NotificationChannels),
|
channels: getEnumAsArray(NotificationChannels),
|
||||||
|
|||||||
@@ -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(
|
const queryKey = useMemo(
|
||||||
() => [API_ENDPOINTS.COMPANIES.READ_ALL, params],
|
() => [API_ENDPOINTS.COMPANIES.READ_ALL, params],
|
||||||
[params],
|
[params],
|
||||||
@@ -116,6 +120,7 @@ export const useCompaniesInfiniteQuery = (params?: Record<string, any>) => {
|
|||||||
companiesService.getCompanies({ ...params, offset: pageParam }),
|
companiesService.getCompanies({ ...params, offset: pageParam }),
|
||||||
|
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
|
enabled,
|
||||||
|
|
||||||
getNextPageParam: (lastPage: any) => {
|
getNextPageParam: (lastPage: any) => {
|
||||||
const { offset, limit, total } = lastPage.meta;
|
const { offset, limit, total } = lastPage.meta;
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { toast } from "react-toastify";
|
|||||||
import { API_ENDPOINTS } from "@/lib/api";
|
import { API_ENDPOINTS } from "@/lib/api";
|
||||||
import { customersService } from "@/lib/api/services/customers-service";
|
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(
|
const queryKey = useMemo(
|
||||||
() => [API_ENDPOINTS.CUSTOMERS.READ_ALL, params],
|
() => [API_ENDPOINTS.CUSTOMERS.READ_ALL, params],
|
||||||
[params],
|
[params],
|
||||||
@@ -25,6 +29,7 @@ export const useCustomersInfiniteQuery = (params?: Record<string, any>) => {
|
|||||||
customersService.getCustomers({ ...params, offset: pageParam }),
|
customersService.getCustomers({ ...params, offset: pageParam }),
|
||||||
|
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
|
enabled,
|
||||||
|
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
if (!lastPage.meta) return;
|
if (!lastPage.meta) return;
|
||||||
|
|||||||
@@ -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]);
|
const queryKey = useMemo(() => [API_ENDPOINTS.USER.ADMINS, params], [params]);
|
||||||
|
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
@@ -110,6 +114,7 @@ export const useAdminListInfiniteQuery = (params?: Record<string, any>) => {
|
|||||||
userService.adminsList({ ...params, offset: pageParam }),
|
userService.adminsList({ ...params, offset: pageParam }),
|
||||||
|
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
|
enabled,
|
||||||
|
|
||||||
getNextPageParam: (lastPage) => {
|
getNextPageParam: (lastPage) => {
|
||||||
if (!lastPage.meta) {
|
if (!lastPage.meta) {
|
||||||
|
|||||||
Reference in New Issue
Block a user