diff --git a/src/main/components/store/merge.ts b/src/main/components/store/merge.ts new file mode 100644 index 00000000..11a8b2c7 --- /dev/null +++ b/src/main/components/store/merge.ts @@ -0,0 +1,44 @@ +import { ModelState } from '../../components/store/model-state'; +import { AssessmentState } from '../../services/assessment/assessment-types'; +import { UMLElementState } from '../../services/uml-element/uml-element-types'; + +/** + * Merges the old state with the new state. In particular, it maintains + * all potential prototypes, and gracefully updates owned elements list in the + * diagram. The boundaries of the diagram are NOT updated, which is to be done, if + * necessary, by some subsequent side-effect. + * @param oldState + * @param newState + * @returns The merged state. + */ +export function merge(oldState: ModelState, newState: ModelState): ModelState { + return { + ...oldState, + diagram: { + ...oldState.diagram, + ownedElements: oldState.diagram.ownedElements.filter( + (id) => !!newState.elements[id] && !newState.elements[id].owner, + ), + ownedRelationships: oldState.diagram.ownedRelationships.filter((id) => !!newState.elements[id]), + }, + elements: Object.keys(newState.elements).reduce((acc, id) => { + return { + ...acc, + [id]: { + ...oldState.elements[id], + ...newState.elements[id], + }, + }; + }, {} as UMLElementState), + interactive: newState.interactive, + assessments: Object.keys(newState.assessments).reduce((acc, id) => { + return { + ...acc, + [id]: { + ...oldState.assessments[id], + ...newState.assessments[id], + }, + }; + }, {} as AssessmentState), + }; +} diff --git a/src/main/components/store/model-store.tsx b/src/main/components/store/model-store.tsx index f5787e76..b463a602 100644 --- a/src/main/components/store/model-store.tsx +++ b/src/main/components/store/model-store.tsx @@ -30,6 +30,7 @@ import { Patcher, } from '../../services/patcher'; import { UMLModel } from '../../typings'; +import { merge } from './merge'; type OwnProps = PropsWithChildren<{ initialState?: PreloadedState; @@ -48,6 +49,7 @@ export const createReduxStore = ( patcher && createPatcherReducer(patcher, { transform: (model) => ModelState.fromModel(model, false) as ModelState, + merge, }); const reducer: Reducer = (state, action) => { diff --git a/src/main/services/patcher/patcher-reducer.ts b/src/main/services/patcher/patcher-reducer.ts index 6cc32fc1..cbede062 100644 --- a/src/main/services/patcher/patcher-reducer.ts +++ b/src/main/services/patcher/patcher-reducer.ts @@ -1,6 +1,7 @@ import { Reducer } from 'redux'; import { Patcher } from './patcher'; import { PatcherActionTypes } from './patcher-types'; +import { deepClone } from 'fast-json-patch'; export type PatcherReducerOptions = { /** @@ -10,10 +11,21 @@ export type PatcherReducerOptions = { * @returns The state in the internal schema. */ transform?: (state: T) => U; + + /** + * Merges the old state with the new state. This is useful when naive strategies + * like `Object.assign` would trigger unwanted side-effects and more context-aware merging + * of state is required. + * @param oldState + * @param newState + * @returns The merged state. + */ + merge?: (oldState: U, newState: U) => U; }; const _DefaultOptions = { transform: (state: any) => state, + merge: (oldState: any, newState: any) => ({ ...oldState, ...newState }), }; /** @@ -27,16 +39,14 @@ export function createPatcherReducer( options: PatcherReducerOptions = _DefaultOptions, ): Reducer { const transform = options.transform || _DefaultOptions.transform; + const merge = options.merge || _DefaultOptions.merge; return (state = {} as U, action) => { const { type, payload } = action; if (type === PatcherActionTypes.PATCH) { const res = transform(patcher.patch(payload)); - return { - ...state, - ...res, - }; + return merge(state, res); } return state; diff --git a/src/tests/unit/components/store/merge-test.ts b/src/tests/unit/components/store/merge-test.ts new file mode 100644 index 00000000..a6211b0e --- /dev/null +++ b/src/tests/unit/components/store/merge-test.ts @@ -0,0 +1,22 @@ +import { merge } from '../../../../main/components/store/merge'; +import { ModelState } from '../../../../main/components/store/model-state'; +import diagram from '../../test-resources/class-diagram.json'; +import diagram3 from '../../test-resources/class-diagram-3.json'; + +describe('merge', () => { + const pkgId = 'c10b995a-036c-4e9e-aa67-0570ada5cb6a'; + const class1Id = '04d3509e-0dce-458b-bf62-f3555497a5a4'; + const class2Id = '9eadc4f6-caa0-4835-a24c-71c0c1ccbc39'; + + test('merges two model states.', () => { + const oldState = ModelState.fromModel(diagram as any) as ModelState; + const newState = ModelState.fromModel(diagram3 as any) as ModelState; + + const merged = merge(oldState, newState); + + expect(merged.elements[pkgId]).not.toBeDefined(); + expect(merged.elements[class1Id]).toBeDefined(); + expect(merged.elements[class2Id]).toBeDefined(); + expect(merged.elements[class1Id].owner).toBeNull(); + }); +}); diff --git a/src/tests/unit/components/store/model-state.test.ts b/src/tests/unit/components/store/model-state-test.ts similarity index 100% rename from src/tests/unit/components/store/model-state.test.ts rename to src/tests/unit/components/store/model-state-test.ts diff --git a/src/tests/unit/services/patcher/patcher-reducer-test.ts b/src/tests/unit/services/patcher/patcher-reducer-test.ts index dfc9311d..f941f0e6 100644 --- a/src/tests/unit/services/patcher/patcher-reducer-test.ts +++ b/src/tests/unit/services/patcher/patcher-reducer-test.ts @@ -26,6 +26,18 @@ describe('test patcher reducer.', () => { expect(nextState).toEqual({ y: 42 }); }); + test('calls given merge function for applying the patch.', () => { + const cb = jest.fn((x, y) => ({ ...x, ...y })); + + const patcher = new Patcher(); + patcher.initialize({}); + + const reducer = createPatcherReducer(patcher, { merge: cb }); + const nextState = reducer({ y: 41 }, PatcherRepository.patch([{ op: 'add', path: '/x', value: 42 }])); + + expect(nextState).toEqual({ x: 42, y: 41 }); + }); + test('handles weird options.', () => { expect(() => createPatcherReducer(new Patcher(), {})).not.toThrow(); }); diff --git a/src/tests/unit/test-resources/class-diagram-3.json b/src/tests/unit/test-resources/class-diagram-3.json new file mode 100644 index 00000000..8c1950a1 --- /dev/null +++ b/src/tests/unit/test-resources/class-diagram-3.json @@ -0,0 +1,90 @@ +{ + "version": "3.0.0", + "type": "ClassDiagram", + "size": { "width": 860, "height": 260 }, + "interactive": { "elements": {}, "relationships": {} }, + "elements": { + "04d3509e-0dce-458b-bf62-f3555497a5a4": { + "id": "04d3509e-0dce-458b-bf62-f3555497a5a4", + "name": "Class", + "type": "Class", + "owner": null, + "bounds": { "x": 200, "y": 70, "width": 200, "height": 140 }, + "attributes": ["90f94404-1fc6-4121-97ed-6b2c6d57d525"], + "methods": ["12d8bb82-e4e5-4505-a0eb-48ddab0fc0a0"] + }, + "90f94404-1fc6-4121-97ed-6b2c6d57d525": { + "id": "90f94404-1fc6-4121-97ed-6b2c6d57d525", + "name": "+ attr: Type", + "type": "ClassAttribute", + "owner": "04d3509e-0dce-458b-bf62-f3555497a5a4", + "bounds": { "x": 80, "y": 110, "width": 200, "height": 30 } + }, + "12d8bb82-e4e5-4505-a0eb-48ddab0fc0a0": { + "id": "12d8bb82-e4e5-4505-a0eb-48ddab0fc0a0", + "name": "+ method()", + "type": "ClassMethod", + "owner": "04d3509e-0dce-458b-bf62-f3555497a5a4", + "bounds": { "x": 80, "y": 140, "width": 200, "height": 30 } + }, + "9eadc4f6-caa0-4835-a24c-71c0c1ccbc39": { + "id": "9eadc4f6-caa0-4835-a24c-71c0c1ccbc39", + "name": "Class", + "type": "Class", + "owner": null, + "bounds": { "x": 620, "y": 90, "width": 200, "height": 100 }, + "attributes": ["dbd4193a-4483-43df-8934-77192be006c2"], + "methods": ["e7ef41ee-290e-4df2-a535-199f1c5521fd"] + }, + "dbd4193a-4483-43df-8934-77192be006c2": { + "id": "dbd4193a-4483-43df-8934-77192be006c2", + "name": "+ attribute: Type", + "type": "ClassAttribute", + "owner": "9eadc4f6-caa0-4835-a24c-71c0c1ccbc39", + "bounds": { "x": 620, "y": 130, "width": 200, "height": 30 } + }, + "e7ef41ee-290e-4df2-a535-199f1c5521fd": { + "id": "e7ef41ee-290e-4df2-a535-199f1c5521fd", + "name": "+ method()", + "type": "ClassMethod", + "owner": "9eadc4f6-caa0-4835-a24c-71c0c1ccbc39", + "bounds": { "x": 620, "y": 160, "width": 200, "height": 30 } + } + }, + "relationships": { + "f5c4e20d-8347-4136-bc02-b7a016e017f5": { + "id": "f5c4e20d-8347-4136-bc02-b7a016e017f5", + "name": "", + "type": "ClassBidirectional", + "owner": null, + "bounds": { "x": 280, "y": 130, "width": 340, "height": 1 }, + "path": [ + { "x": 340, "y": 0 }, + { "x": 0, "y": 0 } + ], + "source": { + "direction": "Left", + "element": "9eadc4f6-caa0-4835-a24c-71c0c1ccbc39", + "multiplicity": "", + "role": "" + }, + "target": { + "direction": "Right", + "element": "04d3509e-0dce-458b-bf62-f3555497a5a4", + "multiplicity": "", + "role": "" + }, + "isManuallyLayouted": false + } + }, + "assessments": { + "04d3509e-0dce-458b-bf62-f3555497a5a4": { + "modelElementId": "04d3509e-0dce-458b-bf62-f3555497a5a4", + "elementType": "Class", + "score": 10, + "feedback": "good", + "label": "Class", + "labelColor": "blue" + } + } +}