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;