src/util.tsx

Summary

Maintainability
A
0 mins
Test Coverage
type PathArray = (string | number)[];
type Path = PathArray | string;

const formatErrorPath = (path: unknown[], more: boolean): string => {
  return path
    .map((val): string => (typeof val === 'string' ? `"${val}"` : `${val}`))
    .concat(more ? ['...'] : [])
    .join(', ');
};

export const pathError = (): string =>
  'Invalid path. Expected array or string.';
export const pathSyntaxError = (path: string): string =>
  `Invalid path. Syntax error at "${path}".`;
export const arrayTargetError = (): string => 'Invalid target. Expected array.';
export const indexError = (): string => 'Invalid index. Expected int.';
export const boundsError = (): string => 'Invalid index. Out of bounds.';
export const pathArrayError = (): string => 'Invalid path. Expected array.';
export const expectedArrayError = (
  value: unknown,
  path: unknown[],
  more: boolean,
): string =>
  `Invalid value. Expected array at path: [ ${formatErrorPath(
    path,
    more,
  )} ]. Encountered value: ${value}.`;
export const expectedPathIntError = (path: unknown[], more: boolean): string =>
  `Invalid path part. Expected int at path: [ ${formatErrorPath(
    path,
    more,
  )} ].`;
export const pathPartError = (path: unknown[], more: boolean): string =>
  `Invalid path part. Expected string or int at path: [ ${formatErrorPath(
    path,
    more,
  )} ].`;

export const isValidPath = (path: unknown): path is string => {
  return (
    (Array.isArray(path) && path.length !== 0) ||
    (typeof path === 'string' && path !== '')
  );
};

export const isInt = (val: unknown): val is number => {
  return typeof val === 'number' && val === (val | 0);
};

export const insert = <T extends any>(
  array: T[],
  index: number,
  value: T,
): T[] => {
  if (!Array.isArray(array)) {
    throw new TypeError(arrayTargetError());
  }

  if (!isInt(index)) {
    throw new TypeError(indexError());
  }

  if (index < 0 || index > array.length) {
    throw new TypeError(boundsError());
  }

  return array.slice(0, index).concat([value], array.slice(index));
};

export const remove = <T extends unknown>(array: T[], index: number): T[] => {
  if (!Array.isArray(array)) {
    throw new TypeError(arrayTargetError());
  }

  if (!isInt(index)) {
    throw new TypeError(indexError());
  }

  if (index < 0 || index >= array.length) {
    throw new TypeError(boundsError());
  }

  return array.slice(0, index).concat(array.slice(index + 1));
};

export const equals = (a: unknown, b: unknown): boolean => {
  if (a === b) {
    return true;
  }
  if (a !== a && b !== b) {
    return true;
  }
  if (a instanceof Date && b instanceof Date && a.getTime() === b.getTime()) {
    return true;
  }
  return false;
};

export const formatPath = (path: Path): string => {
  if (typeof path === 'string') {
    return path;
  }

  if (Array.isArray(path)) {
    let result = '';

    path.forEach((part): void => {
      if (isInt(part)) {
        result += `[${part}]`;
      } else if (result) {
        result += `.${part}`;
      } else {
        result = part;
      }
    });

    return result;
  }

  throw new TypeError(pathError());
};

export const parsePath = (path: Path): PathArray => {
  if (Array.isArray(path)) {
    return path;
  }

  if (typeof path === 'string') {
    return path.split('.').reduce(
      (result, part): PathArray => {
        if (part === '' && result.length === 0) {
          return result;
        }

        const split = part.split('[');
        const key = split[0];
        const rest = split.slice(1);

        return result.concat(
          key === '' ? [] : [key],
          rest.map((i): number => {
            const match = /^([0-9]+)\]$/.exec(i);
            if (!match) {
              throw new TypeError(
                pathSyntaxError(formatPath(result.concat([part]))),
              );
            }
            return parseInt(match[1], 10);
          }),
        );
      },
      [] as PathArray,
    );
  }

  throw new TypeError(pathError());
};

