Source: app/util/componentClassNameUtils.js

import getDisplayName from 'recompose/getDisplayName';
import kebabCase from 'lodash/kebabCase';
import camelCase from 'lodash/camelCase';
import debugLib from 'debug';
import createFunctionalComponentWrapper from './createFunctionalComponentWrapper';

/**
 * Contains utilities to generate classnames for React components. One of these utilities should
 * be used on the root element of every component.
 *
 * @module
 * @category templating
 */

const debug = debugLib('componentClassNameUtils');

/**
 * A set of modifiers that will be appended to each component classname by default.
 * Adds a 'cid-{props.cid}' class when the 'cid' prop is passed to a component.
 * @type {Array}
 */
const DEFAULT_MODIFIERS = ['cid-{cid}'];

/**
 * Utility to generate a className for a non-functional React component.
 *
 * It will add the following classes:
 *  - The component name prefixed with the component type, dashified. For example, the
 *  component `MyComponent` with type `atom` will get the name `atom-my-component`
 *  - If the `cid` prop is passed to the component, it will add an 'cid-{cid}' class
 *  - Classes based on the modifiers argument. See description below
 *
 * @function componentClassName
 * @param {string} componentType The type of component. This will be used as a prefix
 * for the component name.
 * @param {object} componentInstance The instance of a react component. In the `render()`
 * function of a component, pass `this` as component instance.
 * @param {*} modifiers An object with modifiers may add additional classes.
 * Can be of one of the following types:
 *  - **object** An map of classes. The keys are class name strings (see below for syntax),
 *  and the values are conditions that determine if the classes are added. The condition can
 *  be either of the following:
 *     - **string** A string with the name of a prop that should be truthy. For example,
 *     adding the string `"foo.bar"` results in the class only to be added when a prop
 *     `foo` is passed to the component that has a truthy property `bar`. To lookup properties
 *     on React `state` or `context`, simply prefix the string like so: `"state.isActive"`,
 *     `"context.foo.bar"`. To invert the behavior, prefix the string with a `!` (for example,
 *     `"!disabled"`)
 *     - **function** A function that returns a boolean that determines if the class
 *     should be added. The function receives the following parameters:
 *       - **props** the component props
 *       - **context** the component context
 *       - **state** the component state
 *     - **Array** An Array that combines the above. All conditions in the array must be
 *     met in order for the class to be added.
 *     - **other** When passing any other type the class will be added when the value
 *     is truthy
 *  - **string** A string can be used as a shortcut for the object notation when the class
 *  name matches the prop name. For example, `"isActive"` will add an `is-active` class
 *  whenever a truthy `isActive` prop is passed to the component. Only the last property
 *  will be included in the class name, so `"state.input.hasFocus"` will result in a
 *  `has-focus` class to be added whenever there is an object `input` on the component state
 *  with a truthy property `hasFocus`.
 *  - **function** A function that returns a class or an array of classes to add. Receives
 *  the following parameters:
 *     - **props** the component props
 *     - **context** the component context
 *     - **state** the component state
 *  - **Array** An array that combines multiple of the types above
 *
 * **syntax for class names** all class names provided to the `modifier` parameter will be
 * converted to lowercase-with-dashes. A classname can include dynamic property values. Wrapping
 * a prop in curly braces will cause the value to be inserted automatically. For example:
 * `"type-{type}"`, `"color-{state.color}"`, `"{context.theme}-theme"`. If one of the properties
 * is `undefined` or falsy, the class will not be added.
 *
 * @param {Array<string>} additionalClasses An array of additional classes to add
 * to the list of classnames. **This parameter is deprecated** and included for backwards
 * compatibility. It is recommended to add the following modifier instead:
 * ```
 * () => (['foo', 'bar'])
 * ```
 * @returns {string} A className string
 * @example class MyComponent extends PureComponent {
 *   ...
 *   render() {
 *     return (
 *       <div className={componentClassName(
 *         ComponentType.ATOM, this, ['isActive', 'color-{color}']
 *       )}>
 *         ...
 *       </div>
 *     );
 *   }
 * }
 *
 * MyComponent.propTypes = {
 *   isActive: PropTypes.bool,
 *   color: PropTypes.string.isRequired,
 * }
 */
