diff --git a/.env.production b/.env.production index 0a957dd..c6471e6 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,4 @@ -NEXT_PUBLIC_APP_VERSION=2.3.0 +NEXT_PUBLIC_APP_VERSION=2.3.1 NEXT_PUBLIC_TOAST_AUTO_CLOSE_DURATION=2500 NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyCTjdsk2jE34IfnvSKP1JMTIf_Abd7tbt0 NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=opplens-270d1.firebaseapp.com diff --git a/src/components/FormElements/select.tsx b/src/components/FormElements/select.tsx index a5da356..31e53fb 100644 --- a/src/components/FormElements/select.tsx +++ b/src/components/FormElements/select.tsx @@ -133,6 +133,20 @@ type PropsType = { searchQueryKey?: readonly unknown[]; /** Loads options for the debounced search term. Required when `searchMode` is `"server"`. */ searchFn?: (query: string) => Promise; + /** + * 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({ searchDebounceMs = 300, searchQueryKey, searchFn, + onSearch, + onLoadMore, + hasMore = false, + isLoading = false, + isLoadingMore = false, register: _register, ...props }: PropsType) { 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({ const rootRef = useRef(null); const triggerRef = useRef(null); const searchInputRef = useRef(null); + const listRef = useRef(null); const [isOptionSelected, setIsOptionSelected] = useState(!!defaultValue); const [value, setValue] = useState(defaultValue || ""); @@ -221,10 +247,21 @@ export function Select({ }); 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({ }, [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({ 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({ panelRect.placement === "above" ? "translateY(-100%)" : undefined, }} > - {searchable ? ( + {showSearch ? (
) : null} -
    - {searchable && isSearchLoading ? ( +
      + {(isExternal ? isLoading : searchable && isSearchLoading) ? (
    • - Searching… + {isExternal ? "Loading…" : "Searching…"}
    • - ) : searchable && isSearchError ? ( + ) : !isExternal && searchable && isSearchError ? (
    • Could not load options. Try again.
    • ) : dropdownItems.length === 0 ? (
    • - {searchable ? "No matches." : "No options."} + {showSearch ? "No matches." : "No options."}
    • ) : ( - dropdownItems.map((item) => { - const v = String(item.value); - const isActive = v === String(value); - return ( -
    • - + <> + {dropdownItems.map((item) => { + const v = String(item.value); + const isActive = v === String(value); + return ( +
    • + +
    • + ); + })} + {isExternal && isLoadingMore ? ( +
    • +
    • - ); - }) + ) : null} + )}
    , @@ -508,7 +561,7 @@ export function Select({ > {value ? ( - searchable ? ( + showItemValue ? ( <> {selectedLabel} diff --git a/src/components/Tables/customers/CustomerListFilters.tsx b/src/components/Tables/customers/CustomerListFilters.tsx index 9cc5624..74b7cdf 100644 --- a/src/components/Tables/customers/CustomerListFilters.tsx +++ b/src/components/Tables/customers/CustomerListFilters.tsx @@ -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; setValue: UseFormSetValue; 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): 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" >
    + + + { 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} />
    { 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(null); const skeletonTbodyRef = useRef(null); const dataTbodyRef = useRef(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, }; };