icd2k3/react-router-breadcrumbs-hoc

View on GitHub
src/index.tsx

Summary

Maintainability
A
0 mins
Test Coverage
/**
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * This script exports a HOC that accepts a routes array of objects
 * and an options object.
 *
 * API:
 *
 * withBreadcrumbs(
 *   routes?: Array<Route>,
 *   options? Object<Options>,
 * ): HigherOrderComponent
 *
 * More Info:
 *
 * https://github.com/icd2k3/react-router-breadcrumbs-hoc
 *
 */

import React, { createElement } from 'react';
import { useLocation, matchPath } from 'react-router-dom';

const DEFAULT_MATCH_OPTIONS = { exact: true };
const NO_BREADCRUMB = 'NO_BREADCRUMB';

export interface Options {
  currentSection?: string;
  disableDefaults?: boolean;
  excludePaths?: string[];
  pathSection?: string;
}

export interface Location {
  pathname: string
}

export interface MatchOptions {
  exact?: boolean;
  strict?: boolean;
  sensitive?: boolean;
}

export interface BreadcrumbsRoute {
  path: string;
  breadcrumb?: React.ComponentType | React.ElementType | string;
  matchOptions?: MatchOptions;
  routes?: BreadcrumbsRoute[];
  [x: string]: any;
}

/**
 * This method was "borrowed" from https://stackoverflow.com/a/28339742
 * we used to use the humanize-string package, but it added a lot of bundle
 * size and issues with compilation. This 4-liner seems to cover most cases.
 */
const humanize = (str: string): string => str
  .replace(/^[\s_]+|[\s_]+$/g, '')
  .replace(/[_\s]+/g, ' ')
  .replace(/^[a-z]/, (m) => m.toUpperCase());

/**
 * Renders and returns the breadcrumb complete
 * with `match`, `location`, and `key` props.
 */
const render = ({
  breadcrumb: Breadcrumb,
  match,
  location,
  ...rest
}: {
  breadcrumb: React.ComponentType | string,
  match: { url: string },
  location: Location
}): {
  match: { url: string },
  location: Location,
  key: string,
  breadcrumb: React.ReactNode
} => {
  const componentProps = { match, location, key: match.url, ...rest };

  return {
    ...componentProps,
    breadcrumb: typeof Breadcrumb === 'string'
      ? createElement('span', { key: componentProps.key }, Breadcrumb)
      : <Breadcrumb {...componentProps} />,
  };
};

/**
 * Small helper method to get a default breadcrumb if the user hasn't provided one.
*/
const getDefaultBreadcrumb = ({
  currentSection,
  location,
  pathSection,
}: {
  currentSection: string,
  location: Location,
  pathSection: string,
}) => {
  const match = matchPath(pathSection, { ...DEFAULT_MATCH_OPTIONS, path: pathSection })
    /* istanbul ignore next: this is hard to mock in jest :( */
    || { url: 'not-found' };

  return render({
    breadcrumb: humanize(currentSection),
    match,
    location,
  });
};

