Source: app/actions/resources/apiActions/apiGetCollection.js

import compose from 'redux/lib/compose';
import debugLib from 'debug';
import { apiGet } from './apiRequest';
import getDefaultApiEntityTransform from './getDefaultApiEntityTransform';
import { addEntities } from '../../entities/entityActions';
import {
  collectionPartitionSetAfter,
  collectionPartitionSetBefore,
  collectionReplace,
  collectionSetAtOffset,
  collectionSetTotal,
} from '../../entities/collectionActions';
import { updateCollectionPaginationView } from '../../collectionPaginationViewActions';
import {
  PAGINATION_NONE,
  PAGINATION_PARTITION,
  PAGINATION_OFFSET,
} from '../../../data/paginationType';

/**
 * @module apiGetCollection
 */

const debug = debugLib('SlimmingWorld:apiGetCollection');

/**
 * Can be passed as a value for `offset` in
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection} to automatically get the next
 * set we don't have yet
 * @type {Symbol}
 * @category api-calls
 */
export const GET_NEXT = Symbol('GET_NEXT');

/**
 * Can be passed as a value for `offset` in
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection} to automatically get the
 * previous set we don't have yet
 * @type {Symbol}
 * @category api-calls
 */
export const GET_PREVIOUS = Symbol('GET_PREVIOUS');

/**
 * Default transform for the api response. Leaves the data untouched
 * @type {function}
 */
export const IDENTITY_TRANSFORM = d => d;

/**
 * Default caching for {@link module:apiGetCollection~apiGetCollection|apiGetCollection}. Will
 * lookup the requested items in the current collection reducer state. When some items already
 * exist, will truncate the request pagination parameters to not include these items. When all
 * items already exist, will cancel the call.
 * @function collectionCachingDefault
 * @category api-calls
 * @param paginationOptions {GetCollectionPaginationOptions} The pagination options passed to
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param currentCollectionState {object} The state of the `collectionReducer`. If no state is
 * currently present, this will be `undefined`
 * @param requestData {object} The requestData argument of `apiGetCollection`
 * @param getState {function} The redux getState function
 * @param path {string} The api request path. Used for debug logs
 * @returns {GetCollectionPaginationOptions} The paginationOptions parameter modified for caching
 */