export const componentClassName = (
  componentType,
  componentInstance,
  modifiers,
  additionalClasses,
) => {
  if (!componentInstance.constructor) {
    throw new Error('Unable to detect component name from given component instance');
  }
  const componentName = getDisplayName(componentInstance.constructor);
  if (componentName === 'Component') {
    throw new Error('Unable to detect component name from given component instance');
  }

  return generateComponentClassName(
    componentName,
    additionalClasses ? [() => additionalClasses].concat(modifiers || []) : modifiers,
    componentInstance,
    `${componentType}-`,
  );
};

/**
 * Alternative syntax for `componentClassName`. See
 * {@link module:app/util/componentClassNameUtils~componentClassName|componentClassName} for a
 * description of the parameters.
 *
 * @function componentClassNameProp
 * @returns {object} The className string wrapped in an object.
 * @example class MyComponent extends PureComponent {
 *   ...
 *   render() {
 *     return (
 *       <div {...componentClassNameProp(ComponentType.ATOM, this, ['isActive', 'color-{color}'])}>
 *         ...
 *       </div>
 *     );
 *   }
 * }
 *
 * MyComponent.propTypes = {
 *   isActive: PropTypes.bool,
 *   color: PropTypes.string.isRequired,
 * }
 */
export const componentClassNameProp = (
  componentType,
  componentInstance,
  modifiers,
  additionalClasses,
) => ({
  className: componentClassName(componentType, componentInstance, modifiers, additionalClasses),
  'data-testid':
    componentInstance?.props?.dataTestid || kebabCase(componentInstance.constructor.name),
});

/**
 * Utility to generate a className for a functional React component. **It is recommended to use the
 * {@link module:app/util/componentClassNameUtils~withFunctionalClassName|withFunctionalClassName}
 * util instead, which has a more compact syntax.**
 *
 * It will add the following classes:
 *  - The component name prefixed with the component type, dashified. For example, the
 *  component `MyComponent` with type `atom` will get the name `atom-my-component`
 *  - If the `cid` prop is passed to the component, it will add an 'cid-{cid}' class
 *  - Classes based on the modifiers argument. See description below
 *
 * @function functionalComponentClassName
 * @deprecated
 * @param {string} componentType The type of component. This will be used as a prefix
 * for the component name.
 * @param {string} componentName The name of the component. This is used to generate the main
 * class and should exactly match the name of the component. It will be automatically converted
 * to lowercase-with-dashes
 * @param {object} props The props passed to the component. This is used for processing the
 * modifiers
 * @param {*} modifiers An object with modifiers may add additional classes.
 * Can be of one of the following types:
 *  - **object** An map of classes. The keys are class name strings (see below for syntax),
 *  and the values are conditions that determine if the classes are added. The condition can
 *  be either of the following:
 *     - **string** A string with the name of a prop that should be truthy. For example,
 *     adding the string `"foo.bar"` results in the class only to be added when a prop
 *     `foo` is passed to the component that has a truthy property `bar`. To lookup properties
 *     on React `state` or `context`, simply prefix the string like so: `"state.isActive"`,
 *     `"context.foo.bar"`. To invert the behavior, prefix the string with a `!` (for example,
 *     `"!disabled"`)
 *     - **function** A function that returns a boolean that determines if the class
 *     should be added. The function receives the following parameters:
 *       - **props** the component props
 *       - **context** the component context
 *       - **state** the component state
 *     - **Array** An Array that combines the above. All conditions in the array must be
 *     met in order for the class to be added.
 *     - **other** When passing any other type the class will be added when the value
 *     is truthy
 *  - **string** A string can be used as a shortcut for the object notation when the class
 *  name matches the prop name. For example, `"isActive"` will add an `is-active` class
 *  whenever a truthy `isActive` prop is passed to the component. Only the last property
 *  will be included in the class name, so `"state.input.hasFocus"` will result in a
 *  `has-focus` class to be added whenever there is an object `input` on the component state
 *  with a truthy property `hasFocus`.
 *  - **function** A function that returns a class or an array of classes to add. Receives
 *  the following parameters:
 *     - **props** the component props
 *     - **context** the component context
 *     - **state** the component state
 *  - **Array** An array that combines multiple of the types above
 *
 * **syntax for class names** all class names provided to the `modifier` parameter will be
 * converted to lowercase-with-dashes. A classname can include dynamic property values. Wrapping
 * a prop in curly braces will cause the value to be inserted automatically. For example:
 * `"type-{type}"`, `"color-{state.color}"`, `"{context.theme}-theme"`. If one of the properties
 * is `undefined` or falsy, the class will not be added.
 *
 * @param {Array<string>} additionalClasses An array of additional classes to add
 * to the list of classnames. **This parameter is deprecated** and included for backwards
 * compatibility. It is recommended to add the following modifier instead:
 * ```
 * () => (['foo', 'bar'])
 * ```
 * @param context The context passed to the component. This is used for processing any modifiers
 * that use context.
 * @returns {string} A className string
 * @example const MyComponent = (props, context) => {
 *   ...
 *   return (
 *     <div className={functionalComponentClassName(
 *       ComponentType.ATOM, 'MyComponent', props, ['isActive', 'color-{color}']
 *     )}>
 *       ...
 *     </div>
 *   );
 * };
 *
 * MyComponent.propTypes = {
 *   isActive: PropTypes.bool,
 *   color: PropTypes.string.isRequired,
 * }
 */
