pankod/refine

View on GitHub
packages/core/src/hooks/useSelect/index.ts

Summary

Maintainability
C
1 day
Test Coverage
import { useCallback, useMemo, useState } from "react";

import { QueryObserverResult, UseQueryOptions } from "@tanstack/react-query";
import debounce from "lodash/debounce";
import get from "lodash/get";
import uniqBy from "lodash/uniqBy";

import { pickNotDeprecated } from "@definitions/helpers";
import { useList, useMany, useMeta } from "@hooks";

import {
  BaseKey,
  BaseOption,
  BaseRecord,
  CrudFilter,
  CrudSort,
  GetListResponse,
  GetManyResponse,
  HttpError,
  MetaQuery,
  Pagination,
  Prettify,
} from "../../contexts/data/types";
import { LiveModeProps } from "../../contexts/live/types";
import { SuccessErrorNotification } from "../../contexts/notification/types";
import { BaseListProps } from "../data/useList";
import { useResource } from "../resource/useResource/index";
import {
  UseLoadingOvertimeOptionsProps,
  UseLoadingOvertimeReturnType,
  useLoadingOvertime,
} from "../useLoadingOvertime";

export type UseSelectProps<TQueryFnData, TError, TData> = {
  /**
   * Resource name for API data interactions
   */
  resource: string;
  /**
   * Set the option's label value
   * @default `"title"`
   */
  optionLabel?: keyof TData extends string
    ? keyof TData
    : never | ((item: TData) => string);
  /**
   * Set the option's value
   * @default `"id"`
   */
  optionValue?: keyof TData extends string
    ? keyof TData
    : never | ((item: TData) => string);
  /**
   * Field name to search for.
   * @description If provided `optionLabel` is a string, uses `optionLabel`'s value.
   * @default `"title"`
   * @example
   * // when optionLabel is string.
   * useSelect({ optionLabel: "name" })
   * // uses `name` field.
   * @example
   * // when optionLabel is function.
   * useSelect({ optionLabel: (field) => field.description })
   * // uses `title`, since `optionLabel` is a function.
   */
  searchField?: keyof TData extends string ? keyof TData : never;
  /**
   * Allow us to sort the options
   * @deprecated Use `sorters` instead
   */
  sort?: CrudSort[];
  /**
   * Allow us to sort the options
   */
  sorters?: CrudSort[];
  /**
   * Resource name for API data interactions
   */
  filters?: CrudFilter[];
  /**
   * Adds extra `options`
   */
  defaultValue?: BaseKey | BaseKey[];
  /**
   * The number of milliseconds to delay
   * @default `300`
   */
  debounce?: number;
  /**
   * react-query [useQuery](https://react-query.tanstack.com/reference/useQuery) options
   */
  queryOptions?: UseQueryOptions<
    GetListResponse<TQueryFnData>,
    TError,
    GetListResponse<TData>
  >;
  /**
   * Pagination option from [`useList()`](/docs/api-reference/core/hooks/data/useList/)
   * @type {  current?: number; pageSize?: number;}
   * @default `undefined`
   */
  pagination?: Prettify<
    Omit<Pagination, "mode"> & {
      /**
       * Whether to use server side pagination or not.
       * @default "off"
       */
      mode?: Pagination["mode"];
    }
  >;
  /**
   * Disabling pagination option from [`useList()`](/docs/api-reference/core/hooks/data/useList/)
   * @type boolean
   * @default `false`
   * @deprecated `hasPagination` is deprecated, use `pagination.mode` instead.
   */
  hasPagination?: boolean;
  /**
   * react-query [useQuery](https://react-query.tanstack.com/reference/useQuery) options
   */
  defaultValueQueryOptions?: UseQueryOptions<
    GetManyResponse<TQueryFnData>,
    TError
  >;
  /**
   * If defined, this callback allows us to override all filters for every search request.
   * @default `undefined`
   */
  onSearch?: (value: string) => CrudFilter[];
  /**
   * Additional meta data to pass to the `useMany` from the data provider
   */
  meta?: MetaQuery;
  /**
   * Additional meta data to pass to the `useMany` from the data provider
   * @deprecated `metaData` is deprecated with refine@4, refine will pass `meta` instead, however, we still support `metaData` for backward compatibility.
   */
  metaData?: MetaQuery;
  /**
   * If there is more than one `dataProvider`, you should use the `dataProviderName` that you will use.
   * @default `default`
   */
  dataProviderName?: string;
  /**
   * Amount of records to fetch in select box list.
   * @deprecated use [`pagination`](https://refine.dev/docs/api-reference/core/interfaceReferences/#pagination) instead
   * @default `undefined`
   */
  fetchSize?: number;
} & SuccessErrorNotification<
  GetListResponse<TData>,
  TError,
  Prettify<BaseListProps>
> &
  LiveModeProps &
  UseLoadingOvertimeOptionsProps;

export type UseSelectReturnType<
  TData extends BaseRecord = BaseRecord,
  TError extends HttpError = HttpError,
  TOption extends BaseOption = BaseOption,
> = {
  queryResult: QueryObserverResult<GetListResponse<TData>, TError>;
  defaultValueQueryResult: QueryObserverResult<GetManyResponse<TData>>;
  onSearch: (value: string) => void;
  options: TOption[];
} & UseLoadingOvertimeReturnType;

