Source

components/filters/useGroupFilter.js

import React, { useMemo, useState, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { usePermissionsWithContext } from '@redhat-cloud-services/frontend-components-utilities/RBACHook';

import { HOST_GROUP_CHIP } from '../../Utilities/index';
import SearchableGroupFilter from './SearchableGroupFilter';
import { useInfiniteQuery } from '@tanstack/react-query';
import { getGroups } from '../InventoryGroups/utils/api';
import { GENERAL_GROUPS_READ_PERMISSION } from '../../constants';

const PAGE_SIZE = 50;
const INPUT_DEBOUNCE_MS = 300;

export const groupFilterState = { hostGroupFilter: null };
export const GROUP_FILTER = 'GROUP_FILTER';
export const groupFilterReducer = (_state, { type, payload }) => ({
  ...(type === GROUP_FILTER && {
    hostGroupFilter: payload,
  }),
});

export const buildHostGroupChips = (
  selectedGroups = [],
  isKesselEnabled = false,
) => {
  const chips = [...selectedGroups]?.map((group) =>
    group === ''
      ? {
          name: isKesselEnabled ? 'Ungrouped hosts' : 'No workspace',
          value: '',
        }
      : {
          name: group,
          value: group,
        },
  );
  return chips?.length > 0
    ? [
        {
          category: 'Workspace',
          type: HOST_GROUP_CHIP,
          chips,
        },
      ]
    : [];
};

/**
 * Fetches workspaces (host groups) with a search support and infinite pagination.
 *
 * Behavior:
 * - Captures the unfiltered total when debouncedTerm is empty to understand dataset size.
 * - Uses a debounced search term for server-side filtering via getGroups if the total results exceed two pages (PAGE_SIZE * 2).
 * - Exposes setSearchQuery which is debounced; when remote search is disabled it resets
 * the debounced term to initSearchQuery to avoid unnecessary server calls.
 *
 *  @param   {object}  options                   The options object.
 *  @param   {string}  [options.initSearchQuery] Initial query reflected when remote search is disabled.
 *  @param   {boolean} [options.isKesselEnabled] When true, restricts to standard workspaces via type filter.
 *  @param   {boolean} [options.hasAccess]       Enables the underlying query when true, otherwise the query is disabled.
 *  @param   {number}  [options.debounceTime]    Debounce duration for remote search, in ms.
 *  @returns {Array}                             result array of a single object with the following properties:
 *                                               result.groups {Array<{name: string}>} - Flattened list of loaded workspaces.
 *                                               result.setSearchQuery {function(string): void} - Debounced setter for the remote search term.
 *                                               result.fetchNextPage {function(): void} - Load the next page when available.
 *                                               result.hasNextPage {boolean} - Whether there is another page to load.
 *                                               result.isFetchingNextPage {boolean} - True while the next page is loading.
 *                                               result.remoteSearchEnabled {boolean} - True when server-side search should be used (> 2 pages total).
 */
const useGroupsQueryWithFilter = ({
  hasAccess,
  initSearchQuery = '',
  isKesselEnabled = false,
  debounceTime = INPUT_DEBOUNCE_MS,
}) => {
  const [searchTerm, setSearchTerm] = useState(initSearchQuery);
  const [debouncedTerm, setDebouncedTerm] = useState(initSearchQuery);
  const [unfilteredTotal, setUnfilteredTotal] = useState(undefined);

  const remoteSearchEnabled = useMemo(() => {
    return (
      typeof unfilteredTotal === 'number' && unfilteredTotal > PAGE_SIZE * 2
    );
  }, [unfilteredTotal]);

  // Debounce the search term
  const setSearchTermDebounced = useMemo(
    () => debounce((term) => setDebouncedTerm(term), debounceTime),
    [debounceTime, setDebouncedTerm],
  );

  const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['groups', debouncedTerm, isKesselEnabled],
      queryFn: async ({ pageParam = 1 }) =>
        getGroups(
          {
            ...(remoteSearchEnabled ? { name: debouncedTerm } : {}),
            ...(isKesselEnabled ? { type: 'standard' } : {}),
          },
          {
            page: pageParam,
            per_page: PAGE_SIZE,
          },
        ),
      // When menu opens, ensure at least first page is fetched
      enabled: hasAccess,
      refetchOnWindowFocus: false,
      refetchOnReconnect: false,
      getNextPageParam: (lastPage, pages) => {
        const currentCount = pages.reduce(
          (sum, p) => sum + (p?.results?.length || 0),
          0,
        );
        if (lastPage?.total && currentCount < lastPage.total) {
          return pages.length + 1;
        }
        return undefined;
      },
    });

  // Capture the total count for the unfiltered dataset (debouncedTerm === initSearchQuery)
  useEffect(() => {
    const firstPageTotal = data?.pages?.[0]?.total;
    if (
      debouncedTerm === initSearchQuery &&
      typeof firstPageTotal === 'number'
    ) {
      setUnfilteredTotal(firstPageTotal);
    }
  }, [debouncedTerm, data, initSearchQuery, setUnfilteredTotal]);

  // Auto-load subsequent pages if user is searching, to power full-text search
  useEffect(() => {
    if (!hasAccess) return;
    if (!searchTerm || remoteSearchEnabled) return; // Only manual fetch unless searching through local data
    if (hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [
    hasAccess,
    searchTerm,
    hasNextPage,
    isFetchingNextPage,
    fetchNextPage,
    remoteSearchEnabled,
  ]);

  // Collect data from all pages and filter groups based on the search term if remote search is disabled
  const groups = useMemo(() => {
    const allData = data?.pages?.flatMap((p) => p?.results || []) || [];
    if (remoteSearchEnabled || !searchTerm) {
      return allData;
    }

    return allData.filter((group) =>
      String(group.name).toLowerCase().includes(searchTerm.toLowerCase()),
    );
  }, [data, searchTerm, remoteSearchEnabled]);

  // Set the search term and debounce it if remote search is enabled
  const setSearchQuery = useMemo(() => {
    return (term) => {
      setSearchTerm(term);
      if (remoteSearchEnabled) {
        setSearchTermDebounced(term);
      }
    };
  }, [setSearchTerm, setSearchTermDebounced, remoteSearchEnabled]);

  return {
    groups,
    searchQuery: searchTerm,
    setSearchQuery,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    remoteSearchEnabled,
    isLoading,
  };
};

const useGroupFilter = (showNoGroupOption = false, isKesselEnabled = false) => {
  const [selectedGroupNames, setSelectedGroupNames] = useState([]);

  const { hasAccess } = usePermissionsWithContext(
    [GENERAL_GROUPS_READ_PERMISSION],
    true,
    false,
  );

  const {
    groups,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
    setSearchQuery,
    searchQuery,
    isLoading,
  } = useGroupsQueryWithFilter({
    isKesselEnabled,
    hasAccess,
    debounceTime: INPUT_DEBOUNCE_MS,
  });

  const chips = useMemo(
    () => buildHostGroupChips(selectedGroupNames, isKesselEnabled),
    [selectedGroupNames, isKesselEnabled],
  );

  return [
    {
      label: 'Workspace',
      value: 'group-host-filter',
      type: 'custom',
      filterValues: {
        children: (
          <SearchableGroupFilter
            searchQuery={searchQuery}
            setSearchQuery={setSearchQuery}
            isLoading={isLoading}
            isFetchingNextPage={isFetchingNextPage}
            hasNextPage={hasNextPage}
            fetchNextPage={fetchNextPage}
            groups={groups}
            selectedGroupNames={selectedGroupNames}
            setSelectedGroupNames={setSelectedGroupNames}
            showNoGroupOption={showNoGroupOption}
            isKesselEnabled={isKesselEnabled}
          />
        ),
      },
    },
    chips,
    selectedGroupNames,
    (groupNames) => setSelectedGroupNames(groupNames || []),
  ];
};

export default useGroupFilter;