export const functionalComponentClassName = (
  // props is passed as third argument for backwards compatibility
  componentType,
  componentName,
  props,
  modifiers,
  additionalClasses,
  context = {},
) => {
  if (!props) {
    debug(
      'Usage of functionalComponentClassName without passing props is deprecated. Use withFunctionalClassName() wrapper to automatically pass props.',
    );
  }

  return generateComponentClassName(
    componentName,
    additionalClasses ? [() => additionalClasses].concat(modifiers || []) : modifiers,
    {
      props: props || {},
      context,
    },
    `${componentType}-`,
  );
};

/**
 * Utility to generate a className for a functional React component. This utility will return a
 * function that will enhance a component by providing a className string as a third parameter.
 * See the example below for usage.
 *
 * @function withFunctionalClassName
 * @param {string} componentType The type of component. This will be used as a prefix
 * for the component name.
 * @param {string} [componentName] The name of the component. This is used to generate the main
 * class and should exactly match the name of the component. It will be automatically converted
 * to lowercase-with-dashes.
 *
 * _note_: This parameter is not really optional, but is marked as such for usage with
 * `WP_COMPONENT_DEF`, which acts as 2 parameters in 1.
 * @param {*} [modifiers] An object with modifiers may add additional classes.
 * Can be of one of the following types:
 *  - **object** An map of classes. The keys are class name strings (see below for syntax),
 *  and the values are conditions that determine if the classes are added. The condition can
 *  be either of the following:
 *     - **string** A string with the name of a prop that should be truthy. For example,
 *     adding the string `"foo.bar"` results in the class only to be added when a prop
 *     `foo` is passed to the component that has a truthy property `bar`. To lookup properties
 *     on React `state` or `context`, simply prefix the string like so: `"state.isActive"`,
 *     `"context.foo.bar"`. To invert the behavior, prefix the string with a `!` (for example,
 *     `"!disabled"`)
 *     - **function** A function that returns a boolean that determines if the class
 *     should be added. The function receives the following parameters:
 *       - **props** the component props
 *       - **context** the component context
 *       - **state** the component state
 *     - **Array** An Array that combines the above. All conditions in the array must be
 *     met in order for the class to be added.
 *     - **other** When passing any other type the class will be added when the value
 *     is truthy
 *  - **string** A string can be used as a shortcut for the object notation when the class
 *  name matches the prop name. For example, `"isActive"` will add an `is-active` class
 *  whenever a truthy `isActive` prop is passed to the component. Only the last property
 *  will be included in the class name, so `"state.input.hasFocus"` will result in a
 *  `has-focus` class to be added whenever there is an object `input` on the component state
 *  with a truthy property `hasFocus`.
 *  - **function** A function that returns a class or an array of classes to add. Receives
 *  the following parameters:
 *     - **props** the component props
 *     - **context** the component context
 *     - **state** the component state
 *  - **Array** An array that combines multiple of the types above
 *
 * **syntax for class names** all class names provided to the `modifier` parameter will be
 * converted to lowercase-with-dashes. A classname can include dynamic property values. Wrapping
 * a prop in curly braces will cause the value to be inserted automatically. For example:
 * `"type-{type}"`, `"color-{state.color}"`, `"{context.theme}-theme"`. If one of the properties
 * is `undefined` or falsy, the class will not be added.
 * @returns A function that will enhance a functional component by passing the className string
 * as a third parameter.
 * @example const MyComponent = ({ foo, bar }, context, className) => (
 *   <div className={className}>
 *     ...
 *   </div>
 * );
 *
 * MyComponent.propTypes = {
 *   isActive: PropTypes.bool,
 *   color: PropTypes.string.isRequired,
 * }
 *
 * export default withFunctionalClassName(
 *   ComponentType.ATOM, 'MyComponent', ['isActive', 'color-{color}']
 * )(MyComponent);
 */