/**
 * `useSelect` hook is used to fetch data from the dataProvider and return the options for the select box.
 *
 * It uses `getList` method as query function from the dataProvider that is
 * passed to {@link https://refine.dev/docs/api-reference/core/components/refine-config/ `<Refine>`}.
 *
 * @see {@link https://refine.dev/docs/api-reference/core/hooks/useSelect} for more details.
 *
 * @typeParam TQueryFnData - Result data returned by the query function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}
 * @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#httperror `HttpError`}
 * @typeParam TData - Result data returned by the `select` function. Extends {@link https://refine.dev/docs/api-reference/core/interfaceReferences#baserecord `BaseRecord`}. Defaults to `TQueryFnData`
 *
 */

export const useSelect = <
  TQueryFnData extends BaseRecord = BaseRecord,
  TError extends HttpError = HttpError,
  TData extends BaseRecord = TQueryFnData,
  TOption extends BaseOption = BaseOption,
>(
  props: UseSelectProps<TQueryFnData, TError, TData>,
): UseSelectReturnType<TData, TError, TOption> => {
  const [search, setSearch] = useState<CrudFilter[]>([]);
  const [options, setOptions] = useState<TOption[]>([]);
  const [selectedOptions, setSelectedOptions] = useState<TOption[]>([]);

  const {
    resource: resourceFromProps,
    sort,
    sorters,
    filters = [],
    optionLabel = "title",
    optionValue = "id",
    searchField = typeof optionLabel === "string" ? optionLabel : "title",
    debounce: debounceValue = 300,
    successNotification,
    errorNotification,
    defaultValueQueryOptions: defaultValueQueryOptionsFromProps,
    queryOptions,
    fetchSize,
    pagination,
    hasPagination = false,
    liveMode,
    defaultValue = [],
    onLiveEvent,
    onSearch: onSearchFromProp,
    liveParams,
    meta,
    metaData,
    dataProviderName,
    overtimeOptions,
  } = props;

  const getOptionLabel = useCallback(
    (item: TData) => {
      if (typeof optionLabel === "string") {
        return get(item, optionLabel);
      }

      return optionLabel(item);
    },
    [optionLabel],
  );

  const getOptionValue = useCallback(
    (item: TData) => {
      if (typeof optionValue === "string") {
        return get(item, optionValue);
      }

      return optionValue(item);
    },
    [optionValue],
  );

  const { resource, identifier } = useResource(resourceFromProps);

  const getMeta = useMeta();

  const combinedMeta = getMeta({
    resource,
    meta: pickNotDeprecated(meta, metaData),
  });

  const defaultValues = Array.isArray(defaultValue)
    ? defaultValue
    : [defaultValue];

  const defaultValueQueryOnSuccess = useCallback(
    (data: GetManyResponse<TData>) => {
      setSelectedOptions(
        data.data.map(
          (item) =>
            ({
              label: getOptionLabel(item),
              value: getOptionValue(item),
            }) as TOption,
        ),
      );
    },
    [optionLabel, optionValue],
  );

  const defaultValueQueryOptions =
    defaultValueQueryOptionsFromProps ?? (queryOptions as any);

  const defaultValueQueryResult = useMany<TQueryFnData, TError, TData>({
    resource: identifier,
    ids: defaultValues,
    queryOptions: {
      ...defaultValueQueryOptions,
      enabled:
        defaultValues.length > 0 && (defaultValueQueryOptions?.enabled ?? true),
      onSuccess: (data) => {
        defaultValueQueryOnSuccess(data);
        defaultValueQueryOptions?.onSuccess?.(data);
      },
    },
    meta: combinedMeta,
    metaData: combinedMeta,
    liveMode: "off",
    dataProviderName,
  });

  const defaultQueryOnSuccess = useCallback(
    (data: GetListResponse<TData>) => {
      setOptions(
        data.data.map(
          (item) =>
            ({
              label: getOptionLabel(item),
              value: getOptionValue(item),
            }) as TOption,
        ),
      );
    },
    [optionLabel, optionValue],
  );

  const queryResult = useList<TQueryFnData, TError, TData>({
    resource: identifier,
    sorters: pickNotDeprecated(sorters, sort),
    filters: filters.concat(search),
    pagination: {
      current: pagination?.current,
      pageSize: pagination?.pageSize ?? fetchSize,
      mode: pagination?.mode,
    },
    hasPagination,
    queryOptions: {
      ...queryOptions,
      onSuccess: (data) => {
        defaultQueryOnSuccess(data);
        queryOptions?.onSuccess?.(data);
      },
    },
    successNotification,
    errorNotification,
    meta: combinedMeta,
    metaData: combinedMeta,
    liveMode,
    liveParams,
    onLiveEvent,
    dataProviderName,
  });

  const onSearch = (value: string) => {
    if (onSearchFromProp) {
      setSearch(onSearchFromProp(value));
      return;
    }

    if (!value) {
      setSearch([]);
      return;
    }

    setSearch([
      {
        field: searchField,
        operator: "contains",
        value,
      },
    ]);
  };

  const { elapsedTime } = useLoadingOvertime({
    isLoading: queryResult.isFetching || defaultValueQueryResult.isFetching,
    interval: overtimeOptions?.interval,
    onInterval: overtimeOptions?.onInterval,
  });

  const combinedOptions = useMemo(
    () => uniqBy([...options, ...selectedOptions], "value"),
    [options, selectedOptions],
  );

  return {
    queryResult,
    defaultValueQueryResult,
    options: combinedOptions,
    onSearch: debounce(onSearch, debounceValue),
    overtime: { elapsedTime },
  };
};