diff --git a/src/index.js b/src/index.js index a72c9cf..57f5587 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,7 @@ /* eslint-disable no-multi-assign */ const debug = require("debug")("ReduxList:Main") -import { - pipe, - findWith, - sortWith, - head, - is, - isEmpty, - hasWith, -} from "@mutantlove/m" +import { hasKey } from "@mutantlove/m" import { createAction, createStartReducer, @@ -22,6 +14,12 @@ import { readSuccessReducer, readErrorReducer, } from "./read/read" +import { + readOneAction, + readOneStartReducer, + readOneSuccessReducer, + readOneErrorReducer, +} from "./read-one/read-one" import { updateAction, updateStartReducer, @@ -29,18 +27,16 @@ import { updateErrorReducer, } from "./update/update" import { - deleteAction, - deleteStartReducer, - deleteSuccessReducer, - deleteErrorReducer, -} from "./delete/delete" + removeAction, + removeStartReducer, + removeSuccessReducer, + removeErrorReducer, +} from "./remove/remove" import { buildQueue } from "./lib/queue" const collections = Object.create(null) -const hasKey = key => obj => Object.prototype.hasOwnProperty.call(obj, key) - /** * List factory function * @@ -63,124 +59,117 @@ const buildList = (name, methods = {}) => { const readStart = `${name}_READ_START` const readSuccess = `${name}_READ_END` const readError = `${name}_READ_ERROR` + const readOneStart = `${name}_READ-ONE_START` + const readOneSuccess = `${name}_READ-ONE_END` + const readOneError = `${name}_READ-ONE_ERROR` const updateStart = `${name}_UPDATE_START` const updateSuccess = `${name}_UPDATE_SUCCESS` const updateError = `${name}_UPDATE_ERROR` - const deleteStart = `${name}_DELETE_START` - const deleteSuccess = `${name}_DELETE_SUCCESS` - const deleteError = `${name}_DELETE_ERROR` + const removeStart = `${name}_REMOVE_START` + const removeSuccess = `${name}_REMOVE_SUCCESS` + const removeError = `${name}_REMOVE_ERROR` return { name, /** - * Selector over the list's state slice + * Create an item, dispatch events before and after API call * - * @param {Object} state The parent state slice + * @param {Function} dispatch Redux dispatch function + * @param {Array} args API method parameters * - * @return {Object} + * @return {void} */ - selector: state => ({ - head: () => - state[name].items.length === 0 ? undefined : state[name].items[0], - byId: id => findWith({ id })(state[name].items), - - items: () => state[name].items, - creating: () => state[name].creating, - updating: () => state[name].updating, - deleting: () => state[name].deleting, - - error: action => - isEmpty(action) - ? pipe( - Object.entries, - sortWith("date"), - head - )(state[name].errors) - : state[name].errors[action], - - isCreating: () => !isEmpty(state[name].creating), - isLoaded: () => is(state[name].loadDate), - isLoading: () => state[name].isLoading, - isUpdating: id => - id - ? hasWith({ id })(state[name].updating) - : !isEmpty(state[name].updating), - isDeleting: id => - id - ? hasWith({ id })(state[name].deleting) - : !isEmpty(state[name].deleting), - }), + create: dispatch => ( + data, + { isDraft = false, ...restOptions } = {}, + ...rest + ) => { + if (typeof methods.create !== "function") { + throw new TypeError( + `ReduxList: "${name}"."create" must be a function, got "${typeof methods.create}"` + ) + } + + if (isDraft) { + dispatch({ + type: createSuccess, + payload: data, + }) + + return Promise.resolve({ result: data }) + } + + return queue.enqueue({ + fn: createAction({ + dispatch, + api: methods.create, + actionStart: createStart, + actionSuccess: createSuccess, + actionError: createError, + }), + + // queue calls fn(...args) + args: [data, { isDraft, ...restOptions }, ...rest], + }) + }, /** - * Create an item, dispatch events before and after API call + * Load list items, dispatch events before and after * * @param {Function} dispatch Redux dispatch function * @param {Array} args API method parameters * * @return {void} */ - create: dispatch => - typeof methods.create === "function" - ? (data, { isDraft = false, ...restOptions } = {}, ...rest) => { - if (isDraft) { - dispatch({ - type: createSuccess, - payload: data, - }) - - return Promise.resolve({ result: data }) - } - - return queue.enqueue({ - fn: createAction({ - dispatch, - api: methods.create, - actionStart: createStart, - actionSuccess: createSuccess, - actionError: createError, - }), - - // needs array since queue calls fn(...args) - args: [data, { isDraft, ...restOptions }, ...rest], - }) - } - : () => { - throw new TypeError( - `ReduxList: "${name}"."create" should be a function, got "${typeof methods.create}"` - ) - }, + read: dispatch => (...args) => { + if (typeof methods.read !== "function") { + throw new TypeError( + `ReduxList: "${name}"."read" must be a function, got "${typeof methods.read}"` + ) + } + + return queue.enqueue({ + fn: readAction({ + dispatch, + api: methods.read, + actionStart: readStart, + actionSuccess: readSuccess, + actionError: readError, + }), + + // queue calls fn(...args) + args, + }) + }, /** - * Load list items, dispatch events before and after + * Load one item, dispatch events before and after * * @param {Function} dispatch Redux dispatch function * @param {Array} args API method parameters * * @return {void} */ - read: dispatch => { - if (typeof methods.read === "function") { - return (...args) => - queue.enqueue({ - fn: readAction({ - dispatch, - api: methods.read, - actionStart: readStart, - actionSuccess: readSuccess, - actionError: readError, - }), - - // needs array since queue calls fn(...args) - args, - }) - } - - return () => { + readOne: dispatch => (...args) => { + if (typeof methods.readOne !== "function") { throw new TypeError( - `ReduxList: "${name}"."read" should be a function, got "${typeof methods.red}"` + `ReduxList: "${name}"."readOne" must be a function, got "${typeof methods.readOne}"` ) } + + return queue.enqueue({ + fn: readOneAction({ + dispatch, + api: methods.readOne, + actionStart: readOneStart, + actionSuccess: readOneSuccess, + actionError: readOneError, + }), + + // queue calls fn(...args) + args, + }) }, /** @@ -192,36 +181,39 @@ const buildList = (name, methods = {}) => { * * @return {void} */ - update: dispatch => - typeof methods.update === "function" - ? (id, data, { isDraft = false, ...restOptions } = {}, ...rest) => { - if (isDraft) { - dispatch({ - type: updateSuccess, - payload: { id, ...data }, - }) - - return Promise.resolve({ result: { id, ...data } }) - } - - return queue.enqueue({ - fn: updateAction({ - dispatch, - api: methods.update, - actionStart: updateStart, - actionSuccess: updateSuccess, - actionError: updateError, - }), - - // needs array since queue calls fn(...args) - args: [id, data, { isDraft, ...restOptions }, ...rest], - }) - } - : () => { - throw new TypeError( - `ReduxList: "${name}"."update" should be a function, got "${typeof methods.update}"` - ) - }, + update: dispatch => ( + id, + data, + { isDraft = false, ...restOptions } = {}, + ...rest + ) => { + if (typeof methods.update !== "function") { + throw new TypeError( + `ReduxList: "${name}"."update" must be a function, got "${typeof methods.update}"` + ) + } + if (isDraft) { + dispatch({ + type: updateSuccess, + payload: { id, ...data }, + }) + + return Promise.resolve({ result: { id, ...data } }) + } + + return queue.enqueue({ + fn: updateAction({ + dispatch, + api: methods.update, + actionStart: updateStart, + actionSuccess: updateSuccess, + actionError: updateError, + }), + + // queue calls fn(...args) + args: [id, data, { isDraft, ...restOptions }, ...rest], + }) + }, /** * Delete an item, dispatch events before and after @@ -233,26 +225,26 @@ const buildList = (name, methods = {}) => { * * @return {void} */ - delete: dispatch => - typeof methods.delete === "function" - ? (...args) => - queue.enqueue({ - fn: deleteAction({ - dispatch, - api: methods.delete, - actionStart: deleteStart, - actionSuccess: deleteSuccess, - actionError: deleteError, - }), - - // needs array since queue calls fn(...args) - args, - }) - : () => { - throw new TypeError( - `ReduxList: "${name}"."delete" should be a function, got "${typeof methods.delete}"` - ) - }, + remove: dispatch => (...args) => { + if (typeof methods.remove !== "function") { + throw new TypeError( + `ReduxList: "${name}"."remove" must be a function, got "${typeof methods.remove}"` + ) + } + + return queue.enqueue({ + fn: removeAction({ + dispatch, + api: methods.remove, + actionStart: removeStart, + actionSuccess: removeSuccess, + actionError: removeError, + }), + + // queue calls fn(...args) + args, + }) + }, /** * Empty list @@ -283,9 +275,10 @@ const buildList = (name, methods = {}) => { reducer: ( state = { items: [], + reading: null, creating: [], updating: [], - deleting: [], + removing: [], errors: {}, loadDate: null, @@ -310,6 +303,14 @@ const buildList = (name, methods = {}) => { case readError: return readErrorReducer(state, payload) + // ReadOne + case readOneStart: + return readOneStartReducer(state, payload) + case readOneSuccess: + return readOneSuccessReducer(state, payload) + case readOneError: + return readOneErrorReducer(state, payload) + // Update case updateStart: return updateStartReducer(state, payload) @@ -319,12 +320,12 @@ const buildList = (name, methods = {}) => { return updateErrorReducer(state, payload) // Delete - case deleteStart: - return deleteStartReducer(state, payload) - case deleteSuccess: - return deleteSuccessReducer(state, payload) - case deleteError: - return deleteErrorReducer(state, payload) + case removeStart: + return removeStartReducer(state, payload) + case removeSuccess: + return removeSuccessReducer(state, payload) + case removeError: + return removeErrorReducer(state, payload) default: return state @@ -333,4 +334,5 @@ const buildList = (name, methods = {}) => { } } -export { buildList, buildList as buildCollection } +export { buildList } +export { useList } from "./use-list" diff --git a/src/index.test.js b/src/index.test.js index ec70446..56782a0 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -1,27 +1,20 @@ import test from "tape" import { createStore, combineReducers } from "redux" -import { buildList } from "." +import { buildList, useList } from "." -test("List without API methods", t => { +test("List without API methods", async t => { // WHAT TO TEST - const todoList = buildList("TODOS") + const todos = buildList("TODOS") // Redux store const store = createStore( combineReducers({ - [todoList.name]: todoList.reducer, + [todos.name]: todos.reducer, }) ) - // Link lists's action to store's dispatch - const listCreate = todoList.create(store.dispatch) - const listRead = todoList.read(store.dispatch) - const listUpdate = todoList.update(store.dispatch) - const listDelete = todoList.delete(store.dispatch) - const listClear = todoList.clear(store.dispatch) - - t.equals(todoList.name, "TODOS", "New list created with unique name") + t.equals(todos.name, "TODOS", "New list created with unique name") t.throws( () => { @@ -31,77 +24,101 @@ test("List without API methods", t => { "Throw exception when creating a list with a duplicate name" ) - const todosSelector = todoList.selector(store.getState()) + const { selector, create, read, readOne, update, remove, clear } = useList( + todos, + store.dispatch + ) + const { + head, + items, + creating, + removing, + updating, + isCreating, + isLoaded, + isLoading, + isUpdating, + isRemoving, + } = selector(store.getState()) t.deepEquals( { - head: todosSelector.head(), - items: todosSelector.items(), - creating: todosSelector.creating(), - updating: todosSelector.updating(), - deleting: todosSelector.deleting(), - isCreating: todosSelector.isCreating(), - isLoaded: todosSelector.isLoaded(), - isLoading: todosSelector.isLoading(), - isUpdating: todosSelector.isUpdating(), - isDeleting: todosSelector.isDeleting(), + head: head(), + items: items(), + creating: creating(), + updating: updating(), + removing: removing(), + isCreating: isCreating(), + isLoaded: isLoaded(), + isLoading: isLoading(), + isUpdating: isUpdating(), + isRemoving: isRemoving(), }, { head: undefined, items: [], updating: [], - deleting: [], + removing: [], creating: [], isLoading: false, isLoaded: false, isCreating: false, isUpdating: false, - isDeleting: false, + isRemoving: false, }, "Default state initialized in redux store via list selector" ) t.throws( () => { - listCreate({ id: 2 }) + create({ id: 2 }) }, - /ReduxList: "TODOS"."create" should be a function, got "undefined"/, + /ReduxList: "TODOS"."create" must be a function, got "undefined"/, 'Throw exception when calling "create" on list without methods' ) t.throws( () => { - listRead() + read() }, - /ReduxList: "TODOS"."read" should be a function, got "undefined"/, + /ReduxList: "TODOS"."read" must be a function, got "undefined"/, 'Throw exception when calling "read" on list without methods' ) t.throws( () => { - listUpdate(1, { test: 2 }) + readOne() + }, + /ReduxList: "TODOS"."readOne" must be a function, got "undefined"/, + 'Throw exception when calling "readOne" on list without methods' + ) + + t.throws( + () => { + update(1, { test: 2 }) }, - /ReduxList: "TODOS"."update" should be a function, got "undefined"/, + /ReduxList: "TODOS"."update" must be a function, got "undefined"/, 'Throw exception when calling "update" on list without methods' ) t.throws( () => { - listDelete(1, { test: 2 }) + remove(1, { test: 2 }) }, - /ReduxList: "TODOS"."delete" should be a function, got "undefined"/, - 'Throw exception when calling "delete" on list without methods' + /ReduxList: "TODOS"."remove" must be a function, got "undefined"/, + 'Throw exception when calling "remove" on list without methods' ) - listClear().then(() => { - const selector = todoList.selector(store.getState()) + { + await clear() + const { items } = selector(store.getState()) t.deepEquals( - selector.items(), + items(), [], "Builtin .clear should remove all items from state" ) + } - t.end() - }) + t.end() }) diff --git a/src/use-list-selector.js b/src/use-list-selector.js new file mode 100644 index 0000000..5ec01fe --- /dev/null +++ b/src/use-list-selector.js @@ -0,0 +1,62 @@ +const debug = require("debug")("ReduxList:useListSelector") + +import { + pipe, + get, + findWith, + isEmpty, + sortWith, + head, + is, + hasWith, +} from "@mutantlove/m" + +const useListSelector = namespace => state => ({ + head: () => + pipe( + get([namespace, "items"]), + head + )(state), + byId: id => + pipe( + get([namespace, "items"]), + findWith({ id }) + )(state), + items: () => get([namespace, "items"])(state), + creating: () => get([namespace, "creating"])(state), + updating: () => get([namespace, "updating"])(state), + removing: () => get([namespace, "removing"])(state), + allErrors: () => get([namespace, "errors"])(state), + error: action => + isEmpty(action) + ? pipe( + get([namespace, "errors"], {}), + Object.values, + sortWith("date"), + head + )(state) + : get([namespace, "errors", action])(state), + isCreating: () => + pipe( + get([namespace, "creating"]), + items => !isEmpty(items) + )(state), + isRemoving: id => { + const removing = get([namespace, "removing"])(state) + + return is(id) ? hasWith({ id })(removing) : !isEmpty(removing) + }, + isUpdating: id => { + const updating = get([namespace, "updating"])(state) + + return is(id) ? hasWith({ id })(updating) : !isEmpty(updating) + }, + isLoading: () => get([namespace, "isLoading"])(state), + isLoaded: () => + pipe( + get([namespace, "loadDate"]), + is + )(state), +}) + +export { useListSelector } diff --git a/src/use-list.js b/src/use-list.js new file mode 100644 index 0000000..21bc025 --- /dev/null +++ b/src/use-list.js @@ -0,0 +1,24 @@ +const debug = require("debug")("ReduxList:useList") + +import { useListSelector } from "./use-list-selector" + +/** + * Custom hook for easy interfacing Redux List + * + * @param {Object} list Any Redux List object + * @param {Function} dispatch Redux store dispatch + * @param {Object} state Redux store full state + * + * @returns {{selector, create, read, readOne, update, remove, clear}} + */ +const useList = (list, dispatch) => ({ + selector: useListSelector(list.name), + create: (...args) => list.create(dispatch)(...args), + read: (...args) => list.read(dispatch)(...args), + readOne: (...args) => list.readOne(dispatch)(...args), + update: (...args) => list.update(dispatch)(...args), + remove: (...args) => list.remove(dispatch)(...args), + clear: (...args) => list.clear(dispatch)(...args), +}) + +export { useList }