diff --git a/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx b/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx index 9ff2d5c7..a92649f9 100644 --- a/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx +++ b/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx @@ -8,13 +8,14 @@ import { ChipTypeMap } from "@mui/material"; import { AutocompleteProps } from "@mui/material/Autocomplete"; -import { forwardRef, useEffect, useState } from "react"; +import { forwardRef, useEffect, useRef } from "react"; -import { useFind } from "@/hooks/crud/useFind"; +import { useInfiniteFind } from "@/hooks/crud/useInfiniteFind"; import { useSearch } from "@/hooks/useSearch"; -import { Format } from "@/services/types"; +import { Format, QueryType } from "@/services/types"; import { IEntityMapTypes } from "@/types/base.types"; import { TFilterStringFields } from "@/types/search.types"; +import { generateId } from "@/utils/generateId"; import AutoCompleteSelect from "./AutoCompleteSelect"; @@ -62,75 +63,53 @@ const AutoCompleteEntitySelect = < helperText, preprocess, idKey = "id", + labelKey, ...rest }: AutoCompleteEntitySelectProps, ref, ) => { const { onSearch, searchPayload } = useSearch({ - $iLike: searchFields as TFilterStringFields, + $or: (searchFields as TFilterStringFields) || [idKey, labelKey], }); - const [initialValue] = useState(value); - const { data: defaultData, isLoading: isDefaultLoading } = useFind( + const idRef = useRef(generateId()); + const params = { + where: { + or: [ + ...(searchPayload.where.or || []), + ...(value + ? Array.isArray(value) + ? value.map((v) => ({ [idKey]: v })) + : [{ [idKey]: value }] + : []), + ], + }, + }; + const { data, isFetching, fetchNextPage } = useInfiniteFind( { entity, format }, { - params: { - where: { - ...(initialValue && Array.isArray(initialValue) - ? initialValue.length > 1 - ? { or: initialValue.map((v) => ({ [idKey]: v })) } - : { [idKey]: initialValue[0] } - : { [idKey]: initialValue }), - }, - }, + params, hasCount: false, }, { keepPreviousData: true, - enabled: Array.isArray(initialValue) ? initialValue.length > 0 : !!value, + queryKey: [QueryType.collection, entity, `autocomplete/${idRef.current}`], }, ); - const { data: newData, isLoading } = useFind( - { entity, format }, - { - params: { - ...searchPayload, - where: { ...searchPayload.where, skip: 0, limit: 10 }, - }, - hasCount: false, - }, - { - keepPreviousData: true, - }, - ); - const data = [...(defaultData || []), ...(newData || [])]; - const [accumulatedOptions, setAccumulatedOptions] = useState< - Map - >(new Map()); + // flatten & filter unique + const flattenedData = data?.pages + ?.flat() + .filter( + (a, idx, self) => self.findIndex((b) => a[idKey] === b[idKey]) === idx, + ); + const options = + preprocess && flattenedData + ? preprocess((flattenedData || []) as unknown as Value[]) + : ((flattenedData || []) as Value[]); useEffect(() => { - if (data) { - const newOptions = - preprocess && data - ? preprocess((data || []) as Value[]) - : ((data || []) as Value[]); - - setAccumulatedOptions((prevMap) => { - const newMap = new Map(prevMap); - - newOptions.forEach((option) => { - const id = option[idKey]; - - if (!newMap.has(id)) { - newMap.set(id, option); - } - }); - - return newMap; - }); - } - }, [JSON.stringify(newData), idKey]); - - const options = Array.from(accumulatedOptions.values()); + fetchNextPage({ pageParam: params }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(searchPayload)]); return ( @@ -140,11 +119,12 @@ const AutoCompleteEntitySelect = < multiple={multiple} ref={ref} idKey={idKey} + labelKey={labelKey} options={options || []} onSearch={onSearch} error={error} helperText={helperText} - loading={isLoading || isDefaultLoading} + loading={isFetching} {...rest} /> ); diff --git a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx index 98ac040e..3a62a223 100644 --- a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx +++ b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx @@ -6,7 +6,7 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { Box, Chip, ChipTypeMap } from "@mui/material"; +import { Box, Chip, ChipTypeMap, CircularProgress } from "@mui/material"; import Autocomplete, { AutocompleteProps, AutocompleteValue, @@ -185,6 +185,9 @@ const AutoCompleteSelect = < {options.length === 0 && !loading && noOptionsWarning && ( )} + {loading ? ( + + ) : null} {props.InputProps.endAdornment} ), diff --git a/frontend/src/hooks/crud/useInfiniteFind.ts b/frontend/src/hooks/crud/useInfiniteFind.ts new file mode 100644 index 00000000..aefcc629 --- /dev/null +++ b/frontend/src/hooks/crud/useInfiniteFind.ts @@ -0,0 +1,90 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { useInfiniteQuery, UseInfiniteQueryOptions } from "react-query"; + +import { + EntityType, + Format, + QueryType, + TPopulateTypeFromFormat, +} from "@/services/types"; +import { + IBaseSchema, + IDynamicProps, + IFindConfigProps, + POPULATE_BY_TYPE, + TAllowedFormat, + TType, +} from "@/types/base.types"; + +import { useNormalizeAndCache } from "./helpers"; +import { useGetFromCache } from "./useGet"; +import { useEntityApiClient } from "../useApiClient"; + +export const useInfiniteFind = < + TDynamicProps extends IDynamicProps, + TAttr = TType["attributes"], + TBasic extends IBaseSchema = TType["basic"], + TFull extends IBaseSchema = TType["full"], + P = TPopulateTypeFromFormat, +>( + { entity, format }: TDynamicProps & TAllowedFormat, + config?: IFindConfigProps, + options?: Omit< + UseInfiniteQueryOptions< + string[], + Error, + string[], + [QueryType, EntityType, any] + >, + "queryFn" | "onSuccess" + > & { onSuccess?: (result: TBasic[]) => void }, +) => { + const { onSuccess, queryKey, ...otherOptions } = options || {}; + const api = useEntityApiClient(entity); + const normalizeAndCache = useNormalizeAndCache( + entity, + ); + const getFromCache = useGetFromCache(entity); + // @TODO : fix the following + // @ts-ignore + const { data: infiniteData, ...infiniteQuery } = useInfiniteQuery({ + queryKey, + queryFn: async () => { + const data = await api.find( + { + ...(config?.params || {}), + }, + format === Format.FULL && (POPULATE_BY_TYPE[entity] as P), + ); + const { entities, result } = normalizeAndCache(data); + + if (onSuccess) { + onSuccess( + Object.values(entities[entity] as unknown as Record), + ); + } + + return result; + }, + ...(otherOptions || {}), + }); + + return { + ...infiniteQuery, + data: infiniteData + ? { + ...infiniteData, + pages: (infiniteData?.pages || []).map((page) => + page.map((id) => getFromCache(id) as unknown as TBasic), + ), + } + : undefined, + }; +};