diff --git a/package.json b/package.json index 052e1d5..e62b68d 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "redeuce", - "version": "0.1", + "version": "1.0.0-alpha.1", "engines": { - "node": "8.9.3", - "npm": "6.0.1" + "node": "8.9", + "npm": "6" }, "devDependencies": { "@babel/cli": "^7.1.0", @@ -18,5 +18,8 @@ "eslint-plugin-import": "^2.14.0", "jest": "^23.6.0", "regenerator-runtime": "^0.12.1" + }, + "scripts": { + "test": "NODE_ENV=development jest --config jest.conf.json --colors --notify" } } diff --git a/src/collectionStore.js b/src/collectionStore.js new file mode 100644 index 0000000..ef6cc0a --- /dev/null +++ b/src/collectionStore.js @@ -0,0 +1,14 @@ +import { memoize, makeCallable } from './tools'; + +import { buildReducer, buildCollectionReducers } from './generators/reducers'; +import { COLLECTION, generateActionTypes, generateActionCreators } from './generators/actions'; + +const generateCollectionStore = (entityName, { idKey = 'id', defaultValue = [] } = {}) => { + const actionTypes = generateActionTypes(COLLECTION, entityName); + const actionCreators = generateActionCreators(COLLECTION, entityName); + + const reducer = buildReducer(buildCollectionReducers(actionTypes, idKey), defaultValue); + + return makeCallable(reducer, actionCreators); +}; +export default memoize(generateCollectionStore); diff --git a/src/generators/actions.js b/src/generators/actions.js new file mode 100644 index 0000000..5928119 --- /dev/null +++ b/src/generators/actions.js @@ -0,0 +1,39 @@ +export const SIMPLE = 'SIMPLE'; +export const COLLECTION = 'COLLECTION'; +export const VERBS = { + [SIMPLE]: { + SET: 'set' + }, + [COLLECTION]: { + SET: 'set', + UPDATE: 'update', + DELETE: 'delete', + MERGE: 'merge', + MERGEDEEP: 'mergeDeep', + DELETEALL: 'deleteAll', + CLEAR: 'clear' + } +}; + +const generateActionType = (storeType, entityName, verb) => + `REDEUCE:${storeType}@@${entityName}@@${verb}`; +export const generateActionTypes = (storeType, entityId) => + Object.keys(VERBS[storeType]).reduce( + (actionTypes, verb) => ({ + ...actionTypes, + [verb]: generateActionType(storeType, entityId, verb) + }), + {} + ); + +const generateActionCreator = type => payload => ({ type, payload }); +export const generateActionCreators = (storeType, entityId) => { + const actionTypes = generateActionTypes(storeType, entityId); + return Object.keys(VERBS[storeType]).reduce( + (actionCreators, verb) => ({ + ...actionCreators, + [VERBS[storeType][verb]]: generateActionCreator(actionTypes[verb]) + }), + {} + ); +}; diff --git a/src/generators/reducers.js b/src/generators/reducers.js new file mode 100644 index 0000000..5ad5031 --- /dev/null +++ b/src/generators/reducers.js @@ -0,0 +1,101 @@ +const sortByKeyName = keyName => (a, b) => a[keyName] > b[keyName]; +const getKeyNameValues = (entities, keyName) => + entities.map(({ [keyName]: id }) => id); + +const filterByKeyname = (entities, keyName) => + entities.filter(({ [keyName]: id }) => id !== undefined); + +const filterWithoutIndexes = (entities, indexes, keyName) => + entities.filter(({ [keyName]: id }) => indexes.indexOf(id) === -1); + +const filterByIndexes = (entities, indexes, keyName) => + entities.filter(({ [keyName]: id }) => indexes.indexOf(id) > -1); + +const findOneByIndex = (entities, keyName, uniqueId) => + entities.find(({ [keyName]: id }) => id === uniqueId); + +export const buildReducer = (reducers, defaultValue) => ( + state = defaultValue, + { type, payload } +) => (reducers[type] ? reducers[type](state, payload) : state); + +const asArray = o => [].concat(o); + +export const buildCollectionReducers = (actionTypes, keyName) => { + const { + SET, + UPDATE, + DELETE, + MERGE, + MERGEDEEP, + DELETEALL, + CLEAR + } = actionTypes; + + // Reducer to SET a list of objects in the store + const setReducer = (state, payload) => { + const filteredPayload = filterByKeyname(asArray(payload), keyName); + const setIndexes = getKeyNameValues(filteredPayload, keyName); + + return [ + // the list of objects from the previous state that are not to be replaced + ...filterWithoutIndexes(state, setIndexes, keyName), + // the list of new objects + ...filteredPayload + ].sort(sortByKeyName(keyName)); + }; + + // Reducer to UPDATE a list of objects in the store + const updateReducer = (state, payload) => { + const filteredPayload = filterByKeyname(asArray(payload), keyName); + const updateIndexes = getKeyNameValues(filteredPayload, keyName); + const existingIndexes = getKeyNameValues(state, keyName); + + return [ + // the list of objects from the previous state that are not to be updated + ...filterWithoutIndexes(state, updateIndexes, keyName), + // the list of objects from the previous state that have to be updated + // mapped to be merged with the new version + ...filterByIndexes(state, updateIndexes, keyName).map(obj => ({ + ...obj, + ...findOneByIndex(filteredPayload, keyName, obj[keyName]) + })), + // the list new objects that are not existing in the previous state + ...filterWithoutIndexes(filteredPayload, existingIndexes, keyName) + ].sort(sortByKeyName(keyName)); + }; + + // Reducer to DELETE a list of objects in the store + const deleteReducer = (state, payload) => { + const filteredPayload = filterByKeyname(asArray(payload), keyName); + const setIndexes = getKeyNameValues(filteredPayload, keyName); + + return [ + // the list of objects from the previous state that are not to be deleted + ...filterWithoutIndexes(state, setIndexes, keyName) + ].sort(sortByKeyName(keyName)); + }; + + const clearReducer = () => []; + + return { + [SET]: setReducer, + [UPDATE]: updateReducer, + [DELETE]: deleteReducer, + [MERGE]: setReducer, + [MERGEDEEP]: updateReducer, + [DELETEALL]: deleteReducer, + [CLEAR]: clearReducer + }; +}; + +export const buildSimpleReducer = actionTypes => { + const { SET } = actionTypes; + + // Reducer to SET a list of objects in the store + const setReducer = (_, payload) => payload; + + return { + [SET]: setReducer + }; +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..db27632 --- /dev/null +++ b/src/index.js @@ -0,0 +1,2 @@ +export { default as generateCollectionStore } from './collectionStore'; +export { default as generateSimpleStore } from './simpleStore'; diff --git a/src/simpleStore.js b/src/simpleStore.js new file mode 100644 index 0000000..1e00f25 --- /dev/null +++ b/src/simpleStore.js @@ -0,0 +1,14 @@ +import { memoize, makeCallable } from './tools'; + +import { buildReducer, buildSimpleReducer } from './generators/reducers'; +import { SIMPLE, generateActionTypes, generateActionCreators } from './generators/actions'; + +const generateSimpleStore = (entityName, { defaultValue = undefined } = {}) => { + const actionTypes = generateActionTypes(SIMPLE, entityName); + const actionCreators = generateActionCreators(SIMPLE, entityName); + + const reducer = buildReducer(buildSimpleReducer(actionTypes), defaultValue); + + return makeCallable(reducer, actionCreators); +}; +export default memoize(generateSimpleStore); diff --git a/src/tools/index.js b/src/tools/index.js new file mode 100644 index 0000000..e504007 --- /dev/null +++ b/src/tools/index.js @@ -0,0 +1,2 @@ +export { default as makeCallable } from './makeCallable'; +export { default as memoize } from './memoize'; diff --git a/src/tools/makeCallable.js b/src/tools/makeCallable.js new file mode 100644 index 0000000..947b295 --- /dev/null +++ b/src/tools/makeCallable.js @@ -0,0 +1,7 @@ +export default (reducer, actionCreators) => { + const fn = () => ({ reducer, ...actionCreators }); + fn.getReducer = () => reducer; + fn.getActionCreators = () => actionCreators; + + return fn; +}; diff --git a/src/tools/memoize.js b/src/tools/memoize.js new file mode 100644 index 0000000..d9b76d7 --- /dev/null +++ b/src/tools/memoize.js @@ -0,0 +1,16 @@ +export default function memoize(fn) { + memoize.cache = {}; + return (...args) => { + const key = JSON.stringify(args[0]); + if (memoize.cache[key]) { + if (JSON.stringify(args) !== JSON.stringify(memoize.cache[key].args)) { + throw new Error('boo'); + } + return memoize.cache[key].val; + } else { + const val = fn(...args); + memoize.cache[key] = { val, args }; + return val; + } + }; +} diff --git a/tests/collectionStore.test.js b/tests/collectionStore.test.js new file mode 100644 index 0000000..e126bdc --- /dev/null +++ b/tests/collectionStore.test.js @@ -0,0 +1,344 @@ +import { generateCollectionStore } from 'index.js'; + +const makeEntityName = () => `random/${Math.round(Math.random() * 1000000000)}`; + +describe('Collection Store', () => { + test('collection provide the expected tools', () => { + const entityName = makeEntityName(); + const gen = generateCollectionStore(entityName); + expect(gen).toHaveProperty('getActionCreators'); + expect(gen).toHaveProperty('getReducer'); + + expect(gen()).toHaveProperty('set'); + expect(gen()).toHaveProperty('update'); + expect(gen()).toHaveProperty('delete'); + expect(gen()).toHaveProperty('merge'); + expect(gen()).toHaveProperty('mergeDeep'); + expect(gen()).toHaveProperty('deleteAll'); + expect(gen()).toHaveProperty('clear'); + expect(gen()).toHaveProperty('reducer'); + + expect(gen.getReducer()).toBe(gen().reducer); + + expect(gen.getActionCreators().set).toBe(gen().set); + expect(gen.getActionCreators().update).toBe(gen().update); + expect(gen.getActionCreators().delete).toBe(gen().delete); + expect(gen.getActionCreators().merge).toBe(gen().merge); + expect(gen.getActionCreators().mergeDeep).toBe(gen().mergeDeep); + expect(gen.getActionCreators().deleteAll).toBe(gen().deleteAll); + expect(gen.getActionCreators().clear).toBe(gen().clear); + }); + + test('collection are memoized', () => { + const entityName = makeEntityName(); + const gen1 = generateCollectionStore(entityName); + const gen2 = generateCollectionStore(entityName); + + expect(gen1).toBe(gen2); + }); + + test('memoized collections with different options throw an error', () => { + expect(() => { + const entityName = makeEntityName(); + const gen1 = generateCollectionStore(entityName); + const gen2 = generateCollectionStore(entityName, { idKey: 'uuid' }); + }).toThrow(); + }); + + describe('collection action creators', () => { + const entityName = makeEntityName(); + const actions = generateCollectionStore(entityName).getActionCreators(); + + test('single entity set action creator', () => { + expect(actions.set('hello')).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@SET`, + payload: 'hello', + }); + }); + test('single update action creator', () => { + expect(actions.update('hello')).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@UPDATE`, + payload: 'hello', + }); + }); + test('single delete action creator', () => { + expect(actions.delete('hello')).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@DELETE`, + payload: 'hello', + }); + }); + + test('bulk entities set action creator', () => { + expect(actions.merge('hello')).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@MERGE`, + payload: 'hello', + }); + }); + test('bulk update action creator', () => { + expect(actions.mergeDeep('hello')).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@MERGEDEEP`, + payload: 'hello', + }); + }); + test('bulk delete action creator', () => { + expect(actions.deleteAll('hello')).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@DELETEALL`, + payload: 'hello', + }); + }); + test('clear action creator', () => { + expect(actions.clear()).toEqual({ + type: `REDEUCE:COLLECTION@@${entityName}@@CLEAR`, + }); + }); + }); + + describe('reducers', () => { + const state = [ + { id: 1, name: 'hello', value: 'world' }, + { id: 2, name: 'foo', value: 'bar', option: 'baz' }, + ]; + const stateUuid = state.map(({ id, ...props }) => ({ uuid: id, ...props })); + + // default + + test('default reducer call', () => { + const entityName = makeEntityName(); + const { reducer } = generateCollectionStore(entityName)(); + + expect(reducer(undefined, { type: 'NOTHING' })).toEqual([]); + }); + + test('default value', () => { + const entityName = makeEntityName(); + const defaultValue = [{ id: 'yes' }]; + const { reducer } = generateCollectionStore(entityName, { + defaultValue, + })(); + + expect(reducer(undefined, { type: 'NOTHING' })).toEqual(defaultValue); + }); + + // set Single + + test('set a single entity', () => { + const entityName = makeEntityName(); + const entity = { id: 3, name: 'hello' }; + const expected = [...state, entity]; + + const { set, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, set(entity))).toEqual(expected); + }); + test('set a single entity and orders key by keyName', () => { + const entityName = makeEntityName(); + const entity = { id: 0, name: 'hello' }; + const expected = [entity, ...state]; + + const { set, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, set(entity))).toEqual(expected); + }); + test('set a single entity: replace exisitng one', () => { + const entityName = makeEntityName(); + const entity = { id: 1, newkey: 'modified' }; + const expected = [entity, state[1]]; + + const { set, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, set(entity))).toEqual(expected); + }); + test('set a single entity: replace exisitng one - with a custom id key', () => { + const entityName = makeEntityName(); + const entity = { uuid: 1, newkey: 'modified' }; + const expected = [entity, stateUuid[1]]; + + const { set, reducer } = generateCollectionStore(entityName, { + idKey: 'uuid', + })(); + + expect(reducer(stateUuid, set(entity))).toEqual(expected); + }); + test('set a single entity: it should ignore payloads with no id', () => { + const entityName = makeEntityName(); + const entity = { name: 'hello' }; + const expected = state; + + const { set, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, set(entity))).toEqual(expected); + }); + test('set a single entity ignore object with no custom id', () => { + const entityName = makeEntityName(); + const entity = { id: 4, name: 'hello' }; + const expected = stateUuid; + + const { set, reducer } = generateCollectionStore(entityName, { + idKey: 'uuid', + })(); + + expect(reducer(stateUuid, set(entity))).toEqual(expected); + }); + + // update Single + + test('update a single entity', () => { + const entityName = makeEntityName(); + const entity = { id: 1, bar: 'baz' }; + const expected = [{ ...state[0], bar: 'baz' }, state[1]]; + + const { update, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, update(entity))).toEqual(expected); + }); + test('update a single entity with custom id key', () => { + const entityName = makeEntityName(); + const entity = { uuid: 1, bar: 'baz' }; + const expected = [{ ...stateUuid[0], bar: 'baz' }, stateUuid[1]]; + + const { update, reducer } = generateCollectionStore(entityName, { + idKey: 'uuid', + })(); + + expect(reducer(stateUuid, update(entity))).toEqual(expected); + }); + test('update a single entity: it should insert non exisiting id', () => { + const entityName = makeEntityName(); + const entity = { id: 3, bar: 'baz' }; + const expected = [...state, entity]; + + const { update, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, update(entity))).toEqual(expected); + }); + test('update a single entity: it should ignore object with no id', () => { + const entityName = makeEntityName(); + const entity = { id: 3, bar: 'baz' }; + const expected = [...state, entity]; + + const { update, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, update(entity))).toEqual(expected); + }); + + // delete Single + + test('delete a single entity', () => { + const entityName = makeEntityName(); + const entity = { id: 1 }; + const expected = [state[1]]; + + const { reducer, ...actions } = generateCollectionStore(entityName)(); + + expect(reducer(state, actions.delete(entity))).toEqual(expected); + }); + + test('delete ignore non existent', () => { + const entityName = makeEntityName(); + const entity = { id: 4 }; + const expected = state; + + const { reducer, ...actions } = generateCollectionStore(entityName)(); + + expect(reducer(state, actions.delete(entity))).toEqual(expected); + }); + + // set Bulk + + test('set a bulk of entities', () => { + const entityName = makeEntityName(); + const entities = [{ id: 3, name: 'hello' }, { id: 4, name: 'baz' }]; + const expected = [...state, ...entities]; + + const { merge, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, merge(entities))).toEqual(expected); + }); + test('set a bulk of entities: it should replace existing ones', () => { + const entityName = makeEntityName(); + const entities = [{ id: 2, name: 'hello' }, { id: 3, name: 'baz' }]; + const expected = [state[0], ...entities]; + + const { merge, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, merge(entities))).toEqual(expected); + }); + test('set a bulk of entities: it should ignore object with no id', () => { + const entityName = makeEntityName(); + const entities = [{ id: 2, name: 'hello' }, { id: 3, name: 'baz' }, { name: '4' }]; + const expected = [state[0], entities[0], entities[1]]; + + const { merge, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, merge(entities))).toEqual(expected); + }); + test('set a bulk of entities: ignore object with no custom id', () => { + const entityName = makeEntityName(); + const entities = [{ uuid: 2, name: 'hello' }, { uuid: 3, name: 'baz' }, { id: 4, name: '4' }]; + const expected = [stateUuid[0], entities[0], entities[1]]; + + const { merge, reducer } = generateCollectionStore(entityName, { + idKey: 'uuid', + })(); + + expect(reducer(stateUuid, merge(entities))).toEqual(expected); + }); + + // update Bulk + + test('update a bulk of entities', () => { + const entityName = makeEntityName(); + const entities = [{ id: 1, newKey: 'newValue' }, { id: 2, newKey: 'newValue' }]; + const expected = [{ ...state[0], newKey: 'newValue' }, { ...state[1], newKey: 'newValue' }]; + + const { mergeDeep, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, mergeDeep(entities))).toEqual(expected); + }); + test('update a bulk of entities: it should inserts new ids', () => { + const entityName = makeEntityName(); + const entities = [{ id: 2, newKey: 'newValue' }, { id: 3, newKey: 'newValue' }]; + const expected = [ + state[0], + { ...state[1], newKey: 'newValue' }, + { id: 3, newKey: 'newValue' }, + ]; + + const { mergeDeep, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, mergeDeep(entities))).toEqual(expected); + }); + + // delete Bulk + + test('delete a bulk of entities', () => { + const entityName = makeEntityName(); + const entities = [{ id: 1, newKey: 'newValue' }, { id: 2, newKey: 'newValue' }]; + const expected = []; + + const { deleteAll, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, deleteAll(entities))).toEqual(expected); + }); + test('delete a bulk of entities: it should ignore non existing ids', () => { + const entityName = makeEntityName(); + const entities = [{ id: 3, newKey: 'newValue' }, { id: 4, newKey: 'newValue' }]; + const expected = state; + + const { deleteAll, reducer } = generateCollectionStore(entityName)(); + + expect(reducer(state, deleteAll(entities))).toEqual(expected); + }); + + // clear + + test('clear all entities', () => { + const entityName = makeEntityName(); + const expected = []; + + const { reducer, clear } = generateCollectionStore(entityName)(); + + expect(reducer(state, clear())).toEqual(expected); + }); + }); +}); diff --git a/tests/simpleStore.test.js b/tests/simpleStore.test.js new file mode 100644 index 0000000..4fafb64 --- /dev/null +++ b/tests/simpleStore.test.js @@ -0,0 +1,88 @@ +import { generateSimpleStore } from 'index.js'; + +const makeEntityName = () => `random/${Math.round(Math.random() * 1000000000)}`; + +describe('Simple Store', () => { + test('simple store provides the expected tools', () => { + const entityName = makeEntityName(); + const gen = generateSimpleStore(entityName); + expect(gen).toHaveProperty('getActionCreators'); + expect(gen).toHaveProperty('getReducer'); + + expect(gen()).toHaveProperty('set'); + expect(gen()).toHaveProperty('reducer'); + expect(gen()).not.toHaveProperty('update'); + expect(gen()).not.toHaveProperty('delete'); + expect(gen()).not.toHaveProperty('merge'); + expect(gen()).not.toHaveProperty('mergeDeep'); + expect(gen()).not.toHaveProperty('deleteAll'); + expect(gen()).not.toHaveProperty('clear'); + + expect(gen.getReducer()).toBe(gen().reducer); + + expect(gen.getActionCreators().set).toBe(gen().set); + }); + + test('Simple Stores are memoized', () => { + const entityName = makeEntityName(); + const gen1 = generateSimpleStore(entityName); + const gen2 = generateSimpleStore(entityName); + + expect(gen1).toBe(gen2); + }); + + test('memoized collections with different options throw an error', () => { + expect(() => { + const entityName = makeEntityName(); + const gen1 = generateSimpleStore(entityName); + const gen2 = generateSimpleStore(entityName, { idKey: 'uuid' }); + }).toThrow(); + }); + + describe('Simple Store action creators', () => { + const entityName = makeEntityName(); + const actions = generateSimpleStore(entityName).getActionCreators(); + + test('single entity set action creator', () => { + expect(actions.set('hello')).toEqual({ + type: `REDEUCE:SIMPLE@@${entityName}@@SET`, + payload: 'hello', + }); + }); + }); + + describe('reducers', () => { + const state = 'hello world'; + + // default + + test('default reducer call', () => { + const entityName = makeEntityName(); + const { reducer } = generateSimpleStore(entityName)(); + + expect(reducer(undefined, { type: 'NOTHING' })).toEqual(undefined); + expect(reducer(state, { type: 'NOTHING' })).toEqual(state); + }); + + test('default value', () => { + const entityName = makeEntityName(); + const { reducer } = generateSimpleStore(entityName, { + defaultValue: 'thisIsADefaultValue', + })(); + + expect(reducer(undefined, { type: 'NOTHING' })).toEqual('thisIsADefaultValue'); + }); + + // set + + test('set a simple value', () => { + const entityName = makeEntityName(); + const value = 'world'; + const expected = value; + + const { set, reducer } = generateSimpleStore(entityName)(); + + expect(reducer(state, set(value))).toEqual(expected); + }); + }); +});