export const collectionCachingDefault = (
  paginationOptions,
  currentCollectionState = { pagination: { type: PAGINATION_NONE }, refs: [] },
  requestData,
  getState,
  path,
) => {
  if (paginationOptions.limit) {
    if (paginationOptions.from) {
      if (
        typeof paginationOptions.from.value !== 'undefined' &&
        currentCollectionState.pagination.type === PAGINATION_PARTITION
      ) {
        const currentEntities = getEntitiesFromRefs(currentCollectionState.refs, getState);
        const fromValueIndex = currentEntities.findIndex(
          entity => entity[paginationOptions.from.key] === paginationOptions.from.value,
        );

        if (fromValueIndex < 0) {
          debug(
            `collectionCachingDefault for "${path}": requested ${paginationOptions.from.key} not present in state. Executing request normally`,
          );
          return paginationOptions;
        }

        const existingEntities = currentCollectionState.refs.length - fromValueIndex;
        if (existingEntities >= paginationOptions.limit) {
          debug(
            `collectionCachingDefault for "${path}": requested entities already in state. Skipping request`,
          );
          return false;
        }

        const newPaginationOptions = {
          limit: paginationOptions.limit - existingEntities,
          from: {
            key: paginationOptions.from.key,
            param: paginationOptions.from.param,
          },
        };
        debug(
          `collectionCachingDefault for "${path}": some of the requested entities are already in state. Modified request parameters (limit=${newPaginationOptions.limit} from.value=undefined)`,
        );
        return newPaginationOptions;
      }
    } else if (paginationOptions.until) {
      if (
        typeof paginationOptions.until.value !== 'undefined' &&
        currentCollectionState.pagination.type === PAGINATION_PARTITION
      ) {
        const currentEntities = getEntitiesFromRefs(currentCollectionState.refs, getState);

        // a for loop is used instead of .findIndex(), because it makes more sense to start
        // searching from the back of the array
        let untilValueIndex = -1;
        for (let i = currentEntities.length - 1; i >= 0; i--) {
          if (currentEntities[i][paginationOptions.until.key] === paginationOptions.until.value) {
            untilValueIndex = i;
            break;
          }
        }

        if (untilValueIndex < 0) {
          debug(
            `collectionCachingDefault for "${path}": requested ${paginationOptions.until.key} not present in state. Executing request normally`,
          );
          return paginationOptions;
        }

        if (untilValueIndex >= paginationOptions.limit) {
          debug(
            `collectionCachingDefault for "${path}": requested entities already in state. Skipping request`,
          );
          return false;
        }
        // we need some of the entities. modify the parameters
        const newPaginationOptions = {
          limit: paginationOptions.limit - (untilValueIndex + 1),
          until: {
            key: paginationOptions.until.key,
            param: paginationOptions.until.param,
          },
        };

        debug(
          `collectionCachingDefault for "${path}": some of the requested entities are already in state. Modified request parameters (limit=${newPaginationOptions.limit} until.value=undefined)`,
        );
        return newPaginationOptions;
      }
    } else if (currentCollectionState.pagination.type === PAGINATION_OFFSET) {
      const newPaginationOptions = { ...paginationOptions };
      const stripped = [];

      // strip the trailing items from the request that have already been loaded
      while (
        newPaginationOptions.limit > 0 &&
        currentCollectionState.refs[
          (currentCollectionState.pagination.offset || 0) +
            (newPaginationOptions.offset || 0) +
            newPaginationOptions.limit -
            1
        ]
      ) {
        newPaginationOptions.limit -= 1;

        if (!stripped.includes('trailing')) {
          stripped.push('trailing');
        }
      }

      // strip the leading items from the request that have already been loaded
      while (
        newPaginationOptions.limit > 0 &&
        currentCollectionState.refs[
          (currentCollectionState.pagination.offset || 0) + (newPaginationOptions.offset || 0)
        ]
      ) {
        newPaginationOptions.limit -= 1;

        if (currentCollectionState.pagination.total > currentCollectionState.refs.length) {
          newPaginationOptions.offset += 1;
        }

        if (!stripped.includes('leading')) {
          stripped.push('leading');
        }
      }

      // if no items are left, return false
      if (!newPaginationOptions.limit) {
        debug(`collectionCachingDefault for "${path}": all items cached, skipping request`);
        return false;
      }

      if (stripped.length) {
        debug(
          `collectionCachingDefault for "${path}": ${stripped.join(
            ' and ',
          )} items have been stripped from the request because they are already present in state`,
        );
      } else {
        debug(
          `collectionCachingDefault for "${path}": requested data not present. Executing request normally`,
        );
      }
      return newPaginationOptions;
    }
  }

  return paginationOptions;
};

/**
 * Simplified caching for {@link module:apiGetCollection~apiGetCollection|apiGetCollection}. If any
 * data is present in the current collection state, we consider the list as cached (regardless of
 * the amount of data).
 * @function collectionCachingNoPagination
 * @category api-calls
 * @param {object} paginationOptions The pagination options passed to
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param [currentCollectionState] {object} The state of the `collectionReducer`. If no state is
 * currently present, this will be `undefined`
 * @returns {object|boolean} The paginationOptions parameter modified for caching or false if
 * data was already present
 */
export const collectionCachingNoPagination = (paginationOptions, currentCollectionState) =>
  currentCollectionState && currentCollectionState.refs && currentCollectionState.refs.length
    ? false
    : paginationOptions;