const _setWith = <T extends any>(
  value: T,
  path: PathArray,
  updater: (value: any) => any,
  currentPath: PathArray = [],
): T => {
  if (!Array.isArray(path)) {
    throw pathArrayError();
  }

  if (!path.length) {
    return updater(value);
  }

  const key = path[0];
  const intKey = isInt(key) ? key : null;

  if (intKey === null && typeof key !== 'string') {
    throw new TypeError(
      pathPartError(currentPath.concat([key]), path.length > 1),
    );
  }

  const nextValue = value !== undefined ? value[key] : undefined;
  const updateResult =
    path.length === 1
      ? updater(nextValue)
      : _setWith(nextValue, path.slice(1), updater, currentPath.concat[key]);

  if (intKey !== null) {
    if (Array.isArray(value) || value === undefined) {
      if (value && equals(updateResult, value[intKey])) {
        // The correct value is already in place, abort update.
        return value;
      }

      const result = value ? value.slice(0) : [];
      while (result.length <= intKey) {
        result.push(undefined);
      }

      result.splice(intKey, 1, updateResult);
      return result as T;
    }

    throw new TypeError(
      expectedArrayError(value, currentPath.concat([key]), path.length > 1),
    );
  } else if (Array.isArray(value)) {
    throw new TypeError(
      expectedPathIntError(currentPath.concat([key]), path.length > 1),
    );
  }

  if (
    value !== null &&
    value !== undefined &&
    equals(updateResult, value[key])
  ) {
    // The correct value is already in place, abort update.
    return value;
  }

  const result = Object.assign({}, value);
  result[path[0]] = updateResult;
  return result;
};

export const setWith = <T extends any>(
  object: T,
  path: PathArray,
  updater: (value: any) => any,
): T => _setWith(object, path, updater);

export const set = <T extends any>(object: T, path: PathArray, value: any): T =>
  _setWith(object, path, (): any => value);

const _get = (value: any, path: PathArray, currentPath: PathArray): unknown => {
  if (!Array.isArray(path)) {
    throw new TypeError(pathArrayError());
  }

  if (!path.length) {
    return value;
  }

  const key = path[0];
  const keyIsInt = isInt(key);
  const valueIsArray = Array.isArray(value);
  const valueIsUndefined = value === undefined;

  if (!keyIsInt && typeof key !== 'string') {
    throw new TypeError(
      pathPartError(currentPath.concat([key]), path.length > 1),
    );
  }

  if ((keyIsInt || valueIsUndefined) !== (valueIsArray || valueIsUndefined)) {
    throw new TypeError(
      keyIsInt
        ? expectedArrayError(value, currentPath.concat([key]), path.length > 1)
        : expectedPathIntError(currentPath.concat([key]), path.length > 1),
    );
  }

  return _get(
    valueIsUndefined ? undefined : value[key],
    path.slice(1),
    currentPath.concat([key]),
  );
};

export const get = (value: any, path: PathArray): unknown =>
  _get(value, path, []);

export const matchesDeep = (
  value: any,
  test: (value: unknown) => boolean,
): boolean => {
  if (test(value)) {
    return true;
  }

  if (Array.isArray(value)) {
    for (let i = 0; i < value.length; i += 1) {
      if (matchesDeep(value[i], test)) return true;
    }
  }

  if (/^\[object Object\]$/.test(Object.prototype.toString.call(value))) {
    for (const key in value) {
      if (Object.prototype.hasOwnProperty.call(value, key)) {
        if (matchesDeep(value[key], test)) {
          return true;
        }
      }
    }
  }

  return false;
};

export const hasValue = (value: unknown): boolean =>
  matchesDeep(
    value,
    (value): boolean =>
      !/^\[object (Object|Array|Undefined|Null)\]$/.test(
        Object.prototype.toString.call(value),
      ),
  );

export const isEvent = (
  event: any,
): event is Event | React.SyntheticEvent<HTMLElement> => {
  // Duck-type Event and SyntheticEvent instances
  return (
    event !== null &&
    typeof event === 'object' &&
    !!event.target &&
    !!event.target.constructor &&
    /^HTML.*?Element$/.test(event.target.constructor.name)
  );
};

export const mergeHandlers = (
  h1: ((eventOrValue: unknown) => unknown) | void,
  h2: (eventOrValue: unknown) => void,
): ((eventOrValue: unknown) => void) => {
  return (eventOrValue: unknown): void => {
    if (h1) {
      h1(eventOrValue);

      if (isEvent(eventOrValue) && eventOrValue.defaultPrevented) {
        return;
      }
    }

    h2(eventOrValue);
  };
};

export const parseEvent = (eventOrValue: unknown): unknown => {
  if (isEvent(eventOrValue) && eventOrValue.target) {
    const type = (eventOrValue.target as HTMLInputElement).type;

    if (type === 'checkbox' || type === 'radio') {
      return !!(eventOrValue.target as HTMLInputElement).checked;
    }

    // TODO: Parse additional event types: file, multiple-select, etc.

    return (eventOrValue.target as HTMLInputElement).value;
  }

  return eventOrValue;
};