prograsdk/react-searchable

View on GitHub
src/Searchable.tsx

Summary

Maintainability
A
25 mins
Test Coverage
import lodashDebounce from 'lodash.debounce';
import {ChangeEvent, Component, ReactNode} from 'react';

/**
 * A predicate function used for filtering items.
 *
 * @example
 * ```typescript
 *
 * interface User {
 *   name: string;
 *   email: string;
 * };
 *
 * const predicate: IFilteringPredicate<User> = (user, query) =>
 *   user.name.includes(query) || user.email.includes(query);
 * ```
 *
 * @typeparam T - The type of items that will be filtered using the predicate.
 * @param item - The current item in the filtering process.
 * @param query - A search query used for filtering.
 */
export interface IFilteringPredicate<T> {
  (item: T, query: string): boolean;
}

export interface IRenderProp<T> {
  (
    props: {
      items: T[];
      query: string;
      handleChange(event: ChangeEvent<HTMLInputElement>): void;
    },
  ): ReactNode;
}

interface IBaseProps<T> {
  /**
   * The duration for debouncing the filtering function.
   */
  debounce: number | boolean;
  /**
   * An initial search query. Will affect initial state.
   */
  initialQuery: string;
  /**
   * The array of items to filter.
   */
  items: T[];
  /**
   * The predicate used for filtering items.
   */
  predicate: IFilteringPredicate<T>;
  /**
   * Determines wether items should be filtered instead of searched.
   * If `true` all items will be returned on empty query string.
   */
  filter: boolean;
}

interface IPropsWithChildren<T> extends IBaseProps<T> {
  children: IRenderProp<T>;
}

interface IPropsWithRender<T> extends IBaseProps<T> {
  render: IRenderProp<T>;
}

export type IProps<T> = IPropsWithChildren<T> | IPropsWithRender<T>;

interface IState<T> {
  /**
   * An array of filtered items based on [[IProps.items]].
   */
  items: T[];
  /**
   * Current query used for filtering.
   */
  query: string;
}

/**
 * @typeparam T - The type of items to search.
 */
export default class Searchable<T> extends Component<IProps<T>, IState<T>> {
  public static defaultProps = {
    debounce: 300,
    filter: false,
    initialQuery: '',
  };

  /**
   * Filters an array of items based on a search query and a filtering predicate.
   *
   * @typeparam T - The type of items.
   * @param items - The array of items to be filtered.
   * @param query - A search string to be passed to the predicate.
   * @param predicate - A filtering predicate based on an item and the search query.
   * @returns A filtered array of items.
   */
  public static filter<T>(
    items: T[],
    query: string,
    predicate: IFilteringPredicate<T>,
  ) {
    return items.filter((item: T) => predicate(item, query));
  }

  constructor(props: IProps<T>) {
    super(props);

    const {initialQuery: query, predicate, items, debounce, filter} = props;

    this.state = {
      items:
        query !== ''
          ? Searchable.filter<T>(items, query, predicate)
          : filter
            ? items
            : [],
      query,
    };

    if (debounce) {
      const duration =
        typeof debounce === 'number'
          ? debounce
          : Searchable.defaultProps.debounce;
      this.filterAndSetState = lodashDebounce(this.filterAndSetState, duration);
    }
  }

  /**
   * Change event handler for updating query in state.
   *
   * @param event - An instance of ChangeEvent
   */
  public handleChange = (event: ChangeEvent<HTMLInputElement>): void =>
    this.setState({query: event.target.value})

  /**
   * Call filtering method if [[IState.query]] has changed.
   */
  public componentDidUpdate(prevProps: IProps<T>, prevState: IState<T>): void {
    const {query} = this.state;
    const {items} = this.props;

    if (query !== prevState.query || items !== prevProps.items) {
      this.filterAndSetState(items, query);
    }
  }

  public render(): ReactNode {
    const {items, query} = this.state;

    const renderParams = {items, query, handleChange: this.handleChange};

    if (isPropsWithRender(this.props)) {
      return this.props.render(renderParams);
    } else {
      return this.props.children(renderParams);
    }
  }

  /**
   * Filters an array of items and sets state. This method might be debounced in [[constructor]].
   *
   * @param items - The array of items to filter.
   * @param query - The search query to base the filtering on.
   */
  public filterAndSetState(items: T[], query: string): void {
    const {predicate, filter} = this.props;

    this.setState({
      items:
        query !== ''
          ? Searchable.filter<T>(items, query, predicate)
          : filter
            ? items
            : [],
    });
  }
}

function isPropsWithRender<T>(props: IProps<T>): props is IPropsWithRender<T> {
  return (props as IPropsWithRender<T>).render !== undefined;
}