/**
 * Performs an API request to get a list of entities.
 *
 * @function apiGetCollection
 * @category api-calls
 * @param {string} actionType The `type` property of the dispatched api request action is set
 * to this value
 * @param {string} gatewayType The type of gateway to use. These should be one of the strings
 * defined in {@link module:Injectables}
 * @param {string} path The path to the endpoint to request
 * @param {string} collectionId The ID of the collection to store the data in. Should be obtained
 * from {@link module:collectionIds}
 * @param {GetCollectionPaginationOptions} [paginationOptions] Object that
 * contains optional pagination options
 * @param {object} [options] Object with optional options
 * @param {object} [options.requestData=null] Object with data to send with the API request. This
 * is merged with any parameters that result from paginationOptions.
 * @param {object} [options.requestOptions={}] Object with additional options passed to the gateway.
 * Please see gateway documentation for more info
 * @param {function} [options.transformResponse] A function that will transform the response
 * before it is processed
 * @param {string|function} [options.entityType=entity => entity._type] A string that indicates
 * type of entities we expect to retrieve from the API. Can also be a function, that takes a
 * responded entity and returns the type of that entity.
 * @param {function} [options.getId=entity => entity.id] A function that returns the id for
 * each entity returned from the api. Defaults to reading from the `id` property on the entity
 * object.
 * @param {function} [options.storeEntities=true] If `false`, will not store entities to the
 * entities reducer, but only references to the collection reducer. This is useful when the API
 * returns references only, with no data attached.
 * @param {GetCollectionCachingCallback|false} [options.caching] A function that manages caching
 * for this request or `false` to always make a full request. See
 * {@link GetCollectionCachingCallback}
 * @param {string|object} [options.updatePaginationView] If set, will update a
 * `collectionPaginationViewReducer` to match the `paginationOptions` passed to this action.
 * Can be a string id of the `collectionPaginationViewReducer`, or an object with the following
 * properties:
 *  - `target` ID of the `collectionPaginationViewReducer`
 *  - `extend` If true, will extend the current pagination instead of replacing it. This can
 *  be used when doing infinite-scroll type pagination instead of offset-based.
 * @returns {Promise} A Promise that resolves with the entity data
 */
