chore(env): update application version to 2.3.1 in production environment
feat(customers): enhance customer list filters with lazy-loaded company options - Added InputGroup components for searching by full name and email in CustomerListFilters. - Integrated Select component for company selection with lazy-loading capabilities. - Implemented company search and load more functionality using useCompaniesInfiniteQuery. - Updated useCustomerListPresenter to manage company search state and loading indicators. - Enhanced customer list filtering experience by allowing dynamic company selection.
This commit is contained in:
@@ -133,6 +133,20 @@ type PropsType<T extends FieldValues> = {
|
||||
searchQueryKey?: readonly unknown[];
|
||||
/** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */
|
||||
searchFn?: (query: string) => Promise<ILabelValue[]>;
|
||||
/**
|
||||
* External lazy-load mode: the parent owns `items`, searching and pagination.
|
||||
* Providing `onSearch` and/or `onLoadMore` opts in — the dropdown renders a search
|
||||
* box, streams `items` as-is and requests more on scroll. Pairs with an infinite query.
|
||||
*/
|
||||
onSearch?: (query: string) => void;
|
||||
/** Fetch the next page; called when the option list is scrolled near its end. */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether more pages are available to lazy-load. */
|
||||
hasMore?: boolean;
|
||||
/** Initial / search fetch in progress (external mode). */
|
||||
isLoading?: boolean;
|
||||
/** Next-page fetch in progress (external mode). */
|
||||
isLoadingMore?: boolean;
|
||||
} & (
|
||||
| { placeholder?: string; defaultValue?: string }
|
||||
| { placeholder: string; defaultValue?: string }
|
||||
@@ -163,11 +177,22 @@ export function Select<T extends FieldValues>({
|
||||
searchDebounceMs = 300,
|
||||
searchQueryKey,
|
||||
searchFn,
|
||||
onSearch,
|
||||
onLoadMore,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
register: _register,
|
||||
...props
|
||||
}: PropsType<T>) {
|
||||
void _register;
|
||||
|
||||
/** Parent-controlled list: search + pagination handled outside, `items` consumed as-is. */
|
||||
const isExternal =
|
||||
typeof onSearch === "function" || typeof onLoadMore === "function";
|
||||
/** Both `searchable` and external mode render the in-dropdown search box. */
|
||||
const showSearch = searchable || isExternal;
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" &&
|
||||
searchable &&
|
||||
@@ -186,6 +211,7 @@ export function Select<T extends FieldValues>({
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue);
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
@@ -221,10 +247,21 @@ export function Select<T extends FieldValues>({
|
||||
});
|
||||
|
||||
const listItems = useMemo(() => {
|
||||
if (isExternal) return items;
|
||||
if (!searchable) return items;
|
||||
if (isServerMode && serverDisplayItems) return serverDisplayItems;
|
||||
return filterSelectItems(items, query);
|
||||
}, [searchable, isServerMode, serverDisplayItems, items, query]);
|
||||
}, [isExternal, searchable, isServerMode, serverDisplayItems, items, query]);
|
||||
|
||||
/** Lazy-load the next page once the option list nears its bottom (external mode). */
|
||||
const handleListScroll = useCallback(() => {
|
||||
if (!isExternal || !onLoadMore || !hasMore || isLoadingMore) return;
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 40) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [isExternal, onLoadMore, hasMore, isLoadingMore]);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const hit = listItems.find((i) => String(i.value) === String(value));
|
||||
@@ -287,13 +324,13 @@ export function Select<T extends FieldValues>({
|
||||
}, [open, listboxId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchable || !open) return;
|
||||
if (!showSearch || !open) return;
|
||||
const idFrame = window.requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(idFrame);
|
||||
}, [open, searchable]);
|
||||
}, [open, showSearch]);
|
||||
|
||||
const emitChange = (next: string) => {
|
||||
if (controlledValue === undefined) {
|
||||
@@ -323,7 +360,9 @@ export function Select<T extends FieldValues>({
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
const dropdownItems = searchable ? listItems : items;
|
||||
const dropdownItems = showSearch ? listItems : items;
|
||||
/** Mono value chip next to labels only helps the client/server search modes, not external lists. */
|
||||
const showItemValue = searchable && !isExternal;
|
||||
|
||||
const dropdown =
|
||||
mounted &&
|
||||
@@ -345,7 +384,7 @@ export function Select<T extends FieldValues>({
|
||||
panelRect.placement === "above" ? "translateY(-100%)" : undefined,
|
||||
}}
|
||||
>
|
||||
{searchable ? (
|
||||
{showSearch ? (
|
||||
<div className="border-b border-stroke/50 p-2 dark:border-white/[0.08]">
|
||||
<label htmlFor={`${listboxId}-search`} className="sr-only">
|
||||
{searchPlaceholder}
|
||||
@@ -355,7 +394,10 @@ export function Select<T extends FieldValues>({
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
if (isExternal) onSearch?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
@@ -372,51 +414,62 @@ export function Select<T extends FieldValues>({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5">
|
||||
{searchable && isSearchLoading ? (
|
||||
<ul
|
||||
ref={listRef}
|
||||
onScroll={isExternal ? handleListScroll : undefined}
|
||||
className="max-h-[min(18rem,calc(100vh-10rem))] overflow-y-auto overscroll-contain p-1.5"
|
||||
>
|
||||
{(isExternal ? isLoading : searchable && isSearchLoading) ? (
|
||||
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-dark-5 dark:text-dark-6">
|
||||
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
||||
Searching…
|
||||
{isExternal ? "Loading…" : "Searching…"}
|
||||
</li>
|
||||
) : searchable && isSearchError ? (
|
||||
) : !isExternal && searchable && isSearchError ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-error">
|
||||
Could not load options. Try again.
|
||||
</li>
|
||||
) : dropdownItems.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-sm text-dark-5 dark:text-dark-6">
|
||||
{searchable ? "No matches." : "No options."}
|
||||
{showSearch ? "No matches." : "No options."}
|
||||
</li>
|
||||
) : (
|
||||
dropdownItems.map((item) => {
|
||||
const v = String(item.value);
|
||||
const isActive = v === String(value);
|
||||
return (
|
||||
<li key={v}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
data-cy={dataCy ? `${dataCy}-option` : undefined}
|
||||
data-value={v}
|
||||
onClick={() => pickItem(v)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
|
||||
searchable ? "justify-between" : "justify-start",
|
||||
isActive
|
||||
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
|
||||
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
{searchable ? (
|
||||
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
|
||||
{v}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<>
|
||||
{dropdownItems.map((item) => {
|
||||
const v = String(item.value);
|
||||
const isActive = v === String(value);
|
||||
return (
|
||||
<li key={v}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
data-cy={dataCy ? `${dataCy}-option` : undefined}
|
||||
data-value={v}
|
||||
onClick={() => pickItem(v)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-start text-sm transition",
|
||||
showItemValue ? "justify-between" : "justify-start",
|
||||
isActive
|
||||
? "bg-primary/12 font-medium text-primary dark:bg-primary/20 dark:text-white"
|
||||
: "text-dark hover:bg-stroke/30 dark:text-white dark:hover:bg-white/[0.06]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.label}</span>
|
||||
{showItemValue ? (
|
||||
<span className="shrink-0 font-mono text-xs text-dark-5 dark:text-dark-6">
|
||||
{v}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{isExternal && isLoadingMore ? (
|
||||
<li className="flex items-center justify-center py-2">
|
||||
<span className="inline-flex h-4 w-4 animate-spin rounded-full border-2 border-primary/25 border-t-primary" />
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
@@ -508,7 +561,7 @@ export function Select<T extends FieldValues>({
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{value ? (
|
||||
searchable ? (
|
||||
showItemValue ? (
|
||||
<>
|
||||
<span className="text-dark dark:text-white">
|
||||
{selectedLabel}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
|
||||
import { ILabelValue } from "@/types/shared";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
import {
|
||||
FieldValues,
|
||||
@@ -22,11 +24,25 @@ interface CustomerListFiltersProps {
|
||||
watch: UseFormWatch<FieldValues>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
onClearAll: () => void;
|
||||
/** Lazy-loaded company options for the company dropdown. */
|
||||
companyItems: ILabelValue[];
|
||||
onCompanySearch: (query: string) => void;
|
||||
onCompanyLoadMore: () => void;
|
||||
companyHasMore?: boolean;
|
||||
companyIsLoading: boolean;
|
||||
companyIsLoadingMore: boolean;
|
||||
}
|
||||
|
||||
function countActive(values: Record<string, unknown>): number {
|
||||
let n = 0;
|
||||
for (const key of ["status", "role", "type"] as const) {
|
||||
for (const key of [
|
||||
"status",
|
||||
"role",
|
||||
"type",
|
||||
"full_name",
|
||||
"email",
|
||||
"company_id",
|
||||
] as const) {
|
||||
const v = values[key];
|
||||
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
|
||||
}
|
||||
@@ -41,6 +57,12 @@ const CustomerListFilters = ({
|
||||
watch,
|
||||
setValue,
|
||||
onClearAll,
|
||||
companyItems,
|
||||
onCompanySearch,
|
||||
onCompanyLoadMore,
|
||||
companyHasMore,
|
||||
companyIsLoading,
|
||||
companyIsLoadingMore,
|
||||
}: CustomerListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
@@ -63,6 +85,36 @@ const CustomerListFilters = ({
|
||||
data-cy="customers-filter-form"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<InputGroup
|
||||
{...filterRegister("full_name")}
|
||||
name="full_name"
|
||||
label="Full name"
|
||||
type="text"
|
||||
placeholder="Search by full name"
|
||||
/>
|
||||
<InputGroup
|
||||
{...filterRegister("email")}
|
||||
name="email"
|
||||
label="Email"
|
||||
type="text"
|
||||
placeholder="Search by email"
|
||||
/>
|
||||
<Select
|
||||
{...filterRegister("company_id")}
|
||||
items={companyItems}
|
||||
label="Company"
|
||||
name="company_id"
|
||||
placeholder="Select company"
|
||||
clearable
|
||||
value={watch("company_id")}
|
||||
onClear={() => setValue("company_id", undefined)}
|
||||
searchPlaceholder="Search companies…"
|
||||
onSearch={onCompanySearch}
|
||||
onLoadMore={onCompanyLoadMore}
|
||||
hasMore={companyHasMore}
|
||||
isLoading={companyIsLoading}
|
||||
isLoadingMore={companyIsLoadingMore}
|
||||
/>
|
||||
<Select
|
||||
{...filterRegister("status")}
|
||||
items={getEnumAsArray(CustomerStatus)}
|
||||
|
||||
@@ -75,6 +75,12 @@ const CustomersTable = () => {
|
||||
openResetPasswordModalForCustomer,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
companyItems,
|
||||
onCompanySearch,
|
||||
onCompanyLoadMore,
|
||||
companyHasMore,
|
||||
companyIsLoading,
|
||||
companyIsLoadingMore,
|
||||
} = useCustomerListPresenter();
|
||||
|
||||
return (
|
||||
@@ -88,6 +94,12 @@ const CustomersTable = () => {
|
||||
search={search}
|
||||
watch={watch}
|
||||
onClearAll={clearAllFilters}
|
||||
companyItems={companyItems}
|
||||
onCompanySearch={onCompanySearch}
|
||||
onCompanyLoadMore={onCompanyLoadMore}
|
||||
companyHasMore={companyHasMore}
|
||||
companyIsLoading={companyIsLoading}
|
||||
companyIsLoadingMore={companyIsLoadingMore}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<TableSortProvider
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { SortDirection } from "@/components/ui/tableSortContext";
|
||||
import {
|
||||
useAssignCompany,
|
||||
useCompaniesInfiniteQuery,
|
||||
useCompanyFullList,
|
||||
useCustomersQuery,
|
||||
useDeleteCustomerQuery,
|
||||
useResetCustomerPassword,
|
||||
} from "@/hooks/queries";
|
||||
import useDebounce from "@/hooks/useDebounce";
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
||||
@@ -50,6 +52,35 @@ const useCustomerListPresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
// Lazy-loaded company options for the inline filter dropdown.
|
||||
const [companySearch, setCompanySearch] = useState("");
|
||||
const debouncedCompanySearch = useDebounce(companySearch, 400);
|
||||
const companiesQuery = useCompaniesInfiniteQuery(
|
||||
debouncedCompanySearch ? { search: debouncedCompanySearch } : undefined,
|
||||
);
|
||||
|
||||
const companyItems = useMemo(
|
||||
() =>
|
||||
companiesQuery.data?.pages.flatMap(
|
||||
(page) =>
|
||||
page.data.companies?.map((company) => ({
|
||||
label: company.name,
|
||||
value: company.id,
|
||||
})) ?? [],
|
||||
) ?? [],
|
||||
[companiesQuery.data],
|
||||
);
|
||||
|
||||
const onCompanySearch = useCallback((query: string) => {
|
||||
setCompanySearch(query);
|
||||
}, []);
|
||||
|
||||
const onCompanyLoadMore = useCallback(() => {
|
||||
if (companiesQuery.hasNextPage && !companiesQuery.isFetchingNextPage) {
|
||||
companiesQuery.fetchNextPage();
|
||||
}
|
||||
}, [companiesQuery]);
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
@@ -125,7 +156,15 @@ const useCustomerListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
filterFormReset({ status: undefined, role: undefined, type: undefined });
|
||||
filterFormReset({
|
||||
status: undefined,
|
||||
role: undefined,
|
||||
type: undefined,
|
||||
full_name: undefined,
|
||||
email: undefined,
|
||||
company_id: undefined,
|
||||
});
|
||||
setCompanySearch("");
|
||||
router.push(pathName);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
|
||||
@@ -228,6 +267,13 @@ const useCustomerListPresenter = () => {
|
||||
openResetPasswordModalForCustomer,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
companyItems,
|
||||
onCompanySearch,
|
||||
onCompanyLoadMore,
|
||||
companyHasMore: companiesQuery.hasNextPage,
|
||||
companyIsLoading:
|
||||
companiesQuery.isFetching && !companiesQuery.isFetchingNextPage,
|
||||
companyIsLoadingMore: companiesQuery.isFetchingNextPage,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user