/**
 * Loops through the route array (if provided) and returns either a
 * user-provided breadcrumb OR a sensible default (if enabled)
*/
const getBreadcrumbMatch = ({
  currentSection,
  disableDefaults,
  excludePaths,
  location,
  pathSection,
  routes,
}: {
  currentSection: string,
  disableDefaults?: boolean,
  excludePaths?: string[],
  location: { pathname: string },
  pathSection: string,
  routes: BreadcrumbsRoute[]
}) => {
  let breadcrumb;

  // Check the optional `excludePaths` option in `options` to see if the
  // current path should not include a breadcrumb.
  const getIsPathExcluded = (path: string) => matchPath(pathSection, {
    path,
    exact: true,
    strict: false,
  });
  if (excludePaths && excludePaths.some(getIsPathExcluded)) {
    return NO_BREADCRUMB;
  }

  // Loop through the route array and see if the user has provided a custom breadcrumb.
  routes.some(({ breadcrumb: userProvidedBreadcrumb, matchOptions, path, ...rest }) => {
    if (!path) {
      throw new Error('withBreadcrumbs: `path` must be provided in every route object');
    }

    const match = matchPath(pathSection, { ...(matchOptions || DEFAULT_MATCH_OPTIONS), path });

    // If user passed breadcrumb: null OR custom match options to suppress a breadcrumb
    // we need to know NOT to add it to the matches array
    // see: `if (breadcrumb !== NO_BREADCRUMB)` below.
    if ((match && userProvidedBreadcrumb === null) || (!match && matchOptions)) {
      breadcrumb = NO_BREADCRUMB;
      return true;
    }

    if (match) {
      // This covers the case where a user may be extending their react-router route
      // config with breadcrumbs, but also does not want default breadcrumbs to be
      // automatically generated (opt-in).
      if (!userProvidedBreadcrumb && disableDefaults) {
        breadcrumb = NO_BREADCRUMB;
        return true;
      }

      breadcrumb = render({
        // Although we have a match, the user may be passing their react-router config object
        // which we support. The route config object may not have a `breadcrumb` param specified.
        // If this is the case, we should provide a default via `humanize`.
        breadcrumb: userProvidedBreadcrumb || humanize(currentSection),
        match,
        location,
        ...rest,
      });
      return true;
    }
    return false;
  });

  // User provided a breadcrumb prop, or we generated one above.
  if (breadcrumb) {
    return breadcrumb;
  }

  // If there was no breadcrumb provided and user has disableDefaults turned on.
  if (disableDefaults) {
    return NO_BREADCRUMB;
  }

  // If the above conditionals don't fire, generate a default breadcrumb based on the path.
  return getDefaultBreadcrumb({
    pathSection,
    // include a "Home" breadcrumb by default (can be overrode or disabled in config).
    currentSection: pathSection === '/' ? 'Home' : currentSection,
    location,
  });
};

/**
 * Splits the pathname into sections, then search for matches in the routes
 * a user-provided breadcrumb OR a sensible default.
*/
export const getBreadcrumbs = (
  {
    routes,
    location,
    options = {},
  }: {
    routes: BreadcrumbsRoute[],
    location: Location,
    options?: Options
  },
): Array<React.ReactNode | string> => {
  const matches:Array<React.ReactNode | string> = [];
  const { pathname } = location;

  pathname
    .split('?')[0]
    // Split pathname into sections.
    .split('/')
    // Reduce over the sections and call `getBreadcrumbMatch()` for each section.
    .reduce((previousSection: string, currentSection: string, index: number) => {
      // Combine the last route section with the currentSection.
      // For example, `pathname = /1/2/3` results in match checks for
      // `/1`, `/1/2`, `/1/2/3`.
      const pathSection = !currentSection ? '/' : `${previousSection}/${currentSection}`;

      // Ignore trailing slash or double slashes in the URL
      if (pathSection === '/' && index !== 0) {
        return '';
      }

      const breadcrumb = getBreadcrumbMatch({
        currentSection,
        location,
        pathSection,
        routes,
        ...options,
      });

      // Add the breadcrumb to the matches array
      // unless the user has explicitly passed.
      // { path: x, breadcrumb: null } to disable.
      if (breadcrumb !== NO_BREADCRUMB) {
        matches.push(breadcrumb);
      }

      return pathSection === '/' ? '' : pathSection;
    }, '');

  return matches;
};

/**
 * Takes a route array and recursively flattens it IF there are
 * nested routes in the config.
*/
const flattenRoutes = (routes: BreadcrumbsRoute[]) => (routes)
  .reduce((arr, route: BreadcrumbsRoute): BreadcrumbsRoute[] => {
    if (route.routes) {
      return arr.concat([route, ...flattenRoutes(route.routes)]);
    }
    return arr.concat(route);
  }, [] as BreadcrumbsRoute[]);

/**
 * Accepts optional routes array and options and returns an array of
 * breadcrumbs.
 *
 * @example
 * import withBreadcrumbs from 'react-router-breadcrumbs-hoc';
 * const Breadcrumbs = ({ breadcrumbs }) => (
 *  <>{breadcrumbs.map(({ breadcrumb }) => breadcrumb)}</>
 * )
 * export default withBreadcrumbs()(Breadcrumbs);
 */
const withBreadcrumbs = (
  routes?: BreadcrumbsRoute[],
  options?: Options,
) => (
  Component: React.ComponentType<{
    breadcrumbs: Array<React.ReactNode | string>
  }>,
) => (props: any) => React.createElement(Component, {
  ...props,
  breadcrumbs: getBreadcrumbs({
    options,
    routes: flattenRoutes(routes || []),
    location: useLocation(),
  }),
});

export default withBreadcrumbs;