const apiGetCollection = (
  actionType,
  gatewayType,
  path,
  collectionId,
  paginationOptions = {},
  {
    requestData = null,
    requestOptions = {},
    transformResponse = null,
    entityType = entity => entity._type, // eslint-disable-line no-underscore-dangle
    getId = entity => entity.id,
    storeEntities = true,
    caching = collectionCachingDefault,
    transformEntity,
    updatePaginationView = null,
    // pre-fetched response, passed by processApiGetCollectionResponse
    _response = null,
  } = {},
) => async (dispatch, getState) => {
  validatePaginationOptions(paginationOptions);

  const { collections } = getState();
  const { [collectionId]: currentCollectionState } = collections;

  const processedPaginationOptions = preprocessPaginationOptions(
    paginationOptions,
    currentCollectionState,
  );

  if (!processedPaginationOptions.limit && caching === collectionCachingDefault) {
    debug(
      `Warning: No limit given in pagination options for API call to ${path}. This limits caching functionality.`,
    );
  }

  const requestPaginationOptions = caching
    ? caching(processedPaginationOptions, currentCollectionState, requestData, getState, path)
    : processedPaginationOptions;

  let rawResult = _response;
  let currentCollectionEdgeRef = null;

  const data = {
    ...(requestData || {}),
  };
  if (requestPaginationOptions) {
    const { refs = [] } = currentCollectionState || {};
    const { from, andFrom, until, offset, limit } = requestPaginationOptions;

    if (limit) {
      data.limit = limit;
    }

    if (typeof offset !== 'undefined') {
      data.offset = offset;
    } else if (from || andFrom || until) {
      const { param, key, value } = from || until;
      if (typeof value === 'undefined') {
        const currentEntities = getEntitiesFromRefs(refs, getState);

        if (currentEntities.length) {
          currentCollectionEdgeRef = refs[from ? currentEntities.length - 1 : 0];
          data[param] = currentEntities[from ? currentEntities.length - 1 : 0][key];

          /**
           * When want to do a specific search / sort
           *
           * It can be a single or an array object
           */
          if (andFrom) {
            if (!Array.isArray(andFrom)) {
              data[andFrom.param] = currentEntities[currentEntities.length - 1][andFrom.key];
            } else {
              andFrom.forEach(item => {
                data[item.param] = currentEntities[currentEntities.length - 1][item.key];
              });
            }
          }
        }
      } else {
        data[param] = value;
        data.includeBoundaries = true;
      }
    }
  }

  if (!rawResult) {
    if (!requestPaginationOptions) {
      rawResult = null;
    } else {
      rawResult = await dispatch(apiGet(actionType, gatewayType, path, data, requestOptions));
    }
  }

  const result = rawResult ? (transformResponse || IDENTITY_TRANSFORM)(rawResult) : rawResult;
  let entities = [];
  let entityRefs = [];

  // if the request was cancelled (due to caching) there is no result
  if (result) {
    const { data: responseData, pagination } = result;
    const { from, until, offset, limit } = requestPaginationOptions;

    const defaultApiEntityTransform = getDefaultApiEntityTransform(path, actionType);
    const transformer = compose(...[transformEntity, defaultApiEntityTransform].filter(_ => _));

    ({ entities, entityRefs } = dispatch(
      addEntitiesFromApiData(responseData, entityType, getId, transformer, storeEntities),
    ));

    if (typeof offset !== 'undefined') {
      dispatch(
        collectionSetAtOffset(collectionId, entityRefs, offset, pagination && pagination.hasMore),
      );
    } else if (from) {
      dispatch(
        collectionPartitionSetAfter(
          collectionId,
          entityRefs,
          limit || 1,
          currentCollectionEdgeRef,
          typeof from.value === 'undefined',
        ),
      );
    } else if (until) {
      dispatch(
        collectionPartitionSetBefore(
          collectionId,
          entityRefs,
          limit || 1,
          currentCollectionEdgeRef,
        ),
      );
    } else {
      dispatch(collectionReplace(collectionId, entityRefs));
    }

    if (pagination && typeof pagination.total !== 'undefined') {
      dispatch(collectionSetTotal(collectionId, pagination.total));
    }
  }

  if (updatePaginationView) {
    dispatch(
      updateCollectionPaginationViewFromRequest(
        updatePaginationView,
        processedPaginationOptions,
        collectionId,
        entities,
        currentCollectionState,
      ),
    );
  }

  return { result, entities };
};

/**
 * This action will call {@link module:apiGetCollection~apiGetCollection|apiGetCollection}, but
 * will leave out doing an API request. Instead, you should provide the response of the request
 * as the first argument to this function.  All other arguments are the same as
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * This action can be used when we already have some response data, but we still want to make use
 * of the apiGetCollection functionality to process that data.
 * @function processApiGetCollectionResponse
 * @param response {object} An object that is the same shape as a response that would come from
 * a collection api call
 * @param actionType {string} see {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param gatewayType {string} see
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param path {string} see {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param collectionId {string} see
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param paginationOptions {object} see
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param options {object} see {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 */
export const processApiGetCollectionResponse = (
  response,
  actionType,
  gatewayType,
  path,
  collectionId,
  paginationOptions = {},
  options = {},
) => dispatch =>
  dispatch(
    apiGetCollection(actionType, gatewayType, path, collectionId, paginationOptions, {
      ...options,
      _response: response,
    }),
  );