export const withFunctionalClassName = (componentType, componentName, modifiers) =>
  createFunctionalComponentWrapper(component => (props, context) => {
    // The cid prop should not be passed through to children because this can introduce weird side effects
    // eslint-disable-next-line no-unused-vars
    const { cid, dataTestid: dataTestidOverride, ...restProps } = props;
    const dataTestid = dataTestidOverride || kebabCase(componentName);

    return component(
      restProps,
      context,
      functionalComponentClassName(componentType, componentName, props, modifiers, null, context),
      dataTestid,
    );
  });

const generateComponentClassName = (componentName, modifiers, componentInstance, prefix) =>
  DEFAULT_MODIFIERS.concat(modifiers || [])
    .reduce(
      (result, modifier) => result.concat(getClassNamesForModifier(modifier, componentInstance)),
      [componentNameToClass(componentName, prefix)],
    )
    .filter(_ => _)
    .join(' ');

const getClassNamesForModifier = (modifier, componentInstance) => {
  const typeofModifier = typeof modifier;
  switch (typeofModifier) {
    case 'string':
      return processModifier(modifier, modifier, componentInstance);
    case 'function': {
      const { props, context, state } = componentInstance;
      return modifier(props, context, state);
    }
    case 'object':
      if (Array.isArray(modifier)) {
        throw new Error('Unexpected Array inside Array passed to modifiers');
      }
      return Object.keys(modifier).map(className =>
        processModifier(className, modifier[className], componentInstance),
      );
    default:
      throw new TypeError(`Unexpected modifier of type "${typeofModifier}"`);
  }
};

const processModifier = (className, condition, componentInstance) =>
  meetsCondition(condition, componentInstance) && processClassName(className, componentInstance);

const meetsCondition = (condition, componentInstance) => {
  switch (typeof condition) {
    case 'string':
      return meetsStringCondition(condition, componentInstance);
    case 'function': {
      const { props, context, state } = componentInstance;
      return !!condition(props, context, state);
    }
    case 'object':
      if (Array.isArray(condition)) {
        return condition.every(subCondition => meetsCondition(subCondition, componentInstance));
      }
      return condition !== null;
    default:
      return !!condition;
  }
};

const processConditionSegment = camelCase;
const postProcessClassName = kebabCase;

const meetsStringCondition = (condition, componentInstance) => {
  if (condition.match(/{([a-zA-Z.]+)}/)) {
    // interpolation is checked in processClassName
    return true;
  }

  let invert = condition.startsWith('!');
  if (invert) {
    condition = condition.substring(1); // eslint-disable-line no-param-reassign
  }

  // eslint-disable-next-line no-param-reassign
  condition = condition.replace(/!?=/, equals => {
    debug('Syntax state=prop is deprecated. Use state.prop instead.');
    if (equals === '!=') {
      invert = !invert;
    }
    return '.';
  });

  const propSegments = condition.split('.');
  if (!['state', 'context', 'props'].includes(propSegments[0])) {
    propSegments.unshift('props');
  }

  const matches = !!propSegments.reduce(
    (result, segment) => result && result[processConditionSegment(segment)],
    componentInstance,
  );

  return (matches && !invert) || (!matches && invert);
};

const componentNameToClass = (componentName, prefix = 'component-') =>
  `${prefix}${kebabCase(componentName)}`;

const processClassName = (className, componentInstance) => {
  let hasMissingProp = false;

  const replacedClassNameSegments = className
    .replace(/{([a-zA-Z.]+)}/g, (match, prop) => {
      const propSegments = prop.split('.');

      if (!['state', 'context', 'props'].includes(propSegments[0])) {
        propSegments.unshift('props');
      }
      const value = propSegments.reduce(
        (result, segment) => result && result[segment],
        componentInstance,
      );
      hasMissingProp = hasMissingProp || typeof value === 'undefined' || value === null;
      return value;
    })
    .split('.');

  const replacedClassName = replacedClassNameSegments[replacedClassNameSegments.length - 1];

  return hasMissingProp ? false : postProcessClassName(replacedClassName);
};