redux-firestore
Version:
Redux bindings for Firestore.
312 lines (287 loc) • 10.9 kB
JavaScript
import { size, get, unionBy, reject, omit, map, keyBy, isEqual } from 'lodash';
import { merge as mergeObjects } from 'lodash/fp';
import { actionTypes } from '../constants';
import {
updateItemInArray,
createReducer,
preserveValuesFromState,
pathToArr,
} from '../utils/reducers';
const {
DOCUMENT_ADDED,
GET_SUCCESS,
LISTENER_RESPONSE,
CLEAR_DATA,
DELETE_SUCCESS,
DOCUMENT_REMOVED,
DOCUMENT_MODIFIED,
} = actionTypes;
/**
* Create a new copy of an array with the provided item in a new array index
* @param {Array} [collectionState=[]] - Redux state of current collection
* @param {object} meta - Redux Action meta data contains the doc id
* @param {object} ordered - New array metadata
* @param {object} ordered.oldIndex - New array index for the item
* @param {object} ordered.newIndex -
* @param {object} newValue - New value of the item
* @returns {Array} Array with item moved
*/
function newArrayWithItemMoved(collectionState, meta, ordered, newValue) {
const { doc } = meta;
const { oldIndex, newIndex } = ordered || {};
// remove oldIndex from array while creating a copy
const arrayWithoutItem = [
...collectionState.slice(0, oldIndex),
...collectionState.slice(oldIndex + 1),
];
// Insert item in new array while preserving order of other items
return [
...arrayWithoutItem.slice(0, newIndex),
// set new item (falling back to using a copy of the removed item)
{ id: doc, ...newValue } || { ...collectionState[oldIndex] },
...arrayWithoutItem.slice(newIndex),
];
}
/**
* Case reducer for modifying a document within a collection or
* subcollection. When storeAs is being used, subcollections are
* moved to the level of the storeAs (instead of on their parent doc).
* @param {Array} [collectionState=[]] - Redux state of current collection
* @param {object} action - The action that was dispatched
* @returns {Array} State with document modified
*/
function modifyDoc(collectionState, action) {
// Support moving a doc within an array
if (action.payload.ordered) {
const { newIndex, oldIndex } = action.payload.ordered;
// newIndex/oldIndex values exist, item isn't being removed or added, and the index has changed
if (
newIndex !== null &&
oldIndex !== null &&
newIndex > -1 &&
oldIndex > -1 &&
newIndex !== oldIndex
) {
return newArrayWithItemMoved(
collectionState,
action.meta,
action.payload.ordered,
action.payload.data,
);
}
}
if (!action.meta.subcollections || action.meta.storeAs) {
return updateItemInArray(collectionState, action.meta.doc, (item) =>
// Merge is no longer used to prevent removal of subcollections since this will change in v1
({ id: action.meta.doc, ...action.payload.data }),
);
}
// TODO: make this recurisve so it will work multiple subcollections deep
const [, docId, subcollectionName, subDocId] = pathToArr(action.meta.path);
// Update document item within top array
return updateItemInArray(collectionState, docId, (item) => ({
...item, // preserve document (only updating subcollection)
[subcollectionName]: updateItemInArray(
get(item, subcollectionName, []),
subDocId,
// Merge with existing subcollection doc (only updates changed keys)
(subitem) => mergeObjects(subitem, action.payload.data),
),
}));
}
/**
* Case reducer for adding a document to a collection or subcollection.
* @param {Array} [array=[]] - Redux state of current collection
* @param {object} action - The action that was dispatched
* @returns {Array} State with document modified
*/
function addDoc(array = [], action) {
const { meta, payload } = action;
if (!meta.subcollections || meta.storeAs) {
return [
...array.slice(0, payload.ordered.newIndex),
{ id: meta.doc, ...payload.data },
...array.slice(payload.ordered.newIndex),
];
}
// Add doc to subcollection by modifying the existing doc at this level
return modifyDoc(array, action);
}
/**
* Case reducer for adding a document to a collection.
* @param {Array} array - Redux state of current collection
* @param {object} action - The action that was dispatched
* @returns {Array} State with document modified
*/
function removeDoc(array, action) {
// Update is at doc level (not subcollection level)
if (!action.meta.subcollections || action.meta.storeAs) {
// Remove doc from collection array
return reject(array, { id: action.meta.doc }); // returns a new array
}
// Update is at subcollection level
const subcollectionSetting = action.meta.subcollections[0];
// Meta does not contain doc, remove whole subcollection
if (!subcollectionSetting.doc) {
return updateItemInArray(
array,
action.meta.doc,
(item) => omit(item, [subcollectionSetting.collection]), // omit creates a new object
);
}
// Meta contains doc setting, remove doc from subcollection
return updateItemInArray(array, action.meta.doc, (item) => {
const subcollectionVal = get(item, subcollectionSetting.collection, []);
// Subcollection exists within doc, update item within subcollection
if (subcollectionVal.length) {
return {
...item,
[subcollectionSetting.collection]: reject(array, {
id: subcollectionSetting.doc,
}),
};
}
// Item does not contain subcollection
return item;
});
}
/**
* Case reducer for writing/updating a whole collection.
* @param {Array} collectionState - Redux state of current collection
* @param {object} action - The action that was dispatched
* @returns {Array} State with document modified
*/
function writeCollection(collectionState, action) {
const { meta, merge = { doc: true, collections: true } } = action;
if (meta.storeAs) {
return action.payload.ordered;
}
const collectionStateSize = size(collectionState);
const payloadExists = !!size(action.payload.ordered);
// Handle doc update (update item in array instead of whole array)
if (meta.doc && merge.doc && collectionStateSize) {
// Merge if data already exists
// Merge with existing ordered array if collection merge enabled
return modifyDoc(collectionState, action);
}
// Merge with existing ordered array (existing as source) if collection merge enabled
if (collectionStateSize && merge.collections) {
// Listener response is empty - empty state
if (!payloadExists) {
return [];
}
// Key existing state collection by id (used to prevent multiple array lookups)
const existingKeys = collectionState && keyBy(collectionState, 'id');
// Map new doc data with existing doc data (preserves existing sub-collections)
return map(action.payload.ordered, (newDocObj) => {
const existingDoc = get(existingKeys, newDocObj.id);
// merge with existing doc if existing doc is not equal
return !!existingDoc && !isEqual(existingDoc, newDocObj)
? { ...existingDoc, ...newDocObj }
: newDocObj;
});
}
// Handle subcollections (only when storeAs is not being used)
if (meta.doc && meta.subcollections) {
const subcollectionConfig = meta.subcollections[0];
if (!collectionStateSize) {
// Collection state does not already exist, create it with item containing
// subcollection
return [
{
id: meta.doc,
[subcollectionConfig.collection]: action.payload.ordered,
},
];
}
// Merge with existing document if collection state exists
return updateItemInArray(collectionState, meta.doc, (item) =>
// check if action contains ordered payload
payloadExists
? // merge with existing subcollection
{
...item,
[subcollectionConfig.collection]: unionBy(
get(item, subcollectionConfig.collection, []),
action.payload.ordered,
'id',
),
}
: // remove subcollection if payload is empty
omit(item, [subcollectionConfig.collection]),
);
}
if (meta.doc && collectionStateSize) {
// don't update ordered if the doc doesn't exist
if (!size(action.payload.ordered)) {
return collectionState;
}
// Update item in array
return updateItemInArray(collectionState, meta.doc, (item) =>
mergeObjects(item, action.payload.ordered[0]),
);
}
return action.payload.ordered;
}
const actionHandlers = {
[DOCUMENT_ADDED]: addDoc,
[DOCUMENT_MODIFIED]: modifyDoc,
[DOCUMENT_REMOVED]: removeDoc,
[DELETE_SUCCESS]: removeDoc,
[LISTENER_RESPONSE]: writeCollection,
[GET_SUCCESS]: writeCollection,
};
/**
* Reducer for an ordered collection (stored under path within ordered reducer)
* which is a map of handlers which are called for different action types.
* @type {Function}
*/
const orderedCollectionReducer = createReducer(undefined, actionHandlers);
/**
* Reducer for ordered state.
* @param {object} [state={}] - Current ordered redux state
* @param {object} action - The action that was dispatched
* @param {string} action.type - Type of action that was dispatched
* @param {string} action.meta.collection - Name of Collection which the action
* associates with
* @param {string} action.meta.doc - Name of Document which the action
* associates with
* @param {Array} action.meta.subcollections - Subcollections which the action
* associates with
* @param {string} action.meta.storeAs - Another key within redux store that the
* action associates with (used for storing data under a path different
* from its collection/document)
* @param {object} action.payload - Object containing data associated with
* action
* @param {Array} action.payload.ordered - Ordered Array Data associated with
* action
* @returns {object} Ordered state after reduction
*/
export default function orderedReducer(state = {}, action) {
// Return state if action is malformed (i.e. no type)
if (!action.type) {
return state;
}
// Clear state based on config for CLEAR_DATA action type
if (action.type === CLEAR_DATA) {
// support keeping data when logging out - #125
if (action.preserve && action.preserve.ordered) {
return preserveValuesFromState(state, action.preserve.ordered, {});
}
return {};
}
// Return original state if action type is not within actionHandlers
if (!Object.prototype.hasOwnProperty.call(actionHandlers, action.type)) {
return state;
}
// Return state if action does not contain valid meta
if (!action.meta || (!action.meta.storeAs && !action.meta.collection)) {
return state;
}
const storeUnderKey = action.meta.storeAs || action.meta.collection;
const collectionStateSlice = get(state, storeUnderKey);
return {
...state,
[storeUnderKey]: orderedCollectionReducer(collectionStateSlice, action),
};
}