diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6ea47..b058b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ Some guidelines in reading this document: * Being that these are the early days of the repository, we have some code changes that were added directly and without much detail, for these we have a link to the commit instead of the PR. * Annotations starting with **[BC]** indicates breaking change. +## [new release] + +* Expose a denormalization mechanism so consumer can transform local ids into resources denormalized ([#11](https://github.com/log-oscon/redux-wpapi/pull/11)) +* Fix the Promise return from middleware dispatch, which should always resolve to `selectQuery` result ([#9](https://github.com/log-oscon/redux-wpapi/pull/9)) + ## 1.0.1 * Fix selector, which was referring to `entity` instead `resource`. diff --git a/README.md b/README.md index 71ee4ad..c677d36 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A [node-wpapi](https://github.com/WP-API/node-wpapi) integration for a Redux based Application. ## How it Works -This library exposes [node-wpapi](https://github.com/WP-API/node-wpapi) instance through the actionCreator [wp](#wp-Action-Creator). The resulting +This library exposes [node-wpapi](https://github.com/WP-API/node-wpapi) instance through the actionCreator [callAPI](#/src/actions/callAPI.js). The resulting action is interpreted in [the middleware](#the-middleware), doing so by resolving the request and controlling the reducer through actions. ## Installation @@ -35,18 +35,26 @@ import { wp, selectQuery, ResponseStatus } from 'redux-wpapi'; import { connect } from 'react-redux'; export class HomePage extends React.Component { - componentWillMount() { - this.props.wp( + static loadData(props) { + return props.callAPI( // The name where the request state will be placed 'HomePagePosts', // A callback where wpapi instance is injected api => - api.posts() - .page(this.props.page) - .perPage(this.props.perPage) + api.posts() + .page(props.page) + .perPage(props.perPage) ); } + componentWillMount() { + HomePage.loadData(this.props); + } + + componentWillReceiveProps(props) { + HomePage.loadData(props); + } + render() { const { status, data: posts } = this.props.request; @@ -67,7 +75,7 @@ export class HomePage extends React.Component { export default connect({ request: selectQuery('HomePagePosts'), -}, { wp })(HomePage); +}, { callAPI })(HomePage); ``` ## Contributions diff --git a/src/ReduxWPAPI.js b/src/ReduxWPAPI.js index a55c538..d0b7935 100644 --- a/src/ReduxWPAPI.js +++ b/src/ReduxWPAPI.js @@ -10,6 +10,7 @@ import isArray from 'lodash/isArray'; import isUndefined from 'lodash/isUndefined'; import { selectQuery } from './selectors'; import WPAPIAdapter from './adapters/wpapi'; +import { lastCacheUpdate as lastCacheUpdateSymbol } from './symbols'; import { REDUX_WP_API_CALL, @@ -25,7 +26,7 @@ import { rejected, } from './constants/requestStatus'; -const initialReducerState = Immutable.fromJS({ +export const initialReducerState = Immutable.fromJS({ requestsByName: {}, requestsByQuery: {}, @@ -66,7 +67,6 @@ export default class ReduxWPAPI { name: action.payload.name, aggregator: this.adapter.getAggregator(this.adapter.getUrl(request)), operation: this.adapter.getOperation(request), - params: action.payload.params, requestAt: Date.now(), }; @@ -77,24 +77,23 @@ export default class ReduxWPAPI { let data; const state = store.getState().wp; const indexes = this.adapter.getIndexes(request); - const localID = this.getResourceLocalID(state, meta.aggregator, indexes); + const resourceLocalID = this.getResourceLocalID(state, meta.aggregator, indexes); payload.cacheID = this.adapter.generateCacheID(request); payload.page = parseInt(this.adapter.getRequestedPage(request) || 1, 10); - if (localID) { - cache = state.getIn(['resources', localID]); - data = [localID]; + if (resourceLocalID) { + cache = state.getIn(['resources', resourceLocalID]); + data = [resourceLocalID]; } if (cache) { - lastCacheUpdate = cache.lastCacheUpdate; + lastCacheUpdate = cache[lastCacheUpdateSymbol]; } else { cache = state.getIn(['requestsByQuery', payload.cacheID, payload.page]); data = state.get('data'); } - - if (cache && (localID || (isUndefined(localID) && !cache.get('error')))) { + if (cache && (resourceLocalID || (isUndefined(resourceLocalID) && !cache.get('error')))) { lastCacheUpdate = lastCacheUpdate || cache.get('responseAt') || cache.get('requestAt'); next({ meta, @@ -107,14 +106,18 @@ export default class ReduxWPAPI { }, }); - let ttl = this.adapter.getTTL(request); + let ttl; + if (this.adapter.getTTL) { + ttl = this.adapter.getTTL(request); + } + if (ttl !== 0 && !ttl) { ttl = this.settings.ttl; } if (Date.now() - lastCacheUpdate < ttl) { return Promise.resolve( - store.getState().wp.getIn(['requestsByName', meta.name]) + selectQuery(meta.name)(store.getState()) ); } } @@ -126,23 +129,25 @@ export default class ReduxWPAPI { meta, }); - return this.adapter.sendRequest(request) - .then( - response => - next({ - type: REDUX_WP_API_SUCCESS, - payload: { ...payload, response }, - meta: { ...meta, responseAt: Date.now() }, - }), - error => - next({ - type: REDUX_WP_API_FAILURE, - payload, - error, - meta: { ...meta, responseAt: Date.now() }, - }) - ) - .then(() => selectQuery(meta.name)(store.getState())); + return ( + this.adapter.sendRequest(request) + .then( + response => + next({ + type: REDUX_WP_API_SUCCESS, + payload: { ...payload, response }, + meta: { ...meta, responseAt: Date.now() }, + }), + error => + next({ + type: REDUX_WP_API_FAILURE, + payload, + error, + meta: { ...meta, responseAt: Date.now() }, + }) + ) + .then(() => selectQuery(meta.name)(store.getState())) + ); } reducer = (state = initialReducerState, action) => { @@ -217,10 +222,10 @@ export default class ReduxWPAPI { } const data = []; - const aditionalData = { lastCacheUpdate: requestState.responseAt }; + const additionalData = { lastCacheUpdate: requestState.responseAt }; body.forEach(resource => { - newState = this.indexResource(newState, aggregator, resource, aditionalData); + newState = this.indexResource(newState, aggregator, resource, additionalData); data.push(this.getResourceLocalID(newState, aggregator, resource)); }); @@ -281,10 +286,10 @@ export default class ReduxWPAPI { const _links = this.resolveAliases(resource._links, curies) || {}; delete _links.curies; - let localID = this.getResourceLocalID(state, aggregator, resource); + let resourceLocalID = this.getResourceLocalID(state, aggregator, resource); let oldState = {}; - if (!isUndefined(localID)) { - oldState = newState.getIn(['resources', localID]); + if (!isUndefined(resourceLocalID)) { + oldState = newState.getIn(['resources', resourceLocalID]); } if (resource._embedded) { @@ -318,27 +323,34 @@ export default class ReduxWPAPI { }); } - if (isUndefined(localID)) { - localID = newState.get('resources').size; + if (isUndefined(resourceLocalID)) { + resourceLocalID = newState.get('resources').size; } - newState = newState.setIn( - ['resources', localID], - this.settings.transformResource(this.adapter.transformResource({ - ...oldState, - ...resource, - _links, - _embedded, - lastCacheUpdate: meta.lastCacheUpdate, - })) - ); + let resourceTransformed = { + ...oldState, + ...resource, + ...meta, + _links, + _embedded, + }; + + if (this.adapter.transformResource) { + resourceTransformed = this.adapter.transformResource(resourceTransformed); + } + if (this.settings.transformResource) { + resourceTransformed = this.settings.transformResource(resourceTransformed); + } + + newState = newState.setIn(['resources', resourceLocalID], resourceTransformed); const indexers = this.settings.customCacheIndexes[aggregator]; + forEach(isArray(indexers) ? ['id'].concat(indexers) : ['id', indexers], indexer => { if (!isUndefined(resource[indexer])) { newState = newState.setIn( ['resourcesIndexes', aggregator, indexer, resource[indexer]], - localID + resourceLocalID ); } }); diff --git a/src/actions/callAPI.js b/src/actions/callAPI.js index bbe7fe8..8e8c098 100644 --- a/src/actions/callAPI.js +++ b/src/actions/callAPI.js @@ -1,6 +1,6 @@ import { REDUX_WP_API_CALL } from '../constants/actions'; -export default (name, request, aditionalParams = {}) => ({ +export default (name, request, additionalParams = {}) => ({ type: REDUX_WP_API_CALL, - payload: { name, request, aditionalParams }, + payload: { name, request, additionalParams }, }); diff --git a/src/adapters/wpapi.js b/src/adapters/wpapi.js index 900e88c..de9f7ce 100644 --- a/src/adapters/wpapi.js +++ b/src/adapters/wpapi.js @@ -269,16 +269,16 @@ export default class WPAPIAdapter { * fetched. The request must carry at least operation (get|create|update\delete) and required data * in order to call API later at `callAPI`. * - * @param {Object} payload The action payload - * @param {Object} payload.request The lib consumer input for calling the api - * @param {Object} payload.aditionalParams Aditional params in order to make request, generally - * meta data such as method or header to be handled by - * `callAPI` - * @return {Object} The Request Object + * @param {Object} payload The action payload + * @param {Object} payload.request The lib consumer input for calling the api + * @param {Object} payload.additionalParams additional params in order to make request, generally + * meta data such as method or header to be handled by + * `callAPI` + * @return {Object} The Request Object */ - buildRequest({ request: requestBuilder, aditionalParams }) { + buildRequest({ request: requestBuilder, additionalParams }) { const wpRequest = requestBuilder(this.api); - const { operation = 'get', ...body } = aditionalParams; + const { operation = 'get', ...body } = additionalParams; return { wpRequest, operation, body }; } diff --git a/src/callAPI.js b/src/callAPI.js index a84d13a..3670acf 100644 --- a/src/callAPI.js +++ b/src/callAPI.js @@ -1,8 +1,6 @@ import { REDUX_WP_API_CALL } from './constants/actions'; -export * as types from './constants/actions'; - -export default (name, request, aditionalParams = {}) => ({ +export default (name, request, additionalParams = {}) => ({ type: REDUX_WP_API_CALL, - payload: { name, request, aditionalParams }, + payload: { name, request, additionalParams }, }); diff --git a/src/index.js b/src/index.js index 183c08e..52e607a 100644 --- a/src/index.js +++ b/src/index.js @@ -3,4 +3,5 @@ import ReduxWPAPI from './ReduxWPAPI'; export default ReduxWPAPI; export * as RequestStatus from './constants/requestStatus'; export callAPI from './actions/callAPI'; -export { selectQuery } from './selectors'; +export * as Symbols from './symbols'; +export { selectQuery, withDenormalize } from './selectors'; diff --git a/src/selectors.js b/src/selectors.js index 6d3aed5..29c598f 100644 --- a/src/selectors.js +++ b/src/selectors.js @@ -1,13 +1,21 @@ +import isFunction from 'lodash/isFunction'; import { createSelector } from 'reselect'; + import { pending } from './constants/requestStatus'; import { mapDeep } from './helpers'; +import { id as idSymbol } from './symbols'; export const denormalize = (resources, id, memoized = {}) => { /* eslint-disable no-param-reassign, no-underscore-dangle */ if (memoized[id]) return memoized[id]; const resource = resources.get(id); + if (!resource) { + return null; + } + memoized[id] = { + [idSymbol]: id, ...resource, ...mapDeep(resource._embedded || {}, embeddedId => denormalize(resources, embeddedId, memoized) @@ -17,7 +25,21 @@ export const denormalize = (resources, id, memoized = {}) => { return memoized[id]; }; -export const localresources = state => state.wp.getIn(['resources']); +export const localResources = state => state.wp.getIn(['resources']); + +export const withDenormalize = thunk => + createSelector( + localResources, + thunk, + (resources, target) => { + if (!isFunction(target)) { + return target; + } + + const memo = {}; + return target(id => denormalize(resources, id, memo)); + } + ); export const selectQuery = name => createSelector( createSelector( @@ -38,7 +60,7 @@ export const selectQuery = name => createSelector( return currentRequest; } ), - localresources, + localResources, (request, resources) => { if (!request) { return { @@ -63,10 +85,7 @@ export const selectQuery = name => createSelector( } return request.set( - 'data', - request.get('operation') === 'get' ? - data.map(id => denormalize(resources, id, memo)) : - data + 'data', data.map(id => denormalize(resources, id, memo)) ).toJSON(); } ); diff --git a/src/symbols.js b/src/symbols.js new file mode 100644 index 0000000..012fd54 --- /dev/null +++ b/src/symbols.js @@ -0,0 +1,2 @@ +export const id = Symbol('ResourceLocalID'); +export const lastCacheUpdate = Symbol('lastCacheUpdate'); diff --git a/test/adapter.spec.js b/test/adapter.spec.js new file mode 100644 index 0000000..8cadd46 --- /dev/null +++ b/test/adapter.spec.js @@ -0,0 +1,13 @@ +import { describe, it } from 'mocha'; +import expect from 'expect'; +import WPAPIAdapter from '../src/adapters/wpapi'; + +describe('WPAPI Adapter', () => { + it('should complain if no api is given', () => { + expect(() => { + // eslint-disable-next-line + new WPAPIAdapter(); + }).toThrow(); + }); +}); + diff --git a/test/data/collectionResponse.js b/test/data/collectionResponse.js deleted file mode 100644 index 276ec6e..0000000 --- a/test/data/collectionResponse.js +++ /dev/null @@ -1,84 +0,0 @@ -const collectionResponse = [ - { - id: 2, - slug: 'dumb2', - dumbAttr: 'dumb2', - _links: { - self: [{ href: 'http://dumb.url/wp-json/namespace/any/2' }], - collection: [{ href: 'http://dumb.url/wp-json/namespace/any' }], - parent: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/namespace/any/1', - }], - author: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/wp/v2/users/1', - }], - }, - _embedded: { - author: [{ - id: 1, - name: 'admin', - link: 'http://km.nos.dev/author/admin/', - slug: 'admin', - _links: { - self: [{ href: 'http://km.nos.dev/wp-json/wp/v2/users/1' }], - collection: [{ href: 'http://km.nos.dev/wp-json/wp/v2/users' }], - }, - }], - parent: [{ - id: 1, - slug: 'dumb1', - dumbAttr: 'dumb1', - _links: { - self: [{ href: 'http://dumb.url/wp-json/namespace/any/1' }], - collection: [{ href: 'http://dumb.url/wp-json/namespace/any' }], - parent: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/namespace/any/1', - }], - author: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/wp/v2/users/2', - }], - }, - }], - }, - }, - { - id: 1, - slug: 'dumb1', - dumbAttr: 'dumb1', - _links: { - self: [{ href: 'http://dumb.url/wp-json/namespace/any/1' }], - collection: [{ href: 'http://dumb.url/wp-json/namespace/any' }], - parent: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/namespace/any/1', - }], - author: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/wp/v2/users/2', - }], - }, - _embedded: { - author: [{ - id: 2, - name: 'edygar', - link: 'http://km.nos.dev/author/edygar/', - slug: 'edygar', - _links: { - self: [{ href: 'http://km.nos.dev/wp-json/wp/v2/users/2' }], - collection: [{ href: 'http://km.nos.dev/wp-json/wp/v2/users' }], - }, - }], - }, - }, -]; - -collectionResponse._paging = { - total: 2, - totalPages: 1, -}; - -export default collectionResponse; diff --git a/test/data/queryBySlugResponse.js b/test/data/queryBySlugResponse.js deleted file mode 100644 index c479f3c..0000000 --- a/test/data/queryBySlugResponse.js +++ /dev/null @@ -1,39 +0,0 @@ -const queryBySlugResponse = [ - { - id: 1, - slug: 'dumb1-modified', - dumbAttr: 'dumb1 - modified', - _links: { - self: [{ href: 'http://dumb.url/wp-json/namespace/any/1' }], - collection: [{ href: 'http://dumb.url/wp-json/namespace/any' }], - parent: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/namespace/any/1', - }], - author: [{ - embeddable: true, - href: 'http://dumb.url/wp-json/wp/v2/users/2', - }], - }, - _embedded: { - author: [{ - id: 2, - name: 'edygar', - link: 'http://km.nos.dev/author/edygar/', - slug: 'edygar', - _links: { - self: [{ href: 'http://km.nos.dev/wp-json/wp/v2/users/2' }], - collection: [{ href: 'http://km.nos.dev/wp-json/wp/v2/users' }], - }, - }], - }, - }, -]; - -queryBySlugResponse._paging = { - total: 1, - totalPages: 2, -}; - -export default queryBySlugResponse; - diff --git a/test/middleware.spec.js b/test/middleware.spec.js new file mode 100644 index 0000000..4709213 --- /dev/null +++ b/test/middleware.spec.js @@ -0,0 +1,265 @@ +import { describe, it } from 'mocha'; +import expect from 'expect'; +import noop from 'lodash/noop'; +import Immutable from 'immutable'; + +import ReduxWPAPI from '../src/index.js'; +import createFakeAdapter from './mocks/createFakeAdapter'; + +import collectionRequest from './mocks/actions/collectionRequest'; +import successfulCollectionRequest from './mocks/actions/successfulCollectionRequest'; +import unsuccessfulCollectionRequest from './mocks/actions/unsuccessfulCollectionRequest'; +import successfulQueryBySlug from './mocks/actions/successfulQueryBySlug'; +import { createFakeStore } from './mocks/store'; +import { initialReducerState } from '../src/ReduxWPAPI'; +import { REDUX_WP_API_CALL, REDUX_WP_API_CACHE_HIT } from '../src/constants/actions'; +import { resolved, rejected } from '../src/constants/requestStatus'; +import { lastCacheUpdate as lastCacheUpdateSymbol } from '../src/symbols'; + +const createCallAPIActionFrom = ({ + meta: { name }, + payload: { cacheID, page }, + response, +}) => ({ + type: REDUX_WP_API_CALL, + payload: { + name, + request: { cacheID, page }, + additionalParams: !Array.isArray(response) ? response : {}, + }, +}); + +const successfulQueryBySlugState = initialReducerState.set( + 'resources', + new Immutable.List([ + { + ...successfulQueryBySlug.payload.response[0]._embedded.author[0], + [lastCacheUpdateSymbol]: Date.now() }, + { + ...successfulQueryBySlug.payload.response[0], + [lastCacheUpdateSymbol]: Date.now(), + _embedded: { author: 0 }, + }, + ]) +) +.set( + 'resourcesIndexes', + Immutable.fromJS({ + any: { + slug: { 'dumb1-modified': 1 }, + id: { 1: 1 }, + }, + }) +) +.mergeIn( + ['requestsByName', successfulQueryBySlug.meta.name], + { + cacheID: successfulQueryBySlug.payload.cacheID, + page: successfulQueryBySlug.payload.page, + } +) +.mergeIn( + ['requestsByQuery', successfulQueryBySlug.payload.cacheID, successfulQueryBySlug.payload.page], + { + status: resolved, + error: false, + data: [0], + } +); + +describe('Middleware', () => { + it('should implement middleware signature (store => next => action =>)', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(successfulCollectionRequest), + }); + const fakeEmptyStore = createFakeStore(); + const fakeNext = noop; + + expect(middleware(fakeEmptyStore)(fakeNext)).toBeA('function'); + }); + + + it('should propagate other actions and next dispatch/middleware return for NO-OP actions', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(successfulCollectionRequest), + }); + + const dispatched = []; + const nextMiddlewareReturn = Symbol(); + const fakeNext = dispatch => dispatched.push(dispatch) && nextMiddlewareReturn; + const action = { type: 'NO-OP ACTION' }; + + const result = middleware(createFakeStore())(fakeNext)(action); + expect(result).toBe(nextMiddlewareReturn); + + expect(dispatched.length).toBe(1); + expect(dispatched[0]).toBe(action); + }); + + it('should dispatch REQUEST and SUCCESSFUL action when request is not cached', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(successfulCollectionRequest), + }); + + const dispatched = []; + const fakeNext = dispatch => dispatched.push(dispatch); + + const action = createCallAPIActionFrom(successfulCollectionRequest); + const result = middleware(createFakeStore())(fakeNext)(action); + expect(result).toBeA(Promise); + + return result.then(() => { + expect(dispatched.length).toBe(2); + expect(dispatched[0]).toEqual({ + ...collectionRequest, + meta: { + ...collectionRequest.meta, + requestAt: dispatched[0].meta.requestAt, + }, + }); + expect(dispatched[1]).toEqual({ + ...successfulCollectionRequest, + meta: { + ...successfulCollectionRequest.meta, + requestAt: dispatched[1].meta.requestAt, + responseAt: dispatched[1].meta.responseAt, + }, + }); + }); + }); + + it('should dispatch only CACHE_HIT action when request is cached within TTL', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(successfulQueryBySlug, { + getTTL() { return Infinity; }, + getIndexes() { return { slug: 'dumb1-modified' }; }, + }), + customCacheIndexes: { + any: 'slug', + }, + }); + + const dispatched = []; + const nextMiddlewareReturn = Symbol(); + const fakeNext = dispatch => dispatched.push(dispatch) && nextMiddlewareReturn; + const action = createCallAPIActionFrom(successfulQueryBySlug); + + // CACHED STATE + const fakeStore = createFakeStore({ wp: successfulQueryBySlugState }); + const result = middleware(fakeStore)(fakeNext)(action); + expect(result).toBeA(Promise); + + return result.then(() => { + expect(dispatched.length).toBe(1); + expect(dispatched[0].type).toBe(REDUX_WP_API_CACHE_HIT); + }); + }); + + it('should dispatch REQUEST and FAILURE action when request is not cached and fails', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(unsuccessfulCollectionRequest, { + sendRequest: () => Promise.reject(unsuccessfulCollectionRequest.error), + }), + }); + + const dispatched = []; + const fakeNext = dispatch => dispatched.push(dispatch); + const action = createCallAPIActionFrom(unsuccessfulCollectionRequest); + + const result = middleware(createFakeStore())(fakeNext)(action); + expect(result).toBeA(Promise); + + return result.then(() => { + expect(dispatched.length).toBe(2); + expect(dispatched[0]).toEqual({ + ...collectionRequest, + payload: { + ...collectionRequest.payload, + cacheID: unsuccessfulCollectionRequest.payload.cacheID, + }, + meta: { + ...collectionRequest.meta, + requestAt: dispatched[0].meta.requestAt, + }, + }); + expect(dispatched[1]).toEqual({ + ...unsuccessfulCollectionRequest, + meta: { + ...unsuccessfulCollectionRequest.meta, + requestAt: dispatched[1].meta.requestAt, + responseAt: dispatched[1].meta.responseAt, + }, + }); + }); + }); + + it('should return a promise that resolves to selectQuery result', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(successfulCollectionRequest), + }); + + const dispatched = []; + const store = createFakeStore(); + const fakeNext = dispatch => { + dispatched.push(dispatch); + store.state = { wp: successfulQueryBySlugState }; + }; + const action = createCallAPIActionFrom(successfulCollectionRequest); + const result = middleware(store)(fakeNext)(action); + expect(result).toBeA(Promise); + + return result.then(response => { + expect(response) + .toInclude({ + status: resolved, + error: false, + }); + expect(response.data).toBeA('array'); + expect(response.data.length).toBe(1); + expect(response.data[0]).toBeAn('object'); + }); + }); + + it('should return a promise that reject to selectQuery result', () => { + const { middleware } = new ReduxWPAPI({ + adapter: createFakeAdapter(unsuccessfulCollectionRequest, { + sendRequest: () => Promise.reject(unsuccessfulCollectionRequest.error), + }), + }); + + const dispatched = []; + const store = createFakeStore(); + const { name } = unsuccessfulCollectionRequest.meta; + const { page, cacheID } = unsuccessfulCollectionRequest.payload; + const fakeNext = dispatch => { + dispatched.push(dispatch); + store.state = { + wp: initialReducerState.mergeIn( + ['requestsByQuery', cacheID, page], + { + status: rejected, + data: false, + error: unsuccessfulCollectionRequest.error, + } + ) + .mergeIn( + ['requestsByName', name], + { cacheID, page } + ), + }; + }; + const action = createCallAPIActionFrom(successfulCollectionRequest); + const result = middleware(store)(fakeNext)(action); + expect(result).toBeA(Promise); + + return result.then(response => { + expect(response) + .toInclude({ + status: rejected, + data: false, + error: unsuccessfulCollectionRequest.error, + }); + }); + }); +}); + diff --git a/test/mocks/AdapterMockForReducer.js b/test/mocks/AdapterMockForReducer.js new file mode 100644 index 0000000..86c9f8a --- /dev/null +++ b/test/mocks/AdapterMockForReducer.js @@ -0,0 +1,12 @@ +export default class AdapterMockForReducer { + // _paging extractor + getPagination = ({ _paging }) => _paging; + + // no link renaming + embedLinkAs = ({ name }) => name; + + // expects users or any for test reducers + getAggregator(url) { + return url.match(/users/) ? 'users' : 'any'; + } +} diff --git a/test/mocked-actions/cacheHitCollection.js b/test/mocks/actions/cacheHitCollection.js similarity index 78% rename from test/mocked-actions/cacheHitCollection.js rename to test/mocks/actions/cacheHitCollection.js index 9219a61..009352a 100644 --- a/test/mocked-actions/cacheHitCollection.js +++ b/test/mocks/actions/cacheHitCollection.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_CACHE_HIT } from '../../src/constants/actions'; +import { REDUX_WP_API_CACHE_HIT } from '../../../src/constants/actions'; export default { type: REDUX_WP_API_CACHE_HIT, diff --git a/test/mocked-actions/cacheHitSingle.js b/test/mocks/actions/cacheHitSingle.js similarity index 79% rename from test/mocked-actions/cacheHitSingle.js rename to test/mocks/actions/cacheHitSingle.js index 4eeb98f..c9c9fa8 100644 --- a/test/mocked-actions/cacheHitSingle.js +++ b/test/mocks/actions/cacheHitSingle.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_CACHE_HIT } from '../../src/constants/actions'; +import { REDUX_WP_API_CACHE_HIT } from '../../../src/constants/actions'; export default { type: REDUX_WP_API_CACHE_HIT, diff --git a/test/mocked-actions/collectionRequest.js b/test/mocks/actions/collectionRequest.js similarity index 75% rename from test/mocked-actions/collectionRequest.js rename to test/mocks/actions/collectionRequest.js index fdc3a3a..c9baac6 100644 --- a/test/mocked-actions/collectionRequest.js +++ b/test/mocks/actions/collectionRequest.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_REQUEST } from '../../src/constants/actions'; +import { REDUX_WP_API_REQUEST } from '../../../src/constants/actions'; export default { type: REDUX_WP_API_REQUEST, diff --git a/test/mocked-actions/modifyingRequest.js b/test/mocks/actions/modifyingRequest.js similarity index 77% rename from test/mocked-actions/modifyingRequest.js rename to test/mocks/actions/modifyingRequest.js index 3ab1719..f3f750c 100644 --- a/test/mocked-actions/modifyingRequest.js +++ b/test/mocks/actions/modifyingRequest.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_REQUEST } from '../../src/constants/actions'; +import { REDUX_WP_API_REQUEST } from '../../../src/constants/actions'; export default { type: REDUX_WP_API_REQUEST, diff --git a/test/mocked-actions/successfulCollectionRequest.js b/test/mocks/actions/successfulCollectionRequest.js similarity index 82% rename from test/mocked-actions/successfulCollectionRequest.js rename to test/mocks/actions/successfulCollectionRequest.js index 9c2b502..493e119 100644 --- a/test/mocked-actions/successfulCollectionRequest.js +++ b/test/mocks/actions/successfulCollectionRequest.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_SUCCESS } from '../../src/constants/actions'; +import { REDUX_WP_API_SUCCESS } from '../../../src/constants/actions'; import collectionResponse from '../data/collectionResponse'; export default { diff --git a/test/mocked-actions/successfullQueryBySlug.js b/test/mocks/actions/successfulQueryBySlug.js similarity index 71% rename from test/mocked-actions/successfullQueryBySlug.js rename to test/mocks/actions/successfulQueryBySlug.js index ab6bd05..5c9cf2b 100644 --- a/test/mocked-actions/successfullQueryBySlug.js +++ b/test/mocks/actions/successfulQueryBySlug.js @@ -1,10 +1,10 @@ -import { REDUX_WP_API_SUCCESS } from '../../src/constants/actions'; +import { REDUX_WP_API_SUCCESS } from '../../../src/constants/actions'; import queryBySlugResponse from '../data/queryBySlugResponse'; export default { type: REDUX_WP_API_SUCCESS, payload: { - cacheID: '/namespace/any?slug=dumb2', + cacheID: '/namespace/any?slug=dumb1-modified', page: 1, response: queryBySlugResponse, }, diff --git a/test/mocked-actions/unsuccessfulCollectionRequest.js b/test/mocks/actions/unsuccessfulCollectionRequest.js similarity index 80% rename from test/mocked-actions/unsuccessfulCollectionRequest.js rename to test/mocks/actions/unsuccessfulCollectionRequest.js index 25effda..ada7ba9 100644 --- a/test/mocked-actions/unsuccessfulCollectionRequest.js +++ b/test/mocks/actions/unsuccessfulCollectionRequest.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_FAILURE } from '../../src/constants/actions'; +import { REDUX_WP_API_FAILURE } from '../../../src/constants/actions'; export default { type: REDUX_WP_API_FAILURE, diff --git a/test/mocked-actions/unsuccessfulModifyingRequest.js b/test/mocks/actions/unsuccessfulModifyingRequest.js similarity index 78% rename from test/mocked-actions/unsuccessfulModifyingRequest.js rename to test/mocks/actions/unsuccessfulModifyingRequest.js index 111c0cb..e466e67 100644 --- a/test/mocked-actions/unsuccessfulModifyingRequest.js +++ b/test/mocks/actions/unsuccessfulModifyingRequest.js @@ -1,4 +1,4 @@ -import { REDUX_WP_API_FAILURE } from '../../src/constants/actions'; +import { REDUX_WP_API_FAILURE } from '../../../src/constants/actions'; export default { type: REDUX_WP_API_FAILURE, diff --git a/test/mocks/createFakeAdapter.js b/test/mocks/createFakeAdapter.js new file mode 100644 index 0000000..e0ac7c4 --- /dev/null +++ b/test/mocks/createFakeAdapter.js @@ -0,0 +1,17 @@ +export default (actionTarget, override) => { + class Adapter { + getAggregator() { return actionTarget.meta.aggregator; } + getOperation() { return actionTarget.meta.operation; } + generateCacheID() { return actionTarget.payload.cacheID; } + getRequestedPage() { return actionTarget.payload.page; } + + getUrl() { return ''; } + getIndexes() { return {}; } + buildRequest() { return {}; } + sendRequest() { return Promise.resolve(actionTarget.payload.response); } + } + + Object.assign(Adapter.prototype, override); + return new Adapter(); +}; + diff --git a/test/mocks/data/collectionResponse.js b/test/mocks/data/collectionResponse.js new file mode 100644 index 0000000..c90c56f --- /dev/null +++ b/test/mocks/data/collectionResponse.js @@ -0,0 +1,84 @@ +const collectionResponse = [ + { + id: 2, + slug: 'dumb2', + dumbAttr: 'dumb2', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/namespace/any/2' }], + collection: [{ href: 'http://wordpress.dev/wp-json/namespace/any' }], + parent: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/namespace/any/1', + }], + author: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/wp/v2/users/1', + }], + }, + _embedded: { + author: [{ + id: 1, + name: 'admin', + link: 'http://wordpress.dev/wp-json/author/admin/', + slug: 'admin', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/wp/v2/users/1' }], + collection: [{ href: 'http://wordpress.dev/wp-json/wp/v2/users' }], + }, + }], + parent: [{ + id: 1, + slug: 'dumb1', + dumbAttr: 'dumb1', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/namespace/any/1' }], + collection: [{ href: 'http://wordpress.dev/wp-json/namespace/any' }], + parent: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/namespace/any/1', + }], + author: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/wp/v2/users/2', + }], + }, + }], + }, + }, + { + id: 1, + slug: 'dumb1', + dumbAttr: 'dumb1', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/namespace/any/1' }], + collection: [{ href: 'http://wordpress.dev/wp-json/namespace/any' }], + parent: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/namespace/any/1', + }], + author: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/wp/v2/users/2', + }], + }, + _embedded: { + author: [{ + id: 2, + name: 'edygar', + link: 'http://wordpress.dev/wp-json/author/edygar/', + slug: 'edygar', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/wp/v2/users/2' }], + collection: [{ href: 'http://wordpress.dev/wp-json/wp/v2/users' }], + }, + }], + }, + }, +]; + +collectionResponse._paging = { + total: 2, + totalPages: 1, +}; + +export default collectionResponse; diff --git a/test/mocks/data/queryBySlugResponse.js b/test/mocks/data/queryBySlugResponse.js new file mode 100644 index 0000000..1370ce8 --- /dev/null +++ b/test/mocks/data/queryBySlugResponse.js @@ -0,0 +1,39 @@ +const queryBySlugResponse = [ + { + id: 1, + slug: 'dumb1-modified', + dumbAttr: 'dumb1 - modified', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/namespace/any/1' }], + collection: [{ href: 'http://wordpress.dev/wp-json/namespace/any' }], + parent: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/namespace/any/1', + }], + author: [{ + embeddable: true, + href: 'http://wordpress.dev/wp-json/wp/v2/users/2', + }], + }, + _embedded: { + author: [{ + id: 2, + name: 'edygar', + link: 'http://wordpress.dev/author/edygar/', + slug: 'edygar', + _links: { + self: [{ href: 'http://wordpress.dev/wp-json/wp/v2/users/2' }], + collection: [{ href: 'http://wordpress.dev/wp-json/wp/v2/users' }], + }, + }], + }, + }, +]; + +queryBySlugResponse._paging = { + total: 1, + totalPages: 1, +}; + +export default queryBySlugResponse; + diff --git a/test/mocks/store.js b/test/mocks/store.js new file mode 100644 index 0000000..22a70a2 --- /dev/null +++ b/test/mocks/store.js @@ -0,0 +1,7 @@ +import { initialReducerState } from '../../src/ReduxWPAPI'; + +export const initialStore = { wp: initialReducerState }; +export const createFakeStore = (fakeData = initialStore) => ({ + state: fakeData, + getState() { return this.state; }, +}); diff --git a/test/reducer.spec.js b/test/reducer.spec.js index 994f67f..6f75d37 100644 --- a/test/reducer.spec.js +++ b/test/reducer.spec.js @@ -1,26 +1,26 @@ import { describe, it } from 'mocha'; import expect from 'expect'; -import WPAPI from 'wpapi'; import Immutable from 'immutable'; import ReduxWPAPI from '../src/index.js'; import { pending, resolved, rejected } from '../src/constants/requestStatus'; -import collectionRequest from './mocked-actions/collectionRequest'; -import modifyingRequest from './mocked-actions/modifyingRequest'; -import successfulCollectionRequest from './mocked-actions/successfulCollectionRequest'; -import successfullQueryBySlug from './mocked-actions/successfullQueryBySlug'; -import unsuccessfulCollectionRequest from './mocked-actions/unsuccessfulCollectionRequest'; -import unsuccessfulModifyingRequest from './mocked-actions/unsuccessfulModifyingRequest'; -import cacheHitSingle from './mocked-actions/cacheHitSingle'; -import cacheHitCollection from './mocked-actions/cacheHitCollection'; +import collectionRequest from './mocks/actions/collectionRequest'; +import modifyingRequest from './mocks/actions/modifyingRequest'; +import successfulCollectionRequest from './mocks/actions/successfulCollectionRequest'; +import successfulQueryBySlug from './mocks/actions/successfulQueryBySlug'; +import unsuccessfulCollectionRequest from './mocks/actions/unsuccessfulCollectionRequest'; +import unsuccessfulModifyingRequest from './mocks/actions/unsuccessfulModifyingRequest'; +import cacheHitSingle from './mocks/actions/cacheHitSingle'; +import cacheHitCollection from './mocks/actions/cacheHitCollection'; +import AdapterMockForReducer from './mocks/AdapterMockForReducer'; describe('Reducer', () => { - const modifingOperations = ['create', 'update', 'delete']; + const modifyingOperations = ['create', 'update', 'delete']; let reducer; beforeEach(() => { reducer = new ReduxWPAPI({ - api: new WPAPI({ endpoint: 'http://dumb.url/wp-json/' }), + adapter: new AdapterMockForReducer(), customCacheIndexes: { any: 'slug', }, @@ -86,7 +86,7 @@ describe('Reducer', () => { }); }); - modifingOperations + modifyingOperations .forEach(type => describe(`on operation ${type.toUpperCase()}`, () => { const request = { ...modifyingRequest, meta: { ...modifyingRequest.meta, type } }; @@ -165,12 +165,12 @@ describe('Reducer', () => { it('should update previous resource\'s state', () => { const previous = reducer(undefined, successfulCollectionRequest); - const state = reducer(previous, successfullQueryBySlug); - const queryState = state.getIn(['requestsByQuery', successfullQueryBySlug.payload.cacheID]); + const state = reducer(previous, successfulQueryBySlug); + const queryState = state.getIn(['requestsByQuery', successfulQueryBySlug.payload.cacheID]); const [id] = queryState.getIn([1, 'data']); const resource = state.getIn(['resources', id]); expect(resource).toContain({ - link: successfullQueryBySlug.payload.response[0].link, + link: successfulQueryBySlug.payload.response[0].link, }); }); }); @@ -190,7 +190,7 @@ describe('Reducer', () => { }); }); - modifingOperations.forEach(type => + modifyingOperations.forEach(type => describe(`on ${type} operation`, () => { const response = { ...unsuccessfulModifyingRequest, diff --git a/test/selector.spec.js b/test/selector.spec.js new file mode 100644 index 0000000..d563b1b --- /dev/null +++ b/test/selector.spec.js @@ -0,0 +1,173 @@ +import { describe, it } from 'mocha'; +import expect from 'expect'; +import Immutable from 'immutable'; +import { createSelector } from 'reselect'; + +import { initialReducerState } from '../src/ReduxWPAPI'; +import { selectQuery, withDenormalize } from '../src/selectors'; +import { pending, resolved } from '../src/constants/requestStatus'; + +describe('Selector selectQuery', () => { + it('should return a Request for empty state', () => { + const state = { wp: initialReducerState }; + expect(selectQuery('test')(state)) + .toEqual({ + status: pending, + error: false, + data: false, + }); + }); + + it('should return a Request for pending state', () => { + const cacheID = 'test/'; + const queryState = { status: pending, error: false, requestAt: Date.now(), operation: 'get' }; + const state = { + wp: ( + initialReducerState + .mergeIn(['requestsByName', 'test'], { cacheID, page: 1 }) + .setIn(['requestsByQuery', 'test/', 1], queryState) + ), + }; + expect(selectQuery('test')(state)) + .toEqual({ + status: pending, + operation: queryState.operation, + requestAt: queryState.requestAt, + cacheID, + error: false, + data: false, + page: 1, + }); + }); + + it('should return a Request for resolved state without embedded', () => { + const resource = { id: 1, title: 'lol' }; + const cacheID = 'test/'; + const queryState = { + status: resolved, + error: false, + requestAt: Date.now(), + operation: 'get', + data: [0], + }; + const state = { + wp: ( + initialReducerState + .setIn(['resources', 0], resource) + .mergeIn(['requestsByName', 'test'], { cacheID, page: 1 }) + .setIn(['requestsByQuery', 'test/', 1], queryState) + ), + }; + + const selector = selectQuery('test'); + const selectedState = selector(state); + expect(selectedState) + .toContain({ + status: resolved, + operation: queryState.operation, + requestAt: queryState.requestAt, + cacheID, + error: false, + page: 1, + }); + + expect(selectedState.data).toBeAn('array'); + expect(selectedState.data[0]).toEqual(resource); + }); + + it('should return a Request for resolved state with embedded', () => { + const resources = [ + { id: 1, + title: 'lol', + _links: { parent: { url: 'http://dumb.com/test/2' } }, + _embedded: { parent: 1 }, + }, + { id: 2, title: 'lol 2' }, + ]; + const cacheID = 'test/'; + const queryState = { + status: resolved, + error: false, + requestAt: Date.now(), + operation: 'get', + data: [0], + }; + const state = { + wp: ( + initialReducerState + .setIn(['resources'], new Immutable.List(resources)) + .mergeIn(['requestsByName', 'test'], { cacheID, page: 1 }) + .setIn(['requestsByQuery', 'test/', 1], queryState) + ), + }; + + const selector = selectQuery('test'); + const selectedState = selector(state); + expect(selectedState) + .toContain({ + status: resolved, + operation: queryState.operation, + requestAt: queryState.requestAt, + cacheID, + error: false, + page: 1, + }); + + expect(selectedState.data).toBeAn('array'); + expect(selectedState.data[0]).toInclude(resources[0]); + expect(selectedState.data[0].parent).toInclude(resources[1]); + }); + + it('should refer to same objects between two selections with same input state', () => { + const resource = { id: 1, title: 'lol' }; + const cacheID = 'test/'; + const queryState = { + status: resolved, + error: false, + requestAt: Date.now(), + operation: 'get', + data: [0], + }; + const state = { + wp: ( + initialReducerState + .setIn(['resources', 0], resource) + .mergeIn(['requestsByName', 'test'], { cacheID, page: 1 }) + .setIn(['requestsByQuery', 'test/', 1], queryState) + ), + }; + + const selector = selectQuery('test'); + const selectedState = selector(state); + expect(selector(state)).toBe(selectedState); + }); +}); + + +describe('withDenormalize', () => { + it('should allow consumers to denormalize resources by local ids within selector', () => { + const resources = [ + { id: 1, + title: 'lol', + _links: { parent: { url: 'http://dumb.com/test/2' } }, + _embedded: { parent: 1 }, + }, + { id: 2, title: 'lol 2' }, + ]; + const storeState = { + wp: ( + initialReducerState + .setIn(['resources'], new Immutable.List(resources)) + ), + }; + + const selector = withDenormalize( + createSelector( + () => 1, + id => denormalize => denormalize(id) + ) + ); + const selectedState = selector(storeState); + expect(selectedState).toInclude(resources[1]); + }); +});