export const addEntitiesFromApiData = (
  data,
  entityTypeOption = entity => entity._type, // eslint-disable-line no-underscore-dangle,
  getId = entity => entity.id,
  transformEntity,
  storeEntities = true,
) => dispatch => {
  if (!data) {
    throw new ReferenceError('Expected the API response to have a data key');
  }
  const entities = {};
  const staticEntityType = typeof entityTypeOption === 'string';
  const entityRefs = data.map(entity => ({
    id: getId(entity),
    type: staticEntityType ? entityTypeOption : entityTypeOption(entity),
  }));

  data.forEach((entity, index) => {
    const entityType = entityRefs[index].type;

    if (!entityType) {
      throw new TypeError(`Invalid entity type "${entityType}" on API response.`);
    }

    if (!entities[entityType]) {
      entities[entityType] = {};
    }

    entities[entityType][entityRefs[index].id] = transformEntity(entity);
  });

  if (storeEntities) {
    dispatch(addEntities(entities));
  }
  return { entities, entityRefs };
};

export const updateCollectionPaginationViewFromRequest = (
  updatePaginationViewOption,
  paginationOptions,
  collectionId,
  newEntities,
  collectionStateBeforeRequest,
) => (dispatch, getState) => {
  const { from, until, offset, limit } = paginationOptions;

  let targetView = updatePaginationViewOption;
  let extendPagination = false;
  if (typeof updatePaginationViewOption === 'object' && updatePaginationViewOption !== null) {
    targetView = updatePaginationViewOption.target;
    extendPagination = updatePaginationViewOption.extend || false;
  }

  const { refs = [] } = collectionStateBeforeRequest || {};
  const entitiesBeforeRequest = getEntitiesFromRefs(refs, getState);

  const state = getState();
  const currentViewState = state.view.collectionPagination[targetView] || {};
  if (currentViewState.collection !== collectionId) {
    currentViewState.offset = 0;
    currentViewState.limit = 0;
  }

  const numNewEntities = Object.keys(newEntities).reduce(
    (total, type) => total + Object.keys(newEntities[type]).length,
    0,
  );

  if (typeof offset !== 'undefined') {
    let newOffset = offset;
    let newLimit = limit || numNewEntities;
    if (extendPagination) {
      newOffset = Math.min(offset, currentViewState.offset);
      newLimit = Math.max(
        currentViewState.offset - offset + currentViewState.limit,
        offset + limit,
      );
    }

    return dispatch(
      updateCollectionPaginationView(targetView, {
        offset: newOffset,
        limit: newLimit,
        collection: collectionId,
      }),
    );
  }

  if (from) {
    let newOffset = entitiesBeforeRequest.length;
    let newLimit = limit || numNewEntities;

    if (extendPagination) {
      newOffset = Math.min(numNewEntities, currentViewState.offset);
      newLimit = newOffset - currentViewState.offset + currentViewState.limit + newLimit;
    }

    return dispatch(
      updateCollectionPaginationView(targetView, {
        offset: newOffset,
        limit: newLimit,
        collection: collectionId,
      }),
    );
  }

  if (until) {
    return dispatch(
      updateCollectionPaginationView(targetView, {
        offset: 0,
        limit: extendPagination
          ? currentViewState.offset + currentViewState.limit + numNewEntities
          : limit || numNewEntities,
        collection: collectionId,
      }),
    );
  }

  return dispatch(
    updateCollectionPaginationView(targetView, {
      offset: 0,
      collection: collectionId,
      limit: limit || numNewEntities,
    }),
  );
};

const getEntitiesFromRefs = (refs, getState) => {
  const { entities } = getState();

  return refs.map(({ id, type }) => (entities[type] || {})[id]).filter(_ => _);
};

/**
 * Validates if the paginationOptions object passed to apiGetCollection is of correct shape.
 * Will throw if invalid options are passed.
 *
 * @function validatePaginationOptions
 * @private
 * @param {GetCollectionPaginationOptions} paginationOptions
 */
