diff --git a/src/state/__tests__/reducer.ts b/src/state/__tests__/reducer.ts index 1144ec6ef768..09e20b8b52bb 100644 --- a/src/state/__tests__/reducer.ts +++ b/src/state/__tests__/reducer.ts @@ -1,6 +1,65 @@ import { ArticleResource, PaginatedArticleResource } from '../../__tests__/common'; -import reducer from '../reducer'; +import reducer, { resourceCustomizer } from '../reducer'; import { FetchAction, RPCAction, ReceiveAction, PurgeAction, State } from '../../types'; +import { mergeWith } from 'lodash'; + +describe('resourceCustomizer', () => { + it('should merge two Resource instances', () => { + const id = 20; + const a = ArticleResource.fromJS({ id, title: 'hi', content: 'this is the content' }); + const b = ArticleResource.fromJS({ id, title: 'hello' }); + + const merged = resourceCustomizer(a, b); + expect(merged).toBeInstanceOf(ArticleResource); + expect(merged).toEqual( + ArticleResource.fromJS({ + id, + title: 'hello', + content: 'this is the content' + }) + ) + }); + it('should handle merging of Resource instances when used with lodash.mergeWith()', () => { + const id = 20; + const entitiesA = { + [ArticleResource.getKey()]: { + [id]: ArticleResource.fromJS({ id, title: 'hi', content: 'this is the content' }), + }, + } + const entitiesB = { + [ArticleResource.getKey()]: { + [id]: ArticleResource.fromJS({ id, title: 'hello' }), + }, + } + + const merged = mergeWith({ ...entitiesA }, entitiesB, resourceCustomizer); + expect(merged[ArticleResource.getKey()][id]).toBeInstanceOf(ArticleResource); + expect(merged[ArticleResource.getKey()][id]).toEqual( + ArticleResource.fromJS({ + id, + title: 'hello', + content: 'this is the content' + }) + ) + }); + it('should not affect merging of plain objects when used with lodash.mergeWith()', () => { + const id = 20; + const entitiesA = { + [ArticleResource.getKey()]: { + [id]: ArticleResource.fromJS({ id, title: 'hi', content: 'this is the content' }), + [42]: ArticleResource.fromJS({ id: 42, title: 'dont touch me', content: 'this is mine' }), + }, + } + const entitiesB = { + [ArticleResource.getKey()]: { + [id]: ArticleResource.fromJS({ id, title: 'hi', content: 'this is the content' }), + }, + } + + const merged = mergeWith({ ...entitiesA }, entitiesB, resourceCustomizer); + expect(merged[ArticleResource.getKey()][42]).toBe(entitiesA[ArticleResource.getKey()][42]); + }); +}); describe('reducer', () => { describe('singles', () => { @@ -15,6 +74,10 @@ describe('reducer', () => { expiresAt: 5000500000, }, }; + const partialResultAction: ReceiveAction = { + ...action, + payload: { id, title: 'hello' }, + }; const iniState = { entities: {}, results: {}, @@ -33,6 +96,21 @@ describe('reducer', () => { expect(nextEntity).not.toBe(prevEntity); expect(nextEntity).toBeDefined(); }) + it('should merge partial entity with existing entity', () => { + const getEntity = (state: any): ArticleResource => state.entities[ArticleResource.getKey()][`${ArticleResource.pk(action.payload)}`] + const prevEntity = getEntity(newState); + expect(prevEntity).toBeDefined(); + const nextState = reducer(newState, partialResultAction); + const nextEntity = getEntity(nextState); + expect(nextEntity).not.toBe(prevEntity); + expect(nextEntity).toBeDefined(); + + expect(nextEntity.title).not.toBe(prevEntity.title); + expect(nextEntity.title).toBe(partialResultAction.payload.title); + + expect(nextEntity.content).toBe(prevEntity.content); + expect(nextEntity.content).not.toBe(partialResultAction.payload.content); + }) }); it('mutate should never change results', () => { const id = 20; diff --git a/src/state/reducer.ts b/src/state/reducer.ts index caeb5a420cb4..babe29a65dbd 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -1,5 +1,5 @@ import { normalize } from '../resource'; -import { merge } from 'lodash'; +import { mergeWith } from 'lodash'; import { Resource } from '../resource'; import { ActionTypes, State } from '../types'; @@ -13,6 +13,29 @@ type Writable = { [P in keyof T]: NonNullable; } +interface MergeableStatic { + new(): T; + merge(a: T, b: T): T; +} + +function isMergeable( + constructor: any +): constructor is MergeableStatic { + return ( + constructor && + typeof constructor.merge === 'function' + ); +} + +export const resourceCustomizer = (a: any, b: any): any => { + const Static = b && b.constructor; + if (a && Static && isMergeable(Static)) { + return Static.merge(a, b); + } + + // use default merging in lodash.merge() +}; + export default function reducer(state: State, action: ActionTypes) { switch (action.type) { case 'receive': @@ -31,7 +54,7 @@ export default function reducer(state: State, action: ActionTypes) { } const normalized = normalize(action.payload, action.meta.schema); return { - entities: merge({ ...state.entities }, normalized.entities), + entities: mergeWith({ ...state.entities }, normalized.entities, resourceCustomizer), results: { ...state.results, [action.meta.url]: normalized.result, @@ -49,7 +72,7 @@ export default function reducer(state: State, action: ActionTypes) { let { entities } = normalize(action.payload, action.meta.schema); return { ...state, - entities: merge({ ...state.entities }, entities), + entities: mergeWith({ ...state.entities }, entities, resourceCustomizer), }; case 'purge': if (action.error) return state;