diff --git a/__tests__/src/reducers/createActions.test.js b/__tests__/src/reducers/createActions.test.js new file mode 100644 index 0000000000000000000000000000000000000000..74b5755ba3511dfa2373b1867c56d53a889f79fe --- /dev/null +++ b/__tests__/src/reducers/createActions.test.js @@ -0,0 +1,143 @@ +import { + createTableReducerActions, + createSingletonReducerActions, +} from '../../../src/state/reducers/createActions'; + +describe('table reducer actions', () => { + const actionTypes = { + create: 'CREATE_ITEM', + update: 'UPDATE_ITEM', + delete: 'DELETE_ITEM', + order: 'SET_ITEM_ORDER', + }; + + const idPrefix = 'item'; + + const defaultProps = { + foo: 'bar', + }; + + describe('createTableReducerActions', () => { + it('should return object with correct function names', () => { + const actionCreators = createTableReducerActions(actionTypes); + expect(actionCreators.createItem).toBeInstanceOf(Function); + expect(actionCreators.updateItem).toBeInstanceOf(Function); + expect(actionCreators.deleteItem).toBeInstanceOf(Function); + expect(actionCreators.setItemOrder).toBeInstanceOf(Function); + }); + }); + + describe('create action creator', () => { + it('should return correct action type', () => { + const { createItem } = createTableReducerActions(actionTypes); + const action = createItem(); + expect(action.type).toBe('CREATE_ITEM'); + }); + + it('should set the passed id to the action', () => { + const { createItem } = createTableReducerActions(actionTypes, idPrefix, defaultProps); + const action = createItem({}, 'item-xyz'); + expect(action.id).toBe('item-xyz'); + }); + + it('should create an id if no id is given.', () => { + const { createItem } = createTableReducerActions(actionTypes, idPrefix, defaultProps); + const action = createItem(); + expect(typeof action.id).toBe('string'); + }); + + it('should merge default properties to payload', () => { + const { createItem } = createTableReducerActions(actionTypes, idPrefix, defaultProps); + const action = createItem(); + expect(action.payload).toMatchObject(defaultProps); + }); + + + it('should merge passed data object to payload', () => { + const { createItem } = createTableReducerActions(actionTypes, idPrefix, defaultProps); + const action = createItem({ bubu: 'kiki' }); + expect(action.payload).toMatchObject({ foo: 'bar', bubu: 'kiki' }); + }); + + it('passed data object can overwrite default props and id', () => { + const { createItem } = createTableReducerActions(actionTypes, idPrefix, defaultProps); + const action = createItem({ foo: 'kiki', id: 42 }); + expect(action.payload).toEqual({ foo: 'kiki', id: 42 }); + }); + }); + + describe('update action creator', () => { + it('should return correct action type', () => { + const { updateItem } = createTableReducerActions(actionTypes); + const action = updateItem(); + expect(action.type).toBe('UPDATE_ITEM'); + }); + + it('should set the passed id to the action', () => { + const { updateItem } = createTableReducerActions(actionTypes); + const action = updateItem(33, {}); + expect(action.id).toBe(33); + }); + + it('should set the data object to the action', () => { + const { updateItem } = createTableReducerActions(actionTypes); + const action = updateItem(33, { bubu: 'kiki' }); + expect(action.payload).toEqual({ bubu: 'kiki' }); + }); + }); + + describe('delete action creator', () => { + it('should return correct action type', () => { + const { deleteItem } = createTableReducerActions(actionTypes); + const action = deleteItem(); + expect(action.type).toBe('DELETE_ITEM'); + }); + + it('should set the passed id to the action', () => { + const { deleteItem } = createTableReducerActions(actionTypes); + const action = deleteItem(33); + expect(action.id).toBe(33); + }); + }); + + describe('order action creator', () => { + it('should return correct action type', () => { + const { setItemOrder } = createTableReducerActions(actionTypes); + const action = setItemOrder(); + expect(action.type).toBe('SET_ITEM_ORDER'); + }); + + it('should set the passed data object to payload', () => { + const { setItemOrder } = createTableReducerActions(actionTypes); + const action = setItemOrder([1, 2, 3]); + expect(action.payload).toEqual([1, 2, 3]); + }); + }); +}); + +describe('Singleton reducer actions', () => { + const actionTypes = { + update: 'UPDATE_ITEM', + }; + + describe('createSingletonReducerActions', () => { + it('should return object with correct function names', () => { + const actionCreators = createSingletonReducerActions(actionTypes); + expect(actionCreators.updateItem).toBeInstanceOf(Function); + }); + }); + + describe('update action creator', () => { + it('should return correct action type', () => { + const { updateItem } = createSingletonReducerActions(actionTypes); + const action = updateItem(); + expect(action.type).toBe('UPDATE_ITEM'); + }); + + it('should set passe data object to payload', () => { + const { updateItem } = createSingletonReducerActions(actionTypes); + const action = updateItem({ bubu: 'kiki' }); + expect(action.payload).toEqual({ bubu: 'kiki' }); + }); + }); +}); diff --git a/__tests__/src/reducers/createReducers.test.js b/__tests__/src/reducers/createReducers.test.js new file mode 100644 index 0000000000000000000000000000000000000000..bd420ff7e72022116542c4a48be07954b634ca91 --- /dev/null +++ b/__tests__/src/reducers/createReducers.test.js @@ -0,0 +1,155 @@ +import { + createTableReducer, + createSingletonReducer, +} from '../../../src/state/reducers/createReducers'; + +describe('table reducer', () => { + const actionTypes = { + create: 'CREATE_ITEM', + update: 'UPDATE_ITEM', + delete: 'DELETE_ITEM', + order: 'SET_ITEM_ORDER', + }; + + const state = { + 'item-1': { + a: 1, + b: 2, + }, + 'item-2': { + c: 3, + d: 4, + }, + order: ['item-1', 'item-2'], + }; + + describe('createTableReducer', () => { + it('should return a function', () => { + const reducer = createTableReducer(actionTypes); + expect(reducer).toBeInstanceOf(Function); + }); + }); + + describe('created table reducer', () => { + it('initial state is object with empty "order" array', () => { + const reducer = createTableReducer(actionTypes); + const newState = reducer(undefined, {}); + const expected = { order: [] }; + expect(newState).toStrictEqual(expected); + }); + + it('returns unchanged state when action type is not supported', () => { + const reducer = createTableReducer(actionTypes); + const newState = reducer(state, { type: 'UNSUPPORTED_ACTION_TYPE_3E4F' }); + expect(newState).toStrictEqual(state); + }); + + describe('handling of create action', () => { + it('should add action.payload to state. key should be taken from action.id.', () => { + const reducer = createTableReducer(actionTypes); + const payload = { bar: 77 }; + const action = { type: actionTypes.create, id: 'item-3', payload }; + const newState = reducer(state, action); + expect(newState['item-3']).toStrictEqual(payload); + // no changes in other items + expect(newState['item-1']).toStrictEqual(state['item-1']); + expect(newState['item-2']).toStrictEqual(state['item-2']); + }); + }); + + describe('handling of update action', () => { + it('should update item by id. id is taken from action.id', () => { + const reducer = createTableReducer(actionTypes); + const payload = { a: 42, x: 66 }; + const action = { type: actionTypes.update, id: 'item-1', payload }; + const newState = reducer(state, action); + expect(newState['item-1']).toStrictEqual({ a: 42, b: 2, x: 66 }); + // no changes in other items + expect(newState['item-2']).toStrictEqual(state['item-2']); + }); + + it('should not alter the state when payload is empy object', () => { + const reducer = createTableReducer(actionTypes); + const payload = {}; + const action = { type: actionTypes.update, id: 'item-1', payload }; + const newState = reducer(state, action); + expect(newState['item-1']).toStrictEqual(state['item-1']); + // no changes in other items + expect(newState['item-2']).toStrictEqual(state['item-2']); + }); + }); + + describe('handling of delete action', () => { + it('should delete item from state. id is taken from action.id', () => { + const reducer = createTableReducer(actionTypes); + const action = { type: actionTypes.delete, id: 'item-1' }; + const newState = reducer(state, action); + expect(newState['item-1']).toBeUndefined(); + // no changes in other items + expect(newState['item-2']).toStrictEqual(state['item-2']); + }); + }); + + describe('handling of order action', () => { + it('should set payload to "order" property', () => { + const reducer = createTableReducer(actionTypes); + const payload = [7, 6, 5, 4]; + const action = { type: actionTypes.order, payload }; + const newState = reducer(state, action); + expect(newState.order).toStrictEqual(payload); + }); + }); + }); +}); + +describe('singelton reducer', () => { + const actionTypes = { + update: 'UPDATE_ITEM', + }; + + const state = { + a: 1, + b: [2, 3], + c: { + d: 4, + e: { f: 5 }, + }, + }; + + describe('createSingletonReducer', () => { + it('should return a function', () => { + const reducer = createSingletonReducer(actionTypes); + expect(reducer).toBeInstanceOf(Function); + }); + }); + + describe('created singelton reducer', () => { + it('initial state is empty object', () => { + const reducer = createSingletonReducer(actionTypes); + const newState = reducer(undefined, {}); + expect(newState).toStrictEqual({}); + }); + + it('returns unchanged state when action type is not supported', () => { + const reducer = createSingletonReducer(actionTypes); + const newState = reducer(state, { type: 'UNSUPPORTED_ACTION_TYPE_3E4F' }); + expect(newState).toStrictEqual(state); + }); + + describe('handling of update action', () => { + it('should update the state by deep merging payload object', () => { + const reducer = createSingletonReducer(actionTypes); + const payload = { a: 11, c: { e: { f: 55 } } }; + const newState = reducer(state, { type: actionTypes.update, payload }); + expect(newState).toStrictEqual({ + a: 11, + b: [2, 3], + c: { + d: 4, + e: { f: 55 }, + }, + }); + }); + }); + }); +}); diff --git a/src/state/reducers/createActions.js b/src/state/reducers/createActions.js new file mode 100644 index 0000000000000000000000000000000000000000..ae3969752a354f5369306f418bdec8aec07173e3 --- /dev/null +++ b/src/state/reducers/createActions.js @@ -0,0 +1,132 @@ +import uuid from 'uuid/v4'; + +/** + * Create and return action creators for a table reducer. + * + * The `actionTypes` parameter is an object that maps from provided reducer actions + * to action type constants. Example: + * + * { + * create: 'CREATE_ITEM', + * update: 'UPDATE_ITEM', + * delete: 'DELETE_ITEM', + * order: 'SET_ITEM_ORDER' + * } + * + * The function names of the returned action creators are the camel-cased type constants. + * Given the above example, the function name for the delete action will be `deleteItem`. + * + * @param {Object} actionTypes + * @param {String} idPrefix - prefix for the item id in state + * @param {Object} defaultProps - default properties for an item. Used in the create action. + * @return {Object} - Object of actions creator functions. + */ +export function createTableReducerActions(actionTypes, idPrefix, defaultProps) { + /** + * Returns a create action for a table reducer. + * + * @param {Object} payload - Data that will be set to the item by the table reducer. + * It gets shallow merged with the default properties and therefore may overrides the defaults. + * @param {String} id - Optional. Sets the item ID explicitly. + * Otherwise the ID will be created automatically. + * @return {Object} + */ + function createItem(payload, customId) { + const id = customId || `${idPrefix}-${uuid()}`; + return { + type: actionTypes.create, + id, + payload: { ...defaultProps, ...payload }, + }; + } + + /** + * Returns an update action for a table reducer. + * + * @param {String} id - ID of item to be updated. + * @param {Object} payload - Update data. + * It gets deep merged with the existing item data by the table reducer. + * @return {Object} + */ + function updateItem(id, payload) { + return { type: actionTypes.update, id, payload }; + } + + /** + * Returns an delete action for a table reducer. + * + * @param {String} id - ID of item to be deleted. + * @return {Object} + */ + function deleteItem(id) { + return { type: actionTypes.delete, id }; + } + + /** + * Returns an order action for a table reducer. + * + * The `order` property exists in the state of the reducer. + * It can be used to indicate the item order. + * + * @param {Array} payload - An array of IDs that will be set to the `order` property. + * @return {Object} + */ + function setItemOrder(payload) { + return { type: actionTypes.order, payload }; + } + + return { + [toCamelCase(actionTypes.create)]: createItem, + [toCamelCase(actionTypes.update)]: updateItem, + [toCamelCase(actionTypes.delete)]: deleteItem, + [toCamelCase(actionTypes.order)]: setItemOrder, + }; +} + +/** + * Create and return action creators for a singleton reducer. + * + * The `actionTypes` parameter is an object that maps from provided reducer actions + * to action type constants. Example: + * + * { + * update: 'UPDATE_ITEM', + * } + * + * The function names of the returned action creators are the camel-cased type constants. + * Given the above example, the function name for the update action will be `updateItem`. + * + * @param {Object} actionTypes + * @return {Object} - Object of actions creator functions. + */ +export function createSingletonReducerActions(actionTypes) { + /** + * Returns an update action for a singleton reducer. + * + * @param {payload}- Update data. + * It gets deep merged with the existing item data by the singleton reducer. + */ + function updateItem(payload) { + return { type: actionTypes.update, payload }; + } + + return { + [toCamelCase(actionTypes.update)]: updateItem, + }; +} + +/** +* Translates an action type constant to a function name. +* +* E.g.: UPDATE_ITEM --> updateItem +* +* @param {String} constant +* @return {String} +*/ +function toCamelCase(constant) { + return constant + .split('_') + .map(word => word.toLowerCase()) + .map((word, index) => (index === 0 ? word : word[0].toUpperCase() + word.slice(1))) + .join(''); +} diff --git a/src/state/reducers/createReducers.js b/src/state/reducers/createReducers.js new file mode 100644 index 0000000000000000000000000000000000000000..f42e980e84e89ff0eff836336df5b5bd6e611b51 --- /dev/null +++ b/src/state/reducers/createReducers.js @@ -0,0 +1,73 @@ +import deepmerge from 'deepmerge'; + +/** + * Create a table reducer. + * + * A table reducer is a reducer that treats its data as a database-like table + * where each table consists of rows that have fields and each row has an ID. + * + * The `actionTypes` parameter is an object that maps from provided reducer actions + * to action type constants. Example: + * + * { + * create: 'CREATE_ITEM', + * update: 'UPDATE_ITEM', + * delete: 'DELETE_ITEM', + * order: 'SET_ITEM_ORDER' + * } + * + * @param {Object} actionTypes + * @return {Function} + */ +export function createTableReducer(actionTypes) { + return function tableReducer(state = { order: [] }, action) { + switch (action.type) { + case actionTypes.create: + return { ...state, [action.id]: action.payload }; + + case actionTypes.update: + return { ...state, [action.id]: deepmerge(state[action.id], action.payload) }; + + case actionTypes.delete: + return Object.keys(state).reduce((object, key) => { + if (key !== action.id) { + object[key] = state[key]; // eslint-disable-line no-param-reassign + } + return object; + }, {}); + + case actionTypes.order: + return { ...state, order: action.payload }; + + default: + return state; + } + }; +} + +/** + * Create a singleton reducer. + * + * In contrast to a table reducer (see above), a singleton reducer is a reducer + * that treats its data as a plain javascript object with no further constraints + * on the object structure. + * + * The `actionTypes` parameter is an object that maps from provided reducer actions + * to action type constants. Example: + * + * { + * update: 'UPDATE_ITEM', + * } + * @param {Object} actionTypes + * @return {Function} + */ +export function createSingletonReducer(actionTypes) { + return function singletonReducer(state = {}, action) { + switch (action.type) { + case actionTypes.update: + return deepmerge(state, action.payload); + default: + return state; + } + }; +}