function validatePaginationOptions(paginationOptions) {
  const options = ['from', 'until', 'offset'];
  const values = options.map(option => paginationOptions[option]);

  if (values.filter(option => typeof option !== 'undefined').length > 1) {
    throw new Error('The pagination options "until", "from" and "offset" are mutually exclusive.');
  }

  const offset = values.pop();
  const offsetType = typeof offset;
  if (
    offsetType !== 'undefined' &&
    offset !== GET_NEXT &&
    offset !== GET_PREVIOUS &&
    offsetType !== 'number'
  ) {
    throw new TypeError(
      `Invalid "offset" option on paginationOptions. Expected number, got ${offsetType}`,
    );
  }

  values.forEach((value, index) => {
    const valueType = typeof value;
    if (valueType !== 'undefined') {
      if (valueType !== 'object') {
        throw new TypeError(
          `Invalid "${options[index]}" option on paginationOptions: Expected number, got ${valueType}`,
        );
      }
      if (typeof value.param === 'undefined') {
        throw new ReferenceError(
          `Invalid "${options[index]}" option on paginationOptions: a "param" property is required`,
        );
      }
      if (typeof value.key === 'undefined') {
        throw new ReferenceError(
          `Invalid "${options[index]}" option on paginationOptions: a "key" is required`,
        );
      }
    }
  });
}

/**
 * Returns a new paginationOptions object with the GET_NEXT and GET_PREVIOUS symbols on the
 * offset property replaced by the actual index that needs retrieving.
 * @function preprocessPaginationOptions
 * @private
 * @param paginationOptions {GetCollectionPaginationOptions} An object of pagination options
 * passed to {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param [collectionState] {object} The current state of the collection, if any
 * @returns {GetCollectionPaginationOptions}
 */
const preprocessPaginationOptions = (paginationOptions, collectionState = {}) => {
  const processed = { ...paginationOptions };
  const { pagination: { offset = 0 } = {}, refs = [] } = collectionState;

  if (processed.offset === GET_NEXT) {
    processed.offset = offset + refs.length;
  } else if (processed.offset === GET_PREVIOUS) {
    processed.limit = Math.max(offset, processed.limit || 10);
    processed.offset = Math.max(0, offset - processed.limit);
  }

  return processed;
};

/**
 * A callback that manages caching for
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}. Retrieves the pagination
 * options for the current request. Can return one of the following:
 *  - The pagination options unmodified if no cache is available and the full request should be
 *  executed
 *  - The pagination options modified to request a smaller amount of entities if a subset of the
 *  data is available in cache
 *  - `false` if the data is fully available in cache and the request should be aborted
 * @callback GetCollectionCachingCallback
 * @global
 * @param {object} paginationOptions The pagination options passed to
 * {@link module:apiGetCollection~apiGetCollection|apiGetCollection}
 * @param [currentCollectionState] {object} The state of the `collectionReducer`. If no state is
 * currently present, this will be `undefined`
 * @param requestData {object} The data that will be sent with the request
 * @param getState {function} The Redux `getState` function
 * @returns {object|boolean} The `paginationOptions` parameter modified for caching or `false` if
 * the request should be aborted
 */

/**
 * @typedef GetCollectionPaginationOptions
 * @global
 * @type {object}
 * @property [offset] {number} The offset index to get entities from. Should only be used when
 * requesting for offset-based pagination
 * @property [limit] {number} The amount of entities to request
 * @property [until] {object} An object with options to request entities before a specific cursor
 * @property [until.param] {string} The request parameter to set for requesting cursor-based
 * pagination. Example: `"untilId"`
 * @property [until.value] {any} The value for the pagination parameter. If not set, will
 * automatically detect the value of the first item that is currently in the collection.
 * @property until.key {string} _Required_. The key which acts as a cursor in the target collection.
 * @property [from] {object} An object with options to request entities after a specific cursor
 * @property [from.param] {string} The request parameter to set for requesting cursor-based
 * pagination. Example: `"sinceId"`
 * @property [from.value] {any}  The value for the pagination parameter. If not set, will
 * automatically detect the value of the last item that is currently in the collection
 * @property from.key {string} _Required_. The key which acts as a cursor in the target collection.
 */

export default apiGetCollection;